Implement stable unique id for Huawei LTE, requires credentials on setup (#49878)
parent
e652ef51a1
commit
91a2b96da0
|
@ -4,15 +4,11 @@ from __future__ import annotations
|
|||
from collections import defaultdict
|
||||
from contextlib import suppress
|
||||
from datetime import timedelta
|
||||
from functools import partial
|
||||
import ipaddress
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Callable, cast
|
||||
from urllib.parse import urlparse
|
||||
|
||||
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.Connection import Connection
|
||||
|
@ -34,6 +30,7 @@ from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
|
|||
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
|
||||
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
|
||||
from homeassistant.const import (
|
||||
CONF_MAC,
|
||||
CONF_NAME,
|
||||
CONF_PASSWORD,
|
||||
CONF_RECIPIENT,
|
||||
|
@ -41,12 +38,13 @@ from homeassistant.const import (
|
|||
CONF_USERNAME,
|
||||
EVENT_HOMEASSISTANT_STOP,
|
||||
)
|
||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, ServiceCall
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import (
|
||||
config_validation as cv,
|
||||
device_registry as dr,
|
||||
discovery,
|
||||
entity_registry,
|
||||
)
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send
|
||||
from homeassistant.helpers.entity import DeviceInfo, Entity
|
||||
|
@ -56,6 +54,8 @@ from homeassistant.helpers.typing import ConfigType
|
|||
from .const import (
|
||||
ADMIN_SERVICES,
|
||||
ALL_KEYS,
|
||||
ATTR_UNIQUE_ID,
|
||||
CONF_UNAUTHENTICATED_MODE,
|
||||
CONNECTION_TIMEOUT,
|
||||
DEFAULT_DEVICE_NAME,
|
||||
DEFAULT_NOTIFY_SERVICE_NAME,
|
||||
|
@ -81,6 +81,7 @@ from .const import (
|
|||
SERVICE_SUSPEND_INTEGRATION,
|
||||
UPDATE_SIGNAL,
|
||||
)
|
||||
from .utils import get_device_macs
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
@ -131,11 +132,10 @@ CONFIG_ENTRY_PLATFORMS = (
|
|||
class Router:
|
||||
"""Class for router state."""
|
||||
|
||||
hass: HomeAssistant = attr.ib()
|
||||
config_entry: ConfigEntry = attr.ib()
|
||||
connection: Connection = attr.ib()
|
||||
url: str = attr.ib()
|
||||
mac: str = 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(
|
||||
|
@ -165,15 +165,15 @@ class Router:
|
|||
@property
|
||||
def device_identifiers(self) -> set[tuple[str, str]]:
|
||||
"""Get router identifiers for device registry."""
|
||||
try:
|
||||
return {(DOMAIN, self.data[KEY_DEVICE_INFORMATION]["SerialNumber"])}
|
||||
except (KeyError, TypeError):
|
||||
return set()
|
||||
assert self.config_entry.unique_id is not None
|
||||
return {(DOMAIN, self.config_entry.unique_id)}
|
||||
|
||||
@property
|
||||
def device_connections(self) -> set[tuple[str, str]]:
|
||||
"""Get router connections for device registry."""
|
||||
return {(dr.CONNECTION_NETWORK_MAC, self.mac)} if self.mac else set()
|
||||
return {
|
||||
(dr.CONNECTION_NETWORK_MAC, x) for x in self.config_entry.data[CONF_MAC]
|
||||
}
|
||||
|
||||
def _get_data(self, key: str, func: Callable[[], Any]) -> None:
|
||||
if not self.subscriptions.get(key):
|
||||
|
@ -271,7 +271,7 @@ class Router:
|
|||
KEY_WLAN_WIFI_FEATURE_SWITCH, self.client.wlan.wifi_feature_switch
|
||||
)
|
||||
|
||||
self.signal_update()
|
||||
dispatcher_send(self.hass, UPDATE_SIGNAL, self.config_entry.unique_id)
|
||||
|
||||
def logout(self) -> None:
|
||||
"""Log out router session."""
|
||||
|
@ -304,7 +304,9 @@ class HuaweiLteData:
|
|||
routers: dict[str, Router] = attr.ib(init=False, factory=dict)
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
async def async_setup_entry( # noqa: C901
|
||||
hass: HomeAssistant, entry: ConfigEntry
|
||||
) -> bool:
|
||||
"""Set up Huawei LTE component from config entry."""
|
||||
url = entry.data[CONF_URL]
|
||||
|
||||
|
@ -342,61 +344,92 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||
options={**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
|
||||
# through authenticated calls (e.g. device_information.SerialNumber), and
|
||||
# we want this available and the same when unauthenticated too.
|
||||
host = urlparse(url).hostname
|
||||
try:
|
||||
if ipaddress.ip_address(host).version == 6:
|
||||
mode = "ip6"
|
||||
else:
|
||||
mode = "ip"
|
||||
except ValueError:
|
||||
mode = "hostname"
|
||||
mac = await hass.async_add_executor_job(partial(get_mac_address, **{mode: host}))
|
||||
|
||||
def get_connection() -> Connection:
|
||||
"""
|
||||
Set up a connection.
|
||||
|
||||
Authorized one if username/pass specified (even if empty), unauthorized one otherwise.
|
||||
"""
|
||||
username = entry.data.get(CONF_USERNAME)
|
||||
password = entry.data.get(CONF_PASSWORD)
|
||||
if username or password:
|
||||
connection: Connection = AuthorizedConnection(
|
||||
"""Set up a connection."""
|
||||
if entry.options.get(CONF_UNAUTHENTICATED_MODE):
|
||||
_LOGGER.debug("Connecting in unauthenticated mode, reduced feature set")
|
||||
connection = Connection(url, timeout=CONNECTION_TIMEOUT)
|
||||
else:
|
||||
_LOGGER.debug("Connecting in authenticated mode, full feature set")
|
||||
username = entry.data.get(CONF_USERNAME) or ""
|
||||
password = entry.data.get(CONF_PASSWORD) or ""
|
||||
connection = AuthorizedConnection(
|
||||
url, username=username, password=password, timeout=CONNECTION_TIMEOUT
|
||||
)
|
||||
else:
|
||||
connection = Connection(url, timeout=CONNECTION_TIMEOUT)
|
||||
return connection
|
||||
|
||||
def signal_update() -> None:
|
||||
"""Signal updates to data."""
|
||||
dispatcher_send(hass, UPDATE_SIGNAL, url)
|
||||
|
||||
try:
|
||||
connection = await hass.async_add_executor_job(get_connection)
|
||||
except Timeout as ex:
|
||||
raise ConfigEntryNotReady from ex
|
||||
|
||||
# Set up router and store reference to it
|
||||
router = Router(entry, connection, url, mac, signal_update)
|
||||
hass.data[DOMAIN].routers[url] = router
|
||||
# Set up router
|
||||
router = Router(hass, entry, connection, url)
|
||||
|
||||
# Do initial data update
|
||||
await hass.async_add_executor_job(router.update)
|
||||
|
||||
# Check that we found required information
|
||||
device_info = router.data.get(KEY_DEVICE_INFORMATION)
|
||||
if not entry.unique_id:
|
||||
# Transitional from < 2021.8: update None config entry and entity unique ids
|
||||
if device_info and (serial_number := device_info.get("SerialNumber")):
|
||||
hass.config_entries.async_update_entry(entry, unique_id=serial_number)
|
||||
ent_reg = entity_registry.async_get(hass)
|
||||
for entity_entry in entity_registry.async_entries_for_config_entry(
|
||||
ent_reg, entry.entry_id
|
||||
):
|
||||
if not entity_entry.unique_id.startswith("None-"):
|
||||
continue
|
||||
new_unique_id = (
|
||||
f"{serial_number}-{entity_entry.unique_id.split('-', 1)[1]}"
|
||||
)
|
||||
ent_reg.async_update_entity(
|
||||
entity_entry.entity_id, new_unique_id=new_unique_id
|
||||
)
|
||||
else:
|
||||
await hass.async_add_executor_job(router.cleanup)
|
||||
msg = (
|
||||
"Could not resolve serial number to use as unique id for router at %s"
|
||||
", setup failed"
|
||||
)
|
||||
if not entry.data.get(CONF_PASSWORD):
|
||||
msg += (
|
||||
". Try setting up credentials for the router for one startup, "
|
||||
"unauthenticated mode can be enabled after that in integration "
|
||||
"settings"
|
||||
)
|
||||
_LOGGER.error(msg, url)
|
||||
return False
|
||||
|
||||
# Store reference to router
|
||||
hass.data[DOMAIN].routers[entry.unique_id] = router
|
||||
|
||||
# Clear all subscriptions, enabled entities will push back theirs
|
||||
router.subscriptions.clear()
|
||||
|
||||
# Update device MAC addresses on record. These can change due to toggling between
|
||||
# authenticated and unauthenticated modes, or likely also when enabling/disabling
|
||||
# SSIDs in the router config.
|
||||
try:
|
||||
wlan_settings = await hass.async_add_executor_job(
|
||||
router.client.wlan.multi_basic_settings
|
||||
)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
# Assume not supported, or authentication required but in unauthenticated mode
|
||||
wlan_settings = {}
|
||||
macs = get_device_macs(device_info or {}, wlan_settings)
|
||||
# Be careful not to overwrite a previous, more complete set with a partial one
|
||||
if macs and (not entry.data[CONF_MAC] or (device_info and wlan_settings)):
|
||||
new_data = dict(entry.data)
|
||||
new_data[CONF_MAC] = macs
|
||||
hass.config_entries.async_update_entry(entry, data=new_data)
|
||||
|
||||
# Set up device registry
|
||||
if router.device_identifiers or router.device_connections:
|
||||
device_data = {}
|
||||
sw_version = None
|
||||
if router.data.get(KEY_DEVICE_INFORMATION):
|
||||
device_info = router.data[KEY_DEVICE_INFORMATION]
|
||||
if device_info:
|
||||
sw_version = device_info.get("SoftwareVersion")
|
||||
if device_info.get("DeviceName"):
|
||||
device_data["model"] = device_info["DeviceName"]
|
||||
|
@ -425,7 +458,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||
NOTIFY_DOMAIN,
|
||||
DOMAIN,
|
||||
{
|
||||
CONF_URL: url,
|
||||
ATTR_UNIQUE_ID: entry.unique_id,
|
||||
CONF_NAME: entry.options.get(CONF_NAME, DEFAULT_NOTIFY_SERVICE_NAME),
|
||||
CONF_RECIPIENT: entry.options.get(CONF_RECIPIENT),
|
||||
},
|
||||
|
@ -462,7 +495,7 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
|
|||
)
|
||||
|
||||
# Forget about the router and invoke its cleanup
|
||||
router = hass.data[DOMAIN].routers.pop(config_entry.data[CONF_URL])
|
||||
router = hass.data[DOMAIN].routers.pop(config_entry.unique_id)
|
||||
await hass.async_add_executor_job(router.cleanup)
|
||||
|
||||
return True
|
||||
|
@ -483,10 +516,17 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
|||
domain_config[url_normalize(router_config.pop(CONF_URL))] = router_config
|
||||
|
||||
def service_handler(service: ServiceCall) -> None:
|
||||
"""Apply a service."""
|
||||
"""
|
||||
Apply a service.
|
||||
|
||||
We key this using the router URL instead of its unique id / serial number,
|
||||
because the latter is not available anywhere in the UI.
|
||||
"""
|
||||
routers = hass.data[DOMAIN].routers
|
||||
if url := service.data.get(CONF_URL):
|
||||
router = routers.get(url)
|
||||
router = next(
|
||||
(router for router in routers.values() if router.url == url), None
|
||||
)
|
||||
elif not routers:
|
||||
_LOGGER.error("%s: no routers configured", service.service)
|
||||
return
|
||||
|
@ -496,7 +536,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
|||
_LOGGER.error(
|
||||
"%s: more than one router configured, must specify one of URLs %s",
|
||||
service.service,
|
||||
sorted(routers),
|
||||
sorted(router.url for router in routers.values()),
|
||||
)
|
||||
return
|
||||
if not router:
|
||||
|
@ -560,6 +600,12 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
|
|||
config_entry.version = 2
|
||||
hass.config_entries.async_update_entry(config_entry, options=options)
|
||||
_LOGGER.info("Migrated config entry to version %d", config_entry.version)
|
||||
if config_entry.version == 2:
|
||||
config_entry.version = 3
|
||||
data = dict(config_entry.data)
|
||||
data[CONF_MAC] = []
|
||||
hass.config_entries.async_update_entry(config_entry, data=data)
|
||||
_LOGGER.info("Migrated config entry to version %d", config_entry.version)
|
||||
return True
|
||||
|
||||
|
||||
|
@ -584,7 +630,7 @@ class HuaweiLteBaseEntity(Entity):
|
|||
@property
|
||||
def unique_id(self) -> str:
|
||||
"""Return unique ID for entity."""
|
||||
return f"{self.router.mac}-{self._device_unique_id}"
|
||||
return f"{self.router.config_entry.unique_id}-{self._device_unique_id}"
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
|
|
|
@ -12,7 +12,6 @@ from homeassistant.components.binary_sensor import (
|
|||
BinarySensorEntity,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_URL
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
@ -34,7 +33,7 @@ async def async_setup_entry(
|
|||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up from config entry."""
|
||||
router = hass.data[DOMAIN].routers[config_entry.data[CONF_URL]]
|
||||
router = hass.data[DOMAIN].routers[config_entry.unique_id]
|
||||
entities: list[Entity] = []
|
||||
|
||||
if router.data.get(KEY_MONITORING_STATUS):
|
||||
|
|
|
@ -7,7 +7,7 @@ from urllib.parse import urlparse
|
|||
|
||||
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.Connection import GetResponseType
|
||||
from huawei_lte_api.exceptions import (
|
||||
LoginErrorPasswordWrongException,
|
||||
LoginErrorUsernamePasswordOverrunException,
|
||||
|
@ -22,6 +22,7 @@ import voluptuous as vol
|
|||
from homeassistant import config_entries
|
||||
from homeassistant.components import ssdp
|
||||
from homeassistant.const import (
|
||||
CONF_MAC,
|
||||
CONF_NAME,
|
||||
CONF_PASSWORD,
|
||||
CONF_RECIPIENT,
|
||||
|
@ -34,12 +35,15 @@ from homeassistant.helpers.typing import DiscoveryInfoType
|
|||
|
||||
from .const import (
|
||||
CONF_TRACK_WIRED_CLIENTS,
|
||||
CONF_UNAUTHENTICATED_MODE,
|
||||
CONNECTION_TIMEOUT,
|
||||
DEFAULT_DEVICE_NAME,
|
||||
DEFAULT_NOTIFY_SERVICE_NAME,
|
||||
DEFAULT_TRACK_WIRED_CLIENTS,
|
||||
DEFAULT_UNAUTHENTICATED_MODE,
|
||||
DOMAIN,
|
||||
)
|
||||
from .utils import get_device_macs
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
@ -47,7 +51,7 @@ _LOGGER = logging.getLogger(__name__)
|
|||
class ConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
"""Handle Huawei LTE config flow."""
|
||||
|
||||
VERSION = 2
|
||||
VERSION = 3
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
|
@ -76,10 +80,10 @@ class ConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
|||
),
|
||||
): str,
|
||||
vol.Optional(
|
||||
CONF_USERNAME, default=user_input.get(CONF_USERNAME, "")
|
||||
CONF_USERNAME, default=user_input.get(CONF_USERNAME) or ""
|
||||
): str,
|
||||
vol.Optional(
|
||||
CONF_PASSWORD, default=user_input.get(CONF_PASSWORD, "")
|
||||
CONF_PASSWORD, default=user_input.get(CONF_PASSWORD) or ""
|
||||
): str,
|
||||
}
|
||||
),
|
||||
|
@ -92,15 +96,7 @@ class ConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
|||
"""Handle import initiated config flow."""
|
||||
return await self.async_step_user(user_input)
|
||||
|
||||
def _already_configured(self, user_input: dict[str, Any]) -> bool:
|
||||
"""See if we already have a router matching user input configured."""
|
||||
existing_urls = {
|
||||
url_normalize(entry.data[CONF_URL], default_scheme="http")
|
||||
for entry in self._async_current_entries()
|
||||
}
|
||||
return user_input[CONF_URL] in existing_urls
|
||||
|
||||
async def async_step_user( # noqa: C901
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Handle user initiated config flow."""
|
||||
|
@ -119,68 +115,46 @@ class ConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
|||
user_input=user_input, errors=errors
|
||||
)
|
||||
|
||||
if self._already_configured(user_input):
|
||||
return self.async_abort(reason="already_configured")
|
||||
|
||||
conn: Connection | None = None
|
||||
conn: AuthorizedConnection
|
||||
|
||||
def logout() -> None:
|
||||
if isinstance(conn, AuthorizedConnection):
|
||||
try:
|
||||
conn.user.logout()
|
||||
except Exception: # pylint: disable=broad-except
|
||||
_LOGGER.debug("Could not logout", exc_info=True)
|
||||
try:
|
||||
conn.user.logout()
|
||||
except Exception: # pylint: disable=broad-except
|
||||
_LOGGER.debug("Could not logout", exc_info=True)
|
||||
|
||||
def try_connect(user_input: dict[str, Any]) -> Connection:
|
||||
def try_connect(user_input: dict[str, Any]) -> AuthorizedConnection:
|
||||
"""Try connecting with given credentials."""
|
||||
username = user_input.get(CONF_USERNAME)
|
||||
password = user_input.get(CONF_PASSWORD)
|
||||
conn: Connection
|
||||
if username or password:
|
||||
conn = AuthorizedConnection(
|
||||
user_input[CONF_URL],
|
||||
username=username,
|
||||
password=password,
|
||||
timeout=CONNECTION_TIMEOUT,
|
||||
)
|
||||
else:
|
||||
try:
|
||||
conn = AuthorizedConnection(
|
||||
user_input[CONF_URL],
|
||||
username="",
|
||||
password="",
|
||||
timeout=CONNECTION_TIMEOUT,
|
||||
)
|
||||
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], timeout=CONNECTION_TIMEOUT)
|
||||
del user_input[CONF_USERNAME]
|
||||
del user_input[CONF_PASSWORD]
|
||||
username = user_input.get(CONF_USERNAME) or ""
|
||||
password = user_input.get(CONF_PASSWORD) or ""
|
||||
conn = AuthorizedConnection(
|
||||
user_input[CONF_URL],
|
||||
username=username,
|
||||
password=password,
|
||||
timeout=CONNECTION_TIMEOUT,
|
||||
)
|
||||
return conn
|
||||
|
||||
def get_router_title(conn: Connection) -> str:
|
||||
"""Get title for router."""
|
||||
title = None
|
||||
def get_device_info() -> tuple[GetResponseType, GetResponseType]:
|
||||
"""Get router info."""
|
||||
client = Client(conn)
|
||||
try:
|
||||
info = client.device.basic_information()
|
||||
device_info = client.device.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:
|
||||
_LOGGER.debug("Could not get device.information", exc_info=True)
|
||||
try:
|
||||
info = client.device.information()
|
||||
device_info = client.device.basic_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
|
||||
_LOGGER.debug(
|
||||
"Could not get device.basic_information", exc_info=True
|
||||
)
|
||||
device_info = {}
|
||||
try:
|
||||
wlan_settings = client.wlan.multi_basic_settings()
|
||||
except Exception: # pylint: disable=broad-except
|
||||
_LOGGER.debug("Could not get wlan.multi_basic_settings", exc_info=True)
|
||||
wlan_settings = {}
|
||||
return device_info, wlan_settings
|
||||
|
||||
try:
|
||||
conn = await self.hass.async_add_executor_job(try_connect, user_input)
|
||||
|
@ -207,11 +181,25 @@ class ConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
|||
user_input=user_input, errors=errors
|
||||
)
|
||||
|
||||
title = self.context.get("title_placeholders", {}).get(
|
||||
CONF_NAME
|
||||
) or await self.hass.async_add_executor_job(get_router_title, conn)
|
||||
info, wlan_settings = await self.hass.async_add_executor_job(get_device_info)
|
||||
await self.hass.async_add_executor_job(logout)
|
||||
|
||||
if not self.unique_id:
|
||||
if serial_number := info.get("SerialNumber"):
|
||||
await self.async_set_unique_id(serial_number)
|
||||
self._abort_if_unique_id_configured()
|
||||
else:
|
||||
await self._async_handle_discovery_without_unique_id()
|
||||
|
||||
user_input[CONF_MAC] = get_device_macs(info, wlan_settings)
|
||||
|
||||
title = (
|
||||
self.context.get("title_placeholders", {}).get(CONF_NAME)
|
||||
or info.get("DeviceName") # device.information
|
||||
or info.get("devicename") # device.basic_information
|
||||
or DEFAULT_DEVICE_NAME
|
||||
)
|
||||
|
||||
return self.async_create_entry(title=title, data=user_input)
|
||||
|
||||
async def async_step_ssdp(self, discovery_info: DiscoveryInfoType) -> FlowResult:
|
||||
|
@ -224,21 +212,20 @@ class ConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
|||
if "mobile" not in discovery_info.get(ssdp.ATTR_UPNP_FRIENDLY_NAME, "").lower():
|
||||
return self.async_abort(reason="not_huawei_lte")
|
||||
|
||||
url = self.context[CONF_URL] = url_normalize(
|
||||
url = url_normalize(
|
||||
discovery_info.get(
|
||||
ssdp.ATTR_UPNP_PRESENTATION_URL,
|
||||
f"http://{urlparse(discovery_info[ssdp.ATTR_SSDP_LOCATION]).hostname}/",
|
||||
)
|
||||
)
|
||||
|
||||
if any(
|
||||
url == flow["context"].get(CONF_URL) for flow in self._async_in_progress()
|
||||
):
|
||||
return self.async_abort(reason="already_in_progress")
|
||||
if serial_number := discovery_info.get(ssdp.ATTR_UPNP_SERIAL):
|
||||
await self.async_set_unique_id(serial_number)
|
||||
self._abort_if_unique_id_configured()
|
||||
else:
|
||||
await self._async_handle_discovery_without_unique_id()
|
||||
|
||||
user_input = {CONF_URL: url}
|
||||
if self._already_configured(user_input):
|
||||
return self.async_abort(reason="already_configured")
|
||||
|
||||
self.context["title_placeholders"] = {
|
||||
CONF_NAME: discovery_info.get(ssdp.ATTR_UPNP_FRIENDLY_NAME)
|
||||
|
@ -289,6 +276,12 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
|
|||
CONF_TRACK_WIRED_CLIENTS, DEFAULT_TRACK_WIRED_CLIENTS
|
||||
),
|
||||
): bool,
|
||||
vol.Optional(
|
||||
CONF_UNAUTHENTICATED_MODE,
|
||||
default=self.config_entry.options.get(
|
||||
CONF_UNAUTHENTICATED_MODE, DEFAULT_UNAUTHENTICATED_MODE
|
||||
),
|
||||
): bool,
|
||||
}
|
||||
)
|
||||
return self.async_show_form(step_id="init", data_schema=data_schema)
|
||||
|
|
|
@ -2,11 +2,15 @@
|
|||
|
||||
DOMAIN = "huawei_lte"
|
||||
|
||||
ATTR_UNIQUE_ID = "unique_id"
|
||||
|
||||
CONF_TRACK_WIRED_CLIENTS = "track_wired_clients"
|
||||
CONF_UNAUTHENTICATED_MODE = "unauthenticated_mode"
|
||||
|
||||
DEFAULT_DEVICE_NAME = "LTE"
|
||||
DEFAULT_NOTIFY_SERVICE_NAME = DOMAIN
|
||||
DEFAULT_TRACK_WIRED_CLIENTS = True
|
||||
DEFAULT_UNAUTHENTICATED_MODE = False
|
||||
|
||||
UPDATE_SIGNAL = f"{DOMAIN}_update"
|
||||
|
||||
|
|
|
@ -14,7 +14,6 @@ from homeassistant.components.device_tracker.const import (
|
|||
SOURCE_TYPE_ROUTER,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_URL
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import entity_registry
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
|
@ -61,7 +60,7 @@ async def async_setup_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]]
|
||||
router = hass.data[DOMAIN].routers[config_entry.unique_id]
|
||||
if (hosts := _get_hosts(router, True)) is None:
|
||||
return
|
||||
|
||||
|
@ -94,10 +93,10 @@ async def async_setup_entry(
|
|||
router.subscriptions[KEY_LAN_HOST_INFO].add(_DEVICE_SCAN)
|
||||
router.subscriptions[KEY_WLAN_HOST_LIST].add(_DEVICE_SCAN)
|
||||
|
||||
async def _async_maybe_add_new_entities(url: str) -> None:
|
||||
async def _async_maybe_add_new_entities(unique_id: str) -> None:
|
||||
"""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)
|
||||
if config_entry.unique_id == unique_id:
|
||||
async_add_new_entities(router, async_add_entities, tracked)
|
||||
|
||||
# Register to handle router data updates
|
||||
disconnect_dispatcher = async_dispatcher_connect(
|
||||
|
@ -106,7 +105,7 @@ async def async_setup_entry(
|
|||
config_entry.async_on_unload(disconnect_dispatcher)
|
||||
|
||||
# Add new entities from initial scan
|
||||
async_add_new_entities(hass, router.url, async_add_entities, tracked)
|
||||
async_add_new_entities(router, async_add_entities, tracked)
|
||||
|
||||
|
||||
def _is_wireless(host: _HostType) -> bool:
|
||||
|
@ -129,13 +128,11 @@ def _is_us(host: _HostType) -> bool:
|
|||
|
||||
@callback
|
||||
def async_add_new_entities(
|
||||
hass: HomeAssistant,
|
||||
router_url: str,
|
||||
router: Router,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
tracked: set[str],
|
||||
) -> None:
|
||||
"""Add new entities that are not already being tracked."""
|
||||
router = hass.data[DOMAIN].routers[router_url]
|
||||
hosts = _get_hosts(router)
|
||||
if not hosts:
|
||||
return
|
||||
|
|
|
@ -4,7 +4,6 @@
|
|||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/huawei_lte",
|
||||
"requirements": [
|
||||
"getmac==0.8.2",
|
||||
"huawei-lte-api==1.4.18",
|
||||
"stringcase==1.2.0",
|
||||
"url-normalize==1.4.1"
|
||||
|
|
|
@ -9,11 +9,11 @@ import attr
|
|||
from huawei_lte_api.exceptions import ResponseErrorException
|
||||
|
||||
from homeassistant.components.notify import ATTR_TARGET, BaseNotificationService
|
||||
from homeassistant.const import CONF_RECIPIENT, CONF_URL
|
||||
from homeassistant.const import CONF_RECIPIENT
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from . import Router
|
||||
from .const import DOMAIN
|
||||
from .const import ATTR_UNIQUE_ID, DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
@ -27,7 +27,7 @@ async def async_get_service(
|
|||
if discovery_info is None:
|
||||
return None
|
||||
|
||||
router = hass.data[DOMAIN].routers[discovery_info[CONF_URL]]
|
||||
router = hass.data[DOMAIN].routers[discovery_info[ATTR_UNIQUE_ID]]
|
||||
default_targets = discovery_info[CONF_RECIPIENT] or []
|
||||
|
||||
return HuaweiLteSmsNotificationService(router, default_targets)
|
||||
|
|
|
@ -16,7 +16,6 @@ from homeassistant.components.sensor import (
|
|||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
CONF_URL,
|
||||
DATA_BYTES,
|
||||
DATA_RATE_BYTES_PER_SECOND,
|
||||
PERCENTAGE,
|
||||
|
@ -360,7 +359,7 @@ async def async_setup_entry(
|
|||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up from config entry."""
|
||||
router = hass.data[DOMAIN].routers[config_entry.data[CONF_URL]]
|
||||
router = hass.data[DOMAIN].routers[config_entry.unique_id]
|
||||
sensors: list[Entity] = []
|
||||
for key in SENSOR_KEYS:
|
||||
if not (items := router.data.get(key)):
|
||||
|
|
|
@ -1,8 +1,6 @@
|
|||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
|
||||
"not_huawei_lte": "Not a Huawei LTE device"
|
||||
},
|
||||
"error": {
|
||||
|
@ -23,7 +21,7 @@
|
|||
"url": "[%key:common::config_flow::data::url%]",
|
||||
"username": "[%key:common::config_flow::data::username%]"
|
||||
},
|
||||
"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.",
|
||||
"description": "Enter device access details.",
|
||||
"title": "Configure Huawei LTE"
|
||||
}
|
||||
}
|
||||
|
@ -34,7 +32,8 @@
|
|||
"data": {
|
||||
"name": "Notification service name (change requires restart)",
|
||||
"recipient": "SMS notification recipients",
|
||||
"track_wired_clients": "Track wired network clients"
|
||||
"track_wired_clients": "Track wired network clients",
|
||||
"unauthenticated_mode": "Unauthenticated mode (change requires reload)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,7 +12,6 @@ from homeassistant.components.switch import (
|
|||
SwitchEntity,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_URL
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
@ -29,7 +28,7 @@ async def async_setup_entry(
|
|||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up from config entry."""
|
||||
router = hass.data[DOMAIN].routers[config_entry.data[CONF_URL]]
|
||||
router = hass.data[DOMAIN].routers[config_entry.unique_id]
|
||||
switches: list[Entity] = []
|
||||
|
||||
if router.data.get(KEY_DIALUP_MOBILE_DATASWITCH):
|
||||
|
|
|
@ -0,0 +1,23 @@
|
|||
"""Utilities for the Huawei LTE integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
from huawei_lte_api.Connection import GetResponseType
|
||||
|
||||
from homeassistant.helpers.device_registry import format_mac
|
||||
|
||||
|
||||
def get_device_macs(
|
||||
device_info: GetResponseType, wlan_settings: GetResponseType
|
||||
) -> list[str]:
|
||||
"""Get list of device MAC addresses.
|
||||
|
||||
:param device_info: the device.information structure for the device
|
||||
:param wlan_settings: the wlan.multi_basic_settings structure for the device
|
||||
"""
|
||||
macs = [device_info.get("MacAddress1"), device_info.get("MacAddress2")]
|
||||
try:
|
||||
macs.extend(x.get("WifiMac") for x in wlan_settings["Ssids"]["Ssid"])
|
||||
except Exception: # pylint: disable=broad-except
|
||||
# Assume not supported
|
||||
pass
|
||||
return sorted({format_mac(str(x)) for x in macs if x})
|
|
@ -670,7 +670,6 @@ georss_ign_sismologia_client==0.3
|
|||
# homeassistant.components.qld_bushfire
|
||||
georss_qld_bushfire_alert_client==0.5
|
||||
|
||||
# homeassistant.components.huawei_lte
|
||||
# homeassistant.components.kef
|
||||
# homeassistant.components.minecraft_server
|
||||
# homeassistant.components.nmap_tracker
|
||||
|
|
|
@ -376,7 +376,6 @@ georss_ign_sismologia_client==0.3
|
|||
# homeassistant.components.qld_bushfire
|
||||
georss_qld_bushfire_alert_client==0.5
|
||||
|
||||
# homeassistant.components.huawei_lte
|
||||
# homeassistant.components.kef
|
||||
# homeassistant.components.minecraft_server
|
||||
# homeassistant.components.nmap_tracker
|
||||
|
|
|
@ -10,7 +10,7 @@ from requests_mock import ANY
|
|||
|
||||
from homeassistant import config_entries, data_entry_flow
|
||||
from homeassistant.components import ssdp
|
||||
from homeassistant.components.huawei_lte.const import DOMAIN
|
||||
from homeassistant.components.huawei_lte.const import CONF_UNAUTHENTICATED_MODE, DOMAIN
|
||||
from homeassistant.const import (
|
||||
CONF_NAME,
|
||||
CONF_PASSWORD,
|
||||
|
@ -21,6 +21,8 @@ from homeassistant.const import (
|
|||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
FIXTURE_UNIQUE_ID = "SERIALNUMBER"
|
||||
|
||||
FIXTURE_USER_INPUT = {
|
||||
CONF_URL: "http://192.168.1.1/",
|
||||
CONF_USERNAME: "admin",
|
||||
|
@ -57,20 +59,30 @@ async def test_urlize_plain_host(hass, requests_mock):
|
|||
assert user_input[CONF_URL] == f"http://{host}/"
|
||||
|
||||
|
||||
async def test_already_configured(hass):
|
||||
async def test_already_configured(hass, requests_mock, login_requests_mock):
|
||||
"""Test we reject already configured devices."""
|
||||
MockConfigEntry(
|
||||
domain=DOMAIN, data=FIXTURE_USER_INPUT, title="Already configured"
|
||||
domain=DOMAIN,
|
||||
unique_id=FIXTURE_UNIQUE_ID,
|
||||
data=FIXTURE_USER_INPUT,
|
||||
title="Already configured",
|
||||
).add_to_hass(hass)
|
||||
|
||||
login_requests_mock.request(
|
||||
ANY,
|
||||
f"{FIXTURE_USER_INPUT[CONF_URL]}api/user/login",
|
||||
text="<response>OK</response>",
|
||||
)
|
||||
requests_mock.request(
|
||||
ANY,
|
||||
f"{FIXTURE_USER_INPUT[CONF_URL]}api/device/information",
|
||||
text=f"<response><SerialNumber>{FIXTURE_UNIQUE_ID}</SerialNumber></response>",
|
||||
)
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": config_entries.SOURCE_USER},
|
||||
data={
|
||||
**FIXTURE_USER_INPUT,
|
||||
# Tweak URL a bit to check that doesn't fail duplicate detection
|
||||
CONF_URL: FIXTURE_USER_INPUT[CONF_URL].replace("http", "HTTP"),
|
||||
},
|
||||
data=FIXTURE_USER_INPUT,
|
||||
)
|
||||
|
||||
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
|
||||
|
@ -182,7 +194,7 @@ async def test_ssdp(hass):
|
|||
|
||||
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
|
||||
assert result["step_id"] == "user"
|
||||
assert context[CONF_URL] == url
|
||||
assert result["data_schema"]({})[CONF_URL] == url
|
||||
|
||||
|
||||
async def test_options(hass):
|
||||
|
@ -203,3 +215,4 @@ async def test_options(hass):
|
|||
)
|
||||
assert result["data"][CONF_NAME] == DOMAIN
|
||||
assert result["data"][CONF_RECIPIENT] == [recipient]
|
||||
assert result["data"][CONF_UNAUTHENTICATED_MODE] is False
|
||||
|
|
Loading…
Reference in New Issue