|
|
|
|
# -*- 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 storage and certificate management.
|
|
|
|
|
"""
|
|
|
|
|
import os
|
|
|
|
|
import asyncio
|
|
|
|
|
import binascii
|
|
|
|
|
import json
|
|
|
|
|
import shutil
|
|
|
|
|
import time
|
|
|
|
|
import traceback
|
|
|
|
|
import hashlib
|
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
|
from enum import Enum, auto
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
from typing import Optional, Union
|
|
|
|
|
import logging
|
|
|
|
|
from urllib.request import Request, urlopen
|
|
|
|
|
from cryptography.hazmat.primitives import serialization
|
|
|
|
|
from cryptography.hazmat.backends import default_backend
|
|
|
|
|
from cryptography.x509.oid import NameOID
|
|
|
|
|
from cryptography import x509
|
|
|
|
|
from cryptography.hazmat.primitives import hashes
|
|
|
|
|
from cryptography.hazmat.primitives.asymmetric import ed25519
|
|
|
|
|
|
|
|
|
|
# pylint: disable=relative-beyond-top-level
|
|
|
|
|
from .common import load_json_file
|
|
|
|
|
from .const import (
|
|
|
|
|
DEFAULT_INTEGRATION_LANGUAGE,
|
|
|
|
|
MANUFACTURER_EFFECTIVE_TIME,
|
|
|
|
|
MIHOME_CA_CERT_STR,
|
|
|
|
|
MIHOME_CA_CERT_SHA256)
|
|
|
|
|
from .miot_error import MIoTCertError, MIoTError, MIoTStorageError
|
|
|
|
|
|
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class MIoTStorageType(Enum):
|
|
|
|
|
LOAD = auto()
|
|
|
|
|
LOAD_FILE = auto()
|
|
|
|
|
SAVE = auto()
|
|
|
|
|
SAVE_FILE = auto()
|
|
|
|
|
DEL = auto()
|
|
|
|
|
DEL_FILE = auto()
|
|
|
|
|
CLEAR = auto()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class MIoTStorage:
|
|
|
|
|
"""File management.
|
|
|
|
|
|
|
|
|
|
User data will be stored in the `.storage` directory of Home Assistant.
|
|
|
|
|
"""
|
|
|
|
|
_main_loop: asyncio.AbstractEventLoop = None
|
|
|
|
|
_file_future: dict[str, tuple[MIoTStorageType, asyncio.Future]]
|
|
|
|
|
|
|
|
|
|
_root_path: str = None
|
|
|
|
|
|
|
|
|
|
def __init__(
|
|
|
|
|
self, root_path: str,
|
|
|
|
|
loop: Optional[asyncio.AbstractEventLoop] = None
|
|
|
|
|
) -> None:
|
|
|
|
|
"""Initialize with a root path."""
|
|
|
|
|
self._main_loop = loop or asyncio.get_running_loop()
|
|
|
|
|
self._file_future = {}
|
|
|
|
|
|
|
|
|
|
self._root_path = os.path.abspath(root_path)
|
|
|
|
|
os.makedirs(self._root_path, exist_ok=True)
|
|
|
|
|
|
|
|
|
|
_LOGGER.debug('root path, %s', self._root_path)
|
|
|
|
|
|
|
|
|
|
def __get_full_path(self, domain: str, name: str, suffix: str) -> str:
|
|
|
|
|
return os.path.join(
|
|
|
|
|
self._root_path, domain, f'{name}.{suffix}')
|
|
|
|
|
|
|
|
|
|
def __add_file_future(
|
|
|
|
|
self, key: str, op_type: MIoTStorageType, fut: asyncio.Future
|
|
|
|
|
) -> None:
|
|
|
|
|
def fut_done_callback(fut: asyncio.Future):
|
|
|
|
|
del fut
|
|
|
|
|
self._file_future.pop(key, None)
|
|
|
|
|
|
|
|
|
|
fut.add_done_callback(fut_done_callback)
|
|
|
|
|
self._file_future[key] = op_type, fut
|
|
|
|
|
|
|
|
|
|
def __load(
|
|
|
|
|
self, full_path: str, type_: type = bytes, with_hash_check: bool = True
|
|
|
|
|
) -> Union[bytes, str, dict, list, None]:
|
|
|
|
|
if not os.path.exists(full_path):
|
|
|
|
|
_LOGGER.debug('load error, file not exists, %s', full_path)
|
|
|
|
|
return None
|
|
|
|
|
if not os.access(full_path, os.R_OK):
|
|
|
|
|
_LOGGER.error('load error, file not readable, %s', full_path)
|
|
|
|
|
return None
|
|
|
|
|
try:
|
|
|
|
|
with open(full_path, 'rb') as r_file:
|
|
|
|
|
r_data: bytes = r_file.read()
|
|
|
|
|
if r_data is None:
|
|
|
|
|
_LOGGER.error('load error, empty file, %s', full_path)
|
|
|
|
|
return None
|
|
|
|
|
data_bytes: bytes = None
|
|
|
|
|
# Hash check
|
|
|
|
|
if with_hash_check:
|
|
|
|
|
if len(r_data) <= 32:
|
|
|
|
|
return None
|
|
|
|
|
data_bytes = r_data[:-32]
|
|
|
|
|
hash_value = r_data[-32:]
|
|
|
|
|
if hashlib.sha256(data_bytes).digest() != hash_value:
|
|
|
|
|
_LOGGER.error(
|
|
|
|
|
'load error, hash check failed, %s', full_path)
|
|
|
|
|
return None
|
|
|
|
|
else:
|
|
|
|
|
data_bytes = r_data
|
|
|
|
|
if type_ == bytes:
|
|
|
|
|
return data_bytes
|
|
|
|
|
if type_ == str:
|
|
|
|
|
return str(data_bytes, 'utf-8')
|
|
|
|
|
if type_ in [dict, list]:
|
|
|
|
|
return json.loads(data_bytes)
|
|
|
|
|
_LOGGER.error(
|
|
|
|
|
'load error, un-support data type, %s', type_.__name__)
|
|
|
|
|
return None
|
|
|
|
|
except (OSError, TypeError) as e:
|
|
|
|
|
_LOGGER.error('load error, %s, %s', e, traceback.format_exc())
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
def load(
|
|
|
|
|
self, domain: str, name: str, type_: type = bytes
|
|
|
|
|
) -> Union[bytes, str, dict, list, None]:
|
|
|
|
|
full_path = self.__get_full_path(
|
|
|
|
|
domain=domain, name=name, suffix=type_.__name__)
|
|
|
|
|
return self.__load(full_path=full_path, type_=type_)
|
|
|
|
|
|
|
|
|
|
async def load_async(
|
|
|
|
|
self, domain: str, name: str, type_: type = bytes
|
|
|
|
|
) -> Union[bytes, str, dict, list, None]:
|
|
|
|
|
full_path = self.__get_full_path(
|
|
|
|
|
domain=domain, name=name, suffix=type_.__name__)
|
|
|
|
|
if full_path in self._file_future:
|
|
|
|
|
# Waiting for the last task to be completed
|
|
|
|
|
op_type, fut = self._file_future[full_path]
|
|
|
|
|
if op_type == MIoTStorageType.LOAD:
|
|
|
|
|
if not fut.done():
|
|
|
|
|
return await fut
|
|
|
|
|
else:
|
|
|
|
|
await fut
|
|
|
|
|
fut = self._main_loop.run_in_executor(
|
|
|
|
|
None, self.__load, full_path, type_)
|
|
|
|
|
if not fut.done():
|
|
|
|
|
self.__add_file_future(full_path, MIoTStorageType.LOAD, fut)
|
|
|
|
|
return await fut
|
|
|
|
|
|
|
|
|
|
def __save(
|
|
|
|
|
self, full_path: str, data: Union[bytes, str, dict, list, None],
|
|
|
|
|
cover: bool = True, with_hash: bool = True
|
|
|
|
|
) -> bool:
|
|
|
|
|
if data is None:
|
|
|
|
|
_LOGGER.error('save error, save data is None')
|
|
|
|
|
return False
|
|
|
|
|
if os.path.exists(full_path):
|
|
|
|
|
if not cover:
|
|
|
|
|
_LOGGER.error('save error, file exists, cover is False')
|
|
|
|
|
return False
|
|
|
|
|
if not os.access(full_path, os.W_OK):
|
|
|
|
|
_LOGGER.error('save error, file not writeable, %s', full_path)
|
|
|
|
|
return False
|
|
|
|
|
else:
|
|
|
|
|
os.makedirs(os.path.dirname(full_path), exist_ok=True)
|
|
|
|
|
try:
|
|
|
|
|
type_: type = type(data)
|
|
|
|
|
w_bytes: bytes = None
|
|
|
|
|
if type_ == bytes:
|
|
|
|
|
w_bytes = data
|
|
|
|
|
elif type_ == str:
|
|
|
|
|
w_bytes = data.encode('utf-8')
|
|
|
|
|
elif type_ in [dict, list]:
|
|
|
|
|
w_bytes = json.dumps(data).encode('utf-8')
|
|
|
|
|
else:
|
|
|
|
|
_LOGGER.error(
|
|
|
|
|
'save error, un-support data type, %s', type_.__name__)
|
|
|
|
|
return None
|
|
|
|
|
with open(full_path, 'wb') as w_file:
|
|
|
|
|
w_file.write(w_bytes)
|
|
|
|
|
if with_hash:
|
|
|
|
|
w_file.write(hashlib.sha256(w_bytes).digest())
|
|
|
|
|
return True
|
|
|
|
|
except (OSError, TypeError) as e:
|
|
|
|
|
_LOGGER.error('save error, %s, %s', e, traceback.format_exc())
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
def save(
|
|
|
|
|
self, domain: str, name: str, data: Union[bytes, str, dict, list, None]
|
|
|
|
|
) -> bool:
|
|
|
|
|
full_path = self.__get_full_path(
|
|
|
|
|
domain=domain, name=name, suffix=type(data).__name__)
|
|
|
|
|
return self.__save(full_path=full_path, data=data)
|
|
|
|
|
|
|
|
|
|
async def save_async(
|
|
|
|
|
self, domain: str, name: str, data: Union[bytes, str, dict, list, None]
|
|
|
|
|
) -> bool:
|
|
|
|
|
full_path = self.__get_full_path(
|
|
|
|
|
domain=domain, name=name, suffix=type(data).__name__)
|
|
|
|
|
if full_path in self._file_future:
|
|
|
|
|
# Waiting for the last task to be completed
|
|
|
|
|
fut = self._file_future[full_path][1]
|
|
|
|
|
await fut
|
|
|
|
|
fut = self._main_loop.run_in_executor(
|
|
|
|
|
None, self.__save, full_path, data)
|
|
|
|
|
if not fut.done():
|
|
|
|
|
self.__add_file_future(full_path, MIoTStorageType.SAVE, fut)
|
|
|
|
|
return await fut
|
|
|
|
|
|
|
|
|
|
def __remove(self, full_path: str) -> bool:
|
|
|
|
|
item = Path(full_path)
|
|
|
|
|
if item.is_file() or item.is_symlink():
|
|
|
|
|
item.unlink()
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
def remove(self, domain: str, name: str, type_: type) -> bool:
|
|
|
|
|
full_path = self.__get_full_path(
|
|
|
|
|
domain=domain, name=name, suffix=type_.__name__)
|
|
|
|
|
return self.__remove(full_path=full_path)
|
|
|
|
|
|
|
|
|
|
async def remove_async(self, domain: str, name: str, type_: type) -> bool:
|
|
|
|
|
full_path = self.__get_full_path(
|
|
|
|
|
domain=domain, name=name, suffix=type_.__name__)
|
|
|
|
|
if full_path in self._file_future:
|
|
|
|
|
# Waiting for the last task to be completed
|
|
|
|
|
op_type, fut = self._file_future[full_path]
|
|
|
|
|
if op_type == MIoTStorageType.DEL:
|
|
|
|
|
if not fut.done():
|
|
|
|
|
return await fut
|
|
|
|
|
else:
|
|
|
|
|
await fut
|
|
|
|
|
fut = self._main_loop.run_in_executor(None, self.__remove, full_path)
|
|
|
|
|
if not fut.done():
|
|
|
|
|
self.__add_file_future(full_path, MIoTStorageType.DEL, fut)
|
|
|
|
|
return await fut
|
|
|
|
|
|
|
|
|
|
def __remove_domain(self, full_path: str) -> bool:
|
|
|
|
|
path_obj = Path(full_path)
|
|
|
|
|
if path_obj.exists():
|
|
|
|
|
# Recursive deletion
|
|
|
|
|
shutil.rmtree(path_obj)
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
def remove_domain(self, domain: str) -> bool:
|
|
|
|
|
full_path = os.path.join(self._root_path, domain)
|
|
|
|
|
return self.__remove_domain(full_path=full_path)
|
|
|
|
|
|
|
|
|
|
async def remove_domain_async(self, domain: str) -> bool:
|
|
|
|
|
full_path = os.path.join(self._root_path, domain)
|
|
|
|
|
if full_path in self._file_future:
|
|
|
|
|
# Waiting for the last task to be completed
|
|
|
|
|
op_type, fut = self._file_future[full_path]
|
|
|
|
|
if op_type == MIoTStorageType.DEL:
|
|
|
|
|
if not fut.done():
|
|
|
|
|
return await fut
|
|
|
|
|
else:
|
|
|
|
|
await fut
|
|
|
|
|
# Waiting domain tasks finish
|
|
|
|
|
for path, value in self._file_future.items():
|
|
|
|
|
if path.startswith(full_path):
|
|
|
|
|
await value[1]
|
|
|
|
|
fut = self._main_loop.run_in_executor(
|
|
|
|
|
None, self.__remove_domain, full_path)
|
|
|
|
|
if not fut.done():
|
|
|
|
|
self.__add_file_future(full_path, MIoTStorageType.DEL, fut)
|
|
|
|
|
return await fut
|
|
|
|
|
|
|
|
|
|
def get_names(self, domain: str, type_: type) -> list[str]:
|
|
|
|
|
path: str = os.path.join(self._root_path, domain)
|
|
|
|
|
type_str = f'.{type_.__name__}'
|
|
|
|
|
names: list[str] = []
|
|
|
|
|
for item in Path(path).glob(f'*{type_str}'):
|
|
|
|
|
if not item.is_file() and not item.is_symlink():
|
|
|
|
|
continue
|
|
|
|
|
names.append(item.name.replace(type_str, ''))
|
|
|
|
|
return names
|
|
|
|
|
|
|
|
|
|
def file_exists(self, domain: str, name_with_suffix: str) -> bool:
|
|
|
|
|
return os.path.exists(
|
|
|
|
|
os.path.join(self._root_path, domain, name_with_suffix))
|
|
|
|
|
|
|
|
|
|
def save_file(
|
|
|
|
|
self, domain: str, name_with_suffix: str, data: bytes
|
|
|
|
|
) -> bool:
|
|
|
|
|
if not isinstance(data, bytes):
|
|
|
|
|
_LOGGER.error('save file error, file must be bytes')
|
|
|
|
|
return False
|
|
|
|
|
full_path = os.path.join(self._root_path, domain, name_with_suffix)
|
|
|
|
|
return self.__save(full_path=full_path, data=data, with_hash=False)
|
|
|
|
|
|
|
|
|
|
async def save_file_async(
|
|
|
|
|
self, domain: str, name_with_suffix: str, data: bytes
|
|
|
|
|
) -> bool:
|
|
|
|
|
if not isinstance(data, bytes):
|
|
|
|
|
_LOGGER.error('save file error, file must be bytes')
|
|
|
|
|
return False
|
|
|
|
|
full_path = os.path.join(self._root_path, domain, name_with_suffix)
|
|
|
|
|
if full_path in self._file_future:
|
|
|
|
|
# Waiting for the last task to be completed
|
|
|
|
|
fut = self._file_future[full_path][1]
|
|
|
|
|
await fut
|
|
|
|
|
fut = self._main_loop.run_in_executor(
|
|
|
|
|
None, self.__save, full_path, data, True, False)
|
|
|
|
|
if not fut.done():
|
|
|
|
|
self.__add_file_future(full_path, MIoTStorageType.SAVE_FILE, fut)
|
|
|
|
|
return await fut
|
|
|
|
|
|
|
|
|
|
def load_file(self, domain: str, name_with_suffix: str) -> Optional[bytes]:
|
|
|
|
|
full_path = os.path.join(self._root_path, domain, name_with_suffix)
|
|
|
|
|
return self.__load(
|
|
|
|
|
full_path=full_path, type_=bytes, with_hash_check=False)
|
|
|
|
|
|
|
|
|
|
async def load_file_async(
|
|
|
|
|
self, domain: str, name_with_suffix: str
|
|
|
|
|
) -> Optional[bytes]:
|
|
|
|
|
full_path = os.path.join(self._root_path, domain, name_with_suffix)
|
|
|
|
|
if full_path in self._file_future:
|
|
|
|
|
# Waiting for the last task to be completed
|
|
|
|
|
op_type, fut = self._file_future[full_path]
|
|
|
|
|
if op_type == MIoTStorageType.LOAD_FILE:
|
|
|
|
|
if not fut.done():
|
|
|
|
|
return await fut
|
|
|
|
|
else:
|
|
|
|
|
await fut
|
|
|
|
|
fut = self._main_loop.run_in_executor(
|
|
|
|
|
None, self.__load, full_path, bytes, False)
|
|
|
|
|
if not fut.done():
|
|
|
|
|
self.__add_file_future(full_path, MIoTStorageType.LOAD_FILE, fut)
|
|
|
|
|
return await fut
|
|
|
|
|
|
|
|
|
|
def remove_file(self, domain: str, name_with_suffix: str) -> bool:
|
|
|
|
|
full_path = os.path.join(self._root_path, domain, name_with_suffix)
|
|
|
|
|
return self.__remove(full_path=full_path)
|
|
|
|
|
|
|
|
|
|
async def remove_file_async(
|
|
|
|
|
self, domain: str, name_with_suffix: str
|
|
|
|
|
) -> bool:
|
|
|
|
|
full_path = os.path.join(self._root_path, domain, name_with_suffix)
|
|
|
|
|
if full_path in self._file_future:
|
|
|
|
|
# Waiting for the last task to be completed
|
|
|
|
|
op_type, fut = self._file_future[full_path]
|
|
|
|
|
if op_type == MIoTStorageType.DEL_FILE:
|
|
|
|
|
if not fut.done():
|
|
|
|
|
return await fut
|
|
|
|
|
else:
|
|
|
|
|
await fut
|
|
|
|
|
fut = self._main_loop.run_in_executor(None, self.__remove, full_path)
|
|
|
|
|
if not fut.done():
|
|
|
|
|
self.__add_file_future(full_path, MIoTStorageType.DEL_FILE, fut)
|
|
|
|
|
return await fut
|
|
|
|
|
|
|
|
|
|
def clear(self) -> bool:
|
|
|
|
|
root_path = Path(self._root_path)
|
|
|
|
|
for item in root_path.iterdir():
|
|
|
|
|
if item.is_file() or item.is_symlink():
|
|
|
|
|
item.unlink()
|
|
|
|
|
elif item.is_dir():
|
|
|
|
|
shutil.rmtree(item)
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
async def clear_async(self) -> bool:
|
|
|
|
|
if self._root_path in self._file_future:
|
|
|
|
|
op_type, fut = self._file_future[self._root_path]
|
|
|
|
|
if op_type == MIoTStorageType.CLEAR and not fut.done():
|
|
|
|
|
return await fut
|
|
|
|
|
# Waiting all future resolve
|
|
|
|
|
for value in self._file_future.values():
|
|
|
|
|
await value[1]
|
|
|
|
|
|
|
|
|
|
fut = self._main_loop.run_in_executor(None, self.clear)
|
|
|
|
|
if not fut.done():
|
|
|
|
|
self.__add_file_future(
|
|
|
|
|
self._root_path, MIoTStorageType.CLEAR, fut)
|
|
|
|
|
return await fut
|
|
|
|
|
|
|
|
|
|
def update_user_config(
|
|
|
|
|
self, uid: str, cloud_server: str, config: Optional[dict[str, any]],
|
|
|
|
|
replace: bool = False
|
|
|
|
|
) -> bool:
|
|
|
|
|
if config is not None and len(config) == 0:
|
|
|
|
|
# Do nothing
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
config_domain = 'miot_config'
|
|
|
|
|
config_name = f'{uid}_{cloud_server}'
|
|
|
|
|
if config is None:
|
|
|
|
|
# Remove config file
|
|
|
|
|
return self.remove(
|
|
|
|
|
domain=config_domain, name=config_name, type_=dict)
|
|
|
|
|
if replace:
|
|
|
|
|
# Replace config file
|
|
|
|
|
return self.save(
|
|
|
|
|
domain=config_domain, name=config_name, data=config)
|
|
|
|
|
local_config = (self.load(domain=config_domain,
|
|
|
|
|
name=config_name, type_=dict)) or {}
|
|
|
|
|
local_config.update(config)
|
|
|
|
|
return self.save(
|
|
|
|
|
domain=config_domain, name=config_name, data=local_config)
|
|
|
|
|
|
|
|
|
|
async def update_user_config_async(
|
|
|
|
|
self, uid: str, cloud_server: str, config: Optional[dict[str, any]],
|
|
|
|
|
replace: bool = False
|
|
|
|
|
) -> bool:
|
|
|
|
|
"""Update user configuration.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
uid (str): user_id
|
|
|
|
|
config (Optional[dict[str]]):
|
|
|
|
|
remove config file if config is None
|
|
|
|
|
replace (bool, optional):
|
|
|
|
|
replace all config item. Defaults to False.
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
bool: result code
|
|
|
|
|
"""
|
|
|
|
|
if config is not None and len(config) == 0:
|
|
|
|
|
# Do nothing
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
config_domain = 'miot_config'
|
|
|
|
|
config_name = f'{uid}_{cloud_server}'
|
|
|
|
|
if config is None:
|
|
|
|
|
# Remove config file
|
|
|
|
|
return await self.remove_async(
|
|
|
|
|
domain=config_domain, name=config_name, type_=dict)
|
|
|
|
|
if replace:
|
|
|
|
|
# Replace config file
|
|
|
|
|
return await self.save_async(
|
|
|
|
|
domain=config_domain, name=config_name, data=config)
|
|
|
|
|
local_config = (await self.load_async(
|
|
|
|
|
domain=config_domain, name=config_name, type_=dict)) or {}
|
|
|
|
|
local_config.update(config)
|
|
|
|
|
return await self.save_async(
|
|
|
|
|
domain=config_domain, name=config_name, data=local_config)
|
|
|
|
|
|
|
|
|
|
def load_user_config(
|
|
|
|
|
self, uid: str, cloud_server: str, keys: Optional[list[str]] = None
|
|
|
|
|
) -> dict[str, any]:
|
|
|
|
|
if keys is not None and len(keys) == 0:
|
|
|
|
|
# Do nothing
|
|
|
|
|
return {}
|
|
|
|
|
config_domain = 'miot_config'
|
|
|
|
|
config_name = f'{uid}_{cloud_server}'
|
|
|
|
|
local_config = (self.load(domain=config_domain,
|
|
|
|
|
name=config_name, type_=dict)) or {}
|
|
|
|
|
if keys is None:
|
|
|
|
|
return local_config
|
|
|
|
|
return {key: local_config.get(key, None) for key in keys}
|
|
|
|
|
|
|
|
|
|
async def load_user_config_async(
|
|
|
|
|
self, uid: str, cloud_server: str, keys: Optional[list[str]] = None
|
|
|
|
|
) -> dict[str, any]:
|
|
|
|
|
"""Load user configuration.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
uid (str): user id
|
|
|
|
|
keys (list[str]):
|
|
|
|
|
query key list, return all config item if keys is None
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
dict[str, any]: query result
|
|
|
|
|
"""
|
|
|
|
|
if keys is not None and len(keys) == 0:
|
|
|
|
|
# Do nothing
|
|
|
|
|
return {}
|
|
|
|
|
config_domain = 'miot_config'
|
|
|
|
|
config_name = f'{uid}_{cloud_server}'
|
|
|
|
|
local_config = (await self.load_async(
|
|
|
|
|
domain=config_domain, name=config_name, type_=dict)) or {}
|
|
|
|
|
if keys is None:
|
|
|
|
|
return local_config
|
|
|
|
|
return {
|
|
|
|
|
key: local_config[key] for key in keys
|
|
|
|
|
if key in local_config}
|
|
|
|
|
|
|
|
|
|
def gen_storage_path(
|
|
|
|
|
self, domain: str = None, name_with_suffix: str = None
|
|
|
|
|
) -> str:
|
|
|
|
|
"""Generate file path."""
|
|
|
|
|
result = self._root_path
|
|
|
|
|
if domain:
|
|
|
|
|
result = os.path.join(result, domain)
|
|
|
|
|
if name_with_suffix:
|
|
|
|
|
result = os.path.join(result, name_with_suffix)
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class MIoTCert:
|
|
|
|
|
"""MIoT certificate file management."""
|
|
|
|
|
CERT_DOMAIN: str = 'cert'
|
|
|
|
|
CA_NAME: str = 'mihome_ca.cert'
|
|
|
|
|
_loop: asyncio.AbstractEventLoop
|
|
|
|
|
_storage: MIoTStorage
|
|
|
|
|
_uid: str
|
|
|
|
|
_cloud_server: str
|
|
|
|
|
|
|
|
|
|
_key_name: str
|
|
|
|
|
_cert_name: str
|
|
|
|
|
|
|
|
|
|
def __init__(
|
|
|
|
|
self, storage: MIoTStorage, uid: str, cloud_server: str,
|
|
|
|
|
loop: Optional[asyncio.AbstractEventLoop] = None
|
|
|
|
|
) -> None:
|
|
|
|
|
if not isinstance(storage, MIoTStorage) or not isinstance(uid, str):
|
|
|
|
|
raise MIoTError('invalid params')
|
|
|
|
|
self._loop = loop or asyncio.get_running_loop()
|
|
|
|
|
self._storage = storage
|
|
|
|
|
self._uid = uid
|
|
|
|
|
self._cloud_server = cloud_server
|
|
|
|
|
self._key_name = f'{uid}_{cloud_server}.key'
|
|
|
|
|
self._cert_name = f'{uid}_{cloud_server}.cert'
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
def ca_file(self) -> str:
|
|
|
|
|
"""CA certificate file path."""
|
|
|
|
|
return self._storage.gen_storage_path(
|
|
|
|
|
domain=self.CERT_DOMAIN, name_with_suffix=self.CA_NAME)
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
def key_file(self) -> str:
|
|
|
|
|
"""User private key file file path."""
|
|
|
|
|
return self._storage.gen_storage_path(
|
|
|
|
|
domain=self.CERT_DOMAIN, name_with_suffix=self._key_name)
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
def cert_file(self) -> str:
|
|
|
|
|
"""User certificate file path."""
|
|
|
|
|
return self._storage.gen_storage_path(
|
|
|
|
|
domain=self.CERT_DOMAIN, name_with_suffix=self._cert_name)
|
|
|
|
|
|
|
|
|
|
async def verify_ca_cert_async(self) -> bool:
|
|
|
|
|
"""Verify the integrity of the CA certificate file."""
|
|
|
|
|
ca_data = await self._storage.load_file_async(
|
|
|
|
|
domain=self.CERT_DOMAIN, name_with_suffix=self.CA_NAME)
|
|
|
|
|
if ca_data is None:
|
|
|
|
|
if not await self._storage.save_file_async(
|
|
|
|
|
domain=self.CERT_DOMAIN,
|
|
|
|
|
name_with_suffix=self.CA_NAME,
|
|
|
|
|
data=MIHOME_CA_CERT_STR.encode('utf-8')):
|
|
|
|
|
raise MIoTStorageError('ca cert save failed')
|
|
|
|
|
ca_data = await self._storage.load_file_async(
|
|
|
|
|
domain=self.CERT_DOMAIN, name_with_suffix=self.CA_NAME)
|
|
|
|
|
if ca_data is None:
|
|
|
|
|
raise MIoTStorageError('ca cert load failed')
|
|
|
|
|
_LOGGER.debug('ca cert save success')
|
|
|
|
|
# Compare the file sha256sum
|
|
|
|
|
ca_cert_hash = hashlib.sha256(ca_data).digest()
|
|
|
|
|
hash_str = binascii.hexlify(ca_cert_hash).decode('utf-8')
|
|
|
|
|
if hash_str != MIHOME_CA_CERT_SHA256:
|
|
|
|
|
return False
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
async def user_cert_remaining_time_async(
|
|
|
|
|
self, cert_data: Optional[bytes] = None, did: Optional[str] = None
|
|
|
|
|
) -> int:
|
|
|
|
|
"""Get the remaining time of user certificate validity.
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
If the certificate is not valid, return 0.
|
|
|
|
|
"""
|
|
|
|
|
if cert_data is None:
|
|
|
|
|
cert_data = await self._storage.load_file_async(
|
|
|
|
|
domain=self.CERT_DOMAIN, name_with_suffix=self._cert_name)
|
|
|
|
|
if cert_data is None:
|
|
|
|
|
return 0
|
|
|
|
|
# Check user cert
|
|
|
|
|
user_cert: x509.Certificate = None
|
|
|
|
|
try:
|
|
|
|
|
user_cert = x509.load_pem_x509_certificate(
|
|
|
|
|
cert_data, default_backend())
|
|
|
|
|
cert_info = {}
|
|
|
|
|
for attribute in user_cert.subject:
|
|
|
|
|
if attribute.oid == x509.NameOID.COMMON_NAME:
|
|
|
|
|
cert_info['CN'] = attribute.value
|
|
|
|
|
elif attribute.oid == x509.NameOID.COUNTRY_NAME:
|
|
|
|
|
cert_info['C'] = attribute.value
|
|
|
|
|
elif attribute.oid == x509.NameOID.ORGANIZATION_NAME:
|
|
|
|
|
cert_info['O'] = attribute.value
|
|
|
|
|
|
|
|
|
|
if len(cert_info) != 3:
|
|
|
|
|
raise MIoTCertError('invalid cert info')
|
|
|
|
|
if (
|
|
|
|
|
did and cert_info['CN'] !=
|
|
|
|
|
f'mips.{self._uid}.{self.__did_hash(did=did)}.2'
|
|
|
|
|
):
|
|
|
|
|
raise MIoTCertError('invalid COMMON_NAME')
|
|
|
|
|
if 'C' not in cert_info or cert_info['C'] != 'CN':
|
|
|
|
|
raise MIoTCertError('invalid COUNTRY_NAME')
|
|
|
|
|
if 'O' not in cert_info or cert_info['O'] != 'Mijia Device':
|
|
|
|
|
raise MIoTCertError('invalid ORGANIZATION_NAME')
|
|
|
|
|
now_utc: datetime = datetime.now(timezone.utc)
|
|
|
|
|
if (
|
|
|
|
|
now_utc < user_cert.not_valid_before_utc or
|
|
|
|
|
now_utc > user_cert.not_valid_after_utc
|
|
|
|
|
):
|
|
|
|
|
raise MIoTCertError('cert is not valid')
|
|
|
|
|
return int((user_cert.not_valid_after_utc-now_utc).total_seconds())
|
|
|
|
|
except (MIoTCertError, ValueError) as error:
|
|
|
|
|
_LOGGER.error(
|
|
|
|
|
'load_pem_x509_certificate failed, %s, %s',
|
|
|
|
|
error, traceback.format_exc())
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
def gen_user_key(self) -> str:
|
|
|
|
|
"""Generate user private key."""
|
|
|
|
|
private_key = ed25519.Ed25519PrivateKey.generate()
|
|
|
|
|
return private_key.private_bytes(
|
|
|
|
|
encoding=serialization.Encoding.PEM,
|
|
|
|
|
format=serialization.PrivateFormat.PKCS8,
|
|
|
|
|
encryption_algorithm=serialization.NoEncryption()
|
|
|
|
|
).decode('utf-8')
|
|
|
|
|
|
|
|
|
|
def gen_user_csr(self, user_key: str, did: str) -> str:
|
|
|
|
|
"""Generate CSR of user certificate."""
|
|
|
|
|
private_key = serialization.load_pem_private_key(
|
|
|
|
|
data=user_key.encode('utf-8'), password=None)
|
|
|
|
|
did_hash = self.__did_hash(did=did)
|
|
|
|
|
builder = x509.CertificateSigningRequestBuilder().subject_name(
|
|
|
|
|
x509.Name([
|
|
|
|
|
# Central hub gateway service is only supported in China.
|
|
|
|
|
x509.NameAttribute(NameOID.COUNTRY_NAME, 'CN'),
|
|
|
|
|
x509.NameAttribute(NameOID.ORGANIZATION_NAME, 'Mijia Device'),
|
|
|
|
|
x509.NameAttribute(
|
|
|
|
|
NameOID.COMMON_NAME, f'mips.{self._uid}.{did_hash}.2'),
|
|
|
|
|
]))
|
|
|
|
|
csr = builder.sign(
|
|
|
|
|
private_key, algorithm=None, backend=default_backend())
|
|
|
|
|
return csr.public_bytes(serialization.Encoding.PEM).decode('utf-8')
|
|
|
|
|
|
|
|
|
|
async def load_user_key_async(self) -> Optional[str]:
|
|
|
|
|
"""Load user private key."""
|
|
|
|
|
data = await self._storage.load_file_async(
|
|
|
|
|
domain=self.CERT_DOMAIN, name_with_suffix=self._key_name)
|
|
|
|
|
return data.decode('utf-8') if data else None
|
|
|
|
|
|
|
|
|
|
async def update_user_key_async(self, key: str) -> bool:
|
|
|
|
|
"""Update user private key."""
|
|
|
|
|
return await self._storage.save_file_async(
|
|
|
|
|
domain=self.CERT_DOMAIN,
|
|
|
|
|
name_with_suffix=self._key_name,
|
|
|
|
|
data=key.encode('utf-8'))
|
|
|
|
|
|
|
|
|
|
async def load_user_cert_async(self) -> Optional[str]:
|
|
|
|
|
"""Load user certificate."""
|
|
|
|
|
data = await self._storage.load_file_async(
|
|
|
|
|
domain=self.CERT_DOMAIN, name_with_suffix=self._cert_name)
|
|
|
|
|
return data.decode('utf-8') if data else None
|
|
|
|
|
|
|
|
|
|
async def update_user_cert_async(self, cert: str) -> bool:
|
|
|
|
|
"""Update user certificate."""
|
|
|
|
|
return await self._storage.save_file_async(
|
|
|
|
|
domain=self.CERT_DOMAIN,
|
|
|
|
|
name_with_suffix=self._cert_name,
|
|
|
|
|
data=cert.encode('utf-8'))
|
|
|
|
|
|
|
|
|
|
async def remove_ca_cert_async(self) -> bool:
|
|
|
|
|
"""Remove CA certificate."""
|
|
|
|
|
return await self._storage.remove_file_async(
|
|
|
|
|
domain=self.CERT_DOMAIN, name_with_suffix=self.CA_NAME)
|
|
|
|
|
|
|
|
|
|
async def remove_user_key_async(self) -> bool:
|
|
|
|
|
"""Remove user private key."""
|
|
|
|
|
return await self._storage.remove_file_async(
|
|
|
|
|
domain=self.CERT_DOMAIN, name_with_suffix=self._key_name)
|
|
|
|
|
|
|
|
|
|
async def remove_user_cert_async(self) -> bool:
|
|
|
|
|
"""Remove user certificate."""
|
|
|
|
|
return await self._storage.remove_file_async(
|
|
|
|
|
domain=self.CERT_DOMAIN, name_with_suffix=self._cert_name)
|
|
|
|
|
|
|
|
|
|
def __did_hash(self, did: str) -> str:
|
|
|
|
|
sha1_hash = hashes.Hash(hashes.SHA1(), backend=default_backend())
|
|
|
|
|
sha1_hash.update(did.encode('utf-8'))
|
|
|
|
|
return binascii.hexlify(sha1_hash.finalize()).decode('utf-8')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SpecMultiLang:
|
|
|
|
|
"""
|
|
|
|
|
MIoT-Spec-V2 multi-language for entities.
|
|
|
|
|
"""
|
|
|
|
|
MULTI_LANG_FILE = 'specs/multi_lang.json'
|
|
|
|
|
_main_loop: asyncio.AbstractEventLoop
|
|
|
|
|
_lang: str
|
|
|
|
|
_data: Optional[dict[str, dict]]
|
|
|
|
|
|
|
|
|
|
def __init__(
|
|
|
|
|
self, lang: str, loop: Optional[asyncio.AbstractEventLoop] = None
|
|
|
|
|
) -> None:
|
|
|
|
|
self._main_loop = loop or asyncio.get_event_loop()
|
|
|
|
|
self._lang = lang
|
|
|
|
|
self._data = None
|
|
|
|
|
|
|
|
|
|
async def init_async(self) -> None:
|
|
|
|
|
if isinstance(self._data, dict):
|
|
|
|
|
return
|
|
|
|
|
multi_lang_data = None
|
|
|
|
|
self._data = {}
|
|
|
|
|
try:
|
|
|
|
|
multi_lang_data = await self._main_loop.run_in_executor(
|
|
|
|
|
None, load_json_file,
|
|
|
|
|
os.path.join(
|
|
|
|
|
os.path.dirname(os.path.abspath(__file__)),
|
|
|
|
|
self.MULTI_LANG_FILE))
|
|
|
|
|
except Exception as err: # pylint: disable=broad-exception-caught
|
|
|
|
|
_LOGGER.error('multi lang, load file error, %s', err)
|
|
|
|
|
return
|
|
|
|
|
# Check if the file is a valid JSON file
|
|
|
|
|
if not isinstance(multi_lang_data, dict):
|
|
|
|
|
_LOGGER.error('multi lang, invalid file data')
|
|
|
|
|
return
|
|
|
|
|
for lang_data in multi_lang_data.values():
|
|
|
|
|
if not isinstance(lang_data, dict):
|
|
|
|
|
_LOGGER.error('multi lang, invalid lang data')
|
|
|
|
|
return
|
|
|
|
|
for data in lang_data.values():
|
|
|
|
|
if not isinstance(data, dict):
|
|
|
|
|
_LOGGER.error('multi lang, invalid lang data item')
|
|
|
|
|
return
|
|
|
|
|
self._data = multi_lang_data
|
|
|
|
|
|
|
|
|
|
async def deinit_async(self) -> str:
|
|
|
|
|
self._data = None
|
|
|
|
|
|
|
|
|
|
async def translate_async(self, urn_key: str) -> dict[str, str]:
|
|
|
|
|
"""MUST call init_async() first."""
|
|
|
|
|
if urn_key in self._data:
|
|
|
|
|
return self._data[urn_key].get(self._lang, {})
|
|
|
|
|
return {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SpecBoolTranslation:
|
|
|
|
|
"""
|
|
|
|
|
Boolean value translation.
|
|
|
|
|
"""
|
|
|
|
|
BOOL_TRANS_FILE = 'specs/bool_trans.json'
|
|
|
|
|
_main_loop: asyncio.AbstractEventLoop
|
|
|
|
|
_lang: str
|
|
|
|
|
_data: Optional[dict[str, dict]]
|
|
|
|
|
_data_default: dict[str, dict]
|
|
|
|
|
|
|
|
|
|
def __init__(
|
|
|
|
|
self, lang: str, loop: Optional[asyncio.AbstractEventLoop] = None
|
|
|
|
|
) -> None:
|
|
|
|
|
self._main_loop = loop or asyncio.get_event_loop()
|
|
|
|
|
self._lang = lang
|
|
|
|
|
self._data = None
|
|
|
|
|
|
|
|
|
|
async def init_async(self) -> None:
|
|
|
|
|
if isinstance(self._data, dict):
|
|
|
|
|
return
|
|
|
|
|
data = None
|
|
|
|
|
self._data = {}
|
|
|
|
|
try:
|
|
|
|
|
data = await self._main_loop.run_in_executor(
|
|
|
|
|
None, load_json_file,
|
|
|
|
|
os.path.join(
|
|
|
|
|
os.path.dirname(os.path.abspath(__file__)),
|
|
|
|
|
self.BOOL_TRANS_FILE))
|
|
|
|
|
except Exception as err: # pylint: disable=broad-exception-caught
|
|
|
|
|
_LOGGER.error('bool trans, load file error, %s', err)
|
|
|
|
|
return
|
|
|
|
|
# Check if the file is a valid JSON file
|
|
|
|
|
if (
|
|
|
|
|
not isinstance(data, dict)
|
|
|
|
|
or 'data' not in data
|
|
|
|
|
or not isinstance(data['data'], dict)
|
|
|
|
|
or 'translate' not in data
|
|
|
|
|
or not isinstance(data['translate'], dict)
|
|
|
|
|
):
|
|
|
|
|
_LOGGER.error('bool trans, valid file')
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
if 'default' in data['translate']:
|
|
|
|
|
data_default = (
|
|
|
|
|
data['translate']['default'].get(self._lang, None)
|
|
|
|
|
or data['translate']['default'].get(
|
|
|
|
|
DEFAULT_INTEGRATION_LANGUAGE, None))
|
|
|
|
|
if data_default:
|
|
|
|
|
self._data_default = [
|
|
|
|
|
{'value': True, 'description': data_default['true']},
|
|
|
|
|
{'value': False, 'description': data_default['false']}
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
for urn, key in data['data'].items():
|
|
|
|
|
if key not in data['translate']:
|
|
|
|
|
_LOGGER.error('bool trans, unknown key, %s, %s', urn, key)
|
|
|
|
|
continue
|
|
|
|
|
trans_data = (
|
|
|
|
|
data['translate'][key].get(self._lang, None)
|
|
|
|
|
or data['translate'][key].get(
|
|
|
|
|
DEFAULT_INTEGRATION_LANGUAGE, None))
|
|
|
|
|
if trans_data:
|
|
|
|
|
self._data[urn] = [
|
|
|
|
|
{'value': True, 'description': trans_data['true']},
|
|
|
|
|
{'value': False, 'description': trans_data['false']}
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
async def deinit_async(self) -> None:
|
|
|
|
|
self._data = None
|
|
|
|
|
self._data_default = None
|
|
|
|
|
|
|
|
|
|
async def translate_async(self, urn: str) -> list[dict[bool, str]]:
|
|
|
|
|
"""
|
|
|
|
|
MUST call init_async() before calling this method.
|
|
|
|
|
[
|
|
|
|
|
{'value': True, 'description': 'True'},
|
|
|
|
|
{'value': False, 'description': 'False'}
|
|
|
|
|
]
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
return self._data.get(urn, self._data_default)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SpecFilter:
|
|
|
|
|
"""
|
|
|
|
|
MIoT-Spec-V2 filter for entity conversion.
|
|
|
|
|
"""
|
|
|
|
|
SPEC_FILTER_FILE = 'specs/spec_filter.json'
|
|
|
|
|
_main_loop: asyncio.AbstractEventLoop
|
|
|
|
|
_data: dict[str, dict[str, set]]
|
|
|
|
|
_cache: Optional[dict]
|
|
|
|
|
|
|
|
|
|
def __init__(self, loop: Optional[asyncio.AbstractEventLoop]) -> None:
|
|
|
|
|
self._main_loop = loop or asyncio.get_event_loop()
|
|
|
|
|
self._data = None
|
|
|
|
|
self._cache = None
|
|
|
|
|
|
|
|
|
|
async def init_async(self) -> None:
|
|
|
|
|
if isinstance(self._data, dict):
|
|
|
|
|
return
|
|
|
|
|
filter_data = None
|
|
|
|
|
self._data = {}
|
|
|
|
|
try:
|
|
|
|
|
filter_data = await self._main_loop.run_in_executor(
|
|
|
|
|
None, load_json_file,
|
|
|
|
|
os.path.join(
|
|
|
|
|
os.path.dirname(os.path.abspath(__file__)),
|
|
|
|
|
self.SPEC_FILTER_FILE))
|
|
|
|
|
except Exception as err: # pylint: disable=broad-exception-caught
|
|
|
|
|
_LOGGER.error('spec filter, load file error, %s', err)
|
|
|
|
|
return
|
|
|
|
|
if not isinstance(filter_data, dict):
|
|
|
|
|
_LOGGER.error('spec filter, invalid spec filter content')
|
|
|
|
|
return
|
|
|
|
|
for values in list(filter_data.values()):
|
|
|
|
|
if not isinstance(values, dict):
|
|
|
|
|
_LOGGER.error('spec filter, invalid spec filter data')
|
|
|
|
|
return
|
|
|
|
|
for value in values.values():
|
|
|
|
|
if not isinstance(value, list):
|
|
|
|
|
_LOGGER.error('spec filter, invalid spec filter rules')
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
self._data = filter_data
|
|
|
|
|
|
|
|
|
|
async def deinit_async(self) -> None:
|
|
|
|
|
self._cache = None
|
|
|
|
|
self._data = None
|
|
|
|
|
|
|
|
|
|
def filter_spec(self, urn_key: str) -> None:
|
|
|
|
|
"""MUST call init_async() first."""
|
|
|
|
|
if not self._data:
|
|
|
|
|
return
|
|
|
|
|
self._cache = self._data.get(urn_key, None)
|
|
|
|
|
|
|
|
|
|
def filter_service(self, siid: int) -> bool:
|
|
|
|
|
"""Filter service by siid.
|
|
|
|
|
MUST call init_async() and filter_spec() first."""
|
|
|
|
|
if (
|
|
|
|
|
self._cache
|
|
|
|
|
and 'services' in self._cache
|
|
|
|
|
and (
|
|
|
|
|
str(siid) in self._cache['services']
|
|
|
|
|
or '*' in self._cache['services'])
|
|
|
|
|
):
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
def filter_property(self, siid: int, piid: int) -> bool:
|
|
|
|
|
"""Filter property by piid.
|
|
|
|
|
MUST call init_async() and filter_spec() first."""
|
|
|
|
|
if (
|
|
|
|
|
self._cache
|
|
|
|
|
and 'properties' in self._cache
|
|
|
|
|
and (
|
|
|
|
|
f'{siid}.{piid}' in self._cache['properties']
|
|
|
|
|
or f'{siid}.*' in self._cache['properties'])
|
|
|
|
|
):
|
|
|
|
|
return True
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
def filter_event(self, siid: int, eiid: int) -> bool:
|
|
|
|
|
"""Filter event by eiid.
|
|
|
|
|
MUST call init_async() and filter_spec() first."""
|
|
|
|
|
if (
|
|
|
|
|
self._cache
|
|
|
|
|
and 'events' in self._cache
|
|
|
|
|
and (
|
|
|
|
|
f'{siid}.{eiid}' in self._cache['events']
|
|
|
|
|
or f'{siid}.*' in self._cache['events']
|
|
|
|
|
)
|
|
|
|
|
):
|
|
|
|
|
return True
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
def filter_action(self, siid: int, aiid: int) -> bool:
|
|
|
|
|
""""Filter action by aiid.
|
|
|
|
|
MUST call init_async() and filter_spec() first."""
|
|
|
|
|
if (
|
|
|
|
|
self._cache
|
|
|
|
|
and 'actions' in self._cache
|
|
|
|
|
and (
|
|
|
|
|
f'{siid}.{aiid}' in self._cache['actions']
|
|
|
|
|
or f'{siid}.*' in self._cache['actions'])
|
|
|
|
|
):
|
|
|
|
|
return True
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class DeviceManufacturer:
|
|
|
|
|
"""Device manufacturer."""
|
|
|
|
|
DOMAIN: str = 'miot_specs'
|
|
|
|
|
_main_loop: asyncio.AbstractEventLoop
|
|
|
|
|
_storage: MIoTStorage
|
|
|
|
|
_data: dict
|
|
|
|
|
|
|
|
|
|
def __init__(
|
|
|
|
|
self, storage: MIoTStorage,
|
|
|
|
|
loop: Optional[asyncio.AbstractEventLoop] = None
|
|
|
|
|
) -> None:
|
|
|
|
|
self._main_loop = loop or asyncio.get_event_loop()
|
|
|
|
|
self._storage = storage
|
|
|
|
|
self._data = None
|
|
|
|
|
|
|
|
|
|
async def init_async(self) -> None:
|
|
|
|
|
if self._data:
|
|
|
|
|
return
|
|
|
|
|
data_cache: dict = None
|
|
|
|
|
data_cache = await self._storage.load_async(
|
|
|
|
|
domain=self.DOMAIN, name='manufacturer', type_=dict)
|
|
|
|
|
if (
|
|
|
|
|
isinstance(data_cache, dict)
|
|
|
|
|
and 'data' in data_cache
|
|
|
|
|
and 'ts' in data_cache
|
|
|
|
|
and (int(time.time()) - data_cache['ts']) <
|
|
|
|
|
MANUFACTURER_EFFECTIVE_TIME
|
|
|
|
|
):
|
|
|
|
|
self._data = data_cache['data']
|
|
|
|
|
_LOGGER.debug('load manufacturer data success')
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
data_cloud = await self._main_loop.run_in_executor(
|
|
|
|
|
None, self.__get_manufacturer_data)
|
|
|
|
|
if data_cloud:
|
|
|
|
|
await self._storage.save_async(
|
|
|
|
|
domain=self.DOMAIN, name='manufacturer',
|
|
|
|
|
data={'data': data_cloud, 'ts': int(time.time())})
|
|
|
|
|
self._data = data_cloud
|
|
|
|
|
_LOGGER.debug('update manufacturer data success')
|
|
|
|
|
else:
|
|
|
|
|
if data_cache:
|
|
|
|
|
self._data = data_cache.get('data', None)
|
|
|
|
|
_LOGGER.error('load manufacturer data failed, use local data')
|
|
|
|
|
else:
|
|
|
|
|
_LOGGER.error('load manufacturer data failed')
|
|
|
|
|
|
|
|
|
|
async def deinit_async(self) -> None:
|
|
|
|
|
self._data = None
|
|
|
|
|
|
|
|
|
|
def get_name(self, short_name: str) -> str:
|
|
|
|
|
if not self._data or not short_name or short_name not in self._data:
|
|
|
|
|
return short_name
|
|
|
|
|
return self._data[short_name].get('name', None) or short_name
|
|
|
|
|
|
|
|
|
|
def __get_manufacturer_data(self) -> dict:
|
|
|
|
|
try:
|
|
|
|
|
request = Request(
|
|
|
|
|
'https://cdn.cnbj1.fds.api.mi-img.com/res-conf/xiaomi-home/'
|
|
|
|
|
'manufacturer.json',
|
|
|
|
|
method='GET')
|
|
|
|
|
content: bytes = None
|
|
|
|
|
with urlopen(request) as response:
|
|
|
|
|
content = response.read()
|
|
|
|
|
return (
|
|
|
|
|
json.loads(str(content, 'utf-8'))
|
|
|
|
|
if content else None)
|
|
|
|
|
except Exception as err: # pylint: disable=broad-exception-caught
|
|
|
|
|
_LOGGER.error('get manufacturer info failed, %s', err)
|
|
|
|
|
return None
|