feat: improve devices filter & optimize the network detection logic (#458)

* fix: fix miot http type error

* style: change some miot cloud log level

* feat: improve devices filter

* feat: update save devices logic

* refator: refactor miot network

* feat: update miot_client.get_miot_instance_async

* feat: option flow support network detect config

* doc: update translations

* feat: update config flow network detect logic

* style: change miot client refresh prop log level

* feat: config flow support network check

* doc: update translations

* refactor: rename func name

* fix: ignore invalid type error

* feat: option flow add check network deps

* --amend

* --amend

* feat: check mqtt broker

* feat: config flow support check network deps

* feat: update manifest requirements, paho-mqtt<2.0.0

* fix: fix mqtt broker check logic

* style: remove unuse params

* feat: show integration instance id

* feat: update data_schema from required to optional

* fix: translation text error
This commit is contained in:
Paul Shawn
2024-12-31 16:37:46 +08:00
committed by GitHub
parent 40a75bef28
commit 621ca8002b
16 changed files with 1250 additions and 408 deletions

View File

@ -1558,7 +1558,7 @@ class MIoTClient:
None)
self.__on_prop_msg(params=result, ctx=None)
if request_list:
_LOGGER.error(
_LOGGER.info(
'refresh props failed, cloud, %s',
list(request_list.keys()))
request_list = None
@ -1614,7 +1614,7 @@ class MIoTClient:
succeed_once = True
if succeed_once:
return True
_LOGGER.error(
_LOGGER.info(
'refresh props failed, gw, %s', list(request_list.keys()))
# Add failed request back to the list
self._refresh_props_list.update(request_list)
@ -1657,7 +1657,7 @@ class MIoTClient:
succeed_once = True
if succeed_once:
return True
_LOGGER.error(
_LOGGER.info(
'refresh props failed, lan, %s', list(request_list.keys()))
# Add failed request back to the list
self._refresh_props_list.update(request_list)
@ -1689,10 +1689,10 @@ class MIoTClient:
if self._refresh_props_timer:
self._refresh_props_timer.cancel()
self._refresh_props_timer = None
_LOGGER.error('refresh props failed, retry count exceed')
_LOGGER.info('refresh props failed, retry count exceed')
return
self._refresh_props_retry_count += 1
_LOGGER.error(
_LOGGER.info(
'refresh props failed, retry, %s', self._refresh_props_retry_count)
self._refresh_props_timer = self._main_loop.call_later(
3, lambda: self._main_loop.create_task(
@ -1851,15 +1851,6 @@ async def get_miot_instance_async(
loop: asyncio.AbstractEventLoop = asyncio.get_running_loop()
if loop is None:
raise MIoTClientError('loop is None')
# MIoT network
network: Optional[MIoTNetwork] = hass.data[DOMAIN].get(
'miot_network', None)
if not network:
network = MIoTNetwork(loop=loop)
hass.data[DOMAIN]['miot_network'] = network
await network.init_async(
refresh_interval=NETWORK_REFRESH_INTERVAL)
_LOGGER.info('create miot_network instance')
# MIoT storage
storage: Optional[MIoTStorage] = hass.data[DOMAIN].get(
'miot_storage', None)
@ -1868,12 +1859,29 @@ async def get_miot_instance_async(
root_path=entry_data['storage_path'], loop=loop)
hass.data[DOMAIN]['miot_storage'] = storage
_LOGGER.info('create miot_storage instance')
global_config: dict = await storage.load_user_config_async(
uid='global_config', cloud_server='all',
keys=['network_detect_addr', 'net_interfaces', 'enable_subscribe'])
# MIoT network
network_detect_addr: dict = global_config.get(
'network_detect_addr', {})
network: Optional[MIoTNetwork] = hass.data[DOMAIN].get(
'miot_network', None)
if not network:
network = MIoTNetwork(
ip_addr_list=network_detect_addr.get('ip', []),
url_addr_list=network_detect_addr.get('url', []),
refresh_interval=NETWORK_REFRESH_INTERVAL,
loop=loop)
hass.data[DOMAIN]['miot_network'] = network
await network.init_async()
_LOGGER.info('create miot_network instance')
# MIoT service
mips_service: Optional[MipsService] = hass.data[DOMAIN].get(
'mips_service', None)
if not mips_service:
aiozc = await zeroconf.async_get_async_instance(hass)
mips_service: MipsService = MipsService(aiozc=aiozc, loop=loop)
mips_service = MipsService(aiozc=aiozc, loop=loop)
hass.data[DOMAIN]['mips_service'] = mips_service
await mips_service.init_async()
_LOGGER.info('create mips_service instance')
@ -1881,15 +1889,11 @@ async def get_miot_instance_async(
miot_lan: Optional[MIoTLan] = hass.data[DOMAIN].get(
'miot_lan', None)
if not miot_lan:
lan_config = (await storage.load_user_config_async(
uid='global_config',
cloud_server='all',
keys=['net_interfaces', 'enable_subscribe'])) or {}
miot_lan = MIoTLan(
net_ifs=lan_config.get('net_interfaces', []),
net_ifs=global_config.get('net_interfaces', []),
network=network,
mips_service=mips_service,
enable_subscribe=lan_config.get('enable_subscribe', False),
enable_subscribe=global_config.get('enable_subscribe', False),
loop=loop)
hass.data[DOMAIN]['miot_lan'] = miot_lan
_LOGGER.info('create miot_lan instance')

View File

@ -224,20 +224,20 @@ class MIoTHttpClient:
_client_id: str
_access_token: str
_get_prop_timer: asyncio.TimerHandle
_get_prop_list: dict[str, dict[str, asyncio.Future | str | bool]]
_get_prop_timer: Optional[asyncio.TimerHandle]
_get_prop_list: dict[str, dict]
def __init__(
self, cloud_server: str, client_id: str, access_token: str,
loop: Optional[asyncio.AbstractEventLoop] = None
) -> None:
self._main_loop = loop or asyncio.get_running_loop()
self._host = None
self._base_url = None
self._client_id = None
self._access_token = None
self._host = DEFAULT_OAUTH2_API_HOST
self._base_url = ''
self._client_id = ''
self._access_token = ''
self._get_prop_timer: asyncio.TimerHandle = None
self._get_prop_timer = None
self._get_prop_list = {}
if (
@ -258,8 +258,9 @@ class MIoTHttpClient:
self._get_prop_timer.cancel()
self._get_prop_timer = None
for item in self._get_prop_list.values():
fut: asyncio.Future = item.get('fut')
fut.cancel()
fut: Optional[asyncio.Future] = item.get('fut', None)
if fut:
fut.cancel()
self._get_prop_list.clear()
if self._session and not self._session.closed:
await self._session.close()
@ -270,9 +271,7 @@ class MIoTHttpClient:
access_token: Optional[str] = None
) -> None:
if isinstance(cloud_server, str):
if cloud_server == 'cn':
self._host = DEFAULT_OAUTH2_API_HOST
else:
if cloud_server != 'cn':
self._host = f'{cloud_server}.{DEFAULT_OAUTH2_API_HOST}'
self._base_url = f'https://{self._host}'
if isinstance(client_id, str):
@ -350,8 +349,8 @@ class MIoTHttpClient:
async def get_user_info_async(self) -> dict:
http_res = await self._session.get(
url='https://open.account.xiaomi.com/user/profile',
params={'clientId': self._client_id,
'token': self._access_token},
params={
'clientId': self._client_id, 'token': self._access_token},
headers={'content-type': 'application/x-www-form-urlencoded'},
timeout=MIHOME_HTTP_API_TIMEOUT
)
@ -386,7 +385,9 @@ class MIoTHttpClient:
return cert
async def __get_dev_room_page_async(self, max_id: str = None) -> dict:
async def __get_dev_room_page_async(
self, max_id: Optional[str] = None
) -> dict:
res_obj = await self.__mihome_api_post_async(
url_path='/app/v2/homeroom/get_dev_room_page',
data={
@ -442,7 +443,7 @@ class MIoTHttpClient:
if 'result' not in res_obj:
raise MIoTHttpError('invalid response result')
uid: str = None
uid: Optional[str] = None
home_infos: dict = {}
for device_source in ['homelist', 'share_home_list']:
home_infos.setdefault(device_source, {})
@ -507,7 +508,7 @@ class MIoTHttpClient:
return (await self.get_homeinfos_async()).get('uid', None)
async def __get_device_list_page_async(
self, dids: list[str], start_did: str = None
self, dids: list[str], start_did: Optional[str] = None
) -> dict[str, dict]:
req_data: dict = {
'limit': 200,
@ -575,9 +576,9 @@ class MIoTHttpClient:
async def get_devices_with_dids_async(
self, dids: list[str]
) -> dict[str, dict]:
) -> Optional[dict[str, dict]]:
results: list[dict[str, dict]] = await asyncio.gather(
*[self.__get_device_list_page_async(dids[index:index+150])
*[self.__get_device_list_page_async(dids=dids[index:index+150])
for index in range(0, len(dids), 150)])
devices = {}
for result in results:
@ -587,7 +588,7 @@ class MIoTHttpClient:
return devices
async def get_devices_async(
self, home_ids: list[str] = None
self, home_ids: Optional[list[str]] = None
) -> dict[str, dict]:
homeinfos = await self.get_homeinfos_async()
homes: dict[str, dict[str, Any]] = {}
@ -627,8 +628,9 @@ class MIoTHttpClient:
'group_id': group_id
} for did in room_info.get('dids', [])})
dids = sorted(list(devices.keys()))
results: dict[str, dict] = await self.get_devices_with_dids_async(
dids=dids)
results = await self.get_devices_with_dids_async(dids=dids)
if results is None:
raise MIoTHttpError('get devices failed')
for did in dids:
if did not in results:
devices.pop(did, None)
@ -706,7 +708,7 @@ class MIoTHttpClient:
key = f'{result["did"]}.{result["siid"]}.{result["piid"]}'
prop_obj = self._get_prop_list.pop(key, None)
if prop_obj is None:
_LOGGER.error('get prop error, key not exists, %s', result)
_LOGGER.info('get prop error, key not exists, %s', result)
continue
prop_obj['fut'].set_result(result['value'])
props_req.remove(key)
@ -717,7 +719,7 @@ class MIoTHttpClient:
continue
prop_obj['fut'].set_result(None)
if props_req:
_LOGGER.error(
_LOGGER.info(
'get prop from cloud failed, %s, %s', len(key), props_req)
if self._get_prop_list:

View File

@ -53,6 +53,7 @@ from dataclasses import dataclass
from enum import Enum, auto
import subprocess
from typing import Callable, Coroutine, Optional
import aiohttp
import psutil
import ipaddress
@ -77,38 +78,54 @@ class NetworkInfo:
class MIoTNetwork:
"""MIoT network utilities."""
PING_ADDRESS_LIST = [
_IP_ADDRESS_LIST: list[str] = [
'1.2.4.8', # CNNIC sDNS
'8.8.8.8', # Google Public DNS
'233.5.5.5', # AliDNS
'1.1.1.1', # Cloudflare DNS
'114.114.114.114', # 114 DNS
'208.67.222.222', # OpenDNS
'9.9.9.9', # Quad9 DNS
'9.9.9.9' # Quad9
]
_URL_ADDRESS_LIST: list[str] = [
'https://www.bing.com',
'https://www.google.com',
'https://www.baidu.com'
]
_REFRESH_INTERVAL = 30
_DETECT_TIMEOUT = 6
_main_loop: asyncio.AbstractEventLoop
_ip_addr_map: dict[str, float]
_url_addr_list: dict[str, float]
_http_session: aiohttp.ClientSession
_refresh_interval: int
_refresh_task: asyncio.Task
_refresh_timer: asyncio.TimerHandle
_refresh_task: Optional[asyncio.Task]
_refresh_timer: Optional[asyncio.TimerHandle]
_network_status: bool
_network_info: dict[str, NetworkInfo]
_sub_list_network_status: dict[str, Callable[[bool], asyncio.Future]]
_sub_list_network_status: dict[str, Callable[[bool], Coroutine]]
_sub_list_network_info: dict[str, Callable[[
InterfaceStatus, NetworkInfo], Coroutine]]
_ping_address_priority: int
_done_event: asyncio.Event
def __init__(
self, loop: Optional[asyncio.AbstractEventLoop] = None
self,
ip_addr_list: Optional[list[str]] = None,
url_addr_list: Optional[list[str]] = None,
refresh_interval: Optional[int] = None,
loop: Optional[asyncio.AbstractEventLoop] = None
) -> None:
self._main_loop = loop or asyncio.get_running_loop()
self._ip_addr_map = {
ip: self._DETECT_TIMEOUT for ip in
ip_addr_list or self._IP_ADDRESS_LIST}
self._http_addr_map = {
url: self._DETECT_TIMEOUT for url in
url_addr_list or self._URL_ADDRESS_LIST}
self._http_session = aiohttp.ClientSession()
self._refresh_interval = refresh_interval or self._REFRESH_INTERVAL
self._refresh_interval = None
self._refresh_task = None
self._refresh_timer = None
@ -118,10 +135,28 @@ class MIoTNetwork:
self._sub_list_network_status = {}
self._sub_list_network_info = {}
self._ping_address_priority = 0
self._done_event = asyncio.Event()
async def init_async(self) -> bool:
self.__refresh_timer_handler()
# MUST get network info before starting
return await self._done_event.wait()
async def deinit_async(self) -> None:
if self._refresh_task:
self._refresh_task.cancel()
self._refresh_task = None
if self._refresh_timer:
self._refresh_timer.cancel()
self._refresh_timer = None
await self._http_session.close()
self._network_status = False
self._network_info.clear()
self._sub_list_network_status.clear()
self._sub_list_network_info.clear()
self._done_event.clear()
@property
def network_status(self) -> bool:
return self._network_status
@ -130,23 +165,28 @@ class MIoTNetwork:
def network_info(self) -> dict[str, NetworkInfo]:
return self._network_info
async def deinit_async(self) -> None:
if self._refresh_task:
self._refresh_task.cancel()
self._refresh_task = None
if self._refresh_timer:
self._refresh_timer.cancel()
self._refresh_timer = None
self._refresh_interval = None
self._network_status = False
self._network_info.clear()
self._sub_list_network_status.clear()
self._sub_list_network_info.clear()
self._done_event.clear()
async def update_addr_list_async(
self,
ip_addr_list: Optional[list[str]] = None,
url_addr_list: Optional[list[str]] = None,
) -> None:
new_ip_map: dict = {}
for ip in ip_addr_list or self._IP_ADDRESS_LIST:
if ip in self._ip_addr_map:
new_ip_map[ip] = self._ip_addr_map[ip]
else:
new_ip_map[ip] = self._DETECT_TIMEOUT
self._ip_addr_map = new_ip_map
new_url_map: dict = {}
for url in url_addr_list or self._URL_ADDRESS_LIST:
if url in self._http_addr_map:
new_url_map[url] = self._http_addr_map[url]
else:
new_url_map[url] = self._DETECT_TIMEOUT
self._http_addr_map = new_url_map
def sub_network_status(
self, key: str, handler: Callable[[bool], asyncio.Future]
self, key: str, handler: Callable[[bool], Coroutine]
) -> None:
self._sub_list_network_status[key] = handler
@ -162,58 +202,107 @@ class MIoTNetwork:
def unsub_network_info(self, key: str) -> None:
self._sub_list_network_info.pop(key, None)
async def init_async(self, refresh_interval: int = 30) -> bool:
self._refresh_interval = refresh_interval
self.__refresh_timer_handler()
# MUST get network info before starting
return await self._done_event.wait()
async def refresh_async(self) -> None:
self.__refresh_timer_handler()
async def get_network_status_async(self, timeout: int = 6) -> bool:
return await self._main_loop.run_in_executor(
None, self.__get_network_status, False, timeout)
async def get_network_status_async(self) -> bool:
try:
ip_addr: str = ''
ip_ts: float = self._DETECT_TIMEOUT
for ip, ts in self._ip_addr_map.items():
if ts < ip_ts:
ip_addr = ip
ip_ts = ts
if (
ip_ts < self._DETECT_TIMEOUT
and await self.ping_multi_async(ip_list=[ip_addr])
):
return True
url_addr: str = ''
url_ts: float = self._DETECT_TIMEOUT
for http, ts in self._http_addr_map.items():
if ts < url_ts:
url_addr = http
url_ts = ts
if (
url_ts < self._DETECT_TIMEOUT
and await self.http_multi_async(url_list=[url_addr])
):
return True
# Detect all addresses
results = await asyncio.gather(
*[self.ping_multi_async(), self.http_multi_async()])
return any(results)
except Exception as err: # pylint: disable=broad-exception-caught
_LOGGER.error('get network status error, %s', err)
return False
async def get_network_info_async(self) -> dict[str, NetworkInfo]:
return await self._main_loop.run_in_executor(
None, self.__get_network_info)
async def ping_multi_async(
self, ip_list: Optional[list[str]] = None
) -> bool:
addr_list = ip_list or list(self._ip_addr_map.keys())
tasks = []
for addr in addr_list:
tasks.append(self.__ping_async(addr))
results = await asyncio.gather(*tasks)
for addr, ts in zip(addr_list, results):
if addr in self._ip_addr_map:
self._ip_addr_map[addr] = ts
return any([ts < self._DETECT_TIMEOUT for ts in results])
async def http_multi_async(
self, url_list: Optional[list[str]] = None
) -> bool:
addr_list = url_list or list(self._http_addr_map.keys())
tasks = []
for addr in addr_list:
tasks.append(self.__http_async(url=addr))
results = await asyncio.gather(*tasks)
for addr, ts in zip(addr_list, results):
if addr in self._http_addr_map:
self._http_addr_map[addr] = ts
return any([ts < self._DETECT_TIMEOUT for ts in results])
def __calc_network_address(self, ip: str, netmask: str) -> str:
return str(ipaddress.IPv4Network(
f'{ip}/{netmask}', strict=False).network_address)
def __ping(
self, address: Optional[str] = None, timeout: int = 6
) -> bool:
param = '-n' if platform.system().lower() == 'windows' else '-c'
command = ['ping', param, '1', address]
async def __ping_async(self, address: Optional[str] = None) -> float:
start_ts: float = self._main_loop.time()
try:
output = subprocess.run(
command, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
check=True, timeout=timeout)
return output.returncode == 0
process = await asyncio.create_subprocess_exec(
*(
[
'ping', '-n', '1', '-w',
str(self._DETECT_TIMEOUT*1000), address]
if platform.system().lower() == 'windows' else
[
'ping', '-c', '1', '-w',
str(self._DETECT_TIMEOUT), address]),
stdout=subprocess.PIPE,
stderr=subprocess.PIPE
)
await process.communicate()
if process.returncode == 0:
return self._main_loop.time() - start_ts
return self._DETECT_TIMEOUT
except Exception as err: # pylint: disable=broad-exception-caught
print(err)
return self._DETECT_TIMEOUT
async def __http_async(self, url: str) -> float:
start_ts: float = self._main_loop.time()
try:
async with self._http_session.get(
url, timeout=self._DETECT_TIMEOUT):
return self._main_loop.time() - start_ts
except Exception: # pylint: disable=broad-exception-caught
return False
def __get_network_status(
self, with_retry: bool = True, timeout: int = 6
) -> bool:
if self._ping_address_priority >= len(self.PING_ADDRESS_LIST):
self._ping_address_priority = 0
if self.__ping(
self.PING_ADDRESS_LIST[self._ping_address_priority], timeout):
return True
if not with_retry:
return False
for index in range(len(self.PING_ADDRESS_LIST)):
if index == self._ping_address_priority:
continue
if self.__ping(self.PING_ADDRESS_LIST[index], timeout):
self._ping_address_priority = index
return True
return False
pass
return self._DETECT_TIMEOUT
def __get_network_info(self) -> dict[str, NetworkInfo]:
interfaces = psutil.net_if_addrs()
@ -246,12 +335,10 @@ class MIoTNetwork:
for handler in self._sub_list_network_info.values():
self._main_loop.create_task(handler(status, info))
async def __update_status_and_info_async(self, timeout: int = 6) -> None:
async def __update_status_and_info_async(self) -> None:
try:
status: bool = await self._main_loop.run_in_executor(
None, self.__get_network_status, timeout)
infos = await self._main_loop.run_in_executor(
None, self.__get_network_info)
status: bool = await self.get_network_status_async()
infos = await self.get_network_info_async()
if self._network_status != status:
for handler in self._sub_list_network_status.values():
@ -273,7 +360,7 @@ class MIoTNetwork:
# Remove
self.__call_network_info_change(
InterfaceStatus.REMOVE,
self._network_info.pop(name, None))
self._network_info.pop(name))
# Add
for name, info in infos.items():
self._network_info[name] = info