|
|
|
|
|
# -*- coding: utf-8 -*-
|
|
|
|
|
|
"""
|
|
|
|
|
|
Copyright (C) 2024 Xiaomi Corporation.
|
|
|
|
|
|
|
|
|
|
|
|
The ownership and intellectual property rights of Xiaomi Home Assistant
|
|
|
|
|
|
Integration and related Xiaomi cloud service API interface provided under this
|
|
|
|
|
|
license, including source code and object code (collectively, "Licensed Work"),
|
|
|
|
|
|
are owned by Xiaomi. Subject to the terms and conditions of this License, Xiaomi
|
|
|
|
|
|
hereby grants you a personal, limited, non-exclusive, non-transferable,
|
|
|
|
|
|
non-sublicensable, and royalty-free license to reproduce, use, modify, and
|
|
|
|
|
|
distribute the Licensed Work only for your use of Home Assistant for
|
|
|
|
|
|
non-commercial purposes. For the avoidance of doubt, Xiaomi does not authorize
|
|
|
|
|
|
you to use the Licensed Work for any other purpose, including but not limited
|
|
|
|
|
|
to use Licensed Work to develop applications (APP), Web services, and other
|
|
|
|
|
|
forms of software.
|
|
|
|
|
|
|
|
|
|
|
|
You may reproduce and distribute copies of the Licensed Work, with or without
|
|
|
|
|
|
modifications, whether in source or object form, provided that you must give
|
|
|
|
|
|
any other recipients of the Licensed Work a copy of this License and retain all
|
|
|
|
|
|
copyright and disclaimers.
|
|
|
|
|
|
|
|
|
|
|
|
Xiaomi provides the Licensed Work on an "AS IS" BASIS, WITHOUT WARRANTIES OR
|
|
|
|
|
|
CONDITIONS OF ANY KIND, either express or implied, including, without
|
|
|
|
|
|
limitation, any warranties, undertakes, or conditions of TITLE, NO ERROR OR
|
|
|
|
|
|
OMISSION, CONTINUITY, RELIABILITY, NON-INFRINGEMENT, MERCHANTABILITY, or
|
|
|
|
|
|
FITNESS FOR A PARTICULAR PURPOSE. In any event, you are solely responsible
|
|
|
|
|
|
for any direct, indirect, special, incidental, or consequential damages or
|
|
|
|
|
|
losses arising from the use or inability to use the Licensed Work.
|
|
|
|
|
|
|
|
|
|
|
|
Xiaomi reserves all rights not expressly granted to you in this License.
|
|
|
|
|
|
Except for the rights expressly granted by Xiaomi under this License, Xiaomi
|
|
|
|
|
|
does not authorize you in any form to use the trademarks, copyrights, or other
|
|
|
|
|
|
forms of intellectual property rights of Xiaomi and its affiliates, including,
|
|
|
|
|
|
without limitation, without obtaining other written permission from Xiaomi, you
|
|
|
|
|
|
shall not use "Xiaomi", "Mijia" and other words related to Xiaomi or words that
|
|
|
|
|
|
may make the public associate with Xiaomi in any form to publicize or promote
|
|
|
|
|
|
the software or hardware devices that use the Licensed Work.
|
|
|
|
|
|
|
|
|
|
|
|
Xiaomi has the right to immediately terminate all your authorization under this
|
|
|
|
|
|
License in the event:
|
|
|
|
|
|
1. You assert patent invalidation, litigation, or other claims against patents
|
|
|
|
|
|
or other intellectual property rights of Xiaomi or its affiliates; or,
|
|
|
|
|
|
2. You make, have made, manufacture, sell, or offer to sell products that knock
|
|
|
|
|
|
off Xiaomi or its affiliates' products.
|
|
|
|
|
|
|
|
|
|
|
|
MIoT http client.
|
|
|
|
|
|
"""
|
|
|
|
|
|
import asyncio
|
|
|
|
|
|
import base64
|
|
|
|
|
|
import json
|
|
|
|
|
|
import logging
|
|
|
|
|
|
import re
|
|
|
|
|
|
import time
|
|
|
|
|
|
from typing import Any, Optional
|
|
|
|
|
|
from urllib.parse import urlencode
|
|
|
|
|
|
import aiohttp
|
|
|
|
|
|
|
|
|
|
|
|
# pylint: disable=relative-beyond-top-level
|
|
|
|
|
|
from .common import calc_group_id
|
|
|
|
|
|
from .const import (
|
|
|
|
|
|
DEFAULT_OAUTH2_API_HOST,
|
|
|
|
|
|
MIHOME_HTTP_API_TIMEOUT,
|
|
|
|
|
|
OAUTH2_AUTH_URL)
|
|
|
|
|
|
from .miot_error import MIoTErrorCode, MIoTHttpError, MIoTOauthError
|
|
|
|
|
|
|
|
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
TOKEN_EXPIRES_TS_RATIO = 0.7
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class MIoTOauthClient:
|
|
|
|
|
|
"""oauth agent url, default: product env."""
|
|
|
|
|
|
_main_loop: asyncio.AbstractEventLoop
|
|
|
|
|
|
_session: aiohttp.ClientSession
|
|
|
|
|
|
_oauth_host: str
|
|
|
|
|
|
_client_id: int
|
|
|
|
|
|
_redirect_url: str
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(
|
|
|
|
|
|
self, client_id: str, redirect_url: str, cloud_server: str,
|
|
|
|
|
|
loop: Optional[asyncio.AbstractEventLoop] = None
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
self._main_loop = loop or asyncio.get_running_loop()
|
|
|
|
|
|
if client_id is None or client_id.strip() == '':
|
|
|
|
|
|
raise MIoTOauthError('invalid client_id')
|
|
|
|
|
|
if not redirect_url:
|
|
|
|
|
|
raise MIoTOauthError('invalid redirect_url')
|
|
|
|
|
|
if not cloud_server:
|
|
|
|
|
|
raise MIoTOauthError('invalid cloud_server')
|
|
|
|
|
|
|
|
|
|
|
|
self._client_id = int(client_id)
|
|
|
|
|
|
self._redirect_url = redirect_url
|
|
|
|
|
|
if cloud_server == 'cn':
|
|
|
|
|
|
self._oauth_host = DEFAULT_OAUTH2_API_HOST
|
|
|
|
|
|
else:
|
|
|
|
|
|
self._oauth_host = f'{cloud_server}.{DEFAULT_OAUTH2_API_HOST}'
|
|
|
|
|
|
self._session = aiohttp.ClientSession(loop=self._main_loop)
|
|
|
|
|
|
|
|
|
|
|
|
async def deinit_async(self) -> None:
|
|
|
|
|
|
if self._session and not self._session.closed:
|
|
|
|
|
|
await self._session.close()
|
|
|
|
|
|
|
|
|
|
|
|
def set_redirect_url(self, redirect_url: str) -> None:
|
|
|
|
|
|
if not isinstance(redirect_url, str) or redirect_url.strip() == '':
|
|
|
|
|
|
raise MIoTOauthError('invalid redirect_url')
|
|
|
|
|
|
self._redirect_url = redirect_url
|
|
|
|
|
|
|
|
|
|
|
|
def gen_auth_url(
|
|
|
|
|
|
self,
|
|
|
|
|
|
redirect_url: Optional[str] = None,
|
|
|
|
|
|
state: Optional[str] = None,
|
|
|
|
|
|
scope: Optional[list] = None,
|
|
|
|
|
|
skip_confirm: Optional[bool] = False,
|
|
|
|
|
|
) -> str:
|
|
|
|
|
|
"""get auth url
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
redirect_url
|
|
|
|
|
|
state
|
|
|
|
|
|
scope (list, optional):
|
|
|
|
|
|
开放数据接口权限 ID,可以传递多个,用空格分隔,具体值可以参考开放
|
|
|
|
|
|
[数据接口权限列表](https://dev.mi.com/distribute/doc/details?pId=1518).
|
|
|
|
|
|
Defaults to None.\n
|
|
|
|
|
|
skip_confirm (bool, optional):
|
|
|
|
|
|
默认值为true,授权有效期内的用户在已登录情况下,不显示授权页面,直接通过。
|
|
|
|
|
|
如果需要用户每次手动授权,设置为false. Defaults to True.\n
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
str: _description_
|
|
|
|
|
|
"""
|
|
|
|
|
|
params: dict = {
|
|
|
|
|
|
'redirect_uri': redirect_url or self._redirect_url,
|
|
|
|
|
|
'client_id': self._client_id,
|
|
|
|
|
|
'response_type': 'code',
|
|
|
|
|
|
}
|
|
|
|
|
|
if state:
|
|
|
|
|
|
params['state'] = state
|
|
|
|
|
|
if scope:
|
|
|
|
|
|
params['scope'] = ' '.join(scope).strip()
|
|
|
|
|
|
params['skip_confirm'] = skip_confirm
|
|
|
|
|
|
encoded_params = urlencode(params)
|
|
|
|
|
|
|
|
|
|
|
|
return f'{OAUTH2_AUTH_URL}?{encoded_params}'
|
|
|
|
|
|
|
|
|
|
|
|
async def __get_token_async(self, data) -> dict:
|
|
|
|
|
|
http_res = await self._session.get(
|
|
|
|
|
|
url=f'https://{self._oauth_host}/app/v2/ha/oauth/get_token',
|
|
|
|
|
|
params={'data': json.dumps(data)},
|
|
|
|
|
|
headers={'content-type': 'application/x-www-form-urlencoded'},
|
|
|
|
|
|
timeout=MIHOME_HTTP_API_TIMEOUT
|
|
|
|
|
|
)
|
|
|
|
|
|
if http_res.status == 401:
|
|
|
|
|
|
raise MIoTOauthError(
|
|
|
|
|
|
'unauthorized(401)', MIoTErrorCode.CODE_OAUTH_UNAUTHORIZED)
|
|
|
|
|
|
if http_res.status != 200:
|
|
|
|
|
|
raise MIoTOauthError(
|
|
|
|
|
|
f'invalid http status code, {http_res.status}')
|
|
|
|
|
|
|
|
|
|
|
|
res_str = await http_res.text()
|
|
|
|
|
|
res_obj = json.loads(res_str)
|
|
|
|
|
|
if (
|
|
|
|
|
|
not res_obj
|
|
|
|
|
|
or res_obj.get('code', None) != 0
|
|
|
|
|
|
or 'result' not in res_obj
|
|
|
|
|
|
or not all(
|
|
|
|
|
|
key in res_obj['result']
|
|
|
|
|
|
for key in ['access_token', 'refresh_token', 'expires_in'])
|
|
|
|
|
|
):
|
|
|
|
|
|
raise MIoTOauthError(f'invalid http response, {http_res.text}')
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
**res_obj['result'],
|
|
|
|
|
|
'expires_ts': int(
|
|
|
|
|
|
time.time() +
|
|
|
|
|
|
(res_obj['result'].get('expires_in', 0)*TOKEN_EXPIRES_TS_RATIO))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async def get_access_token_async(self, code: str) -> dict:
|
|
|
|
|
|
"""get access token by authorization code
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
code (str): auth code
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
str: _description_
|
|
|
|
|
|
"""
|
|
|
|
|
|
if not isinstance(code, str):
|
|
|
|
|
|
raise MIoTOauthError('invalid code')
|
|
|
|
|
|
|
|
|
|
|
|
return await self.__get_token_async(data={
|
|
|
|
|
|
'client_id': self._client_id,
|
|
|
|
|
|
'redirect_uri': self._redirect_url,
|
|
|
|
|
|
'code': code,
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
async def refresh_access_token_async(self, refresh_token: str) -> dict:
|
|
|
|
|
|
"""get access token by refresh token.
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
refresh_token (str): refresh_token
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
str: _description_
|
|
|
|
|
|
"""
|
|
|
|
|
|
if not isinstance(refresh_token, str):
|
|
|
|
|
|
raise MIoTOauthError('invalid refresh_token')
|
|
|
|
|
|
|
|
|
|
|
|
return await self.__get_token_async(data={
|
|
|
|
|
|
'client_id': self._client_id,
|
|
|
|
|
|
'redirect_uri': self._redirect_url,
|
|
|
|
|
|
'refresh_token': refresh_token,
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class MIoTHttpClient:
|
|
|
|
|
|
"""MIoT http client."""
|
|
|
|
|
|
# pylint: disable=inconsistent-quotes
|
|
|
|
|
|
GET_PROP_AGGREGATE_INTERVAL: float = 0.2
|
|
|
|
|
|
GET_PROP_MAX_REQ_COUNT = 150
|
|
|
|
|
|
_main_loop: asyncio.AbstractEventLoop
|
|
|
|
|
|
_session: aiohttp.ClientSession
|
|
|
|
|
|
_host: str
|
|
|
|
|
|
_base_url: str
|
|
|
|
|
|
_client_id: str
|
|
|
|
|
|
_access_token: str
|
|
|
|
|
|
|
|
|
|
|
|
_get_prop_timer: asyncio.TimerHandle
|
|
|
|
|
|
_get_prop_list: dict[str, dict[str, asyncio.Future | str | bool]]
|
|
|
|
|
|
|
|
|
|
|
|
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._get_prop_timer: asyncio.TimerHandle = None
|
|
|
|
|
|
self._get_prop_list = {}
|
|
|
|
|
|
|
|
|
|
|
|
if (
|
|
|
|
|
|
not isinstance(cloud_server, str)
|
|
|
|
|
|
or not isinstance(client_id, str)
|
|
|
|
|
|
or not isinstance(access_token, str)
|
|
|
|
|
|
):
|
|
|
|
|
|
raise MIoTHttpError('invalid params')
|
|
|
|
|
|
|
|
|
|
|
|
self.update_http_header(
|
|
|
|
|
|
cloud_server=cloud_server, client_id=client_id,
|
|
|
|
|
|
access_token=access_token)
|
|
|
|
|
|
|
|
|
|
|
|
self._session = aiohttp.ClientSession(loop=self._main_loop)
|
|
|
|
|
|
|
|
|
|
|
|
async def deinit_async(self) -> None:
|
|
|
|
|
|
if self._session and not self._session.closed:
|
|
|
|
|
|
await self._session.close()
|
|
|
|
|
|
|
|
|
|
|
|
def update_http_header(
|
|
|
|
|
|
self, cloud_server: Optional[str] = None,
|
|
|
|
|
|
client_id: Optional[str] = None,
|
|
|
|
|
|
access_token: Optional[str] = None
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
if isinstance(cloud_server, str):
|
|
|
|
|
|
if cloud_server == 'cn':
|
|
|
|
|
|
self._host = DEFAULT_OAUTH2_API_HOST
|
|
|
|
|
|
else:
|
|
|
|
|
|
self._host = f'{cloud_server}.{DEFAULT_OAUTH2_API_HOST}'
|
|
|
|
|
|
self._base_url = f'https://{self._host}'
|
|
|
|
|
|
if isinstance(client_id, str):
|
|
|
|
|
|
self._client_id = client_id
|
|
|
|
|
|
if isinstance(access_token, str):
|
|
|
|
|
|
self._access_token = access_token
|
|
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
|
def __api_request_headers(self) -> dict:
|
|
|
|
|
|
return {
|
|
|
|
|
|
'Host': self._host,
|
|
|
|
|
|
'X-Client-BizId': 'haapi',
|
|
|
|
|
|
'Content-Type': 'application/json',
|
|
|
|
|
|
'Authorization': f'Bearer{self._access_token}',
|
|
|
|
|
|
'X-Client-AppId': self._client_id,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
# pylint: disable=unused-private-member
|
|
|
|
|
|
async def __mihome_api_get_async(
|
|
|
|
|
|
self, url_path: str, params: dict,
|
|
|
|
|
|
timeout: int = MIHOME_HTTP_API_TIMEOUT
|
|
|
|
|
|
) -> dict:
|
|
|
|
|
|
http_res = await self._session.get(
|
|
|
|
|
|
url=f'{self._base_url}{url_path}',
|
|
|
|
|
|
params=params,
|
|
|
|
|
|
headers=self.__api_request_headers,
|
|
|
|
|
|
timeout=timeout)
|
|
|
|
|
|
if http_res.status == 401:
|
|
|
|
|
|
raise MIoTHttpError(
|
|
|
|
|
|
'mihome api get failed, unauthorized(401)',
|
|
|
|
|
|
MIoTErrorCode.CODE_HTTP_INVALID_ACCESS_TOKEN)
|
|
|
|
|
|
if http_res.status != 200:
|
|
|
|
|
|
raise MIoTHttpError(
|
|
|
|
|
|
f'mihome api get failed, {http_res.status}, '
|
|
|
|
|
|
f'{url_path}, {params}')
|
|
|
|
|
|
res_str = await http_res.text()
|
|
|
|
|
|
res_obj: dict = json.loads(res_str)
|
|
|
|
|
|
if res_obj.get('code', None) != 0:
|
|
|
|
|
|
raise MIoTHttpError(
|
|
|
|
|
|
f'invalid response code, {res_obj.get("code",None)}, '
|
|
|
|
|
|
f'{res_obj.get("message","")}')
|
|
|
|
|
|
_LOGGER.debug(
|
|
|
|
|
|
'mihome api get, %s%s, %s -> %s',
|
|
|
|
|
|
self._base_url, url_path, params, res_obj)
|
|
|
|
|
|
return res_obj
|
|
|
|
|
|
|
|
|
|
|
|
async def __mihome_api_post_async(
|
|
|
|
|
|
self, url_path: str, data: dict,
|
|
|
|
|
|
timeout: int = MIHOME_HTTP_API_TIMEOUT
|
|
|
|
|
|
) -> dict:
|
|
|
|
|
|
http_res = await self._session.post(
|
|
|
|
|
|
url=f'{self._base_url}{url_path}',
|
|
|
|
|
|
json=data,
|
|
|
|
|
|
headers=self.__api_request_headers,
|
|
|
|
|
|
timeout=timeout)
|
|
|
|
|
|
if http_res.status == 401:
|
|
|
|
|
|
raise MIoTHttpError(
|
|
|
|
|
|
'mihome api get failed, unauthorized(401)',
|
|
|
|
|
|
MIoTErrorCode.CODE_HTTP_INVALID_ACCESS_TOKEN)
|
|
|
|
|
|
if http_res.status != 200:
|
|
|
|
|
|
raise MIoTHttpError(
|
|
|
|
|
|
f'mihome api post failed, {http_res.status}, '
|
|
|
|
|
|
f'{url_path}, {data}')
|
|
|
|
|
|
res_str = await http_res.text()
|
|
|
|
|
|
res_obj: dict = json.loads(res_str)
|
|
|
|
|
|
if res_obj.get('code', None) != 0:
|
|
|
|
|
|
raise MIoTHttpError(
|
|
|
|
|
|
f'invalid response code, {res_obj.get("code",None)}, '
|
|
|
|
|
|
f'{res_obj.get("message","")}')
|
|
|
|
|
|
_LOGGER.debug(
|
|
|
|
|
|
'mihome api post, %s%s, %s -> %s',
|
|
|
|
|
|
self._base_url, url_path, data, res_obj)
|
|
|
|
|
|
return res_obj
|
|
|
|
|
|
|
|
|
|
|
|
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},
|
|
|
|
|
|
headers={'content-type': 'application/x-www-form-urlencoded'},
|
|
|
|
|
|
timeout=MIHOME_HTTP_API_TIMEOUT
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
res_str = await http_res.text()
|
|
|
|
|
|
res_obj = json.loads(res_str)
|
|
|
|
|
|
if (
|
|
|
|
|
|
not res_obj
|
|
|
|
|
|
or res_obj.get('code', None) != 0
|
|
|
|
|
|
or 'data' not in res_obj
|
|
|
|
|
|
or 'miliaoNick' not in res_obj['data']
|
|
|
|
|
|
):
|
|
|
|
|
|
raise MIoTOauthError(f'invalid http response, {http_res.text}')
|
|
|
|
|
|
|
|
|
|
|
|
return res_obj['data']
|
|
|
|
|
|
|
|
|
|
|
|
async def get_central_cert_async(self, csr: str) -> Optional[str]:
|
|
|
|
|
|
if not isinstance(csr, str):
|
|
|
|
|
|
raise MIoTHttpError('invalid params')
|
|
|
|
|
|
|
|
|
|
|
|
res_obj: dict = await self.__mihome_api_post_async(
|
|
|
|
|
|
url_path='/app/v2/ha/oauth/get_central_crt',
|
|
|
|
|
|
data={
|
|
|
|
|
|
'csr': str(base64.b64encode(csr.encode('utf-8')), 'utf-8')
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
if 'result' not in res_obj:
|
|
|
|
|
|
raise MIoTHttpError('invalid response result')
|
|
|
|
|
|
cert: str = res_obj['result'].get('cert', None)
|
|
|
|
|
|
if not isinstance(cert, str):
|
|
|
|
|
|
raise MIoTHttpError('invalid cert')
|
|
|
|
|
|
|
|
|
|
|
|
return cert
|
|
|
|
|
|
|
|
|
|
|
|
async def __get_dev_room_page_async(self, max_id: str = None) -> dict:
|
|
|
|
|
|
res_obj = await self.__mihome_api_post_async(
|
|
|
|
|
|
url_path='/app/v2/homeroom/get_dev_room_page',
|
|
|
|
|
|
data={
|
|
|
|
|
|
'start_id': max_id,
|
|
|
|
|
|
'limit': 150,
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
if 'result' not in res_obj and 'info' not in res_obj['result']:
|
|
|
|
|
|
raise MIoTHttpError('invalid response result')
|
|
|
|
|
|
home_list: dict = {}
|
|
|
|
|
|
for home in res_obj['result']['info']:
|
|
|
|
|
|
if 'id' not in home:
|
|
|
|
|
|
_LOGGER.error(
|
|
|
|
|
|
'get dev room page error, invalid home, %s', home)
|
|
|
|
|
|
continue
|
|
|
|
|
|
home_list[str(home['id'])] = {'dids': home.get(
|
|
|
|
|
|
'dids', None) or [], 'room_info': {}}
|
|
|
|
|
|
for room in home.get('roomlist', []):
|
|
|
|
|
|
if 'id' not in room:
|
|
|
|
|
|
_LOGGER.error(
|
|
|
|
|
|
'get dev room page error, invalid room, %s', room)
|
|
|
|
|
|
continue
|
|
|
|
|
|
home_list[str(home['id'])]['room_info'][str(room['id'])] = {
|
|
|
|
|
|
'dids': room.get('dids', None) or []}
|
|
|
|
|
|
if (
|
|
|
|
|
|
res_obj['result'].get('has_more', False)
|
|
|
|
|
|
and isinstance(res_obj['result'].get('max_id', None), str)
|
|
|
|
|
|
):
|
|
|
|
|
|
next_list = await self.__get_dev_room_page_async(
|
|
|
|
|
|
max_id=res_obj['result']['max_id'])
|
|
|
|
|
|
for home_id, info in next_list.items():
|
|
|
|
|
|
home_list.setdefault(home_id, {'dids': [], 'room_info': {}})
|
|
|
|
|
|
home_list[home_id]['dids'].extend(info['dids'])
|
|
|
|
|
|
for room_id, info in info['room_info'].items():
|
|
|
|
|
|
home_list[home_id]['room_info'].setdefault(
|
|
|
|
|
|
room_id, {'dids': []})
|
|
|
|
|
|
home_list[home_id]['room_info'][room_id]['dids'].extend(
|
|
|
|
|
|
info['dids'])
|
|
|
|
|
|
|
|
|
|
|
|
return home_list
|
|
|
|
|
|
|
|
|
|
|
|
async def get_homeinfos_async(self) -> dict:
|
|
|
|
|
|
res_obj = await self.__mihome_api_post_async(
|
|
|
|
|
|
url_path='/app/v2/homeroom/gethome',
|
|
|
|
|
|
data={
|
|
|
|
|
|
'limit': 150,
|
|
|
|
|
|
'fetch_share': True,
|
|
|
|
|
|
'fetch_share_dev': True,
|
|
|
|
|
|
'plat_form': 0,
|
|
|
|
|
|
'app_ver': 9,
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
if 'result' not in res_obj:
|
|
|
|
|
|
raise MIoTHttpError('invalid response result')
|
|
|
|
|
|
|
|
|
|
|
|
uid: str = None
|
|
|
|
|
|
home_infos: dict = {}
|
|
|
|
|
|
for device_source in ['homelist', 'share_home_list']:
|
|
|
|
|
|
home_infos.setdefault(device_source, {})
|
|
|
|
|
|
for home in res_obj['result'].get(device_source, []):
|
|
|
|
|
|
if (
|
|
|
|
|
|
'id' not in home
|
|
|
|
|
|
or 'name' not in home
|
|
|
|
|
|
or 'roomlist' not in home
|
|
|
|
|
|
):
|
|
|
|
|
|
continue
|
|
|
|
|
|
if uid is None and device_source == 'homelist':
|
|
|
|
|
|
uid = str(home['uid'])
|
|
|
|
|
|
home_infos[device_source][home['id']] = {
|
|
|
|
|
|
'home_id': home['id'],
|
|
|
|
|
|
'home_name': home['name'],
|
|
|
|
|
|
'city_id': home.get('city_id', None),
|
|
|
|
|
|
'longitude': home.get('longitude', None),
|
|
|
|
|
|
'latitude': home.get('latitude', None),
|
|
|
|
|
|
'address': home.get('address', None),
|
|
|
|
|
|
'dids': home.get('dids', []),
|
|
|
|
|
|
'room_info': {
|
|
|
|
|
|
room['id']: {
|
|
|
|
|
|
'room_id': room['id'],
|
|
|
|
|
|
'room_name': room['name'],
|
|
|
|
|
|
'dids': room.get('dids', [])
|
|
|
|
|
|
}
|
|
|
|
|
|
for room in home.get('roomlist', [])
|
|
|
|
|
|
if 'id' in room
|
|
|
|
|
|
},
|
|
|
|
|
|
'group_id': calc_group_id(
|
|
|
|
|
|
uid=home['uid'], home_id=home['id']),
|
|
|
|
|
|
'uid': str(home['uid'])
|
|
|
|
|
|
}
|
|
|
|
|
|
home_infos['uid'] = uid
|
|
|
|
|
|
if (
|
|
|
|
|
|
res_obj['result'].get('has_more', False)
|
|
|
|
|
|
and isinstance(res_obj['result'].get('max_id', None), str)
|
|
|
|
|
|
):
|
|
|
|
|
|
more_list = await self.__get_dev_room_page_async(
|
|
|
|
|
|
max_id=res_obj['result']['max_id'])
|
|
|
|
|
|
for home_id, info in more_list.items():
|
|
|
|
|
|
if home_id not in home_infos['homelist']:
|
|
|
|
|
|
_LOGGER.info('unknown home, %s, %s', home_id, info)
|
|
|
|
|
|
continue
|
|
|
|
|
|
home_infos['homelist'][home_id]['dids'].extend(info['dids'])
|
|
|
|
|
|
for room_id, info in info['room_info'].items():
|
|
|
|
|
|
home_infos['homelist'][home_id]['room_info'].setdefault(
|
|
|
|
|
|
room_id, {
|
|
|
|
|
|
'room_id': room_id,
|
|
|
|
|
|
'room_name': '',
|
|
|
|
|
|
'dids': []})
|
|
|
|
|
|
home_infos['homelist'][home_id]['room_info'][
|
|
|
|
|
|
room_id]['dids'].extend(info['dids'])
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
'uid': uid,
|
|
|
|
|
|
'home_list': home_infos.get('homelist', {}),
|
|
|
|
|
|
'share_home_list': home_infos.get('share_home_list', [])
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async def get_uid_async(self) -> str:
|
|
|
|
|
|
return (await self.get_homeinfos_async()).get('uid', None)
|
|
|
|
|
|
|
|
|
|
|
|
async def __get_device_list_page_async(
|
|
|
|
|
|
self, dids: list[str], start_did: str = None
|
|
|
|
|
|
) -> dict[str, dict]:
|
|
|
|
|
|
req_data: dict = {
|
|
|
|
|
|
'limit': 200,
|
|
|
|
|
|
'get_split_device': True,
|
|
|
|
|
|
'dids': dids
|
|
|
|
|
|
}
|
|
|
|
|
|
if start_did:
|
|
|
|
|
|
req_data['start_did'] = start_did
|
|
|
|
|
|
device_infos: dict = {}
|
|
|
|
|
|
res_obj = await self.__mihome_api_post_async(
|
|
|
|
|
|
url_path='/app/v2/home/device_list_page',
|
|
|
|
|
|
data=req_data
|
|
|
|
|
|
)
|
|
|
|
|
|
if 'result' not in res_obj:
|
|
|
|
|
|
raise MIoTHttpError('invalid response result')
|
|
|
|
|
|
res_obj = res_obj['result']
|
|
|
|
|
|
|
|
|
|
|
|
for device in res_obj.get('list', []) or []:
|
|
|
|
|
|
did = device.get('did', None)
|
|
|
|
|
|
name = device.get('name', None)
|
|
|
|
|
|
urn = device.get('spec_type', None)
|
|
|
|
|
|
model = device.get('model', None)
|
|
|
|
|
|
if did is None or name is None or urn is None or model is None:
|
|
|
|
|
|
_LOGGER.error(
|
|
|
|
|
|
'get_device_list, cloud, invalid device, %s', device)
|
|
|
|
|
|
continue
|
|
|
|
|
|
device_infos[did] = {
|
|
|
|
|
|
'did': did,
|
|
|
|
|
|
'uid': device.get('uid', None),
|
|
|
|
|
|
'name': name,
|
|
|
|
|
|
'urn': urn,
|
|
|
|
|
|
'model': model,
|
|
|
|
|
|
'connect_type': device.get('pid', -1),
|
|
|
|
|
|
'token': device.get('token', None),
|
|
|
|
|
|
'online': device.get('isOnline', False),
|
|
|
|
|
|
'icon': device.get('icon', None),
|
|
|
|
|
|
'parent_id': device.get('parent_id', None),
|
|
|
|
|
|
'manufacturer': model.split('.')[0],
|
|
|
|
|
|
# 2: xiao-ai, 1: general speaker
|
|
|
|
|
|
'voice_ctrl': device.get('voice_ctrl', 0),
|
|
|
|
|
|
'rssi': device.get('rssi', None),
|
|
|
|
|
|
'owner': device.get('owner', None),
|
|
|
|
|
|
'pid': device.get('pid', None),
|
|
|
|
|
|
'local_ip': device.get('local_ip', None),
|
|
|
|
|
|
'ssid': device.get('ssid', None),
|
|
|
|
|
|
'bssid': device.get('bssid', None),
|
|
|
|
|
|
'order_time': device.get('orderTime', 0),
|
|
|
|
|
|
'fw_version': device.get('extra', {}).get(
|
|
|
|
|
|
'fw_version', 'unknown'),
|
|
|
|
|
|
}
|
|
|
|
|
|
if isinstance(device.get('extra', None), dict) and device['extra']:
|
|
|
|
|
|
device_infos[did]['fw_version'] = device['extra'].get(
|
|
|
|
|
|
'fw_version', None)
|
|
|
|
|
|
device_infos[did]['mcu_version'] = device['extra'].get(
|
|
|
|
|
|
'mcu_version', None)
|
|
|
|
|
|
device_infos[did]['platform'] = device['extra'].get(
|
|
|
|
|
|
'platform', None)
|
|
|
|
|
|
|
|
|
|
|
|
next_start_did = res_obj.get('next_start_did', None)
|
|
|
|
|
|
if res_obj.get('has_more', False) and next_start_did:
|
|
|
|
|
|
device_infos.update(await self.__get_device_list_page_async(
|
|
|
|
|
|
dids=dids, start_did=next_start_did))
|
|
|
|
|
|
|
|
|
|
|
|
return device_infos
|
|
|
|
|
|
|
|
|
|
|
|
async def get_devices_with_dids_async(
|
|
|
|
|
|
self, dids: list[str]
|
|
|
|
|
|
) -> dict[str, dict]:
|
|
|
|
|
|
results: list[dict[str, dict]] = await asyncio.gather(
|
|
|
|
|
|
*[self.__get_device_list_page_async(dids[index:index+150])
|
|
|
|
|
|
for index in range(0, len(dids), 150)])
|
|
|
|
|
|
devices = {}
|
|
|
|
|
|
for result in results:
|
|
|
|
|
|
if result is None:
|
|
|
|
|
|
return None
|
|
|
|
|
|
devices.update(result)
|
|
|
|
|
|
return devices
|
|
|
|
|
|
|
|
|
|
|
|
async def get_devices_async(
|
|
|
|
|
|
self, home_ids: list[str] = None
|
|
|
|
|
|
) -> dict[str, dict]:
|
|
|
|
|
|
homeinfos = await self.get_homeinfos_async()
|
|
|
|
|
|
homes: dict[str, dict[str, Any]] = {}
|
|
|
|
|
|
devices: dict[str, dict] = {}
|
|
|
|
|
|
for device_type in ['home_list', 'share_home_list']:
|
|
|
|
|
|
homes.setdefault(device_type, {})
|
|
|
|
|
|
for home_id, home_info in (homeinfos.get(
|
|
|
|
|
|
device_type, None) or {}).items():
|
|
|
|
|
|
if isinstance(home_ids, list) and home_id not in home_ids:
|
|
|
|
|
|
continue
|
|
|
|
|
|
home_name: str = home_info['home_name']
|
|
|
|
|
|
group_id: str = home_info['group_id']
|
|
|
|
|
|
homes[device_type].setdefault(
|
|
|
|
|
|
home_id, {
|
|
|
|
|
|
'home_name': home_name,
|
|
|
|
|
|
'uid': home_info['uid'],
|
|
|
|
|
|
'group_id': group_id,
|
|
|
|
|
|
'room_info': {}
|
|
|
|
|
|
})
|
|
|
|
|
|
devices.update({did: {
|
|
|
|
|
|
'home_id': home_id,
|
|
|
|
|
|
'home_name': home_name,
|
|
|
|
|
|
'room_id': home_id,
|
|
|
|
|
|
'room_name': home_name,
|
|
|
|
|
|
'group_id': group_id
|
|
|
|
|
|
} for did in home_info.get('dids', [])})
|
|
|
|
|
|
for room_id, room_info in home_info.get('room_info').items():
|
|
|
|
|
|
room_name: str = room_info.get('room_name', '')
|
|
|
|
|
|
homes[device_type][home_id]['room_info'][
|
|
|
|
|
|
room_id] = room_name
|
|
|
|
|
|
devices.update({
|
|
|
|
|
|
did: {
|
|
|
|
|
|
'home_id': home_id,
|
|
|
|
|
|
'home_name': home_name,
|
|
|
|
|
|
'room_id': room_id,
|
|
|
|
|
|
'room_name': room_name,
|
|
|
|
|
|
'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)
|
|
|
|
|
|
for did in dids:
|
|
|
|
|
|
if did not in results:
|
|
|
|
|
|
devices.pop(did, None)
|
|
|
|
|
|
_LOGGER.error('get device info failed, %s', did)
|
|
|
|
|
|
continue
|
|
|
|
|
|
devices[did].update(results[did])
|
|
|
|
|
|
# Whether sub devices
|
|
|
|
|
|
match_str = re.search(r'\.s\d+$', did)
|
|
|
|
|
|
if not match_str:
|
|
|
|
|
|
continue
|
|
|
|
|
|
device = devices.pop(did, None)
|
|
|
|
|
|
parent_did = did.replace(match_str.group(), '')
|
|
|
|
|
|
if parent_did in devices:
|
|
|
|
|
|
devices[parent_did].setdefault('sub_devices', {})
|
|
|
|
|
|
devices[parent_did]['sub_devices'][match_str.group()[
|
|
|
|
|
|
1:]] = device
|
|
|
|
|
|
else:
|
|
|
|
|
|
_LOGGER.error(
|
|
|
|
|
|
'unknown sub devices, %s, %s', did, parent_did)
|
|
|
|
|
|
return {
|
|
|
|
|
|
'uid': homeinfos['uid'],
|
|
|
|
|
|
'homes': homes,
|
|
|
|
|
|
'devices': devices
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async def get_props_async(self, params: list) -> list:
|
|
|
|
|
|
"""
|
|
|
|
|
|
params = [{"did": "xxxx", "siid": 2, "piid": 1},
|
|
|
|
|
|
{"did": "xxxxxx", "siid": 2, "piid": 2}]
|
|
|
|
|
|
"""
|
|
|
|
|
|
res_obj = await self.__mihome_api_post_async(
|
|
|
|
|
|
url_path='/app/v2/miotspec/prop/get',
|
|
|
|
|
|
data={
|
|
|
|
|
|
'datasource': 1,
|
|
|
|
|
|
'params': params
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
if 'result' not in res_obj:
|
|
|
|
|
|
raise MIoTHttpError('invalid response result')
|
|
|
|
|
|
return res_obj['result']
|
|
|
|
|
|
|
|
|
|
|
|
async def __get_prop_async(self, did: str, siid: int, piid: int) -> Any:
|
|
|
|
|
|
results = await self.get_props_async(
|
|
|
|
|
|
params=[{'did': did, 'siid': siid, 'piid': piid}])
|
|
|
|
|
|
if not results:
|
|
|
|
|
|
return None
|
|
|
|
|
|
result = results[0]
|
|
|
|
|
|
if 'value' not in result:
|
|
|
|
|
|
return None
|
|
|
|
|
|
return result['value']
|
|
|
|
|
|
|
|
|
|
|
|
async def __get_prop_handler(self) -> bool:
|
|
|
|
|
|
props_req: set[str] = set()
|
|
|
|
|
|
props_buffer: list[dict] = []
|
|
|
|
|
|
|
|
|
|
|
|
for key, item in self._get_prop_list.items():
|
|
|
|
|
|
if item.get('tag', False):
|
|
|
|
|
|
continue
|
|
|
|
|
|
# NOTICE: max req prop
|
|
|
|
|
|
if len(props_req) >= self.GET_PROP_MAX_REQ_COUNT:
|
|
|
|
|
|
break
|
|
|
|
|
|
item['tag'] = True
|
|
|
|
|
|
props_buffer.append(item['param'])
|
|
|
|
|
|
props_req.add(key)
|
|
|
|
|
|
|
|
|
|
|
|
if not props_buffer:
|
|
|
|
|
|
_LOGGER.error('get prop error, empty request list')
|
|
|
|
|
|
return False
|
|
|
|
|
|
results = await self.get_props_async(props_buffer)
|
|
|
|
|
|
|
|
|
|
|
|
for result in results:
|
|
|
|
|
|
if not all(
|
|
|
|
|
|
key in result for key in ['did', 'siid', 'piid', 'value']):
|
|
|
|
|
|
continue
|
|
|
|
|
|
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)
|
|
|
|
|
|
continue
|
|
|
|
|
|
prop_obj['fut'].set_result(result['value'])
|
|
|
|
|
|
props_req.remove(key)
|
|
|
|
|
|
|
|
|
|
|
|
for key in props_req:
|
|
|
|
|
|
prop_obj = self._get_prop_list.pop(key, None)
|
|
|
|
|
|
if prop_obj is None:
|
|
|
|
|
|
continue
|
|
|
|
|
|
prop_obj['fut'].set_result(None)
|
|
|
|
|
|
if props_req:
|
|
|
|
|
|
_LOGGER.error(
|
|
|
|
|
|
'get prop from cloud failed, %s, %s', len(key), props_req)
|
|
|
|
|
|
|
|
|
|
|
|
if self._get_prop_list:
|
|
|
|
|
|
self._get_prop_timer = self._main_loop.call_later(
|
|
|
|
|
|
self.GET_PROP_AGGREGATE_INTERVAL,
|
|
|
|
|
|
lambda: self._main_loop.create_task(
|
|
|
|
|
|
self.__get_prop_handler()))
|
|
|
|
|
|
else:
|
|
|
|
|
|
self._get_prop_timer = None
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
async def get_prop_async(
|
|
|
|
|
|
self, did: str, siid: int, piid: int, immediately: bool = False
|
|
|
|
|
|
) -> Any:
|
|
|
|
|
|
if immediately:
|
|
|
|
|
|
return await self.__get_prop_async(did, siid, piid)
|
|
|
|
|
|
key: str = f'{did}.{siid}.{piid}'
|
|
|
|
|
|
prop_obj = self._get_prop_list.get(key, None)
|
|
|
|
|
|
if prop_obj:
|
|
|
|
|
|
return await prop_obj['fut']
|
|
|
|
|
|
fut = self._main_loop.create_future()
|
|
|
|
|
|
self._get_prop_list[key] = {
|
|
|
|
|
|
'param': {'did': did, 'siid': siid, 'piid': piid},
|
|
|
|
|
|
'fut': fut
|
|
|
|
|
|
}
|
|
|
|
|
|
if self._get_prop_timer is None:
|
|
|
|
|
|
self._get_prop_timer = self._main_loop.call_later(
|
|
|
|
|
|
self.GET_PROP_AGGREGATE_INTERVAL,
|
|
|
|
|
|
lambda: self._main_loop.create_task(
|
|
|
|
|
|
self.__get_prop_handler()))
|
|
|
|
|
|
|
|
|
|
|
|
return await fut
|
|
|
|
|
|
|
|
|
|
|
|
async def set_prop_async(self, params: list) -> list:
|
|
|
|
|
|
"""
|
|
|
|
|
|
params = [{"did": "xxxx", "siid": 2, "piid": 1, "value": False}]
|
|
|
|
|
|
"""
|
|
|
|
|
|
res_obj = await self.__mihome_api_post_async(
|
|
|
|
|
|
url_path='/app/v2/miotspec/prop/set',
|
|
|
|
|
|
data={
|
|
|
|
|
|
'params': params
|
|
|
|
|
|
},
|
|
|
|
|
|
timeout=15
|
|
|
|
|
|
)
|
|
|
|
|
|
if 'result' not in res_obj:
|
|
|
|
|
|
raise MIoTHttpError('invalid response result')
|
|
|
|
|
|
|
|
|
|
|
|
return res_obj['result']
|
|
|
|
|
|
|
|
|
|
|
|
async def action_async(
|
|
|
|
|
|
self, did: str, siid: int, aiid: int, in_list: list[dict]
|
|
|
|
|
|
) -> dict:
|
|
|
|
|
|
"""
|
|
|
|
|
|
params = {"did": "xxxx", "siid": 2, "aiid": 1, "in": []}
|
|
|
|
|
|
"""
|
|
|
|
|
|
# NOTICE: Non-standard action param
|
|
|
|
|
|
res_obj = await self.__mihome_api_post_async(
|
|
|
|
|
|
url_path='/app/v2/miotspec/action',
|
|
|
|
|
|
data={
|
|
|
|
|
|
'params': {
|
|
|
|
|
|
'did': did,
|
|
|
|
|
|
'siid': siid,
|
|
|
|
|
|
'aiid': aiid,
|
|
|
|
|
|
'in': [item['value'] for item in in_list]}
|
|
|
|
|
|
},
|
|
|
|
|
|
timeout=15
|
|
|
|
|
|
)
|
|
|
|
|
|
if 'result' not in res_obj:
|
|
|
|
|
|
raise MIoTHttpError('invalid response result')
|
|
|
|
|
|
|
|
|
|
|
|
return res_obj['result']
|