From 5adb615f3721828850a67dbeecfa6ba2472fb550 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Wed, 11 Sep 2019 23:34:06 +0300 Subject: [PATCH 01/34] Modernization rework - config entry support, with override support from huawei_lte platform in YAML - device tracker entity registry support - refactor for easier addition of more features - internal code cleanups --- .../huawei_lte/.translations/en.json | 37 ++ .../components/huawei_lte/__init__.py | 335 +++++++++++++----- .../components/huawei_lte/config_flow.py | 197 ++++++++++ homeassistant/components/huawei_lte/const.py | 13 + .../components/huawei_lte/device_tracker.py | 114 +++--- .../components/huawei_lte/manifest.json | 4 +- homeassistant/components/huawei_lte/notify.py | 48 ++- homeassistant/components/huawei_lte/sensor.py | 149 ++++---- .../components/huawei_lte/strings.json | 37 ++ homeassistant/generated/config_flows.py | 1 + homeassistant/helpers/event.py | 10 +- requirements_all.txt | 3 + requirements_test_all.txt | 3 + script/gen_requirements_all.py | 1 + .../components/huawei_lte/test_config_flow.py | 148 ++++++++ tests/components/huawei_lte/test_init.py | 48 --- 16 files changed, 877 insertions(+), 271 deletions(-) create mode 100644 homeassistant/components/huawei_lte/.translations/en.json create mode 100644 homeassistant/components/huawei_lte/config_flow.py create mode 100644 homeassistant/components/huawei_lte/strings.json create mode 100644 tests/components/huawei_lte/test_config_flow.py delete mode 100644 tests/components/huawei_lte/test_init.py diff --git a/homeassistant/components/huawei_lte/.translations/en.json b/homeassistant/components/huawei_lte/.translations/en.json new file mode 100644 index 00000000000000..389a1be7b49ba3 --- /dev/null +++ b/homeassistant/components/huawei_lte/.translations/en.json @@ -0,0 +1,37 @@ +{ + "config": { + "abort": { + "already_configured": "This device is already configured" + }, + "error": { + "connection_failed": "Connection failed", + "incorrect_password": "Incorrect password", + "incorrect_username": "Incorrect username", + "incorrect_username_or_password": "Incorrect username or password", + "invalid_url": "Invalid URL", + "login_attempts_exceeded": "Maximum login attempts exceeded, please try again later", + "response_error": "Unknown error from device" + }, + "step": { + "user": { + "data": { + "password": "Password", + "url": "URL", + "username": "User name" + }, + "description": "Enter device access details.", + "title": "Configure Huawei LTE" + } + }, + "title": "Huawei LTE" + }, + "options": { + "step": { + "init": { + "data": { + "recipient": "SMS notification recipients" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/huawei_lte/__init__.py b/homeassistant/components/huawei_lte/__init__.py index f09788b7220e6f..4f3d9987024e41 100644 --- a/homeassistant/components/huawei_lte/__init__.py +++ b/homeassistant/components/huawei_lte/__init__.py @@ -1,30 +1,44 @@ """Support for Huawei LTE routers.""" +from collections import defaultdict from datetime import timedelta -from functools import reduce from urllib.parse import urlparse import ipaddress import logging -import operator -from typing import Any, Callable +from typing import Any, Callable, Dict, Set import voluptuous as vol import attr from getmac import get_mac_address from huawei_lte_api.AuthorizedConnection import AuthorizedConnection from huawei_lte_api.Client import Client -from huawei_lte_api.exceptions import ResponseErrorNotSupportedException +from huawei_lte_api.exceptions import ( + ResponseErrorLoginRequiredException, + ResponseErrorNotSupportedException, +) +from url_normalize import url_normalize +from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER_DOMAIN +from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.config_entries import ConfigEntry, SOURCE_IMPORT from homeassistant.const import ( + CONF_PASSWORD, + CONF_RECIPIENT, CONF_URL, CONF_USERNAME, - CONF_PASSWORD, EVENT_HOMEASSISTANT_STOP, ) -from homeassistant.helpers import config_validation as cv -from homeassistant.util import Throttle +from homeassistant.helpers import config_validation as cv, discovery +from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.event import track_time_interval +from homeassistant.helpers.typing import HomeAssistantType from .const import ( + ALL_KEYS, + DEFAULT_DEVICE_NAME, DOMAIN, + KEY_DEVICE_BASIC_INFORMATION, KEY_DEVICE_INFORMATION, KEY_DEVICE_SIGNAL, KEY_MONITORING_TRAFFIC_STATISTICS, @@ -38,7 +52,22 @@ # https://github.com/quandyfactory/dicttoxml/issues/60 logging.getLogger("dicttoxml").setLevel(logging.WARNING) -MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=10) +DEFAULT_NAME_TEMPLATE = "Huawei {} {}" + +UPDATE_SIGNAL = f"{DOMAIN}_update" + +SCAN_INTERVAL = timedelta(seconds=10) + +NOTIFY_SCHEMA = vol.Any( + None, + vol.Schema( + { + vol.Optional(CONF_RECIPIENT): vol.Any( + None, vol.All(cv.ensure_list, [cv.string]) + ) + } + ), +) CONFIG_SCHEMA = vol.Schema( { @@ -50,6 +79,7 @@ vol.Required(CONF_URL): cv.url, vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(NOTIFY_DOMAIN): NOTIFY_SCHEMA, } ) ], @@ -60,97 +90,156 @@ @attr.s -class RouterData: +class Router: """Class for router state.""" - client = attr.ib() - mac = attr.ib() - device_information = attr.ib(init=False, factory=dict) - device_signal = attr.ib(init=False, factory=dict) - monitoring_traffic_statistics = attr.ib(init=False, factory=dict) - wlan_host_list = attr.ib(init=False, factory=dict) - - _subscriptions = attr.ib(init=False, factory=set) - - def __getitem__(self, path: str): - """ - Get value corresponding to a dotted path. - - The first path component designates a member of this class - such as device_information, device_signal etc, and the remaining - path points to a value in the member's data structure. - """ - root, *rest = path.split(".") - try: - data = getattr(self, root) - except AttributeError as err: - raise KeyError from err - return reduce(operator.getitem, rest, data) - - def subscribe(self, path: str) -> None: - """Subscribe to given router data entries.""" - self._subscriptions.add(path.split(".")[0]) + hass: HomeAssistantType = attr.ib() + client: Client = attr.ib() + url: str = attr.ib() + mac: str = attr.ib() + + data: Dict[str, Any] = attr.ib(init=False, factory=dict) + subscriptions: Dict[str, Set[str]] = attr.ib( + init=False, default=defaultdict(set, ((x, {"init"}) for x in ALL_KEYS)) + ) + + @property + def device_name(self) -> str: + """Get router device name.""" + for key, item in ( + (KEY_DEVICE_BASIC_INFORMATION, "devicename"), + (KEY_DEVICE_INFORMATION, "DeviceName"), + ): + try: + return self.data[key][item] + except (KeyError, TypeError): + pass + return DEFAULT_DEVICE_NAME - def unsubscribe(self, path: str) -> None: - """Unsubscribe from given router data entries.""" - self._subscriptions.discard(path.split(".")[0]) - - @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self) -> None: - """Call API to update data.""" - self._update() - - def _update(self) -> None: + """Update router data.""" debugging = _LOGGER.isEnabledFor(logging.DEBUG) - def get_data(path: str, func: Callable[[None], Any]) -> None: - if debugging or path in self._subscriptions: - try: - setattr(self, path, func()) - except ResponseErrorNotSupportedException: - _LOGGER.warning("%s not supported by device", path) - self._subscriptions.discard(path) - finally: - _LOGGER.debug("%s=%s", path, getattr(self, path)) + def get_data(key: str, func: Callable[[None], Any]) -> None: + if debugging or not self.subscriptions[key]: + return + _LOGGER.debug("Getting %s for subscribers %s", key, self.subscriptions[key]) + try: + self.data[key] = func() + except ResponseErrorNotSupportedException: + _LOGGER.info( + "%s not supported by device, excluding from future updates", key + ) + self.subscriptions.pop(key) + finally: + _LOGGER.debug("%s=%s", key, self.data[key]) get_data(KEY_DEVICE_INFORMATION, self.client.device.information) + if self.data.get(KEY_DEVICE_INFORMATION): + # Full information includes everything in basic + self.subscriptions.pop(KEY_DEVICE_BASIC_INFORMATION, None) + get_data(KEY_DEVICE_BASIC_INFORMATION, self.client.device.basic_information) get_data(KEY_DEVICE_SIGNAL, self.client.device.signal) get_data( KEY_MONITORING_TRAFFIC_STATISTICS, self.client.monitoring.traffic_statistics ) get_data(KEY_WLAN_HOST_LIST, self.client.wlan.host_list) + dispatcher_send(self.hass, UPDATE_SIGNAL, self.url) + + def cleanup(self, *_) -> None: + """Clean up resources.""" + try: + self.client.user.logout() + except ResponseErrorNotSupportedException: + _LOGGER.debug("Logout not supported by device", exc_info=True) + except ResponseErrorLoginRequiredException: + _LOGGER.debug("Logout not supported when not logged in", exc_info=True) + except Exception: # pylint: disable=broad-except + _LOGGER.warning("Logout error", exc_info=True) + @attr.s class HuaweiLteData: """Shared state.""" - data = attr.ib(init=False, factory=dict) + hass_config: dict = attr.ib() + # Our YAML config, keyed by router URL + config: Dict[str, Dict[str, Any]] = attr.ib() + routers: Dict[str, Router] = attr.ib(init=False, factory=dict) - def get_data(self, config): - """Get the requested or the only data value.""" - if CONF_URL in config: - return self.data.get(config[CONF_URL]) - if len(self.data) == 1: - return next(iter(self.data.values())) - return None +async def async_setup_entry(hass: HomeAssistantType, config_entry: ConfigEntry) -> bool: + """Set up Huawei LTE component from config entry.""" + await hass.async_add_executor_job(_setup_lte, hass, config_entry) + return True -def setup(hass, config) -> bool: +async def async_unload_entry( + hass: HomeAssistantType, config_entry: ConfigEntry +) -> bool: + """Unload config entry.""" + router = hass.data[DOMAIN].routers.pop(config_entry.data[CONF_URL]) + await hass.async_add_executor_job(router.cleanup) + return True + + +async def async_setup(hass: HomeAssistantType, config) -> bool: """Set up Huawei LTE component.""" + + # Arrange our YAML config to dict with normalized URLs as keys + domain_config = {} if DOMAIN not in hass.data: - hass.data[DOMAIN] = HuaweiLteData() - for conf in config.get(DOMAIN, []): - _setup_lte(hass, conf) + hass.data[DOMAIN] = HuaweiLteData(hass_config=config, config=domain_config) + for router_config in config.get(DOMAIN, []): + domain_config[url_normalize(router_config.pop(CONF_URL))] = router_config + + for url, router_config in domain_config.items(): + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_URL: url, + CONF_USERNAME: router_config[CONF_USERNAME], + CONF_PASSWORD: router_config[CONF_PASSWORD], + }, + ) + ) + return True -def _setup_lte(hass, lte_config) -> None: +def _setup_lte(hass: HomeAssistantType, config_entry: ConfigEntry) -> None: """Set up Huawei LTE router.""" - url = lte_config[CONF_URL] - username = lte_config[CONF_USERNAME] - password = lte_config[CONF_PASSWORD] + url = config_entry.data[CONF_URL] + + # Override settings from YAML config, but only if they're changed in it + # Old values are stored as *_from_yaml in the config entry + yaml_config = hass.data[DOMAIN].config.get(url) + if yaml_config: + # Config values + new_data = {} + for key in CONF_USERNAME, CONF_PASSWORD: + value = yaml_config[key] + if value != config_entry.data.get(f"{key}_from_yaml"): + new_data[f"{key}_from_yaml"] = value + new_data[key] = value + # Options + new_options = {} + yaml_recipient = yaml_config.get(NOTIFY_DOMAIN, {}).get(CONF_RECIPIENT) + if yaml_recipient is not None and yaml_recipient != config_entry.options.get( + f"{CONF_RECIPIENT}_from_yaml" + ): + new_options[f"{CONF_RECIPIENT}_from_yaml"] = yaml_recipient + new_options[CONF_RECIPIENT] = yaml_recipient + # Update entry if overrides were found + if new_data or new_options: + hass.config_entries.async_update_entry( + config_entry, + data={**config_entry.data, **new_data}, + options={**config_entry.options, **new_options}, + ) # Get MAC address for use in unique ids. Being able to use something # from the API would be nice, but all of that seems to be available only @@ -166,17 +255,103 @@ def _setup_lte(hass, lte_config) -> None: mode = "hostname" mac = get_mac_address(**{mode: host}) + username = config_entry.data[CONF_USERNAME] + password = config_entry.data[CONF_PASSWORD] connection = AuthorizedConnection(url, username=username, password=password) - client = Client(connection) - data = RouterData(client, mac) - hass.data[DOMAIN].data[url] = data + # Set up router and store reference to it + router = Router(hass, Client(connection), url, mac) + hass.data[DOMAIN].routers[url] = router - def cleanup(event): - """Clean up resources.""" - try: - client.user.logout() - except ResponseErrorNotSupportedException as ex: - _LOGGER.debug("Logout not supported by device", exc_info=ex) + # Do initial data update + router.update() + + # Clear all subscriptions, enabled entities will push back theirs + router.subscriptions.clear() + + # Forward config entry setup to platforms + for domain in (DEVICE_TRACKER_DOMAIN, SENSOR_DOMAIN): + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(config_entry, domain) + ) + # Notify doesn't support config entry setup yet, load with discovery for now + discovery.load_platform( + hass, + NOTIFY_DOMAIN, + DOMAIN, + {CONF_URL: url, CONF_RECIPIENT: config_entry.options.get(CONF_RECIPIENT)}, + hass.data[DOMAIN].hass_config, + ) + + def _update_router(*_: Any) -> None: + """ + Update router data. + + Separate passthrough function because lambdas don't work with track_time_interval. + """ + router.update() + + # Set up periodic update + track_time_interval(hass, _update_router, SCAN_INTERVAL) + + # Clean up at end + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, router.cleanup) + + +@attr.s +class HuaweiLteBaseEntity(Entity): + """Huawei LTE entity base class.""" + + router: Router = attr.ib() + + _available: bool = attr.ib(init=False, default=True) + _disconnect_dispatcher: Callable = attr.ib(init=False) + + @property + def _entity_name(self) -> str: + raise NotImplementedError + + @property + def _device_unique_id(self) -> str: + """Return unique ID for entity within a router.""" + raise NotImplementedError + + @property + def unique_id(self) -> str: + """Return unique ID for entity.""" + return f"{self.router.mac}-{self._device_unique_id}" + + @property + def name(self) -> str: + """Return entity name.""" + return DEFAULT_NAME_TEMPLATE.format(self.router.device_name, self._entity_name) + + @property + def available(self) -> bool: + """Return whether the entity is available.""" + return self._available + + @property + def should_poll(self) -> bool: + """Huawei LTE entities report their state without polling.""" + return False + + async def async_update(self) -> None: + """Update state.""" + raise NotImplementedError + + async def async_added_to_hass(self) -> None: + """Connect to router update signal.""" + self._disconnect_dispatcher = async_dispatcher_connect( + self.router.hass, UPDATE_SIGNAL, self._async_maybe_update + ) + + async def _async_maybe_update(self, url: str) -> None: + """Update state if the update signal comes from our router.""" + if url == self.router.url: + await self.async_update() - hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, cleanup) + async def async_will_remove_from_hass(self) -> None: + """Disconnect from router update signal.""" + if self._disconnect_dispatcher: + self._disconnect_dispatcher() diff --git a/homeassistant/components/huawei_lte/config_flow.py b/homeassistant/components/huawei_lte/config_flow.py new file mode 100644 index 00000000000000..bbf18565d43ecb --- /dev/null +++ b/homeassistant/components/huawei_lte/config_flow.py @@ -0,0 +1,197 @@ +"""Config flow for the Huawei LTE platform.""" + +from collections import OrderedDict +import logging + +from huawei_lte_api.AuthorizedConnection import AuthorizedConnection +from huawei_lte_api.Client import Client +from huawei_lte_api.Connection import Connection +from huawei_lte_api.exceptions import ( + LoginErrorUsernameWrongException, + LoginErrorPasswordWrongException, + LoginErrorUsernamePasswordWrongException, + LoginErrorUsernamePasswordOverrunException, + ResponseErrorException, +) +from url_normalize import url_normalize +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_PASSWORD, CONF_RECIPIENT, CONF_URL, CONF_USERNAME +from homeassistant.core import callback +from .const import DEFAULT_DEVICE_NAME, DOMAIN + + +_LOGGER = logging.getLogger(__name__) + + +@config_entries.HANDLERS.register(DOMAIN) +class ConfigFlowHandler(config_entries.ConfigFlow): + """Handle Huawei LTE config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Get options flow.""" + return OptionsFlowHandler(config_entry) + + async def _async_show_user_form(self, user_input=None, errors=None): + if user_input is None: + user_input = {} + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + OrderedDict( + ( + ( + vol.Required( + CONF_URL, default=user_input.get(CONF_URL, "") + ), + str, + ), + ( + vol.Required( + CONF_USERNAME, default=user_input.get(CONF_USERNAME, "") + ), + str, + ), + ( + vol.Required( + CONF_PASSWORD, default=user_input.get(CONF_PASSWORD, "") + ), + str, + ), + ) + ) + ), + errors=errors or {}, + ) + + async def async_step_import(self, user_input=None): + """Handle import initiated config flow.""" + return await self.async_step_user(user_input) + + async def async_step_user(self, user_input=None): + """Handle user initiated config flow.""" + if user_input is None: + return await self._async_show_user_form() + + errors = {} + + # Normalize URL + user_input[CONF_URL] = url_normalize( + user_input[CONF_URL], default_scheme="http" + ) + if "://" not in user_input[CONF_URL]: + errors[CONF_URL] = "invalid_url" + return await self._async_show_user_form( + user_input=user_input, errors=errors + ) + + # See if we already have a router configured with this URL + existing_urls = [ # existing entries + url_normalize(entry.data[CONF_URL], default_scheme="http") + for entry in self._async_current_entries() + ] + if DOMAIN in self.hass.data: + existing_urls.extend( # yaml configs + url_normalize(x, default_scheme="http") + for x in self.hass.data[DOMAIN].routers + ) + if user_input[CONF_URL] in existing_urls: + return self.async_abort(reason="already_configured") + + conn = None + + def logout(): + if hasattr(conn, "user"): + try: + conn.user.logout() + except Exception: # pylint: disable=broad-except + _LOGGER.debug("Could not logout", exc_info=True) + + username = user_input.get(CONF_USERNAME) + password = user_input.get(CONF_PASSWORD) + try: + if username or password: + conn = AuthorizedConnection( + user_input[CONF_URL], username=username, password=password + ) + else: + try: + conn = AuthorizedConnection( + user_input[CONF_URL], username="", password="" + ) + user_input[CONF_USERNAME] = "" + user_input[CONF_PASSWORD] = "" + except ResponseErrorException: + _LOGGER.debug( + "Could not login with empty credentials, proceeding unauthenticated", + exc_info=True, + ) + conn = Connection(user_input[CONF_URL]) + del user_input[CONF_USERNAME] + del user_input[CONF_PASSWORD] + except LoginErrorUsernameWrongException: + errors[CONF_USERNAME] = "incorrect_username" + except LoginErrorPasswordWrongException: + errors[CONF_PASSWORD] = "incorrect_password" + except LoginErrorUsernamePasswordWrongException: + errors[CONF_USERNAME] = "incorrect_username_or_password" + except LoginErrorUsernamePasswordOverrunException: + errors["base"] = "login_attempts_exceeded" + except ResponseErrorException: + _LOGGER.warning("Response error", exc_info=True) + errors["base"] = "response_error" + except Exception: # pylint: disable=broad-except + _LOGGER.warning("Connection error", exc_info=True) + errors[CONF_URL] = "connection_failed" + if errors: + logout() + return await self._async_show_user_form( + user_input=user_input, errors=errors + ) + + title = None + client = Client(conn) + try: + info = client.device.basic_information() + title = info["devicename"] + except Exception: # pylint: disable=broad-except + _LOGGER.debug( + "Could not get device.basic_information[devicename]", exc_info=True + ) + if not title: + try: + info = client.device.information() + title = info["DeviceName"] + except Exception: # pylint: disable=broad-except + _LOGGER.debug( + "Could not get device.information[DeviceName]", exc_info=True + ) + logout() + + return self.async_create_entry( + title=title or DEFAULT_DEVICE_NAME, + data=user_input, + system_options={"disable_new_entities": True}, + ) + + +class OptionsFlowHandler(config_entries.OptionsFlow): + """Huawei LTE options flow.""" + + def __init__(self, config_entry: config_entries.ConfigEntry): + """Initialize options flow.""" + self.config_entry = config_entry + + async def async_step_init(self, user_input=None): + """Handle options flow.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + data_schema = vol.Schema(OrderedDict(((vol.Optional(CONF_RECIPIENT), str),))) + return self.async_show_form(step_id="init", data_schema=data_schema) diff --git a/homeassistant/components/huawei_lte/const.py b/homeassistant/components/huawei_lte/const.py index 0134417d5fe21c..527fcb3d72f16e 100644 --- a/homeassistant/components/huawei_lte/const.py +++ b/homeassistant/components/huawei_lte/const.py @@ -2,7 +2,20 @@ DOMAIN = "huawei_lte" +DEFAULT_DEVICE_NAME = "LTE" + +KEY_DEVICE_BASIC_INFORMATION = "device_basic_information" KEY_DEVICE_INFORMATION = "device_information" KEY_DEVICE_SIGNAL = "device_signal" KEY_MONITORING_TRAFFIC_STATISTICS = "monitoring_traffic_statistics" KEY_WLAN_HOST_LIST = "wlan_host_list" + +DEVICE_TRACKER_KEYS = {KEY_WLAN_HOST_LIST} + +SENSOR_KEYS = { + KEY_DEVICE_INFORMATION, + KEY_DEVICE_SIGNAL, + KEY_MONITORING_TRAFFIC_STATISTICS, +} + +ALL_KEYS = DEVICE_TRACKER_KEYS | SENSOR_KEYS diff --git a/homeassistant/components/huawei_lte/device_tracker.py b/homeassistant/components/huawei_lte/device_tracker.py index bad9253f4e7957..3ff98594028d0c 100644 --- a/homeassistant/components/huawei_lte/device_tracker.py +++ b/homeassistant/components/huawei_lte/device_tracker.py @@ -1,63 +1,95 @@ """Support for device tracking of Huawei LTE routers.""" +import asyncio import logging -from typing import Any, Dict, List, Optional +from typing import Any, Dict import attr -import voluptuous as vol -import homeassistant.helpers.config_validation as cv -from homeassistant.components.device_tracker import PLATFORM_SCHEMA, DeviceScanner +from homeassistant.components.device_tracker import SOURCE_TYPE_ROUTER +from homeassistant.components.device_tracker.config_entry import ScannerEntity from homeassistant.const import CONF_URL -from . import RouterData +from . import HuaweiLteBaseEntity from .const import DOMAIN, KEY_WLAN_HOST_LIST _LOGGER = logging.getLogger(__name__) -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({vol.Optional(CONF_URL): cv.url}) -HOSTS_PATH = f"{KEY_WLAN_HOST_LIST}.Hosts.Host" +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up from config entry.""" + router = hass.data[DOMAIN].routers[config_entry.data[CONF_URL]] + try: + hosts = router.data[KEY_WLAN_HOST_LIST]["Hosts"]["Host"] + except (KeyError, TypeError): + _LOGGER.debug("%s[%s][%s] not in data", KEY_WLAN_HOST_LIST, "Hosts", "Host") + return - -def get_scanner(hass, config): - """Get a Huawei LTE router scanner.""" - data = hass.data[DOMAIN].get_data(config) - data.subscribe(HOSTS_PATH) - return HuaweiLteScanner(data) + entities = [] + for host in (x for x in hosts if x.get("MacAddress")): + entities.append(HuaweiLteScannerEntity(router, host["MacAddress"])) + async_add_entities(entities) @attr.s -class HuaweiLteScanner(DeviceScanner): - """Huawei LTE router scanner.""" +class HuaweiLteScannerEntity(HuaweiLteBaseEntity, ScannerEntity): + """Huawei LTE router scanner entity.""" + + mac: str = attr.ib() + + _is_connected: bool = attr.ib(init=False, default=False) + _name: str = attr.ib(init=False, default="device") + _device_state_attributes: Dict[str, Any] = attr.ib(init=False, factory=dict) + + def __attrs_post_init__(self): + """Set up internal state on init.""" + asyncio.run_coroutine_threadsafe(self.async_update(), self.router.hass.loop) + + @property + def _entity_name(self) -> str: + return self._name + + @property + def _device_unique_id(self) -> str: + return self.mac - data = attr.ib(type=RouterData) + @property + def source_type(self) -> str: + """Return SOURCE_TYPE_ROUTER.""" + return SOURCE_TYPE_ROUTER - _hosts = attr.ib(init=False, factory=dict) + @property + def is_connected(self) -> bool: + """Get whether the entity is connected.""" + return self._is_connected - def scan_devices(self) -> List[str]: - """Scan for devices.""" - self.data.update() + @property + def device_state_attributes(self) -> Dict[str, Any]: + """Get additional attributes related to entity state.""" + return self._device_state_attributes + + async def async_update(self) -> None: + """Update state.""" try: - self._hosts = { - x["MacAddress"]: x for x in self.data[HOSTS_PATH] if x.get("MacAddress") - } + hosts = self.router.data[KEY_WLAN_HOST_LIST]["Hosts"]["Host"] except KeyError: - _LOGGER.debug("%s not in data", HOSTS_PATH) - return list(self._hosts) - - def get_device_name(self, device: str) -> Optional[str]: - """Get name for a device.""" - host = self._hosts.get(device) - return host.get("HostName") or None if host else None - - def get_extra_attributes(self, device: str) -> Dict[str, Any]: - """ - Get extra attributes of a device. - - Some known extra attributes that may be returned in the dict - include MacAddress (MAC address), ID (client ID), IpAddress - (IP address), AssociatedSsid (associated SSID), AssociatedTime - (associated time in seconds), and HostName (host name). - """ - return self._hosts.get(device) or {} + _LOGGER.debug("%s[Hosts][Host] not in data", self.key) + self._available = False + return + self._available = True + + host = next((x for x in hosts if x.get("MacAddress") == self.mac), None) + self._is_connected = host is not None + if self._is_connected: + self._name = host.get("HostName", self.mac) + self._device_state_attributes = { + k: v for k, v in host.items() if k not in ("MacAddress", "HostName") + } + + +def get_scanner(*args, **kwargs): + """Old no longer used way to set up Huawei LTE device tracker.""" + _LOGGER.warning( + "Loading and configuring as a platform is no longer supported or " + "required, convert to enabling/disabling available entities" + ) diff --git a/homeassistant/components/huawei_lte/manifest.json b/homeassistant/components/huawei_lte/manifest.json index 5d559cc60c5c3e..42fc085458fdc5 100644 --- a/homeassistant/components/huawei_lte/manifest.json +++ b/homeassistant/components/huawei_lte/manifest.json @@ -1,10 +1,12 @@ { "domain": "huawei_lte", "name": "Huawei LTE", + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/huawei_lte", "requirements": [ "getmac==0.8.1", - "huawei-lte-api==1.3.0" + "huawei-lte-api==1.3.0", + "url-normalize==1.4.1" ], "dependencies": [], "codeowners": [ diff --git a/homeassistant/components/huawei_lte/notify.py b/homeassistant/components/huawei_lte/notify.py index e882509c04c4fb..4b5a63756b5eb6 100644 --- a/homeassistant/components/huawei_lte/notify.py +++ b/homeassistant/components/huawei_lte/notify.py @@ -1,58 +1,54 @@ """Support for Huawei LTE router notifications.""" import logging +from typing import Any, List -import voluptuous as vol import attr +from huawei_lte_api.exceptions import ResponseErrorException -from homeassistant.components.notify import ( - BaseNotificationService, - ATTR_TARGET, - PLATFORM_SCHEMA, -) +from homeassistant.components.notify import BaseNotificationService, ATTR_TARGET from homeassistant.const import CONF_RECIPIENT, CONF_URL -import homeassistant.helpers.config_validation as cv +from . import Router from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Optional(CONF_URL): cv.url, - vol.Required(CONF_RECIPIENT): vol.All(cv.ensure_list, [cv.string]), - } -) - async def async_get_service(hass, config, discovery_info=None): """Get the notification service.""" - return HuaweiLteSmsNotificationService(hass, config) + if discovery_info is None: + _LOGGER.warning( + "Loading as a platform is no longer supported, convert to use " + "config entries or the huawei_lte component" + ) + return None + + router = hass.data[DOMAIN].routers[discovery_info[CONF_URL]] + default_targets = discovery_info[CONF_RECIPIENT] or [] + + return HuaweiLteSmsNotificationService(router, default_targets) @attr.s class HuaweiLteSmsNotificationService(BaseNotificationService): """Huawei LTE router SMS notification service.""" - hass = attr.ib() - config = attr.ib() + router: Router = attr.ib() + default_targets: List[str] = attr.ib() - def send_message(self, message="", **kwargs): + def send_message(self, message: str = "", **kwargs: Any) -> None: """Send message to target numbers.""" - from huawei_lte_api.exceptions import ResponseErrorException - targets = kwargs.get(ATTR_TARGET, self.config.get(CONF_RECIPIENT)) + targets = kwargs.get(ATTR_TARGET, self.default_targets) if not targets or not message: return - data = self.hass.data[DOMAIN].get_data(self.config) - if not data: - _LOGGER.error("Router not available") - return - try: - resp = data.client.sms.send_sms(phone_numbers=targets, message=message) + resp = self.router.client.sms.send_sms( + phone_numbers=targets, message=message + ) _LOGGER.debug("Sent to %s: %s", targets, resp) except ResponseErrorException as ex: _LOGGER.error("Could not send to %s: %s", targets, ex) diff --git a/homeassistant/components/huawei_lte/sensor.py b/homeassistant/components/huawei_lte/sensor.py index cb8f5fb5766aab..beadefd5220f2d 100644 --- a/homeassistant/components/huawei_lte/sensor.py +++ b/homeassistant/components/huawei_lte/sensor.py @@ -5,18 +5,12 @@ from typing import Optional import attr -import voluptuous as vol -from homeassistant.const import CONF_URL, CONF_MONITORED_CONDITIONS, STATE_UNKNOWN -from homeassistant.components.sensor import ( - PLATFORM_SCHEMA, - DEVICE_CLASS_SIGNAL_STRENGTH, -) +from homeassistant.const import CONF_URL, STATE_UNKNOWN +from homeassistant.components.sensor import DEVICE_CLASS_SIGNAL_STRENGTH from homeassistant.helpers import entity_registry -from homeassistant.helpers.entity import Entity -import homeassistant.helpers.config_validation as cv -from . import RouterData +from . import HuaweiLteBaseEntity from .const import ( DOMAIN, KEY_DEVICE_INFORMATION, @@ -27,34 +21,27 @@ _LOGGER = logging.getLogger(__name__) -DEFAULT_NAME_TEMPLATE = "Huawei {} {}" -DEFAULT_DEVICE_NAME = "LTE" - -DEFAULT_SENSORS = [ - f"{KEY_DEVICE_INFORMATION}.WanIPAddress", - f"{KEY_DEVICE_SIGNAL}.rsrq", - f"{KEY_DEVICE_SIGNAL}.rsrp", - f"{KEY_DEVICE_SIGNAL}.rssi", - f"{KEY_DEVICE_SIGNAL}.sinr", -] SENSOR_META = { - f"{KEY_DEVICE_INFORMATION}.SoftwareVersion": dict(name="Software version"), - f"{KEY_DEVICE_INFORMATION}.WanIPAddress": dict( + KEY_DEVICE_INFORMATION: dict( + include=re.compile(r"^WanIP.*Address$", re.IGNORECASE) + ), + (KEY_DEVICE_INFORMATION, "SoftwareVersion"): dict(name="Software version"), + (KEY_DEVICE_INFORMATION, "WanIPAddress"): dict( name="WAN IP address", icon="mdi:ip" ), - f"{KEY_DEVICE_INFORMATION}.WanIPv6Address": dict( + (KEY_DEVICE_INFORMATION, "WanIPv6Address"): dict( name="WAN IPv6 address", icon="mdi:ip" ), - f"{KEY_DEVICE_SIGNAL}.band": dict(name="Band"), - f"{KEY_DEVICE_SIGNAL}.cell_id": dict(name="Cell ID"), - f"{KEY_DEVICE_SIGNAL}.lac": dict(name="LAC"), - f"{KEY_DEVICE_SIGNAL}.mode": dict( + (KEY_DEVICE_SIGNAL, "band"): dict(name="Band"), + (KEY_DEVICE_SIGNAL, "cell_id"): dict(name="Cell ID"), + (KEY_DEVICE_SIGNAL, "lac"): dict(name="LAC"), + (KEY_DEVICE_SIGNAL, "mode"): dict( name="Mode", formatter=lambda x: ({"0": "2G", "2": "3G", "7": "4G"}.get(x, "Unknown"), None), ), - f"{KEY_DEVICE_SIGNAL}.pci": dict(name="PCI"), - f"{KEY_DEVICE_SIGNAL}.rsrq": dict( + (KEY_DEVICE_SIGNAL, "pci"): dict(name="PCI"), + (KEY_DEVICE_SIGNAL, "rsrq"): dict( name="RSRQ", device_class=DEVICE_CLASS_SIGNAL_STRENGTH, # http://www.lte-anbieter.info/technik/rsrq.php @@ -66,7 +53,7 @@ and "mdi:signal-cellular-2" or "mdi:signal-cellular-3", ), - f"{KEY_DEVICE_SIGNAL}.rsrp": dict( + (KEY_DEVICE_SIGNAL, "rsrp"): dict( name="RSRP", device_class=DEVICE_CLASS_SIGNAL_STRENGTH, # http://www.lte-anbieter.info/technik/rsrp.php @@ -78,7 +65,7 @@ and "mdi:signal-cellular-2" or "mdi:signal-cellular-3", ), - f"{KEY_DEVICE_SIGNAL}.rssi": dict( + (KEY_DEVICE_SIGNAL, "rssi"): dict( name="RSSI", device_class=DEVICE_CLASS_SIGNAL_STRENGTH, # https://eyesaas.com/wi-fi-signal-strength/ @@ -90,7 +77,7 @@ and "mdi:signal-cellular-2" or "mdi:signal-cellular-3", ), - f"{KEY_DEVICE_SIGNAL}.sinr": dict( + (KEY_DEVICE_SIGNAL, "sinr"): dict( name="SINR", device_class=DEVICE_CLASS_SIGNAL_STRENGTH, # http://www.lte-anbieter.info/technik/sinr.php @@ -102,27 +89,36 @@ and "mdi:signal-cellular-2" or "mdi:signal-cellular-3", ), + KEY_MONITORING_TRAFFIC_STATISTICS: dict( + exclude=re.compile(r"^showtraffic$", re.IGNORECASE) + ), } -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Optional(CONF_URL): cv.url, - vol.Optional( - CONF_MONITORED_CONDITIONS, default=DEFAULT_SENSORS - ): cv.ensure_list, - } -) - -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up Huawei LTE sensor devices.""" - data = hass.data[DOMAIN].get_data(config) +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up from config entry.""" + router = hass.data[DOMAIN].routers[config_entry.data[CONF_URL]] sensors = [] - for path in config.get(CONF_MONITORED_CONDITIONS): - if path == "traffic_statistics": # backwards compatibility - path = KEY_MONITORING_TRAFFIC_STATISTICS - data.subscribe(path) - sensors.append(HuaweiLteSensor(data, path, SENSOR_META.get(path, {}))) + for key in ( + KEY_DEVICE_INFORMATION, + KEY_DEVICE_SIGNAL, + KEY_MONITORING_TRAFFIC_STATISTICS, + ): + items = router.data.get(key) + if not items: + continue + key_meta = SENSOR_META.get(key) + if key_meta: + include = key_meta.get("include") + if include: + items = filter(include.search, items) + exclude = key_meta.get("exclude") + if exclude: + items = [x for x in items if not exclude.search(x)] + for item in items: + sensors.append( + HuaweiLteSensor(router, key, item, SENSOR_META.get((key, item), {})) + ) # Pre-0.97 unique id migration. Old ones used the device serial number # (see comments in HuaweiLteData._setup_lte for more info), as well as @@ -134,7 +130,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= if ent.platform != DOMAIN: continue for sensor in sensors: - oldsuf = ".".join(sensor.path) + oldsuf = ".".join(f"{sensor.key}.{sensor.item}") if ent.unique_id.endswith(f"_{oldsuf}"): entreg.async_update_entity(entid, new_unique_id=sensor.unique_id) _LOGGER.debug( @@ -162,30 +158,33 @@ def format_default(value): @attr.s -class HuaweiLteSensor(Entity): +class HuaweiLteSensor(HuaweiLteBaseEntity): """Huawei LTE sensor entity.""" - data = attr.ib(type=RouterData) - path = attr.ib(type=str) - meta = attr.ib(type=dict) + key: str = attr.ib() + item: str = attr.ib() + meta: dict = attr.ib() _state = attr.ib(init=False, default=STATE_UNKNOWN) - _unit = attr.ib(init=False, type=str) + _unit: str = attr.ib(init=False) + + async def async_added_to_hass(self): + """Subscribe to needed data on add.""" + await super().async_added_to_hass() + self.router.subscriptions[self.key].add(self.item) + + async def async_will_remove_from_hass(self): + """Unsubscribe from needed data on remove.""" + await super().async_will_remove_from_hass() + self.router.subscriptions[self.key].remove(self.item) @property - def unique_id(self) -> str: - """Return unique ID for sensor.""" - return f"{self.data.mac}-{self.path}" + def _entity_name(self) -> str: + return self.meta.get("name", self.item) @property - def name(self) -> str: - """Return sensor name.""" - try: - dname = self.data[f"{KEY_DEVICE_INFORMATION}.DeviceName"] - except KeyError: - dname = None - vname = self.meta.get("name", self.path) - return DEFAULT_NAME_TEMPLATE.format(dname or DEFAULT_DEVICE_NAME, vname) + def _device_unique_id(self) -> str: + return f"{self.key}.{self.item}" @property def state(self): @@ -210,18 +209,26 @@ def icon(self): return icon(self.state) return icon - def update(self): + async def async_update(self): """Update state.""" - self.data.update() - try: - value = self.data[self.path] + value = self.router.data[self.key][self.item] except KeyError: - _LOGGER.debug("%s not in data", self.path) - value = None + _LOGGER.debug("%s[%s] not in data", self.key, self.item) + self._available = False + return + self._available = True formatter = self.meta.get("formatter") if not callable(formatter): formatter = format_default self._state, self._unit = formatter(value) + + +async def async_setup_platform(*args, **kwargs): + """Old no longer used way to set up Huawei LTE sensors.""" + _LOGGER.warning( + "Loading and configuring as a platform is no longer supported or " + "required, convert to enabling/disabling available entities" + ) diff --git a/homeassistant/components/huawei_lte/strings.json b/homeassistant/components/huawei_lte/strings.json new file mode 100644 index 00000000000000..389a1be7b49ba3 --- /dev/null +++ b/homeassistant/components/huawei_lte/strings.json @@ -0,0 +1,37 @@ +{ + "config": { + "abort": { + "already_configured": "This device is already configured" + }, + "error": { + "connection_failed": "Connection failed", + "incorrect_password": "Incorrect password", + "incorrect_username": "Incorrect username", + "incorrect_username_or_password": "Incorrect username or password", + "invalid_url": "Invalid URL", + "login_attempts_exceeded": "Maximum login attempts exceeded, please try again later", + "response_error": "Unknown error from device" + }, + "step": { + "user": { + "data": { + "password": "Password", + "url": "URL", + "username": "User name" + }, + "description": "Enter device access details.", + "title": "Configure Huawei LTE" + } + }, + "title": "Huawei LTE" + }, + "options": { + "step": { + "init": { + "data": { + "recipient": "SMS notification recipients" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 1eb08709741394..d84eb57a879659 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -26,6 +26,7 @@ "heos", "homekit_controller", "homematicip_cloud", + "huawei_lte", "hue", "iaqualink", "ifttt", diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index b7707b844d417a..e3e585f716cb70 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -252,16 +252,18 @@ def async_call_later(hass, delay, action): @callback @bind_hass -def async_track_time_interval(hass, action, interval): +def async_track_time_interval( + hass: HomeAssistant, action: Callable[..., None], interval: timedelta +) -> CALLBACK_TYPE: """Add a listener that fires repetitively at every timedelta interval.""" remove = None - def next_interval(): + def next_interval() -> datetime: """Return the next interval.""" return dt_util.utcnow() + interval @callback - def interval_listener(now): + def interval_listener(now: datetime) -> None: """Handle elapsed intervals.""" nonlocal remove remove = async_track_point_in_utc_time(hass, interval_listener, next_interval()) @@ -269,7 +271,7 @@ def interval_listener(now): remove = async_track_point_in_utc_time(hass, interval_listener, next_interval()) - def remove_listener(): + def remove_listener() -> None: """Remove interval listener.""" remove() diff --git a/requirements_all.txt b/requirements_all.txt index 112304cd48ae61..4393dcd0e0778a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1920,6 +1920,9 @@ twilio==6.19.1 # homeassistant.components.upcloud upcloud-api==0.4.3 +# homeassistant.components.huawei_lte +url-normalize==1.4.1 + # homeassistant.components.uscis uscisstatus==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e8114352b04d0b..602b1ee0035ce1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -440,6 +440,9 @@ transmissionrpc==0.11 # homeassistant.components.twentemilieu twentemilieu==0.1.0 +# homeassistant.components.huawei_lte +url-normalize==1.4.1 + # homeassistant.components.uvc uvcclient==0.11.0 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 70c81c660256dc..94e66028fb6794 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -179,6 +179,7 @@ "toonapilib", "transmissionrpc", "twentemilieu", + "url-normalize", "uvcclient", "vsure", "vultr", diff --git a/tests/components/huawei_lte/test_config_flow.py b/tests/components/huawei_lte/test_config_flow.py new file mode 100644 index 00000000000000..0e5261e763bb9e --- /dev/null +++ b/tests/components/huawei_lte/test_config_flow.py @@ -0,0 +1,148 @@ +"""Tests for the Huawei LTE config flow.""" + +from huawei_lte_api.enums.client import ResponseCodeEnum +from huawei_lte_api.enums.user import LoginErrorEnum, LoginStateEnum, PasswordTypeEnum +from requests_mock import ANY +from requests.exceptions import ConnectionError +import pytest + +from homeassistant import data_entry_flow +from homeassistant.const import CONF_USERNAME, CONF_PASSWORD, CONF_URL +from homeassistant.components.huawei_lte.const import DOMAIN +from homeassistant.components.huawei_lte.config_flow import ConfigFlowHandler +from tests.common import MockConfigEntry + + +FIXTURE_USER_INPUT = { + CONF_URL: "http://192.168.1.1/", + CONF_USERNAME: "admin", + CONF_PASSWORD: "secret", +} + + +async def test_show_set_form(hass): + """Test that the setup form is served.""" + flow = ConfigFlowHandler() + flow.hass = hass + result = await flow.async_step_user(user_input=None) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + +async def test_urlize_plain_host(hass, requests_mock): + """Test that plain host or IP gets converted to a URL.""" + requests_mock.request(ANY, ANY, exc=ConnectionError()) + flow = ConfigFlowHandler() + flow.hass = hass + host = "192.168.100.1" + user_input = {**FIXTURE_USER_INPUT, CONF_URL: host} + result = await flow.async_step_user(user_input=user_input) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert user_input[CONF_URL] == f"http://{host}/" + + +async def test_already_configured(hass): + """Test we reject already configured devices.""" + MockConfigEntry( + domain=DOMAIN, data=FIXTURE_USER_INPUT, title="Already configured" + ).add_to_hass(hass) + + flow = ConfigFlowHandler() + flow.hass = hass + # Tweak URL a bit to check that doesn't fail duplicate detection + result = await flow.async_step_user( + user_input={ + **FIXTURE_USER_INPUT, + CONF_URL: FIXTURE_USER_INPUT[CONF_URL].replace("http", "HTTP"), + } + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_connection_error(hass, requests_mock): + """Test we show user form on connection error.""" + + requests_mock.request(ANY, ANY, exc=ConnectionError()) + flow = ConfigFlowHandler() + flow.hass = hass + result = await flow.async_step_user(user_input=FIXTURE_USER_INPUT) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["errors"] == {CONF_URL: "connection_failed"} + + +@pytest.fixture +def login_requests_mock(requests_mock): + """Set up a requests_mock with base mocks for login tests.""" + requests_mock.request( + ANY, FIXTURE_USER_INPUT[CONF_URL], text='' + ) + requests_mock.request( + ANY, + f"{FIXTURE_USER_INPUT[CONF_URL]}api/user/state-login", + text=( + f"{LoginStateEnum.LOGGED_OUT}" + f"{PasswordTypeEnum.SHA256}" + ), + ) + return requests_mock + + +async def _test_login_error(hass, req_mock, code, error_key, error_value): + req_mock.request( + ANY, + f"{FIXTURE_USER_INPUT[CONF_URL]}api/user/login", + text=f"{code}", + ) + flow = ConfigFlowHandler() + flow.hass = hass + result = await flow.async_step_user(user_input=FIXTURE_USER_INPUT) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["errors"] == {error_key: error_value} + + +async def test_incorrect_credentials(hass, login_requests_mock): + """Test we show user form on invalid credentials.""" + await _test_login_error( + hass, + login_requests_mock, + LoginErrorEnum.USERNAME_PWD_WRONG, + CONF_USERNAME, + "incorrect_username_or_password", + ) + + +async def test_response_error(hass, login_requests_mock): + """Test we show user form on generic response error.""" + await _test_login_error( + hass, + login_requests_mock, + ResponseCodeEnum.ERROR_SYSTEM_UNKNOWN, + "base", + "response_error", + ) + + +async def test_success(hass, login_requests_mock): + """Test successful flow provides entry creation data.""" + login_requests_mock.request( + ANY, + f"{FIXTURE_USER_INPUT[CONF_URL]}api/user/login", + text=f"OK", + ) + flow = ConfigFlowHandler() + flow.hass = hass + result = await flow.async_step_user(user_input=FIXTURE_USER_INPUT) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["data"][CONF_URL] == FIXTURE_USER_INPUT[CONF_URL] + assert result["data"][CONF_USERNAME] == FIXTURE_USER_INPUT[CONF_USERNAME] + assert result["data"][CONF_PASSWORD] == FIXTURE_USER_INPUT[CONF_PASSWORD] diff --git a/tests/components/huawei_lte/test_init.py b/tests/components/huawei_lte/test_init.py deleted file mode 100644 index e7323e1629e200..00000000000000 --- a/tests/components/huawei_lte/test_init.py +++ /dev/null @@ -1,48 +0,0 @@ -"""Huawei LTE component tests.""" -from unittest.mock import Mock - -import pytest - -from homeassistant.components import huawei_lte -from homeassistant.components.huawei_lte.const import KEY_DEVICE_INFORMATION - - -@pytest.fixture(autouse=True) -def routerdata(): - """Set up a router data for testing.""" - rd = huawei_lte.RouterData(Mock(), "de:ad:be:ef:00:00") - rd.device_information = {"SoftwareVersion": "1.0", "nested": {"foo": "bar"}} - return rd - - -async def test_routerdata_get_nonexistent_root(routerdata): - """Test that accessing a nonexistent root element raises KeyError.""" - with pytest.raises(KeyError): # NOT AttributeError - routerdata["nonexistent_root.foo"] - - -async def test_routerdata_get_nonexistent_leaf(routerdata): - """Test that accessing a nonexistent leaf element raises KeyError.""" - with pytest.raises(KeyError): - routerdata[f"{KEY_DEVICE_INFORMATION}.foo"] - - -async def test_routerdata_get_nonexistent_leaf_path(routerdata): - """Test that accessing a nonexistent long path raises KeyError.""" - with pytest.raises(KeyError): - routerdata[f"{KEY_DEVICE_INFORMATION}.long.path.foo"] - - -async def test_routerdata_get_simple(routerdata): - """Test that accessing a short, simple path works.""" - assert routerdata[f"{KEY_DEVICE_INFORMATION}.SoftwareVersion"] == "1.0" - - -async def test_routerdata_get_longer(routerdata): - """Test that accessing a longer path works.""" - assert routerdata[f"{KEY_DEVICE_INFORMATION}.nested.foo"] == "bar" - - -async def test_routerdata_get_dict(routerdata): - """Test that returning an intermediate dict works.""" - assert routerdata[f"{KEY_DEVICE_INFORMATION}.nested"] == {"foo": "bar"} From af286b74472b4441737f98e8852c3e7df1f56700 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Fri, 4 Oct 2019 20:13:58 +0300 Subject: [PATCH 02/34] Remove log level dependent subscription/data debug hack No longer needed, because pretty much all keys from supported categories are exposed as sensors. Closes https://github.com/home-assistant/home-assistant/issues/23819 --- homeassistant/components/huawei_lte/__init__.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/homeassistant/components/huawei_lte/__init__.py b/homeassistant/components/huawei_lte/__init__.py index 4f3d9987024e41..7ddc71c08eee0c 100644 --- a/homeassistant/components/huawei_lte/__init__.py +++ b/homeassistant/components/huawei_lte/__init__.py @@ -118,10 +118,9 @@ def device_name(self) -> str: def update(self) -> None: """Update router data.""" - debugging = _LOGGER.isEnabledFor(logging.DEBUG) def get_data(key: str, func: Callable[[None], Any]) -> None: - if debugging or not self.subscriptions[key]: + if not self.subscriptions[key]: return _LOGGER.debug("Getting %s for subscribers %s", key, self.subscriptions[key]) try: From 02036c3bd46241b005d17a00928e8e4cf6da3b04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Fri, 4 Oct 2019 20:26:13 +0300 Subject: [PATCH 03/34] Upgrade huawei-lte-api to 1.4.1 https://github.com/Salamek/huawei-lte-api/releases --- homeassistant/components/huawei_lte/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/huawei_lte/manifest.json b/homeassistant/components/huawei_lte/manifest.json index 42fc085458fdc5..1a4716e1c86105 100644 --- a/homeassistant/components/huawei_lte/manifest.json +++ b/homeassistant/components/huawei_lte/manifest.json @@ -5,7 +5,7 @@ "documentation": "https://www.home-assistant.io/integrations/huawei_lte", "requirements": [ "getmac==0.8.1", - "huawei-lte-api==1.3.0", + "huawei-lte-api==1.4.1", "url-normalize==1.4.1" ], "dependencies": [], diff --git a/requirements_all.txt b/requirements_all.txt index 4393dcd0e0778a..747baddfcaba0a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -662,7 +662,7 @@ horimote==0.4.1 httplib2==0.10.3 # homeassistant.components.huawei_lte -huawei-lte-api==1.3.0 +huawei-lte-api==1.4.1 # homeassistant.components.hydrawise hydrawiser==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 602b1ee0035ce1..8b1b721a586239 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -198,7 +198,7 @@ homematicip==0.10.12 httplib2==0.10.3 # homeassistant.components.huawei_lte -huawei-lte-api==1.3.0 +huawei-lte-api==1.4.1 # homeassistant.components.iaqualink iaqualink==0.2.9 From d66fd8fec7c0e8185d69e856c6eab5c2d7891755 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Fri, 4 Oct 2019 20:56:42 +0300 Subject: [PATCH 04/34] Add support for access without username and password --- .../huawei_lte/.translations/en.json | 2 +- .../components/huawei_lte/__init__.py | 45 +++++++++++++------ .../components/huawei_lte/config_flow.py | 4 +- .../components/huawei_lte/strings.json | 2 +- 4 files changed, 35 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/huawei_lte/.translations/en.json b/homeassistant/components/huawei_lte/.translations/en.json index 389a1be7b49ba3..e8b654b8eee4ae 100644 --- a/homeassistant/components/huawei_lte/.translations/en.json +++ b/homeassistant/components/huawei_lte/.translations/en.json @@ -19,7 +19,7 @@ "url": "URL", "username": "User name" }, - "description": "Enter device access details.", + "description": "Enter device access details. Specifying username and password is optional, but enables support for more integration features. On the other hand, use of an authorized connection may cause problems accessing the device web interface from outside Home Assistant while the integration is active, and the other way around.", "title": "Configure Huawei LTE" } }, diff --git a/homeassistant/components/huawei_lte/__init__.py b/homeassistant/components/huawei_lte/__init__.py index 7ddc71c08eee0c..4ea45977b6073a 100644 --- a/homeassistant/components/huawei_lte/__init__.py +++ b/homeassistant/components/huawei_lte/__init__.py @@ -12,6 +12,7 @@ from getmac import get_mac_address from huawei_lte_api.AuthorizedConnection import AuthorizedConnection from huawei_lte_api.Client import Client +from huawei_lte_api.Connection import Connection from huawei_lte_api.exceptions import ( ResponseErrorLoginRequiredException, ResponseErrorNotSupportedException, @@ -77,8 +78,8 @@ vol.Schema( { vol.Required(CONF_URL): cv.url, - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_USERNAME): cv.string, + vol.Optional(CONF_PASSWORD): cv.string, vol.Optional(NOTIFY_DOMAIN): NOTIFY_SCHEMA, } ) @@ -94,7 +95,7 @@ class Router: """Class for router state.""" hass: HomeAssistantType = attr.ib() - client: Client = attr.ib() + connection: Connection = attr.ib() url: str = attr.ib() mac: str = attr.ib() @@ -103,6 +104,10 @@ class Router: init=False, default=defaultdict(set, ((x, {"init"}) for x in ALL_KEYS)) ) + def __attrs_post_init__(self): + """Set up internal state on init.""" + self.client = Client(self.connection) + @property def device_name(self) -> str: """Get router device name.""" @@ -130,8 +135,13 @@ def get_data(key: str, func: Callable[[None], Any]) -> None: "%s not supported by device, excluding from future updates", key ) self.subscriptions.pop(key) + except ResponseErrorLoginRequiredException: + _LOGGER.info( + "%s requires authorization, excluding from future updates", key + ) + self.subscriptions.pop(key) finally: - _LOGGER.debug("%s=%s", key, self.data[key]) + _LOGGER.debug("%s=%s", key, self.data.get(key)) get_data(KEY_DEVICE_INFORMATION, self.client.device.information) if self.data.get(KEY_DEVICE_INFORMATION): @@ -148,6 +158,8 @@ def get_data(key: str, func: Callable[[None], Any]) -> None: def cleanup(self, *_) -> None: """Clean up resources.""" + if not isinstance(self.connection, AuthorizedConnection): + return try: self.client.user.logout() except ResponseErrorNotSupportedException: @@ -200,8 +212,8 @@ async def async_setup(hass: HomeAssistantType, config) -> bool: context={"source": SOURCE_IMPORT}, data={ CONF_URL: url, - CONF_USERNAME: router_config[CONF_USERNAME], - CONF_PASSWORD: router_config[CONF_PASSWORD], + CONF_USERNAME: router_config.get(CONF_USERNAME), + CONF_PASSWORD: router_config.get(CONF_PASSWORD), }, ) ) @@ -220,10 +232,11 @@ def _setup_lte(hass: HomeAssistantType, config_entry: ConfigEntry) -> None: # Config values new_data = {} for key in CONF_USERNAME, CONF_PASSWORD: - value = yaml_config[key] - if value != config_entry.data.get(f"{key}_from_yaml"): - new_data[f"{key}_from_yaml"] = value - new_data[key] = value + if key in yaml_config: + value = yaml_config[key] + if value != config_entry.data.get(f"{key}_from_yaml"): + new_data[f"{key}_from_yaml"] = value + new_data[key] = value # Options new_options = {} yaml_recipient = yaml_config.get(NOTIFY_DOMAIN, {}).get(CONF_RECIPIENT) @@ -254,12 +267,16 @@ def _setup_lte(hass: HomeAssistantType, config_entry: ConfigEntry) -> None: mode = "hostname" mac = get_mac_address(**{mode: host}) - username = config_entry.data[CONF_USERNAME] - password = config_entry.data[CONF_PASSWORD] - connection = AuthorizedConnection(url, username=username, password=password) + # Set up a connection: authorized one if username/pass specified (even if empty), unauthorized one otherwise + username = config_entry.data.get(CONF_USERNAME) + password = config_entry.data.get(CONF_PASSWORD) + if username or password: + connection = AuthorizedConnection(url, username=username, password=password) + else: + connection = Connection(url) # Set up router and store reference to it - router = Router(hass, Client(connection), url, mac) + router = Router(hass, connection, url, mac) hass.data[DOMAIN].routers[url] = router # Do initial data update diff --git a/homeassistant/components/huawei_lte/config_flow.py b/homeassistant/components/huawei_lte/config_flow.py index bbf18565d43ecb..ff8a06c8b24e06 100644 --- a/homeassistant/components/huawei_lte/config_flow.py +++ b/homeassistant/components/huawei_lte/config_flow.py @@ -53,13 +53,13 @@ async def _async_show_user_form(self, user_input=None, errors=None): str, ), ( - vol.Required( + vol.Optional( CONF_USERNAME, default=user_input.get(CONF_USERNAME, "") ), str, ), ( - vol.Required( + vol.Optional( CONF_PASSWORD, default=user_input.get(CONF_PASSWORD, "") ), str, diff --git a/homeassistant/components/huawei_lte/strings.json b/homeassistant/components/huawei_lte/strings.json index 389a1be7b49ba3..e8b654b8eee4ae 100644 --- a/homeassistant/components/huawei_lte/strings.json +++ b/homeassistant/components/huawei_lte/strings.json @@ -19,7 +19,7 @@ "url": "URL", "username": "User name" }, - "description": "Enter device access details.", + "description": "Enter device access details. Specifying username and password is optional, but enables support for more integration features. On the other hand, use of an authorized connection may cause problems accessing the device web interface from outside Home Assistant while the integration is active, and the other way around.", "title": "Configure Huawei LTE" } }, From c68b359d15d915d0a4994e7bcb49935d2ea6b4fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Mon, 7 Oct 2019 07:42:08 +0300 Subject: [PATCH 05/34] Use subclass init instead of config_entries.HANDLERS --- homeassistant/components/huawei_lte/config_flow.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/homeassistant/components/huawei_lte/config_flow.py b/homeassistant/components/huawei_lte/config_flow.py index ff8a06c8b24e06..e1dcfa90304c0f 100644 --- a/homeassistant/components/huawei_lte/config_flow.py +++ b/homeassistant/components/huawei_lte/config_flow.py @@ -25,8 +25,7 @@ _LOGGER = logging.getLogger(__name__) -@config_entries.HANDLERS.register(DOMAIN) -class ConfigFlowHandler(config_entries.ConfigFlow): +class ConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle Huawei LTE config flow.""" VERSION = 1 From d03847caf68a9418f3344947573aae807df9dff9 Mon Sep 17 00:00:00 2001 From: Robert Chmielowiec Date: Sun, 6 Oct 2019 23:28:29 +0200 Subject: [PATCH 06/34] Update huawei-lte-api to 1.4.3 (#27269) --- homeassistant/components/huawei_lte/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/huawei_lte/manifest.json b/homeassistant/components/huawei_lte/manifest.json index 1a4716e1c86105..7113577ff50b4e 100644 --- a/homeassistant/components/huawei_lte/manifest.json +++ b/homeassistant/components/huawei_lte/manifest.json @@ -5,7 +5,7 @@ "documentation": "https://www.home-assistant.io/integrations/huawei_lte", "requirements": [ "getmac==0.8.1", - "huawei-lte-api==1.4.1", + "huawei-lte-api==1.4.3", "url-normalize==1.4.1" ], "dependencies": [], diff --git a/requirements_all.txt b/requirements_all.txt index 747baddfcaba0a..6016ac210263df 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -662,7 +662,7 @@ horimote==0.4.1 httplib2==0.10.3 # homeassistant.components.huawei_lte -huawei-lte-api==1.4.1 +huawei-lte-api==1.4.3 # homeassistant.components.hydrawise hydrawiser==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8b1b721a586239..e6f11cc88e9f88 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -198,7 +198,7 @@ homematicip==0.10.12 httplib2==0.10.3 # homeassistant.components.huawei_lte -huawei-lte-api==1.4.1 +huawei-lte-api==1.4.3 # homeassistant.components.iaqualink iaqualink==0.2.9 From e1daf0de7f28d783ba0b8a2b1ad2e97b4ebc1aba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Mon, 7 Oct 2019 18:00:58 +0300 Subject: [PATCH 07/34] Convert device state attributes to snake_case --- .../components/huawei_lte/device_tracker.py | 21 ++++++++++++++++++- .../components/huawei_lte/manifest.json | 1 + requirements_all.txt | 1 + .../huawei_lte/test_device_tracker.py | 20 ++++++++++++++++++ 4 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 tests/components/huawei_lte/test_device_tracker.py diff --git a/homeassistant/components/huawei_lte/device_tracker.py b/homeassistant/components/huawei_lte/device_tracker.py index 3ff98594028d0c..4d365f27ffcae5 100644 --- a/homeassistant/components/huawei_lte/device_tracker.py +++ b/homeassistant/components/huawei_lte/device_tracker.py @@ -2,9 +2,11 @@ import asyncio import logging +import re from typing import Any, Dict import attr +from stringcase import snakecase from homeassistant.components.device_tracker import SOURCE_TYPE_ROUTER from homeassistant.components.device_tracker.config_entry import ScannerEntity @@ -31,6 +33,21 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async_add_entities(entities) +def _better_snakecase(s: str) -> str: + if s == s.upper(): + # All uppercase to all lowercase to get http for HTTP, not h_t_t_p + s = s.lower() + else: + # Three or more consecutive uppercase with middle part lowercased + # to get http_response for HTTPResponse, not h_t_t_p_response + s = re.sub( + r"([A-Z])([A-Z]+)([A-Z](?:[^A-Z]|$))", + lambda match: f"{match.group(1)}{match.group(2).lower()}{match.group(3)}", + s, + ) + return snakecase(s) + + @attr.s class HuaweiLteScannerEntity(HuaweiLteBaseEntity, ScannerEntity): """Huawei LTE router scanner entity.""" @@ -83,7 +100,9 @@ async def async_update(self) -> None: if self._is_connected: self._name = host.get("HostName", self.mac) self._device_state_attributes = { - k: v for k, v in host.items() if k not in ("MacAddress", "HostName") + _better_snakecase(k): v + for k, v in host.items() + if k not in ("MacAddress", "HostName") } diff --git a/homeassistant/components/huawei_lte/manifest.json b/homeassistant/components/huawei_lte/manifest.json index 7113577ff50b4e..b3c4442caa9a65 100644 --- a/homeassistant/components/huawei_lte/manifest.json +++ b/homeassistant/components/huawei_lte/manifest.json @@ -6,6 +6,7 @@ "requirements": [ "getmac==0.8.1", "huawei-lte-api==1.4.3", + "stringcase==1.2.0", "url-normalize==1.4.1" ], "dependencies": [], diff --git a/requirements_all.txt b/requirements_all.txt index 6016ac210263df..025db827907d2b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1837,6 +1837,7 @@ steamodd==4.21 # homeassistant.components.streamlabswater streamlabswater==1.0.1 +# homeassistant.components.huawei_lte # homeassistant.components.solaredge # homeassistant.components.thermoworks_smoke # homeassistant.components.traccar diff --git a/tests/components/huawei_lte/test_device_tracker.py b/tests/components/huawei_lte/test_device_tracker.py new file mode 100644 index 00000000000000..143fa5760e3335 --- /dev/null +++ b/tests/components/huawei_lte/test_device_tracker.py @@ -0,0 +1,20 @@ +"""Huawei LTE device tracker tests.""" + +import pytest + +from homeassistant.components.huawei_lte import device_tracker + + +@pytest.mark.parametrize( + ("value", "expected"), + ( + ("HTTP", "http"), + ("ID", "id"), + ("IPAddress", "ip_address"), + ("HTTPResponse", "http_response"), + ("foo_bar", "foo_bar"), + ), +) +def test_better_snakecase(value, expected): + """Test that better snakecase works better.""" + assert device_tracker._better_snakecase(value) == expected From 489fec4ab98fe3e1e8049090fd78fbde5f16534f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Mon, 7 Oct 2019 18:15:20 +0300 Subject: [PATCH 08/34] Simplify scanner entity initialization --- homeassistant/components/huawei_lte/device_tracker.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/homeassistant/components/huawei_lte/device_tracker.py b/homeassistant/components/huawei_lte/device_tracker.py index 4d365f27ffcae5..f15f1d79069d12 100644 --- a/homeassistant/components/huawei_lte/device_tracker.py +++ b/homeassistant/components/huawei_lte/device_tracker.py @@ -1,6 +1,5 @@ """Support for device tracking of Huawei LTE routers.""" -import asyncio import logging import re from typing import Any, Dict @@ -30,7 +29,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): entities = [] for host in (x for x in hosts if x.get("MacAddress")): entities.append(HuaweiLteScannerEntity(router, host["MacAddress"])) - async_add_entities(entities) + async_add_entities(entities, True) def _better_snakecase(s: str) -> str: @@ -58,10 +57,6 @@ class HuaweiLteScannerEntity(HuaweiLteBaseEntity, ScannerEntity): _name: str = attr.ib(init=False, default="device") _device_state_attributes: Dict[str, Any] = attr.ib(init=False, factory=dict) - def __attrs_post_init__(self): - """Set up internal state on init.""" - asyncio.run_coroutine_threadsafe(self.async_update(), self.router.hass.loop) - @property def _entity_name(self) -> str: return self._name From 04159e087adea5a17d37525f65a7961c5827bd96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Mon, 7 Oct 2019 18:23:29 +0300 Subject: [PATCH 09/34] Remove not needed hass reference from Router --- homeassistant/components/huawei_lte/__init__.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/huawei_lte/__init__.py b/homeassistant/components/huawei_lte/__init__.py index 4ea45977b6073a..ba463823d8d370 100644 --- a/homeassistant/components/huawei_lte/__init__.py +++ b/homeassistant/components/huawei_lte/__init__.py @@ -94,10 +94,10 @@ class Router: """Class for router state.""" - hass: HomeAssistantType = attr.ib() connection: Connection = attr.ib() url: str = attr.ib() mac: str = attr.ib() + signal_update: Callable[[], None] = attr.ib() data: Dict[str, Any] = attr.ib(init=False, factory=dict) subscriptions: Dict[str, Set[str]] = attr.ib( @@ -154,7 +154,7 @@ def get_data(key: str, func: Callable[[None], Any]) -> None: ) get_data(KEY_WLAN_HOST_LIST, self.client.wlan.host_list) - dispatcher_send(self.hass, UPDATE_SIGNAL, self.url) + self.signal_update() def cleanup(self, *_) -> None: """Clean up resources.""" @@ -275,8 +275,12 @@ def _setup_lte(hass: HomeAssistantType, config_entry: ConfigEntry) -> None: else: connection = Connection(url) + def signal_update() -> None: + """Signal updates to data.""" + dispatcher_send(hass, UPDATE_SIGNAL, url) + # Set up router and store reference to it - router = Router(hass, connection, url, mac) + router = Router(connection, url, mac, signal_update) hass.data[DOMAIN].routers[url] = router # Do initial data update @@ -359,7 +363,7 @@ async def async_update(self) -> None: async def async_added_to_hass(self) -> None: """Connect to router update signal.""" self._disconnect_dispatcher = async_dispatcher_connect( - self.router.hass, UPDATE_SIGNAL, self._async_maybe_update + self.hass, UPDATE_SIGNAL, self._async_maybe_update ) async def _async_maybe_update(self, url: str) -> None: From b3d802b57e911f0586798568f0544729af0cdf52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Mon, 7 Oct 2019 18:23:58 +0300 Subject: [PATCH 10/34] Return explicit None from unsupported old device tracker setup --- homeassistant/components/huawei_lte/device_tracker.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/huawei_lte/device_tracker.py b/homeassistant/components/huawei_lte/device_tracker.py index f15f1d79069d12..3c9eb02d072555 100644 --- a/homeassistant/components/huawei_lte/device_tracker.py +++ b/homeassistant/components/huawei_lte/device_tracker.py @@ -107,3 +107,4 @@ def get_scanner(*args, **kwargs): "Loading and configuring as a platform is no longer supported or " "required, convert to enabling/disabling available entities" ) + return None From 0c7d9d1a6b6aa7438fc6cff5cf64fa56c067e44e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Mon, 7 Oct 2019 18:38:31 +0300 Subject: [PATCH 11/34] Mark unknown connection errors during config as such --- homeassistant/components/huawei_lte/.translations/en.json | 3 ++- homeassistant/components/huawei_lte/config_flow.py | 4 ++-- homeassistant/components/huawei_lte/strings.json | 3 ++- tests/components/huawei_lte/test_config_flow.py | 2 +- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/huawei_lte/.translations/en.json b/homeassistant/components/huawei_lte/.translations/en.json index e8b654b8eee4ae..a83ac33deb8d16 100644 --- a/homeassistant/components/huawei_lte/.translations/en.json +++ b/homeassistant/components/huawei_lte/.translations/en.json @@ -10,7 +10,8 @@ "incorrect_username_or_password": "Incorrect username or password", "invalid_url": "Invalid URL", "login_attempts_exceeded": "Maximum login attempts exceeded, please try again later", - "response_error": "Unknown error from device" + "response_error": "Unknown error from device", + "unknown_connection_error": "Unknown error connecting to device" }, "step": { "user": { diff --git a/homeassistant/components/huawei_lte/config_flow.py b/homeassistant/components/huawei_lte/config_flow.py index e1dcfa90304c0f..09fc5bf6ab04ed 100644 --- a/homeassistant/components/huawei_lte/config_flow.py +++ b/homeassistant/components/huawei_lte/config_flow.py @@ -146,8 +146,8 @@ def logout(): _LOGGER.warning("Response error", exc_info=True) errors["base"] = "response_error" except Exception: # pylint: disable=broad-except - _LOGGER.warning("Connection error", exc_info=True) - errors[CONF_URL] = "connection_failed" + _LOGGER.warning("Unknown error connecting to device", exc_info=True) + errors[CONF_URL] = "unknown_connection_error" if errors: logout() return await self._async_show_user_form( diff --git a/homeassistant/components/huawei_lte/strings.json b/homeassistant/components/huawei_lte/strings.json index e8b654b8eee4ae..a83ac33deb8d16 100644 --- a/homeassistant/components/huawei_lte/strings.json +++ b/homeassistant/components/huawei_lte/strings.json @@ -10,7 +10,8 @@ "incorrect_username_or_password": "Incorrect username or password", "invalid_url": "Invalid URL", "login_attempts_exceeded": "Maximum login attempts exceeded, please try again later", - "response_error": "Unknown error from device" + "response_error": "Unknown error from device", + "unknown_connection_error": "Unknown error connecting to device" }, "step": { "user": { diff --git a/tests/components/huawei_lte/test_config_flow.py b/tests/components/huawei_lte/test_config_flow.py index 0e5261e763bb9e..99c085605b31ef 100644 --- a/tests/components/huawei_lte/test_config_flow.py +++ b/tests/components/huawei_lte/test_config_flow.py @@ -74,7 +74,7 @@ async def test_connection_error(hass, requests_mock): assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "user" - assert result["errors"] == {CONF_URL: "connection_failed"} + assert result["errors"] == {CONF_URL: "unknown_connection_error"} @pytest.fixture From 1cd76049e486500c76a2b371ef69dec1fde36392 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Mon, 7 Oct 2019 19:06:04 +0300 Subject: [PATCH 12/34] Drop some dead config flow code --- homeassistant/components/huawei_lte/config_flow.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/huawei_lte/config_flow.py b/homeassistant/components/huawei_lte/config_flow.py index 09fc5bf6ab04ed..dd2886099671b8 100644 --- a/homeassistant/components/huawei_lte/config_flow.py +++ b/homeassistant/components/huawei_lte/config_flow.py @@ -91,15 +91,10 @@ async def async_step_user(self, user_input=None): ) # See if we already have a router configured with this URL - existing_urls = [ # existing entries + existing_urls = { # existing entries url_normalize(entry.data[CONF_URL], default_scheme="http") for entry in self._async_current_entries() - ] - if DOMAIN in self.hass.data: - existing_urls.extend( # yaml configs - url_normalize(x, default_scheme="http") - for x in self.hass.data[DOMAIN].routers - ) + } if user_input[CONF_URL] in existing_urls: return self.async_abort(reason="already_configured") From f949511bd08cd3f1295e193c45c087887bec1cbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Mon, 7 Oct 2019 19:16:49 +0300 Subject: [PATCH 13/34] Run config flow sync I/O in executor --- .../components/huawei_lte/config_flow.py | 59 +++++++++++-------- 1 file changed, 34 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/huawei_lte/config_flow.py b/homeassistant/components/huawei_lte/config_flow.py index dd2886099671b8..c896cc6427d4a1 100644 --- a/homeassistant/components/huawei_lte/config_flow.py +++ b/homeassistant/components/huawei_lte/config_flow.py @@ -2,6 +2,7 @@ from collections import OrderedDict import logging +from typing import Optional from huawei_lte_api.AuthorizedConnection import AuthorizedConnection from huawei_lte_api.Client import Client @@ -107,9 +108,8 @@ def logout(): except Exception: # pylint: disable=broad-except _LOGGER.debug("Could not logout", exc_info=True) - username = user_input.get(CONF_USERNAME) - password = user_input.get(CONF_PASSWORD) - try: + def try_connect(username: Optional[str], password: Optional[str]) -> Connection: + """Try connecting with given credentials.""" if username or password: conn = AuthorizedConnection( user_input[CONF_URL], username=username, password=password @@ -129,6 +129,33 @@ def logout(): conn = Connection(user_input[CONF_URL]) del user_input[CONF_USERNAME] del user_input[CONF_PASSWORD] + return conn + + def get_router_title(conn: Connection) -> str: + """Get title for router.""" + title = None + client = Client(conn) + try: + info = client.device.basic_information() + except Exception: # pylint: disable=broad-except + _LOGGER.debug("Could not get device.basic_information", exc_info=True) + else: + title = info.get("devicename") + if not title: + try: + info = client.device.information() + except Exception: # pylint: disable=broad-except + _LOGGER.debug("Could not get device.information", exc_info=True) + else: + title = info.get("DeviceName") + return title or DEFAULT_DEVICE_NAME + + username = user_input.get(CONF_USERNAME) + password = user_input.get(CONF_PASSWORD) + try: + conn = await self.hass.async_add_executor_job( + try_connect, username, password + ) except LoginErrorUsernameWrongException: errors[CONF_USERNAME] = "incorrect_username" except LoginErrorPasswordWrongException: @@ -144,34 +171,16 @@ def logout(): _LOGGER.warning("Unknown error connecting to device", exc_info=True) errors[CONF_URL] = "unknown_connection_error" if errors: - logout() + await self.hass.async_add_executor_job(logout) return await self._async_show_user_form( user_input=user_input, errors=errors ) - title = None - client = Client(conn) - try: - info = client.device.basic_information() - title = info["devicename"] - except Exception: # pylint: disable=broad-except - _LOGGER.debug( - "Could not get device.basic_information[devicename]", exc_info=True - ) - if not title: - try: - info = client.device.information() - title = info["DeviceName"] - except Exception: # pylint: disable=broad-except - _LOGGER.debug( - "Could not get device.information[DeviceName]", exc_info=True - ) - logout() + title = await self.hass.async_add_executor_job(get_router_title, conn) + await self.hass.async_add_executor_job(logout) return self.async_create_entry( - title=title or DEFAULT_DEVICE_NAME, - data=user_input, - system_options={"disable_new_entities": True}, + title=title, data=user_input, system_options={"disable_new_entities": True} ) From 2d9fc96a1181ca40c17882651ea11533e1dcaa5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Mon, 7 Oct 2019 21:41:21 +0300 Subject: [PATCH 14/34] Parametrize config flow login error tests --- .../components/huawei_lte/test_config_flow.py | 42 ++++++++----------- 1 file changed, 17 insertions(+), 25 deletions(-) diff --git a/tests/components/huawei_lte/test_config_flow.py b/tests/components/huawei_lte/test_config_flow.py index 99c085605b31ef..aafa6abd57fb6e 100644 --- a/tests/components/huawei_lte/test_config_flow.py +++ b/tests/components/huawei_lte/test_config_flow.py @@ -94,8 +94,22 @@ def login_requests_mock(requests_mock): return requests_mock -async def _test_login_error(hass, req_mock, code, error_key, error_value): - req_mock.request( +@pytest.mark.parametrize( + ("code", "errors"), + ( + (LoginErrorEnum.USERNAME_WRONG, {CONF_USERNAME: "incorrect_username"}), + (LoginErrorEnum.PASSWORD_WRONG, {CONF_PASSWORD: "incorrect_password"}), + ( + LoginErrorEnum.USERNAME_PWD_WRONG, + {CONF_USERNAME: "incorrect_username_or_password"}, + ), + (LoginErrorEnum.USERNAME_PWD_ORERRUN, {"base": "login_attempts_exceeded"}), + (ResponseCodeEnum.ERROR_SYSTEM_UNKNOWN, {"base": "response_error"}), + ), +) +async def test_login_error(hass, login_requests_mock, code, errors): + """Test we show user form with appropriate error on response failure.""" + login_requests_mock.request( ANY, f"{FIXTURE_USER_INPUT[CONF_URL]}api/user/login", text=f"{code}", @@ -106,29 +120,7 @@ async def _test_login_error(hass, req_mock, code, error_key, error_value): assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "user" - assert result["errors"] == {error_key: error_value} - - -async def test_incorrect_credentials(hass, login_requests_mock): - """Test we show user form on invalid credentials.""" - await _test_login_error( - hass, - login_requests_mock, - LoginErrorEnum.USERNAME_PWD_WRONG, - CONF_USERNAME, - "incorrect_username_or_password", - ) - - -async def test_response_error(hass, login_requests_mock): - """Test we show user form on generic response error.""" - await _test_login_error( - hass, - login_requests_mock, - ResponseCodeEnum.ERROR_SYSTEM_UNKNOWN, - "base", - "response_error", - ) + assert result["errors"] == errors async def test_success(hass, login_requests_mock): From 29018d0b4a8cf0a9d7a922b09531188711834793 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Mon, 7 Oct 2019 22:03:26 +0300 Subject: [PATCH 15/34] Forward entry unload to platforms --- homeassistant/components/huawei_lte/__init__.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/homeassistant/components/huawei_lte/__init__.py b/homeassistant/components/huawei_lte/__init__.py index ba463823d8d370..98474c89cecfd1 100644 --- a/homeassistant/components/huawei_lte/__init__.py +++ b/homeassistant/components/huawei_lte/__init__.py @@ -190,8 +190,15 @@ async def async_unload_entry( hass: HomeAssistantType, config_entry: ConfigEntry ) -> bool: """Unload config entry.""" + + # Forward config entry unload to platforms + for domain in (DEVICE_TRACKER_DOMAIN, SENSOR_DOMAIN): + await hass.config_entries.async_forward_entry_unload(config_entry, domain) + + # Forget about the router and invoke its cleanup router = hass.data[DOMAIN].routers.pop(config_entry.data[CONF_URL]) await hass.async_add_executor_job(router.cleanup) + return True From e7acfbc2f2d8a228c39a3be56177a102daa74272 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Mon, 7 Oct 2019 22:31:01 +0300 Subject: [PATCH 16/34] Async/sync fixups --- .../components/huawei_lte/__init__.py | 39 ++++++++++++------- 1 file changed, 24 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/huawei_lte/__init__.py b/homeassistant/components/huawei_lte/__init__.py index 98474c89cecfd1..ce2f76edb95887 100644 --- a/homeassistant/components/huawei_lte/__init__.py +++ b/homeassistant/components/huawei_lte/__init__.py @@ -2,6 +2,7 @@ from collections import defaultdict from datetime import timedelta +from functools import partial from urllib.parse import urlparse import ipaddress import logging @@ -33,7 +34,7 @@ from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send from homeassistant.helpers.entity import Entity -from homeassistant.helpers.event import track_time_interval +from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import HomeAssistantType from .const import ( ALL_KEYS, @@ -182,7 +183,7 @@ class HuaweiLteData: async def async_setup_entry(hass: HomeAssistantType, config_entry: ConfigEntry) -> bool: """Set up Huawei LTE component from config entry.""" - await hass.async_add_executor_job(_setup_lte, hass, config_entry) + await _async_setup_lte(hass, config_entry) return True @@ -228,7 +229,7 @@ async def async_setup(hass: HomeAssistantType, config) -> bool: return True -def _setup_lte(hass: HomeAssistantType, config_entry: ConfigEntry) -> None: +async def _async_setup_lte(hass: HomeAssistantType, config_entry: ConfigEntry) -> None: """Set up Huawei LTE router.""" url = config_entry.data[CONF_URL] @@ -272,26 +273,34 @@ def _setup_lte(hass: HomeAssistantType, config_entry: ConfigEntry) -> None: mode = "ip" except ValueError: mode = "hostname" - mac = get_mac_address(**{mode: host}) + mac = await hass.async_add_executor_job(partial(get_mac_address, **{mode: host})) - # Set up a connection: authorized one if username/pass specified (even if empty), unauthorized one otherwise - username = config_entry.data.get(CONF_USERNAME) - password = config_entry.data.get(CONF_PASSWORD) - if username or password: - connection = AuthorizedConnection(url, username=username, password=password) - else: - connection = Connection(url) + def get_connection() -> Connection: + """ + Set up a connection. + + Authorized one if username/pass specified (even if empty), unauthorized one otherwise. + """ + username = config_entry.data.get(CONF_USERNAME) + password = config_entry.data.get(CONF_PASSWORD) + if username or password: + connection = AuthorizedConnection(url, username=username, password=password) + else: + connection = Connection(url) + return connection def signal_update() -> None: """Signal updates to data.""" dispatcher_send(hass, UPDATE_SIGNAL, url) + connection = await hass.async_add_executor_job(get_connection) + # Set up router and store reference to it router = Router(connection, url, mac, signal_update) hass.data[DOMAIN].routers[url] = router # Do initial data update - router.update() + await hass.async_add_executor_job(router.update) # Clear all subscriptions, enabled entities will push back theirs router.subscriptions.clear() @@ -302,7 +311,7 @@ def signal_update() -> None: hass.config_entries.async_forward_entry_setup(config_entry, domain) ) # Notify doesn't support config entry setup yet, load with discovery for now - discovery.load_platform( + await discovery.async_load_platform( hass, NOTIFY_DOMAIN, DOMAIN, @@ -319,10 +328,10 @@ def _update_router(*_: Any) -> None: router.update() # Set up periodic update - track_time_interval(hass, _update_router, SCAN_INTERVAL) + async_track_time_interval(hass, _update_router, SCAN_INTERVAL) # Clean up at end - hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, router.cleanup) + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, router.cleanup) @attr.s From 7f03f9924ecd9f8135a9cfc7af9cd401bf95e0c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Tue, 8 Oct 2019 20:11:03 +0300 Subject: [PATCH 17/34] Improve data subscription debug logging --- homeassistant/components/huawei_lte/__init__.py | 2 +- homeassistant/components/huawei_lte/sensor.py | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/huawei_lte/__init__.py b/homeassistant/components/huawei_lte/__init__.py index ce2f76edb95887..f0587b9a2151e4 100644 --- a/homeassistant/components/huawei_lte/__init__.py +++ b/homeassistant/components/huawei_lte/__init__.py @@ -102,7 +102,7 @@ class Router: data: Dict[str, Any] = attr.ib(init=False, factory=dict) subscriptions: Dict[str, Set[str]] = attr.ib( - init=False, default=defaultdict(set, ((x, {"init"}) for x in ALL_KEYS)) + init=False, default=defaultdict(set, ((x, {"initial_scan"}) for x in ALL_KEYS)) ) def __attrs_post_init__(self): diff --git a/homeassistant/components/huawei_lte/sensor.py b/homeassistant/components/huawei_lte/sensor.py index beadefd5220f2d..dd7f62593a27fd 100644 --- a/homeassistant/components/huawei_lte/sensor.py +++ b/homeassistant/components/huawei_lte/sensor.py @@ -7,7 +7,10 @@ import attr from homeassistant.const import CONF_URL, STATE_UNKNOWN -from homeassistant.components.sensor import DEVICE_CLASS_SIGNAL_STRENGTH +from homeassistant.components.sensor import ( + DEVICE_CLASS_SIGNAL_STRENGTH, + DOMAIN as SENSOR_DOMAIN, +) from homeassistant.helpers import entity_registry from . import HuaweiLteBaseEntity @@ -171,12 +174,12 @@ class HuaweiLteSensor(HuaweiLteBaseEntity): async def async_added_to_hass(self): """Subscribe to needed data on add.""" await super().async_added_to_hass() - self.router.subscriptions[self.key].add(self.item) + self.router.subscriptions[self.key].add(f"{SENSOR_DOMAIN}/{self.item}") async def async_will_remove_from_hass(self): """Unsubscribe from needed data on remove.""" await super().async_will_remove_from_hass() - self.router.subscriptions[self.key].remove(self.item) + self.router.subscriptions[self.key].remove(f"{SENSOR_DOMAIN}/{self.item}") @property def _entity_name(self) -> str: From 2b22c2270e8ba730a7733c6050d82315aeda2e33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Tue, 8 Oct 2019 20:23:47 +0300 Subject: [PATCH 18/34] Implement on the fly add of new and tracking of seen device tracker entities --- .../components/huawei_lte/__init__.py | 4 +- homeassistant/components/huawei_lte/const.py | 2 + .../components/huawei_lte/device_tracker.py | 89 +++++++++++++++++-- 3 files changed, 86 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/huawei_lte/__init__.py b/homeassistant/components/huawei_lte/__init__.py index f0587b9a2151e4..bf229ad1332073 100644 --- a/homeassistant/components/huawei_lte/__init__.py +++ b/homeassistant/components/huawei_lte/__init__.py @@ -45,6 +45,7 @@ KEY_DEVICE_SIGNAL, KEY_MONITORING_TRAFFIC_STATISTICS, KEY_WLAN_HOST_LIST, + UPDATE_SIGNAL, ) @@ -56,8 +57,6 @@ DEFAULT_NAME_TEMPLATE = "Huawei {} {}" -UPDATE_SIGNAL = f"{DOMAIN}_update" - SCAN_INTERVAL = timedelta(seconds=10) NOTIFY_SCHEMA = vol.Any( @@ -104,6 +103,7 @@ class Router: subscriptions: Dict[str, Set[str]] = attr.ib( init=False, default=defaultdict(set, ((x, {"initial_scan"}) for x in ALL_KEYS)) ) + signal_handlers: Dict[str, Callable] = attr.ib(init=False, factory=dict) def __attrs_post_init__(self): """Set up internal state on init.""" diff --git a/homeassistant/components/huawei_lte/const.py b/homeassistant/components/huawei_lte/const.py index 527fcb3d72f16e..b470e34078d105 100644 --- a/homeassistant/components/huawei_lte/const.py +++ b/homeassistant/components/huawei_lte/const.py @@ -4,6 +4,8 @@ DEFAULT_DEVICE_NAME = "LTE" +UPDATE_SIGNAL = f"{DOMAIN}_update" + KEY_DEVICE_BASIC_INFORMATION = "device_basic_information" KEY_DEVICE_INFORMATION = "device_information" KEY_DEVICE_SIGNAL = "device_signal" diff --git a/homeassistant/components/huawei_lte/device_tracker.py b/homeassistant/components/huawei_lte/device_tracker.py index 3c9eb02d072555..d321cad79f546c 100644 --- a/homeassistant/components/huawei_lte/device_tracker.py +++ b/homeassistant/components/huawei_lte/device_tracker.py @@ -2,34 +2,95 @@ import logging import re -from typing import Any, Dict +from typing import Any, Dict, Set import attr from stringcase import snakecase -from homeassistant.components.device_tracker import SOURCE_TYPE_ROUTER +from homeassistant.components.device_tracker import ( + DOMAIN as DEVICE_TRACKER_DOMAIN, + SOURCE_TYPE_ROUTER, +) from homeassistant.components.device_tracker.config_entry import ScannerEntity from homeassistant.const import CONF_URL +from homeassistant.helpers.dispatcher import async_dispatcher_connect from . import HuaweiLteBaseEntity -from .const import DOMAIN, KEY_WLAN_HOST_LIST +from .const import DOMAIN, KEY_WLAN_HOST_LIST, UPDATE_SIGNAL _LOGGER = logging.getLogger(__name__) +_NEW_DEVICE_SCAN = "new_device_scan" + async def async_setup_entry(hass, config_entry, async_add_entities): """Set up from config entry.""" + + # Grab hosts list once to examine whether the initial fetch has got some data for + # us, i.e. if wlan host list is supported. Only set up a subscription and proceed + # with adding and tracking entities if it is. router = hass.data[DOMAIN].routers[config_entry.data[CONF_URL]] + try: + _ = router.data[KEY_WLAN_HOST_LIST]["Hosts"]["Host"] + except KeyError: + _LOGGER.debug("%s[%s][%s] not in data", KEY_WLAN_HOST_LIST, "Hosts", "Host") + return + + # Set of entities we've added already + tracked: Set[str] = set() + + # Tell parent router to grab hosts list so we can get new entities + router.subscriptions[KEY_WLAN_HOST_LIST].add( + f"{DEVICE_TRACKER_DOMAIN}/{_NEW_DEVICE_SCAN}" + ) + + async def _async_maybe_add_new_entities(url: str) -> None: + """Add new entities if the update signal comes from our router.""" + if url == router.url: + await async_add_new_entities(hass, url, async_add_entities, tracked) + + # Register to handle router data updates + disconnect_dispatcher = async_dispatcher_connect( + hass, UPDATE_SIGNAL, _async_maybe_add_new_entities + ) + router.signal_handlers[ + f"{DEVICE_TRACKER_DOMAIN}_disconnect" + ] = disconnect_dispatcher + + # Add new entities waiting to be added from initial scan + await async_add_new_entities(hass, router.url, async_add_entities, tracked) + + +async def async_add_new_entities(hass, router_url, async_add_entities, tracked): + """Add new entities.""" + router = hass.data[DOMAIN].routers[router_url] try: hosts = router.data[KEY_WLAN_HOST_LIST]["Hosts"]["Host"] - except (KeyError, TypeError): + except KeyError: _LOGGER.debug("%s[%s][%s] not in data", KEY_WLAN_HOST_LIST, "Hosts", "Host") return - entities = [] + new_entities = [] for host in (x for x in hosts if x.get("MacAddress")): - entities.append(HuaweiLteScannerEntity(router, host["MacAddress"])) - async_add_entities(entities, True) + entity = HuaweiLteScannerEntity(router, host["MacAddress"]) + if entity.unique_id in tracked: + continue + tracked.add(entity.unique_id) + new_entities.append(entity) + async_add_entities(new_entities, True) + + +async def async_unload_entry(hass, config_entry): + """Unload config entry.""" + router = hass.data[DOMAIN].routers[config_entry.data[CONF_URL]] + router.subscriptions[KEY_WLAN_HOST_LIST].discard( + f"{DEVICE_TRACKER_DOMAIN}/{_NEW_DEVICE_SCAN}" + ) + disconnect_dispatcher = router.signal_handlers.pop( + f"{DEVICE_TRACKER_DOMAIN}_disconnect", None + ) + if disconnect_dispatcher is not None: + disconnect_dispatcher() def _better_snakecase(s: str) -> str: @@ -57,6 +118,20 @@ class HuaweiLteScannerEntity(HuaweiLteBaseEntity, ScannerEntity): _name: str = attr.ib(init=False, default="device") _device_state_attributes: Dict[str, Any] = attr.ib(init=False, factory=dict) + async def async_added_to_hass(self): + """Subscribe to needed data on add.""" + await super().async_added_to_hass() + self.router.subscriptions[KEY_WLAN_HOST_LIST].add( + f"{DEVICE_TRACKER_DOMAIN}/{self.mac}" + ) + + async def async_will_remove_from_hass(self): + """Unsubscribe from needed data on remove.""" + await super().async_will_remove_from_hass() + self.router.subscriptions[KEY_WLAN_HOST_LIST].remove( + f"{DEVICE_TRACKER_DOMAIN}/{self.mac}" + ) + @property def _entity_name(self) -> str: return self._name From 0a0866592d884af987de1283cdd363496941e02e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Tue, 8 Oct 2019 22:17:26 +0300 Subject: [PATCH 19/34] Handle device tracker entry unload cleanup in component --- homeassistant/components/huawei_lte/__init__.py | 14 +++++++++++--- .../components/huawei_lte/device_tracker.py | 17 +---------------- 2 files changed, 12 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/huawei_lte/__init__.py b/homeassistant/components/huawei_lte/__init__.py index bf229ad1332073..438c2e7b9541de 100644 --- a/homeassistant/components/huawei_lte/__init__.py +++ b/homeassistant/components/huawei_lte/__init__.py @@ -6,7 +6,7 @@ from urllib.parse import urlparse import ipaddress import logging -from typing import Any, Callable, Dict, Set +from typing import Any, Callable, Dict, List, Set import voluptuous as vol import attr @@ -31,6 +31,7 @@ CONF_USERNAME, EVENT_HOMEASSISTANT_STOP, ) +from homeassistant.core import CALLBACK_TYPE from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send from homeassistant.helpers.entity import Entity @@ -97,13 +98,13 @@ class Router: connection: Connection = attr.ib() url: str = attr.ib() mac: str = attr.ib() - signal_update: Callable[[], None] = attr.ib() + signal_update: CALLBACK_TYPE = attr.ib() data: Dict[str, Any] = attr.ib(init=False, factory=dict) subscriptions: Dict[str, Set[str]] = attr.ib( init=False, default=defaultdict(set, ((x, {"initial_scan"}) for x in ALL_KEYS)) ) - signal_handlers: Dict[str, Callable] = attr.ib(init=False, factory=dict) + unload_handlers: List[CALLBACK_TYPE] = attr.ib(init=False, factory=list) def __attrs_post_init__(self): """Set up internal state on init.""" @@ -159,6 +160,13 @@ def get_data(key: str, func: Callable[[None], Any]) -> None: def cleanup(self, *_) -> None: """Clean up resources.""" + + self.subscriptions.clear() + + for handler in self.unload_handlers: + handler() + self.unload_handlers.clear() + if not isinstance(self.connection, AuthorizedConnection): return try: diff --git a/homeassistant/components/huawei_lte/device_tracker.py b/homeassistant/components/huawei_lte/device_tracker.py index d321cad79f546c..ca68e74f7102bc 100644 --- a/homeassistant/components/huawei_lte/device_tracker.py +++ b/homeassistant/components/huawei_lte/device_tracker.py @@ -53,9 +53,7 @@ async def _async_maybe_add_new_entities(url: str) -> None: disconnect_dispatcher = async_dispatcher_connect( hass, UPDATE_SIGNAL, _async_maybe_add_new_entities ) - router.signal_handlers[ - f"{DEVICE_TRACKER_DOMAIN}_disconnect" - ] = disconnect_dispatcher + router.unload_handlers.append(disconnect_dispatcher) # Add new entities waiting to be added from initial scan await async_add_new_entities(hass, router.url, async_add_entities, tracked) @@ -80,19 +78,6 @@ async def async_add_new_entities(hass, router_url, async_add_entities, tracked): async_add_entities(new_entities, True) -async def async_unload_entry(hass, config_entry): - """Unload config entry.""" - router = hass.data[DOMAIN].routers[config_entry.data[CONF_URL]] - router.subscriptions[KEY_WLAN_HOST_LIST].discard( - f"{DEVICE_TRACKER_DOMAIN}/{_NEW_DEVICE_SCAN}" - ) - disconnect_dispatcher = router.signal_handlers.pop( - f"{DEVICE_TRACKER_DOMAIN}_disconnect", None - ) - if disconnect_dispatcher is not None: - disconnect_dispatcher() - - def _better_snakecase(s: str) -> str: if s == s.upper(): # All uppercase to all lowercase to get http for HTTP, not h_t_t_p From 5b2e9e5f320469268b7273513abb54b5f0341b09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Thu, 10 Oct 2019 23:18:30 +0300 Subject: [PATCH 20/34] Remove unnecessary _async_setup_lte, just have code in async_setup_entry --- .../components/huawei_lte/__init__.py | 92 +++++++++---------- 1 file changed, 44 insertions(+), 48 deletions(-) diff --git a/homeassistant/components/huawei_lte/__init__.py b/homeassistant/components/huawei_lte/__init__.py index 438c2e7b9541de..dfbb9c3e8de423 100644 --- a/homeassistant/components/huawei_lte/__init__.py +++ b/homeassistant/components/huawei_lte/__init__.py @@ -191,54 +191,6 @@ class HuaweiLteData: async def async_setup_entry(hass: HomeAssistantType, config_entry: ConfigEntry) -> bool: """Set up Huawei LTE component from config entry.""" - await _async_setup_lte(hass, config_entry) - return True - - -async def async_unload_entry( - hass: HomeAssistantType, config_entry: ConfigEntry -) -> bool: - """Unload config entry.""" - - # Forward config entry unload to platforms - for domain in (DEVICE_TRACKER_DOMAIN, SENSOR_DOMAIN): - await hass.config_entries.async_forward_entry_unload(config_entry, domain) - - # Forget about the router and invoke its cleanup - router = hass.data[DOMAIN].routers.pop(config_entry.data[CONF_URL]) - await hass.async_add_executor_job(router.cleanup) - - return True - - -async def async_setup(hass: HomeAssistantType, config) -> bool: - """Set up Huawei LTE component.""" - - # Arrange our YAML config to dict with normalized URLs as keys - domain_config = {} - if DOMAIN not in hass.data: - hass.data[DOMAIN] = HuaweiLteData(hass_config=config, config=domain_config) - for router_config in config.get(DOMAIN, []): - domain_config[url_normalize(router_config.pop(CONF_URL))] = router_config - - for url, router_config in domain_config.items(): - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={ - CONF_URL: url, - CONF_USERNAME: router_config.get(CONF_USERNAME), - CONF_PASSWORD: router_config.get(CONF_PASSWORD), - }, - ) - ) - - return True - - -async def _async_setup_lte(hass: HomeAssistantType, config_entry: ConfigEntry) -> None: - """Set up Huawei LTE router.""" url = config_entry.data[CONF_URL] # Override settings from YAML config, but only if they're changed in it @@ -341,6 +293,50 @@ def _update_router(*_: Any) -> None: # Clean up at end hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, router.cleanup) + return True + + +async def async_unload_entry( + hass: HomeAssistantType, config_entry: ConfigEntry +) -> bool: + """Unload config entry.""" + + # Forward config entry unload to platforms + for domain in (DEVICE_TRACKER_DOMAIN, SENSOR_DOMAIN): + await hass.config_entries.async_forward_entry_unload(config_entry, domain) + + # Forget about the router and invoke its cleanup + router = hass.data[DOMAIN].routers.pop(config_entry.data[CONF_URL]) + await hass.async_add_executor_job(router.cleanup) + + return True + + +async def async_setup(hass: HomeAssistantType, config) -> bool: + """Set up Huawei LTE component.""" + + # Arrange our YAML config to dict with normalized URLs as keys + domain_config = {} + if DOMAIN not in hass.data: + hass.data[DOMAIN] = HuaweiLteData(hass_config=config, config=domain_config) + for router_config in config.get(DOMAIN, []): + domain_config[url_normalize(router_config.pop(CONF_URL))] = router_config + + for url, router_config in domain_config.items(): + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_URL: url, + CONF_USERNAME: router_config.get(CONF_USERNAME), + CONF_PASSWORD: router_config.get(CONF_PASSWORD), + }, + ) + ) + + return True + @attr.s class HuaweiLteBaseEntity(Entity): From 266a57b33a8c49dd12f6590f74960dc66d1a498e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Fri, 11 Oct 2019 09:44:39 +0300 Subject: [PATCH 21/34] Remove time tracker on unload --- homeassistant/components/huawei_lte/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/huawei_lte/__init__.py b/homeassistant/components/huawei_lte/__init__.py index dfbb9c3e8de423..f0b4df98e947b1 100644 --- a/homeassistant/components/huawei_lte/__init__.py +++ b/homeassistant/components/huawei_lte/__init__.py @@ -288,7 +288,9 @@ def _update_router(*_: Any) -> None: router.update() # Set up periodic update - async_track_time_interval(hass, _update_router, SCAN_INTERVAL) + router.unload_handlers.append( + async_track_time_interval(hass, _update_router, SCAN_INTERVAL) + ) # Clean up at end hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, router.cleanup) From 5cc2cbeacbb2ca806fceb48bb180f5eac48ce43c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Fri, 11 Oct 2019 09:55:39 +0300 Subject: [PATCH 22/34] Fix to not use same mutable default subscription set for all routers --- homeassistant/components/huawei_lte/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/huawei_lte/__init__.py b/homeassistant/components/huawei_lte/__init__.py index f0b4df98e947b1..4868afb35f7705 100644 --- a/homeassistant/components/huawei_lte/__init__.py +++ b/homeassistant/components/huawei_lte/__init__.py @@ -102,7 +102,8 @@ class Router: data: Dict[str, Any] = attr.ib(init=False, factory=dict) subscriptions: Dict[str, Set[str]] = attr.ib( - init=False, default=defaultdict(set, ((x, {"initial_scan"}) for x in ALL_KEYS)) + init=False, + factory=lambda: defaultdict(set, ((x, {"initial_scan"}) for x in ALL_KEYS)), ) unload_handlers: List[CALLBACK_TYPE] = attr.ib(init=False, factory=list) From f829b9f16764f7ae4cb1e886d928972e6f68ad78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Fri, 11 Oct 2019 10:20:42 +0300 Subject: [PATCH 23/34] Pylint fixes --- homeassistant/components/huawei_lte/__init__.py | 1 + .../components/huawei_lte/device_tracker.py | 13 ++++++------- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/huawei_lte/__init__.py b/homeassistant/components/huawei_lte/__init__.py index 4868afb35f7705..8be6543588a8e2 100644 --- a/homeassistant/components/huawei_lte/__init__.py +++ b/homeassistant/components/huawei_lte/__init__.py @@ -106,6 +106,7 @@ class Router: factory=lambda: defaultdict(set, ((x, {"initial_scan"}) for x in ALL_KEYS)), ) unload_handlers: List[CALLBACK_TYPE] = attr.ib(init=False, factory=list) + client: Client def __attrs_post_init__(self): """Set up internal state on init.""" diff --git a/homeassistant/components/huawei_lte/device_tracker.py b/homeassistant/components/huawei_lte/device_tracker.py index ca68e74f7102bc..af3d31470d914c 100644 --- a/homeassistant/components/huawei_lte/device_tracker.py +++ b/homeassistant/components/huawei_lte/device_tracker.py @@ -78,19 +78,19 @@ async def async_add_new_entities(hass, router_url, async_add_entities, tracked): async_add_entities(new_entities, True) -def _better_snakecase(s: str) -> str: - if s == s.upper(): +def _better_snakecase(text: str) -> str: + if text == text.upper(): # All uppercase to all lowercase to get http for HTTP, not h_t_t_p - s = s.lower() + text = text.lower() else: # Three or more consecutive uppercase with middle part lowercased # to get http_response for HTTPResponse, not h_t_t_p_response - s = re.sub( + text = re.sub( r"([A-Z])([A-Z]+)([A-Z](?:[^A-Z]|$))", lambda match: f"{match.group(1)}{match.group(2).lower()}{match.group(3)}", - s, + text, ) - return snakecase(s) + return snakecase(text) @attr.s @@ -167,4 +167,3 @@ def get_scanner(*args, **kwargs): "Loading and configuring as a platform is no longer supported or " "required, convert to enabling/disabling available entities" ) - return None From 0182b3f85cdf00937dee6e8d13059921e5061704 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Fri, 11 Oct 2019 10:21:08 +0300 Subject: [PATCH 24/34] Remove some redundant defensive device tracker code --- homeassistant/components/huawei_lte/device_tracker.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/homeassistant/components/huawei_lte/device_tracker.py b/homeassistant/components/huawei_lte/device_tracker.py index af3d31470d914c..2fade172136a63 100644 --- a/homeassistant/components/huawei_lte/device_tracker.py +++ b/homeassistant/components/huawei_lte/device_tracker.py @@ -142,14 +142,7 @@ def device_state_attributes(self) -> Dict[str, Any]: async def async_update(self) -> None: """Update state.""" - try: - hosts = self.router.data[KEY_WLAN_HOST_LIST]["Hosts"]["Host"] - except KeyError: - _LOGGER.debug("%s[Hosts][Host] not in data", self.key) - self._available = False - return - self._available = True - + hosts = self.router.data[KEY_WLAN_HOST_LIST]["Hosts"]["Host"] host = next((x for x in hosts if x.get("MacAddress") == self.mac), None) self._is_connected = host is not None if self._is_connected: From f543426f9f2ca05e6313067171386c21dad13af8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Sun, 13 Oct 2019 09:54:06 +0300 Subject: [PATCH 25/34] Add back explicit get_scanner None return, hush pylint --- homeassistant/components/huawei_lte/device_tracker.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/huawei_lte/device_tracker.py b/homeassistant/components/huawei_lte/device_tracker.py index 2fade172136a63..39a4e0f2dd410d 100644 --- a/homeassistant/components/huawei_lte/device_tracker.py +++ b/homeassistant/components/huawei_lte/device_tracker.py @@ -154,9 +154,10 @@ async def async_update(self) -> None: } -def get_scanner(*args, **kwargs): +def get_scanner(*args, **kwargs): # pylint: disable=useless-return """Old no longer used way to set up Huawei LTE device tracker.""" _LOGGER.warning( "Loading and configuring as a platform is no longer supported or " "required, convert to enabling/disabling available entities" ) + return None From be692fb6f90836186cf440235b4446f28bd30cc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Sun, 13 Oct 2019 22:37:49 +0300 Subject: [PATCH 26/34] Adjust approach to set system_options on entry create --- homeassistant/components/huawei_lte/config_flow.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/huawei_lte/config_flow.py b/homeassistant/components/huawei_lte/config_flow.py index c896cc6427d4a1..adc566faeb2e4f 100644 --- a/homeassistant/components/huawei_lte/config_flow.py +++ b/homeassistant/components/huawei_lte/config_flow.py @@ -179,9 +179,9 @@ def get_router_title(conn: Connection) -> str: title = await self.hass.async_add_executor_job(get_router_title, conn) await self.hass.async_add_executor_job(logout) - return self.async_create_entry( - title=title, data=user_input, system_options={"disable_new_entities": True} - ) + result = self.async_create_entry(title=title, data=user_input) + result.setdefault("system_options", {})["disable_new_entities"] = True + return result class OptionsFlowHandler(config_entries.OptionsFlow): From a353a1d47eddb268f28d791ad7581c42eafe0144 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Wed, 16 Oct 2019 22:05:10 +0300 Subject: [PATCH 27/34] Enable some sensors on first add instead of disabling everything --- homeassistant/components/huawei_lte/config_flow.py | 4 +--- homeassistant/components/huawei_lte/sensor.py | 11 ++++++++++- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/huawei_lte/config_flow.py b/homeassistant/components/huawei_lte/config_flow.py index adc566faeb2e4f..61540fcb2c71c8 100644 --- a/homeassistant/components/huawei_lte/config_flow.py +++ b/homeassistant/components/huawei_lte/config_flow.py @@ -179,9 +179,7 @@ def get_router_title(conn: Connection) -> str: title = await self.hass.async_add_executor_job(get_router_title, conn) await self.hass.async_add_executor_job(logout) - result = self.async_create_entry(title=title, data=user_input) - result.setdefault("system_options", {})["disable_new_entities"] = True - return result + return self.async_create_entry(title=title, data=user_input) class OptionsFlowHandler(config_entries.OptionsFlow): diff --git a/homeassistant/components/huawei_lte/sensor.py b/homeassistant/components/huawei_lte/sensor.py index dd7f62593a27fd..e5b65c723f043b 100644 --- a/homeassistant/components/huawei_lte/sensor.py +++ b/homeassistant/components/huawei_lte/sensor.py @@ -31,7 +31,7 @@ ), (KEY_DEVICE_INFORMATION, "SoftwareVersion"): dict(name="Software version"), (KEY_DEVICE_INFORMATION, "WanIPAddress"): dict( - name="WAN IP address", icon="mdi:ip" + name="WAN IP address", icon="mdi:ip", enabled_default=True ), (KEY_DEVICE_INFORMATION, "WanIPv6Address"): dict( name="WAN IPv6 address", icon="mdi:ip" @@ -55,6 +55,7 @@ or x < -5 and "mdi:signal-cellular-2" or "mdi:signal-cellular-3", + enabled_default=True, ), (KEY_DEVICE_SIGNAL, "rsrp"): dict( name="RSRP", @@ -67,6 +68,7 @@ or x < -80 and "mdi:signal-cellular-2" or "mdi:signal-cellular-3", + enabled_default=True, ), (KEY_DEVICE_SIGNAL, "rssi"): dict( name="RSSI", @@ -79,6 +81,7 @@ or x < -60 and "mdi:signal-cellular-2" or "mdi:signal-cellular-3", + enabled_default=True, ), (KEY_DEVICE_SIGNAL, "sinr"): dict( name="SINR", @@ -91,6 +94,7 @@ or x < 10 and "mdi:signal-cellular-2" or "mdi:signal-cellular-3", + enabled_default=True, ), KEY_MONITORING_TRAFFIC_STATISTICS: dict( exclude=re.compile(r"^showtraffic$", re.IGNORECASE) @@ -212,6 +216,11 @@ def icon(self): return icon(self.state) return icon + @property + def entity_registry_enabled_default(self) -> bool: + """Return if the entity should be enabled when first added to the entity registry.""" + return bool(self.meta.get("enabled_default")) + async def async_update(self): """Update state.""" try: From 1ae13a9983d79eecc7bfc83038f6acdd2dfda9f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Wed, 16 Oct 2019 22:22:00 +0300 Subject: [PATCH 28/34] Fix SMS notification recipients default value --- homeassistant/components/huawei_lte/config_flow.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/huawei_lte/config_flow.py b/homeassistant/components/huawei_lte/config_flow.py index 61540fcb2c71c8..c8ef6f979e9bd4 100644 --- a/homeassistant/components/huawei_lte/config_flow.py +++ b/homeassistant/components/huawei_lte/config_flow.py @@ -194,5 +194,17 @@ async def async_step_init(self, user_input=None): if user_input is not None: return self.async_create_entry(title="", data=user_input) - data_schema = vol.Schema(OrderedDict(((vol.Optional(CONF_RECIPIENT), str),))) + data_schema = vol.Schema( + OrderedDict( + ( + ( + vol.Optional( + CONF_RECIPIENT, + default=self.config_entry.options.get(CONF_RECIPIENT), + ), + str, + ), + ) + ) + ) return self.async_show_form(step_id="init", data_schema=data_schema) From de561ff50767183845800bf31554997fc6f414b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Thu, 17 Oct 2019 14:27:06 +0300 Subject: [PATCH 29/34] Add option to skip new device tracker entities --- .../huawei_lte/.translations/en.json | 3 +- .../components/huawei_lte/__init__.py | 48 +++++++++++++--- .../components/huawei_lte/config_flow.py | 12 +++- homeassistant/components/huawei_lte/const.py | 2 + .../components/huawei_lte/device_tracker.py | 56 ++++++++++++++----- .../components/huawei_lte/strings.json | 3 +- 6 files changed, 98 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/huawei_lte/.translations/en.json b/homeassistant/components/huawei_lte/.translations/en.json index a83ac33deb8d16..8681e3355a46c5 100644 --- a/homeassistant/components/huawei_lte/.translations/en.json +++ b/homeassistant/components/huawei_lte/.translations/en.json @@ -30,7 +30,8 @@ "step": { "init": { "data": { - "recipient": "SMS notification recipients" + "recipient": "SMS notification recipients", + "track_new_devices": "Track new devices" } } } diff --git a/homeassistant/components/huawei_lte/__init__.py b/homeassistant/components/huawei_lte/__init__.py index 8be6543588a8e2..18f7035a885268 100644 --- a/homeassistant/components/huawei_lte/__init__.py +++ b/homeassistant/components/huawei_lte/__init__.py @@ -33,7 +33,11 @@ ) from homeassistant.core import CALLBACK_TYPE from homeassistant.helpers import config_validation as cv, discovery -from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, + dispatcher_send, +) from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import HomeAssistantType @@ -46,6 +50,7 @@ KEY_DEVICE_SIGNAL, KEY_MONITORING_TRAFFIC_STATISTICS, KEY_WLAN_HOST_LIST, + UPDATE_OPTIONS_SIGNAL, UPDATE_SIGNAL, ) @@ -281,6 +286,11 @@ def signal_update() -> None: hass.data[DOMAIN].hass_config, ) + # Add config entry options update listener + router.unload_handlers.append( + config_entry.add_update_listener(async_signal_options_update) + ) + def _update_router(*_: Any) -> None: """ Update router data. @@ -342,6 +352,13 @@ async def async_setup(hass: HomeAssistantType, config) -> bool: return True +async def async_signal_options_update( + hass: HomeAssistantType, config_entry: ConfigEntry +) -> None: + """Handle config entry options update.""" + async_dispatcher_send(hass, UPDATE_OPTIONS_SIGNAL, config_entry) + + @attr.s class HuaweiLteBaseEntity(Entity): """Huawei LTE entity base class.""" @@ -349,7 +366,7 @@ class HuaweiLteBaseEntity(Entity): router: Router = attr.ib() _available: bool = attr.ib(init=False, default=True) - _disconnect_dispatcher: Callable = attr.ib(init=False) + _unsub_handlers: List[Callable] = attr.ib(init=False, factory=list) @property def _entity_name(self) -> str: @@ -384,10 +401,19 @@ async def async_update(self) -> None: """Update state.""" raise NotImplementedError + async def async_update_options(self, config_entry: ConfigEntry) -> None: + """Update config entry options.""" + pass + async def async_added_to_hass(self) -> None: - """Connect to router update signal.""" - self._disconnect_dispatcher = async_dispatcher_connect( - self.hass, UPDATE_SIGNAL, self._async_maybe_update + """Connect to update signals.""" + self._unsub_handlers.append( + async_dispatcher_connect(self.hass, UPDATE_SIGNAL, self._async_maybe_update) + ) + self._unsub_handlers.append( + async_dispatcher_connect( + self.hass, UPDATE_OPTIONS_SIGNAL, self._async_maybe_update_options + ) ) async def _async_maybe_update(self, url: str) -> None: @@ -395,7 +421,13 @@ async def _async_maybe_update(self, url: str) -> None: if url == self.router.url: await self.async_update() + async def _async_maybe_update_options(self, config_entry: ConfigEntry) -> None: + """Update options if the update signal comes from our router.""" + if config_entry.data[CONF_URL] == self.router.url: + await self.async_update_options(config_entry) + async def async_will_remove_from_hass(self) -> None: - """Disconnect from router update signal.""" - if self._disconnect_dispatcher: - self._disconnect_dispatcher() + """Invoke unsubscription handlers.""" + for unsub in self._unsub_handlers: + unsub() + self._unsub_handlers.clear() diff --git a/homeassistant/components/huawei_lte/config_flow.py b/homeassistant/components/huawei_lte/config_flow.py index c8ef6f979e9bd4..2e15c3db1f69f2 100644 --- a/homeassistant/components/huawei_lte/config_flow.py +++ b/homeassistant/components/huawei_lte/config_flow.py @@ -19,8 +19,9 @@ from homeassistant import config_entries from homeassistant.const import CONF_PASSWORD, CONF_RECIPIENT, CONF_URL, CONF_USERNAME +from homeassistant.components.device_tracker import CONF_TRACK_NEW from homeassistant.core import callback -from .const import DEFAULT_DEVICE_NAME, DOMAIN +from .const import DEFAULT_DEVICE_NAME, DEFAULT_TRACK_NEW, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -197,6 +198,15 @@ async def async_step_init(self, user_input=None): data_schema = vol.Schema( OrderedDict( ( + ( + vol.Optional( + CONF_TRACK_NEW, + default=self.config_entry.options.get( + CONF_TRACK_NEW, DEFAULT_TRACK_NEW + ), + ), + bool, + ), ( vol.Optional( CONF_RECIPIENT, diff --git a/homeassistant/components/huawei_lte/const.py b/homeassistant/components/huawei_lte/const.py index b470e34078d105..bd4178ae45fd99 100644 --- a/homeassistant/components/huawei_lte/const.py +++ b/homeassistant/components/huawei_lte/const.py @@ -3,8 +3,10 @@ DOMAIN = "huawei_lte" DEFAULT_DEVICE_NAME = "LTE" +DEFAULT_TRACK_NEW = True UPDATE_SIGNAL = f"{DOMAIN}_update" +UPDATE_OPTIONS_SIGNAL = f"{DOMAIN}_options_update" KEY_DEVICE_BASIC_INFORMATION = "device_basic_information" KEY_DEVICE_INFORMATION = "device_information" diff --git a/homeassistant/components/huawei_lte/device_tracker.py b/homeassistant/components/huawei_lte/device_tracker.py index 39a4e0f2dd410d..4c9460322e5a10 100644 --- a/homeassistant/components/huawei_lte/device_tracker.py +++ b/homeassistant/components/huawei_lte/device_tracker.py @@ -8,19 +8,22 @@ from stringcase import snakecase from homeassistant.components.device_tracker import ( + CONF_TRACK_NEW, DOMAIN as DEVICE_TRACKER_DOMAIN, SOURCE_TYPE_ROUTER, ) from homeassistant.components.device_tracker.config_entry import ScannerEntity +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_URL +from homeassistant.helpers import entity_registry from homeassistant.helpers.dispatcher import async_dispatcher_connect from . import HuaweiLteBaseEntity -from .const import DOMAIN, KEY_WLAN_HOST_LIST, UPDATE_SIGNAL +from .const import DEFAULT_TRACK_NEW, DOMAIN, KEY_WLAN_HOST_LIST, UPDATE_SIGNAL _LOGGER = logging.getLogger(__name__) -_NEW_DEVICE_SCAN = "new_device_scan" +_NEW_DEVICE_SCAN = f"{DEVICE_TRACKER_DOMAIN}/new_device_scan" async def async_setup_entry(hass, config_entry, async_add_entities): @@ -36,17 +39,24 @@ async def async_setup_entry(hass, config_entry, async_add_entities): _LOGGER.debug("%s[%s][%s] not in data", KEY_WLAN_HOST_LIST, "Hosts", "Host") return - # Set of entities we've added already + # Initialize already tracked entities tracked: Set[str] = set() - - # Tell parent router to grab hosts list so we can get new entities - router.subscriptions[KEY_WLAN_HOST_LIST].add( - f"{DEVICE_TRACKER_DOMAIN}/{_NEW_DEVICE_SCAN}" - ) + registry = await entity_registry.async_get_registry(hass) + for entity in registry.entities.values(): + if ( + entity.domain == DEVICE_TRACKER_DOMAIN + and entity.config_entry_id == config_entry.entry_id + ): + tracked.add(entity.unique_id) + await async_add_new_entities(hass, router.url, async_add_entities, tracked, True) + + # Tell parent router to poll hosts list for new devices, if enabled + if config_entry.options.setdefault(CONF_TRACK_NEW, DEFAULT_TRACK_NEW): + router.subscriptions[KEY_WLAN_HOST_LIST].add(_NEW_DEVICE_SCAN) async def _async_maybe_add_new_entities(url: str) -> None: - """Add new entities if the update signal comes from our router.""" - if url == router.url: + """Add new entities if enabled and the update signal comes from our router.""" + if url == router.url and config_entry.options.get(CONF_TRACK_NEW): await async_add_new_entities(hass, url, async_add_entities, tracked) # Register to handle router data updates @@ -55,12 +65,18 @@ async def _async_maybe_add_new_entities(url: str) -> None: ) router.unload_handlers.append(disconnect_dispatcher) - # Add new entities waiting to be added from initial scan - await async_add_new_entities(hass, router.url, async_add_entities, tracked) + # Add new entities from initial scan, if enabled + if config_entry.options.get(CONF_TRACK_NEW): + await async_add_new_entities(hass, router.url, async_add_entities, tracked) -async def async_add_new_entities(hass, router_url, async_add_entities, tracked): - """Add new entities.""" +async def async_add_new_entities( + hass, router_url, async_add_entities, tracked, included: bool = False +): + """Add new entities. + + :param included: if True, setup only items in tracked, and vice versa + """ router = hass.data[DOMAIN].routers[router_url] try: hosts = router.data[KEY_WLAN_HOST_LIST]["Hosts"]["Host"] @@ -71,7 +87,8 @@ async def async_add_new_entities(hass, router_url, async_add_entities, tracked): new_entities = [] for host in (x for x in hosts if x.get("MacAddress")): entity = HuaweiLteScannerEntity(router, host["MacAddress"]) - if entity.unique_id in tracked: + tracking = entity.unique_id in tracked + if tracking != included: continue tracked.add(entity.unique_id) new_entities.append(entity) @@ -153,6 +170,15 @@ async def async_update(self) -> None: if k not in ("MacAddress", "HostName") } + async def async_update_options(self, config_entry: ConfigEntry) -> None: + """Update config entry options.""" + await super().async_update_options(config_entry) + subscriptions = self.router.subscriptions[KEY_WLAN_HOST_LIST] + if config_entry.options.get(CONF_TRACK_NEW): + subscriptions.add(_NEW_DEVICE_SCAN) + else: + subscriptions.discard(_NEW_DEVICE_SCAN) + def get_scanner(*args, **kwargs): # pylint: disable=useless-return """Old no longer used way to set up Huawei LTE device tracker.""" diff --git a/homeassistant/components/huawei_lte/strings.json b/homeassistant/components/huawei_lte/strings.json index a83ac33deb8d16..8681e3355a46c5 100644 --- a/homeassistant/components/huawei_lte/strings.json +++ b/homeassistant/components/huawei_lte/strings.json @@ -30,7 +30,8 @@ "step": { "init": { "data": { - "recipient": "SMS notification recipients" + "recipient": "SMS notification recipients", + "track_new_devices": "Track new devices" } } } From 02cfd940d14972ef63e25abf46c6c3880f39c6d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Sat, 19 Oct 2019 00:04:16 +0300 Subject: [PATCH 30/34] Fix SMS notification recipient option default --- homeassistant/components/huawei_lte/config_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/huawei_lte/config_flow.py b/homeassistant/components/huawei_lte/config_flow.py index 2e15c3db1f69f2..146f2c604c87db 100644 --- a/homeassistant/components/huawei_lte/config_flow.py +++ b/homeassistant/components/huawei_lte/config_flow.py @@ -210,7 +210,7 @@ async def async_step_init(self, user_input=None): ( vol.Optional( CONF_RECIPIENT, - default=self.config_entry.options.get(CONF_RECIPIENT), + default=self.config_entry.options.get(CONF_RECIPIENT, ""), ), str, ), From 438dbaa077bbf027c0a24d0b18e7ad5bdc28a561 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Sat, 19 Oct 2019 00:22:23 +0300 Subject: [PATCH 31/34] Work around https://github.com/PyCQA/pylint/issues/3202 --- homeassistant/components/huawei_lte/config_flow.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/huawei_lte/config_flow.py b/homeassistant/components/huawei_lte/config_flow.py index 146f2c604c87db..3e54f0bc2bacfa 100644 --- a/homeassistant/components/huawei_lte/config_flow.py +++ b/homeassistant/components/huawei_lte/config_flow.py @@ -21,7 +21,10 @@ from homeassistant.const import CONF_PASSWORD, CONF_RECIPIENT, CONF_URL, CONF_USERNAME from homeassistant.components.device_tracker import CONF_TRACK_NEW from homeassistant.core import callback -from .const import DEFAULT_DEVICE_NAME, DEFAULT_TRACK_NEW, DOMAIN +from .const import DEFAULT_DEVICE_NAME, DEFAULT_TRACK_NEW + +# https://github.com/PyCQA/pylint/issues/3202 +from .const import DOMAIN # pylint: disable=unused-import _LOGGER = logging.getLogger(__name__) From 1f1d8eb2cd5c173828b7efc6fe36c21443afc9a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Sat, 19 Oct 2019 14:34:05 +0300 Subject: [PATCH 32/34] Remove unrelated type hint additions --- homeassistant/helpers/event.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index e3e585f716cb70..b7707b844d417a 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -252,18 +252,16 @@ def async_call_later(hass, delay, action): @callback @bind_hass -def async_track_time_interval( - hass: HomeAssistant, action: Callable[..., None], interval: timedelta -) -> CALLBACK_TYPE: +def async_track_time_interval(hass, action, interval): """Add a listener that fires repetitively at every timedelta interval.""" remove = None - def next_interval() -> datetime: + def next_interval(): """Return the next interval.""" return dt_util.utcnow() + interval @callback - def interval_listener(now: datetime) -> None: + def interval_listener(now): """Handle elapsed intervals.""" nonlocal remove remove = async_track_point_in_utc_time(hass, interval_listener, next_interval()) @@ -271,7 +269,7 @@ def interval_listener(now: datetime) -> None: remove = async_track_point_in_utc_time(hass, interval_listener, next_interval()) - def remove_listener() -> None: + def remove_listener(): """Remove interval listener.""" remove() From ba398864e5057bf35939924780b593ded3d3c1a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Sat, 19 Oct 2019 17:53:29 +0300 Subject: [PATCH 33/34] Change async_add_new_entities to a regular function --- homeassistant/components/huawei_lte/device_tracker.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/huawei_lte/device_tracker.py b/homeassistant/components/huawei_lte/device_tracker.py index 4c9460322e5a10..f54adf23c29aed 100644 --- a/homeassistant/components/huawei_lte/device_tracker.py +++ b/homeassistant/components/huawei_lte/device_tracker.py @@ -48,7 +48,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): and entity.config_entry_id == config_entry.entry_id ): tracked.add(entity.unique_id) - await async_add_new_entities(hass, router.url, async_add_entities, tracked, True) + async_add_new_entities(hass, router.url, async_add_entities, tracked, True) # Tell parent router to poll hosts list for new devices, if enabled if config_entry.options.setdefault(CONF_TRACK_NEW, DEFAULT_TRACK_NEW): @@ -57,7 +57,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async def _async_maybe_add_new_entities(url: str) -> None: """Add new entities if enabled and the update signal comes from our router.""" if url == router.url and config_entry.options.get(CONF_TRACK_NEW): - await async_add_new_entities(hass, url, async_add_entities, tracked) + async_add_new_entities(hass, url, async_add_entities, tracked) # Register to handle router data updates disconnect_dispatcher = async_dispatcher_connect( @@ -67,10 +67,10 @@ async def _async_maybe_add_new_entities(url: str) -> None: # Add new entities from initial scan, if enabled if config_entry.options.get(CONF_TRACK_NEW): - await async_add_new_entities(hass, router.url, async_add_entities, tracked) + async_add_new_entities(hass, router.url, async_add_entities, tracked) -async def async_add_new_entities( +def async_add_new_entities( hass, router_url, async_add_entities, tracked, included: bool = False ): """Add new entities. From 90e0e425b245f7b720c6a7b817d93b0e4906c798 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Thu, 24 Oct 2019 17:32:45 +0300 Subject: [PATCH 34/34] Remove option to disable polling for new device tracker entries --- .../components/huawei_lte/config_flow.py | 29 +++---------- homeassistant/components/huawei_lte/const.py | 1 - .../components/huawei_lte/device_tracker.py | 43 ++++--------------- 3 files changed, 15 insertions(+), 58 deletions(-) diff --git a/homeassistant/components/huawei_lte/config_flow.py b/homeassistant/components/huawei_lte/config_flow.py index 3e54f0bc2bacfa..52d586d088ac2b 100644 --- a/homeassistant/components/huawei_lte/config_flow.py +++ b/homeassistant/components/huawei_lte/config_flow.py @@ -19,9 +19,8 @@ from homeassistant import config_entries from homeassistant.const import CONF_PASSWORD, CONF_RECIPIENT, CONF_URL, CONF_USERNAME -from homeassistant.components.device_tracker import CONF_TRACK_NEW from homeassistant.core import callback -from .const import DEFAULT_DEVICE_NAME, DEFAULT_TRACK_NEW +from .const import DEFAULT_DEVICE_NAME # https://github.com/PyCQA/pylint/issues/3202 from .const import DOMAIN # pylint: disable=unused-import @@ -199,25 +198,11 @@ async def async_step_init(self, user_input=None): return self.async_create_entry(title="", data=user_input) data_schema = vol.Schema( - OrderedDict( - ( - ( - vol.Optional( - CONF_TRACK_NEW, - default=self.config_entry.options.get( - CONF_TRACK_NEW, DEFAULT_TRACK_NEW - ), - ), - bool, - ), - ( - vol.Optional( - CONF_RECIPIENT, - default=self.config_entry.options.get(CONF_RECIPIENT, ""), - ), - str, - ), - ) - ) + { + vol.Optional( + CONF_RECIPIENT, + default=self.config_entry.options.get(CONF_RECIPIENT, ""), + ): str + } ) return self.async_show_form(step_id="init", data_schema=data_schema) diff --git a/homeassistant/components/huawei_lte/const.py b/homeassistant/components/huawei_lte/const.py index bd4178ae45fd99..77126b61c22680 100644 --- a/homeassistant/components/huawei_lte/const.py +++ b/homeassistant/components/huawei_lte/const.py @@ -3,7 +3,6 @@ DOMAIN = "huawei_lte" DEFAULT_DEVICE_NAME = "LTE" -DEFAULT_TRACK_NEW = True UPDATE_SIGNAL = f"{DOMAIN}_update" UPDATE_OPTIONS_SIGNAL = f"{DOMAIN}_options_update" diff --git a/homeassistant/components/huawei_lte/device_tracker.py b/homeassistant/components/huawei_lte/device_tracker.py index f54adf23c29aed..d95d99e71264e2 100644 --- a/homeassistant/components/huawei_lte/device_tracker.py +++ b/homeassistant/components/huawei_lte/device_tracker.py @@ -8,22 +8,20 @@ from stringcase import snakecase from homeassistant.components.device_tracker import ( - CONF_TRACK_NEW, DOMAIN as DEVICE_TRACKER_DOMAIN, SOURCE_TYPE_ROUTER, ) from homeassistant.components.device_tracker.config_entry import ScannerEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_URL from homeassistant.helpers import entity_registry from homeassistant.helpers.dispatcher import async_dispatcher_connect from . import HuaweiLteBaseEntity -from .const import DEFAULT_TRACK_NEW, DOMAIN, KEY_WLAN_HOST_LIST, UPDATE_SIGNAL +from .const import DOMAIN, KEY_WLAN_HOST_LIST, UPDATE_SIGNAL _LOGGER = logging.getLogger(__name__) -_NEW_DEVICE_SCAN = f"{DEVICE_TRACKER_DOMAIN}/new_device_scan" +_DEVICE_SCAN = f"{DEVICE_TRACKER_DOMAIN}/device_scan" async def async_setup_entry(hass, config_entry, async_add_entities): @@ -50,13 +48,12 @@ async def async_setup_entry(hass, config_entry, async_add_entities): tracked.add(entity.unique_id) async_add_new_entities(hass, router.url, async_add_entities, tracked, True) - # Tell parent router to poll hosts list for new devices, if enabled - if config_entry.options.setdefault(CONF_TRACK_NEW, DEFAULT_TRACK_NEW): - router.subscriptions[KEY_WLAN_HOST_LIST].add(_NEW_DEVICE_SCAN) + # Tell parent router to poll hosts list to gather new devices + router.subscriptions[KEY_WLAN_HOST_LIST].add(_DEVICE_SCAN) async def _async_maybe_add_new_entities(url: str) -> None: - """Add new entities if enabled and the update signal comes from our router.""" - if url == router.url and config_entry.options.get(CONF_TRACK_NEW): + """Add new entities if the update signal comes from our router.""" + if url == router.url: async_add_new_entities(hass, url, async_add_entities, tracked) # Register to handle router data updates @@ -65,9 +62,8 @@ async def _async_maybe_add_new_entities(url: str) -> None: ) router.unload_handlers.append(disconnect_dispatcher) - # Add new entities from initial scan, if enabled - if config_entry.options.get(CONF_TRACK_NEW): - async_add_new_entities(hass, router.url, async_add_entities, tracked) + # Add new entities from initial scan + async_add_new_entities(hass, router.url, async_add_entities, tracked) def async_add_new_entities( @@ -120,20 +116,6 @@ class HuaweiLteScannerEntity(HuaweiLteBaseEntity, ScannerEntity): _name: str = attr.ib(init=False, default="device") _device_state_attributes: Dict[str, Any] = attr.ib(init=False, factory=dict) - async def async_added_to_hass(self): - """Subscribe to needed data on add.""" - await super().async_added_to_hass() - self.router.subscriptions[KEY_WLAN_HOST_LIST].add( - f"{DEVICE_TRACKER_DOMAIN}/{self.mac}" - ) - - async def async_will_remove_from_hass(self): - """Unsubscribe from needed data on remove.""" - await super().async_will_remove_from_hass() - self.router.subscriptions[KEY_WLAN_HOST_LIST].remove( - f"{DEVICE_TRACKER_DOMAIN}/{self.mac}" - ) - @property def _entity_name(self) -> str: return self._name @@ -170,15 +152,6 @@ async def async_update(self) -> None: if k not in ("MacAddress", "HostName") } - async def async_update_options(self, config_entry: ConfigEntry) -> None: - """Update config entry options.""" - await super().async_update_options(config_entry) - subscriptions = self.router.subscriptions[KEY_WLAN_HOST_LIST] - if config_entry.options.get(CONF_TRACK_NEW): - subscriptions.add(_NEW_DEVICE_SCAN) - else: - subscriptions.discard(_NEW_DEVICE_SCAN) - def get_scanner(*args, **kwargs): # pylint: disable=useless-return """Old no longer used way to set up Huawei LTE device tracker."""