# -*- 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 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