From 6557b22a529695f345e444e71ea40b67e91e1dfe Mon Sep 17 00:00:00 2001 From: Paul Shawn <32349595+topsworld@users.noreply.github.com> Date: Fri, 10 Jan 2025 09:19:24 +0800 Subject: [PATCH 01/14] fix: fix multi ha instance login (#560) * fix: fix multi ha instance login * fix: fix option flow oauth --- custom_components/xiaomi_home/config_flow.py | 25 ++++++++++++------- .../xiaomi_home/miot/miot_client.py | 1 + .../xiaomi_home/miot/miot_cloud.py | 8 +++++- .../xiaomi_home/translations/de.json | 1 + .../xiaomi_home/translations/en.json | 1 + .../xiaomi_home/translations/es.json | 1 + .../xiaomi_home/translations/fr.json | 1 + .../xiaomi_home/translations/ja.json | 1 + .../xiaomi_home/translations/nl.json | 1 + .../xiaomi_home/translations/pt-BR.json | 1 + .../xiaomi_home/translations/pt.json | 1 + .../xiaomi_home/translations/ru.json | 1 + .../xiaomi_home/translations/zh-Hans.json | 1 + .../xiaomi_home/translations/zh-Hant.json | 1 + 14 files changed, 35 insertions(+), 10 deletions(-) diff --git a/custom_components/xiaomi_home/config_flow.py b/custom_components/xiaomi_home/config_flow.py index 667e5fe..8e48849 100644 --- a/custom_components/xiaomi_home/config_flow.py +++ b/custom_components/xiaomi_home/config_flow.py @@ -68,6 +68,7 @@ from homeassistant.components.webhook import ( ) from homeassistant.core import callback from homeassistant.data_entry_flow import AbortFlow +from homeassistant.helpers.instance_id import async_get import homeassistant.helpers.config_validation as cv from .miot.const import ( @@ -247,6 +248,13 @@ class XiaomiMihomeConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if user_input: self._cloud_server = user_input.get( 'cloud_server', self._cloud_server) + # Gen instance uuid + ha_uuid = await async_get(self.hass) + if not ha_uuid: + raise AbortFlow(reason='ha_uuid_get_failed') + self._uuid = hashlib.sha256( + f'{ha_uuid}.{self._virtual_did}.{self._cloud_server}'.encode( + 'utf-8')).hexdigest()[:32] self._integration_language = user_input.get( 'integration_language', DEFAULT_INTEGRATION_LANGUAGE) self._miot_i18n = MIoTI18n( @@ -415,9 +423,11 @@ class XiaomiMihomeConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): miot_oauth = MIoTOauthClient( client_id=OAUTH2_CLIENT_ID, redirect_url=self._oauth_redirect_url_full, - cloud_server=self._cloud_server - ) - state = str(secrets.randbits(64)) + cloud_server=self._cloud_server, + uuid=self._uuid, + loop=self._main_loop) + state = hashlib.sha1( + f'd=ha.{self._uuid}'.encode('utf-8')).hexdigest() self.hass.data[DOMAIN][self._virtual_did]['oauth_state'] = state self._cc_oauth_auth_url = miot_oauth.gen_auth_url( redirect_url=self._oauth_redirect_url_full, state=state) @@ -498,11 +508,6 @@ class XiaomiMihomeConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): client_id=OAUTH2_CLIENT_ID, access_token=auth_info['access_token']) self._auth_info = auth_info - # Gen uuid - self._uuid = hashlib.sha256( - f'{self._virtual_did}.{auth_info["access_token"]}'.encode( - 'utf-8') - ).hexdigest()[:32] try: self._nick_name = ( await self._miot_http.get_user_info_async() or {} @@ -1145,7 +1150,9 @@ class OptionsFlowHandler(config_entries.OptionsFlow): async def async_step_oauth(self, user_input=None): try: if self._cc_task_oauth is None: - state = str(secrets.randbits(64)) + state = hashlib.sha1( + f'd=ha.{self._entry_data["uuid"]}'.encode('utf-8') + ).hexdigest() self.hass.data[DOMAIN][self._virtual_did]['oauth_state'] = state self._miot_oauth.set_redirect_url( redirect_url=self._oauth_redirect_url_full) diff --git a/custom_components/xiaomi_home/miot/miot_client.py b/custom_components/xiaomi_home/miot/miot_client.py index b762ce3..b618ea5 100644 --- a/custom_components/xiaomi_home/miot/miot_client.py +++ b/custom_components/xiaomi_home/miot/miot_client.py @@ -257,6 +257,7 @@ class MIoTClient: client_id=OAUTH2_CLIENT_ID, redirect_url=self._entry_data['oauth_redirect_url'], cloud_server=self._cloud_server, + uuid=self._entry_data["uuid"], loop=self._main_loop) # MIoT http client instance self._http = MIoTHttpClient( diff --git a/custom_components/xiaomi_home/miot/miot_cloud.py b/custom_components/xiaomi_home/miot/miot_cloud.py index 7ed3875..4c076fe 100644 --- a/custom_components/xiaomi_home/miot/miot_cloud.py +++ b/custom_components/xiaomi_home/miot/miot_cloud.py @@ -75,10 +75,11 @@ class MIoTOauthClient: _oauth_host: str _client_id: int _redirect_url: str + _device_id: str def __init__( self, client_id: str, redirect_url: str, cloud_server: str, - loop: Optional[asyncio.AbstractEventLoop] = None + uuid: 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() == '': @@ -87,6 +88,8 @@ class MIoTOauthClient: raise MIoTOauthError('invalid redirect_url') if not cloud_server: raise MIoTOauthError('invalid cloud_server') + if not uuid: + raise MIoTOauthError('invalid uuid') self._client_id = int(client_id) self._redirect_url = redirect_url @@ -94,6 +97,7 @@ class MIoTOauthClient: self._oauth_host = DEFAULT_OAUTH2_API_HOST else: self._oauth_host = f'{cloud_server}.{DEFAULT_OAUTH2_API_HOST}' + self._device_id = f'ha.{uuid}' self._session = aiohttp.ClientSession(loop=self._main_loop) async def deinit_async(self) -> None: @@ -132,6 +136,7 @@ class MIoTOauthClient: 'redirect_uri': redirect_url or self._redirect_url, 'client_id': self._client_id, 'response_type': 'code', + 'device_id': self._device_id } if state: params['state'] = state @@ -191,6 +196,7 @@ class MIoTOauthClient: 'client_id': self._client_id, 'redirect_uri': self._redirect_url, 'code': code, + 'device_id': self._device_id }) async def refresh_access_token_async(self, refresh_token: str) -> dict: diff --git a/custom_components/xiaomi_home/translations/de.json b/custom_components/xiaomi_home/translations/de.json index 68a0373..25dfd02 100644 --- a/custom_components/xiaomi_home/translations/de.json +++ b/custom_components/xiaomi_home/translations/de.json @@ -90,6 +90,7 @@ "unreachable_mqtt_broker": "Xiaomi MQTT Broker-Adresse ist nicht erreichbar, bitte überprüfen Sie die Netzwerkkonfiguration." }, "abort": { + "ha_uuid_get_failed": "Fehler beim Abrufen der Home Assistant-UUID.", "network_connect_error": "Konfiguration fehlgeschlagen. Netzwerkverbindung fehlgeschlagen. Überprüfen Sie die Netzwerkkonfiguration des Geräts.", "already_configured": "Dieser Benutzer hat die Konfiguration bereits abgeschlossen. Gehen Sie zur Integrationsseite und klicken Sie auf die Schaltfläche \"Konfiguration\", um die Konfiguration zu ändern.", "invalid_auth_info": "Authentifizierungsinformationen sind abgelaufen. Gehen Sie zur Integrationsseite und klicken Sie auf die Schaltfläche \"Konfiguration\", um die Authentifizierung erneut durchzuführen.", diff --git a/custom_components/xiaomi_home/translations/en.json b/custom_components/xiaomi_home/translations/en.json index 2244730..0ee151c 100644 --- a/custom_components/xiaomi_home/translations/en.json +++ b/custom_components/xiaomi_home/translations/en.json @@ -90,6 +90,7 @@ "unreachable_mqtt_broker": "Unable to reach Xiaomi MQTT Broker address, please check network configuration." }, "abort": { + "ha_uuid_get_failed": "Failed to get Home Assistant UUID.", "network_connect_error": "Configuration failed. The network connection is abnormal. Please check the equipment network configuration.", "already_configured": "Configuration for this user is already completed. Please go to the integration page and click the CONFIGURE button for modifications.", "invalid_auth_info": "Authentication information has expired. Please go to the integration page and click the CONFIGURE button to re-authenticate.", diff --git a/custom_components/xiaomi_home/translations/es.json b/custom_components/xiaomi_home/translations/es.json index 2942567..e7b0c75 100644 --- a/custom_components/xiaomi_home/translations/es.json +++ b/custom_components/xiaomi_home/translations/es.json @@ -90,6 +90,7 @@ "unreachable_mqtt_broker": "No se puede acceder a la dirección del Broker MQTT de Xiaomi, por favor verifique la configuración de la red." }, "abort": { + "ha_uuid_get_failed": "Error al obtener el UUID de Home Assistant.", "network_connect_error": "La configuración ha fallado. Existe un problema con la conexión de red, verifique la configuración de red del dispositivo.", "already_configured": "Esta cuenta ya ha finalizado la configuración. Ingrese a la página de integración y haga clic en el botón \"Configurar\" para modificar la configuración.", "invalid_auth_info": "La información de autorización ha caducado. Ingrese a la página de integración y haga clic en el botón \"Configurar\" para volver a autenticarse.", diff --git a/custom_components/xiaomi_home/translations/fr.json b/custom_components/xiaomi_home/translations/fr.json index fa1b84d..63b9c44 100644 --- a/custom_components/xiaomi_home/translations/fr.json +++ b/custom_components/xiaomi_home/translations/fr.json @@ -90,6 +90,7 @@ "unreachable_mqtt_broker": "Impossible d'atteindre l'adresse du Broker MQTT de Xiaomi, veuillez vérifier la configuration réseau." }, "abort": { + "ha_uuid_get_failed": "Échec de l'obtention de l'UUID de Home Assistant.", "network_connect_error": "La configuration a échoué. Erreur de connexion réseau. Veuillez vérifier la configuration du réseau de l'appareil.", "already_configured": "Cet utilisateur a déjà terminé la configuration. Veuillez accéder à la page d'intégration et cliquer sur le bouton \"Configurer\" pour modifier la configuration.", "invalid_auth_info": "Les informations d'authentification ont expiré. Veuillez accéder à la page d'intégration et cliquer sur le bouton \"Configurer\" pour vous authentifier à nouveau.", diff --git a/custom_components/xiaomi_home/translations/ja.json b/custom_components/xiaomi_home/translations/ja.json index d63201b..2b07b06 100644 --- a/custom_components/xiaomi_home/translations/ja.json +++ b/custom_components/xiaomi_home/translations/ja.json @@ -90,6 +90,7 @@ "unreachable_mqtt_broker": "Xiaomi MQTT ブローカーアドレスにアクセスできません。ネットワーク設定を確認してください。" }, "abort": { + "ha_uuid_get_failed": "Home Assistant インスタンスIDを取得できませんでした。", "network_connect_error": "設定に失敗しました。ネットワーク接続に異常があります。デバイスのネットワーク設定を確認してください。", "already_configured": "このユーザーはすでに設定が完了しています。統合ページにアクセスして、「設定」ボタンをクリックして設定を変更してください。", "invalid_auth_info": "認証情報が期限切れになりました。統合ページにアクセスして、「設定」ボタンをクリックして再度認証してください。", diff --git a/custom_components/xiaomi_home/translations/nl.json b/custom_components/xiaomi_home/translations/nl.json index 6c4c436..6e28936 100644 --- a/custom_components/xiaomi_home/translations/nl.json +++ b/custom_components/xiaomi_home/translations/nl.json @@ -90,6 +90,7 @@ "unreachable_mqtt_broker": "Kan Xiaomi MQTT Broker-adres niet bereiken, controleer de netwerkconfiguratie." }, "abort": { + "ha_uuid_get_failed": "Mislukt bij het ophalen van Home Assistant UUID.", "network_connect_error": "Configuratie mislukt. De netwerkverbinding is abnormaal. Controleer de netwerkinstellingen van de apparatuur.", "already_configured": "Configuratie voor deze gebruiker is al voltooid. Ga naar de integratiepagina en klik op de CONFIGUREER-knop om wijzigingen aan te brengen.", "invalid_auth_info": "Authenticatie-informatie is verlopen. Ga naar de integratiepagina en klik op de CONFIGUREER-knop om opnieuw te authentiseren.", diff --git a/custom_components/xiaomi_home/translations/pt-BR.json b/custom_components/xiaomi_home/translations/pt-BR.json index 0c453b5..3adcd0d 100644 --- a/custom_components/xiaomi_home/translations/pt-BR.json +++ b/custom_components/xiaomi_home/translations/pt-BR.json @@ -90,6 +90,7 @@ "unreachable_mqtt_broker": "Não é possível acessar o endereço do Broker MQTT da Xiaomi, verifique a configuração da rede." }, "abort": { + "ha_uuid_get_failed": "Falha ao obter o UUID do Home Assistant.", "network_connect_error": "Configuração falhou. A conexão de rede está anormal. Verifique a configuração de rede do equipamento.", "already_configured": "A configuração para este usuário já foi concluída. Vá para a página de integrações e clique no botão CONFIGURAR para modificações.", "invalid_auth_info": "As informações de autenticação expiraram. Vá para a página de integrações e clique em CONFIGURAR para reautenticar.", diff --git a/custom_components/xiaomi_home/translations/pt.json b/custom_components/xiaomi_home/translations/pt.json index 787ddcd..ce58cd5 100644 --- a/custom_components/xiaomi_home/translations/pt.json +++ b/custom_components/xiaomi_home/translations/pt.json @@ -90,6 +90,7 @@ "unreachable_mqtt_broker": "Não é possível acessar o endereço do Broker MQTT da Xiaomi, verifique a configuração da rede." }, "abort": { + "ha_uuid_get_failed": "Não foi possível obter o UUID do Home Assistant.", "network_connect_error": "A configuração falhou. A ligação de rede é anormal. Verifique a configuração de rede do equipamento.", "already_configured": "A configuração para este utilizador já foi concluída. Vá à página de integrações e clique em CONFIGURAR para efetuar alterações.", "invalid_auth_info": "A informação de autenticação expirou. Vá à página de integrações e clique em CONFIGURAR para reautenticar.", diff --git a/custom_components/xiaomi_home/translations/ru.json b/custom_components/xiaomi_home/translations/ru.json index 7e06055..a492869 100644 --- a/custom_components/xiaomi_home/translations/ru.json +++ b/custom_components/xiaomi_home/translations/ru.json @@ -90,6 +90,7 @@ "unreachable_mqtt_broker": "Не удается подключиться к адресу MQTT брокера Xiaomi, проверьте настройки сети." }, "abort": { + "ha_uuid_get_failed": "Не удалось получить UUID Home Assistant.", "network_connect_error": "Ошибка настройки. Сетевое подключение недоступно. Проверьте настройки сети устройства.", "already_configured": "Этот пользователь уже настроен. Перейдите на страницу интеграции и нажмите кнопку «Настроить», чтобы изменить настройки.", "invalid_auth_info": "Информация об авторизации истекла. Перейдите на страницу интеграции и нажмите кнопку «Настроить», чтобы переавторизоваться.", diff --git a/custom_components/xiaomi_home/translations/zh-Hans.json b/custom_components/xiaomi_home/translations/zh-Hans.json index 1b6a138..39859da 100644 --- a/custom_components/xiaomi_home/translations/zh-Hans.json +++ b/custom_components/xiaomi_home/translations/zh-Hans.json @@ -90,6 +90,7 @@ "unreachable_mqtt_broker": "无法访问小米 MQTT Broker 地址,请检查网络配置。" }, "abort": { + "ha_uuid_get_failed": "获取 Home Assistant UUID 失败。", "network_connect_error": "配置失败。网络连接异常,请检查设备网络配置。", "already_configured": "该用户已配置完成。请进入集成页面,点击“配置”按钮修改配置。", "invalid_auth_info": "认证信息已过期。请进入集成页面,点击“配置”按钮重新认证。", diff --git a/custom_components/xiaomi_home/translations/zh-Hant.json b/custom_components/xiaomi_home/translations/zh-Hant.json index 7fcfb67..59580ae 100644 --- a/custom_components/xiaomi_home/translations/zh-Hant.json +++ b/custom_components/xiaomi_home/translations/zh-Hant.json @@ -90,6 +90,7 @@ "unreachable_mqtt_broker": "無法訪問小米 MQTT Broker 地址,請檢查網絡配置。" }, "abort": { + "ha_uuid_get_failed": "獲取 Home Assistant UUID 失敗。", "network_connect_error": "配置失敗。網絡連接異常,請檢查設備網絡配置。", "already_configured": "該用戶已配置完成。請進入集成頁面,點擊“配置”按鈕修改配置。", "invalid_auth_info": "認證信息已過期。請進入集成頁面,點擊“配置”按鈕重新認證。", From 152933a22398d05f6adb727db6938fcbd262d1e8 Mon Sep 17 00:00:00 2001 From: Paul Shawn <32349595+topsworld@users.noreply.github.com> Date: Fri, 10 Jan 2025 09:46:34 +0800 Subject: [PATCH 02/14] docs: update changelog and version to v0.1.5b1 (#616) --- CHANGELOG.md | 11 +++++++++++ custom_components/xiaomi_home/manifest.json | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 22fdb9a..c07dace 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # CHANGELOG +## v0.1.5b1 +This version will cause some Xiaomi routers that do not support access (#564) to become unavailable. You can update the device list in the configuration or delete it manually. +### Added +- Fan entity support direction ctrl [#556](https://github.com/XiaoMi/ha_xiaomi_home/pull/556) +### Changed +- Filter miwifi.* devices and xiaomi.router.rd03 [#564](https://github.com/XiaoMi/ha_xiaomi_home/pull/564) +### Fixed +- Fix multi ha instance login [#560](https://github.com/XiaoMi/ha_xiaomi_home/pull/560) +- Fix fan speed [#464](https://github.com/XiaoMi/ha_xiaomi_home/pull/464) +- The number of profile models updated from 660 to 823. [#583](https://github.com/XiaoMi/ha_xiaomi_home/pull/583) + ## v0.1.5b0 ### Added - Add missing parameter state_class [#101](https://github.com/XiaoMi/ha_xiaomi_home/pull/101) diff --git a/custom_components/xiaomi_home/manifest.json b/custom_components/xiaomi_home/manifest.json index 3e07f1c..624ae29 100644 --- a/custom_components/xiaomi_home/manifest.json +++ b/custom_components/xiaomi_home/manifest.json @@ -25,7 +25,7 @@ "cryptography", "psutil" ], - "version": "v0.1.5b0", + "version": "v0.1.5b1", "zeroconf": [ "_miot-central._tcp.local." ] From 9ceca34b28d67bc2564e28615ed77596e5fa6fc0 Mon Sep 17 00:00:00 2001 From: Feng Wang Date: Fri, 10 Jan 2025 21:46:00 +0800 Subject: [PATCH 03/14] refactor: refactor miot mips & fix type errors (#365) * remove use of tev & fix type errors * lint fix * make private classes private * simplify inheritance * fix thread naming * fix the deleted public data class * remove tev * fix access violation * style: format code * style: param init * fix: fix event async set * fix: fix mips re-connect error --------- Co-authored-by: topsworld --- .../xiaomi_home/miot/miot_client.py | 4 +- custom_components/xiaomi_home/miot/miot_ev.py | 324 ----- .../xiaomi_home/miot/miot_i18n.py | 4 +- .../xiaomi_home/miot/miot_lan.py | 27 +- .../xiaomi_home/miot/miot_mips.py | 1155 +++++++---------- test/conftest.py | 1 - test/test_ev.py | 55 - 7 files changed, 493 insertions(+), 1077 deletions(-) delete mode 100644 custom_components/xiaomi_home/miot/miot_ev.py delete mode 100644 test/test_ev.py diff --git a/custom_components/xiaomi_home/miot/miot_client.py b/custom_components/xiaomi_home/miot/miot_client.py index b618ea5..58fb504 100644 --- a/custom_components/xiaomi_home/miot/miot_client.py +++ b/custom_components/xiaomi_home/miot/miot_client.py @@ -357,7 +357,7 @@ class MIoTClient: # Cloud mips self._mips_cloud.unsub_mips_state( key=f'{self._uid}-{self._cloud_server}') - self._mips_cloud.disconnect() + self._mips_cloud.deinit() # Cancel refresh cloud devices if self._refresh_cloud_devices_timer: self._refresh_cloud_devices_timer.cancel() @@ -370,7 +370,7 @@ class MIoTClient: for mips in self._mips_local.values(): mips.on_dev_list_changed = None mips.unsub_mips_state(key=mips.group_id) - mips.disconnect() + mips.deinit() if self._mips_local_state_changed_timers: for timer_item in ( self._mips_local_state_changed_timers.values()): diff --git a/custom_components/xiaomi_home/miot/miot_ev.py b/custom_components/xiaomi_home/miot/miot_ev.py deleted file mode 100644 index c0cc97f..0000000 --- a/custom_components/xiaomi_home/miot/miot_ev.py +++ /dev/null @@ -1,324 +0,0 @@ -# -*- 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 event loop. -""" -import selectors -import heapq -import time -import traceback -from typing import Any, Callable, TypeVar -import logging -import threading - -# pylint: disable=relative-beyond-top-level -from .miot_error import MIoTEvError - -_LOGGER = logging.getLogger(__name__) - -TimeoutHandle = TypeVar('TimeoutHandle') - - -class MIoTFdHandler: - """File descriptor handler.""" - fd: int - read_handler: Callable[[Any], None] - read_handler_ctx: Any - write_handler: Callable[[Any], None] - write_handler_ctx: Any - - def __init__( - self, fd: int, - read_handler: Callable[[Any], None] = None, - read_handler_ctx: Any = None, - write_handler: Callable[[Any], None] = None, - write_handler_ctx: Any = None - ) -> None: - self.fd = fd - self.read_handler = read_handler - self.read_handler_ctx = read_handler_ctx - self.write_handler = write_handler - self.write_handler_ctx = write_handler_ctx - - -class MIoTTimeout: - """Timeout handler.""" - key: TimeoutHandle - target: int - handler: Callable[[Any], None] - handler_ctx: Any - - def __init__( - self, key: str = None, target: int = None, - handler: Callable[[Any], None] = None, - handler_ctx: Any = None - ) -> None: - self.key = key - self.target = target - self.handler = handler - self.handler_ctx = handler_ctx - - def __lt__(self, other): - return self.target < other.target - - -class MIoTEventLoop: - """MIoT event loop.""" - _poll_fd: selectors.DefaultSelector - - _fd_handlers: dict[str, MIoTFdHandler] - - _timer_heap: list[MIoTTimeout] - _timer_handlers: dict[str, MIoTTimeout] - _timer_handle_seed: int - - # Label if the current fd handler is freed inside a read handler to - # avoid invalid reading. - _fd_handler_freed_in_read_handler: bool - - def __init__(self) -> None: - self._poll_fd = selectors.DefaultSelector() - self._timer_heap = [] - self._timer_handlers = {} - self._timer_handle_seed = 1 - self._fd_handlers = {} - self._fd_handler_freed_in_read_handler = False - - def loop_forever(self) -> None: - """Run an event loop in current thread.""" - next_timeout: int - while True: - next_timeout = 0 - # Handle timer - now_ms: int = self.__get_monotonic_ms - while len(self._timer_heap) > 0: - timer: MIoTTimeout = self._timer_heap[0] - if timer is None: - break - if timer.target <= now_ms: - heapq.heappop(self._timer_heap) - del self._timer_handlers[timer.key] - if timer.handler: - timer.handler(timer.handler_ctx) - else: - next_timeout = timer.target-now_ms - break - # Are there any files to listen to - if next_timeout == 0 and self._fd_handlers: - next_timeout = None # None == infinite - # Wait for timers & fds - if next_timeout == 0: - # Neither timer nor fds exist, exit loop - break - # Handle fd event - events = self._poll_fd.select( - timeout=next_timeout/1000.0 if next_timeout else next_timeout) - for key, mask in events: - fd_handler: MIoTFdHandler = key.data - if fd_handler is None: - continue - self._fd_handler_freed_in_read_handler = False - fd_key = str(id(fd_handler.fd)) - if fd_key not in self._fd_handlers: - continue - if ( - mask & selectors.EVENT_READ > 0 - and fd_handler.read_handler - ): - fd_handler.read_handler(fd_handler.read_handler_ctx) - if ( - mask & selectors.EVENT_WRITE > 0 - and self._fd_handler_freed_in_read_handler is False - and fd_handler.write_handler - ): - fd_handler.write_handler(fd_handler.write_handler_ctx) - - def loop_stop(self) -> None: - """Stop the event loop.""" - if self._poll_fd: - self._poll_fd.close() - self._poll_fd = None - self._fd_handlers = {} - self._timer_heap = [] - self._timer_handlers = {} - - def set_timeout( - self, timeout_ms: int, handler: Callable[[Any], None], - handler_ctx: Any = None - ) -> TimeoutHandle: - """Set a timer.""" - if timeout_ms is None or handler is None: - raise MIoTEvError('invalid params') - new_timeout: MIoTTimeout = MIoTTimeout() - new_timeout.key = self.__get_next_timeout_handle - new_timeout.target = self.__get_monotonic_ms + timeout_ms - new_timeout.handler = handler - new_timeout.handler_ctx = handler_ctx - heapq.heappush(self._timer_heap, new_timeout) - self._timer_handlers[new_timeout.key] = new_timeout - return new_timeout.key - - def clear_timeout(self, timer_key: TimeoutHandle) -> None: - """Stop and remove the timer.""" - if timer_key is None: - return - timer: MIoTTimeout = self._timer_handlers.pop(timer_key, None) - if timer: - self._timer_heap = list(self._timer_heap) - self._timer_heap.remove(timer) - heapq.heapify(self._timer_heap) - - def set_read_handler( - self, fd: int, handler: Callable[[Any], None], handler_ctx: Any = None - ) -> bool: - """Set a read handler for a file descriptor. - - Returns: - bool: True, success. False, failed. - """ - self.__set_handler( - fd, is_read=True, handler=handler, handler_ctx=handler_ctx) - - def set_write_handler( - self, fd: int, handler: Callable[[Any], None], handler_ctx: Any = None - ) -> bool: - """Set a write handler for a file descriptor. - - Returns: - bool: True, success. False, failed. - """ - self.__set_handler( - fd, is_read=False, handler=handler, handler_ctx=handler_ctx) - - def __set_handler( - self, fd, is_read: bool, handler: Callable[[Any], None], - handler_ctx: Any = None - ) -> bool: - """Set a handler.""" - if fd is None: - raise MIoTEvError('invalid params') - - if not self._poll_fd: - raise MIoTEvError('event loop not started') - - fd_key: str = str(id(fd)) - fd_handler = self._fd_handlers.get(fd_key, None) - - if fd_handler is None: - fd_handler = MIoTFdHandler(fd=fd) - fd_handler.fd = fd - self._fd_handlers[fd_key] = fd_handler - - read_handler_existed = fd_handler.read_handler is not None - write_handler_existed = fd_handler.write_handler is not None - if is_read is True: - fd_handler.read_handler = handler - fd_handler.read_handler_ctx = handler_ctx - else: - fd_handler.write_handler = handler - fd_handler.write_handler_ctx = handler_ctx - - if fd_handler.read_handler is None and fd_handler.write_handler is None: - # Remove from epoll and map - try: - self._poll_fd.unregister(fd) - except (KeyError, ValueError, OSError) as e: - del e - self._fd_handlers.pop(fd_key, None) - # May be inside a read handler, if not, this has no effect - self._fd_handler_freed_in_read_handler = True - elif read_handler_existed is False and write_handler_existed is False: - # Add to epoll - events = 0x0 - if fd_handler.read_handler: - events |= selectors.EVENT_READ - if fd_handler.write_handler: - events |= selectors.EVENT_WRITE - try: - self._poll_fd.register(fd, events=events, data=fd_handler) - except (KeyError, ValueError, OSError) as e: - _LOGGER.error( - '%s, register fd, error, %s, %s, %s, %s, %s', - threading.current_thread().name, - 'read' if is_read else 'write', - fd_key, handler, e, traceback.format_exc()) - self._fd_handlers.pop(fd_key, None) - return False - elif ( - read_handler_existed != (fd_handler.read_handler is not None) - or write_handler_existed != (fd_handler.write_handler is not None) - ): - # Modify epoll - events = 0x0 - if fd_handler.read_handler: - events |= selectors.EVENT_READ - if fd_handler.write_handler: - events |= selectors.EVENT_WRITE - try: - self._poll_fd.modify(fd, events=events, data=fd_handler) - except (KeyError, ValueError, OSError) as e: - _LOGGER.error( - '%s, modify fd, error, %s, %s, %s, %s, %s', - threading.current_thread().name, - 'read' if is_read else 'write', - fd_key, handler, e, traceback.format_exc()) - self._fd_handlers.pop(fd_key, None) - return False - - return True - - @property - def __get_next_timeout_handle(self) -> str: - # Get next timeout handle, that is not larger than the maximum - # value of UINT64 type. - self._timer_handle_seed += 1 - # uint64 max - self._timer_handle_seed %= 0xFFFFFFFFFFFFFFFF - return str(self._timer_handle_seed) - - @property - def __get_monotonic_ms(self) -> int: - """Get monotonic ms timestamp.""" - return int(time.monotonic()*1000) diff --git a/custom_components/xiaomi_home/miot/miot_i18n.py b/custom_components/xiaomi_home/miot/miot_i18n.py index 152bc08..b6e96f4 100644 --- a/custom_components/xiaomi_home/miot/miot_i18n.py +++ b/custom_components/xiaomi_home/miot/miot_i18n.py @@ -48,7 +48,7 @@ MIoT internationalization translation. import asyncio import logging import os -from typing import Optional +from typing import Optional, Union # pylint: disable=relative-beyond-top-level from .common import load_json_file @@ -98,7 +98,7 @@ class MIoTI18n: def translate( self, key: str, replace: Optional[dict[str, str]] = None - ) -> str | dict | None: + ) -> Union[str, dict, None]: result = self._data for item in key.split('.'): if item not in result: diff --git a/custom_components/xiaomi_home/miot/miot_lan.py b/custom_components/xiaomi_home/miot/miot_lan.py index 3191166..fd9ff47 100644 --- a/custom_components/xiaomi_home/miot/miot_lan.py +++ b/custom_components/xiaomi_home/miot/miot_lan.py @@ -381,7 +381,8 @@ class _MIoTLanDevice: _MIoTLanDeviceState(state.value+1)) # Fast ping if self._if_name is None: - _LOGGER.error('if_name is Not set for device, %s', self.did) + _LOGGER.error( + 'if_name is Not set for device, %s', self.did) return if self.ip is None: _LOGGER.error('ip is Not set for device, %s', self.did) @@ -419,10 +420,10 @@ class _MIoTLanDevice: self.online = True else: _LOGGER.info('unstable device detected, %s', self.did) - self._online_offline_timer = \ + self._online_offline_timer = ( self._manager.internal_loop.call_later( self.NETWORK_UNSTABLE_RESUME_TH, - self.__online_resume_handler) + self.__online_resume_handler)) def __online_resume_handler(self) -> None: _LOGGER.info('unstable resume threshold past, %s', self.did) @@ -508,9 +509,9 @@ class MIoTLan: key='miot_lan', group_id='*', handler=self.__on_mips_service_change) self._enable_subscribe = enable_subscribe - self._virtual_did = str(virtual_did) \ - if (virtual_did is not None) \ - else str(secrets.randbits(64)) + self._virtual_did = ( + str(virtual_did) if (virtual_did is not None) + else str(secrets.randbits(64))) # Init socket probe message probe_bytes = bytearray(self.OT_PROBE_LEN) probe_bytes[:20] = ( @@ -948,7 +949,7 @@ class MIoTLan: # The following methods SHOULD ONLY be called in the internal loop - def ping(self, if_name: str | None, target_ip: str) -> None: + def ping(self, if_name: Optional[str], target_ip: str) -> None: if not target_ip: return self.__sendto( @@ -964,7 +965,7 @@ class MIoTLan: ) -> None: if timeout_ms and not handler: raise ValueError('handler is required when timeout_ms is set') - device: _MIoTLanDevice | None = self._lan_devices.get(did) + device: Optional[_MIoTLanDevice] = self._lan_devices.get(did) if not device: raise ValueError('invalid device') if not device.cipher: @@ -1232,7 +1233,7 @@ class MIoTLan: return # Keep alive message did: str = str(struct.unpack('>Q', data[4:12])[0]) - device: _MIoTLanDevice | None = self._lan_devices.get(did) + device: Optional[_MIoTLanDevice] = self._lan_devices.get(did) if not device: return timestamp: int = struct.unpack('>I', data[12:16])[0] @@ -1272,8 +1273,8 @@ class MIoTLan: _LOGGER.warning('invalid message, no id, %s, %s', did, msg) return # Reply - req: _MIoTLanRequestData | None = \ - self._pending_requests.pop(msg['id'], None) + req: Optional[_MIoTLanRequestData] = ( + self._pending_requests.pop(msg['id'], None)) if req: if req.timeout: req.timeout.cancel() @@ -1334,7 +1335,7 @@ class MIoTLan: return False def __sendto( - self, if_name: str | None, data: bytes, address: str, port: int + self, if_name: Optional[str], data: bytes, address: str, port: int ) -> None: if if_name is None: # Broadcast @@ -1356,7 +1357,7 @@ class MIoTLan: try: # Scan devices self.ping(if_name=None, target_ip='255.255.255.255') - except Exception as err: # pylint: disable=broad-exception-caught + except Exception as err: # pylint: disable=broad-exception-caught # Ignore any exceptions to avoid blocking the loop _LOGGER.error('ping device error, %s', err) pass diff --git a/custom_components/xiaomi_home/miot/miot_mips.py b/custom_components/xiaomi_home/miot/miot_mips.py index 6c6b358..1cade87 100644 --- a/custom_components/xiaomi_home/miot/miot_mips.py +++ b/custom_components/xiaomi_home/miot/miot_mips.py @@ -48,8 +48,6 @@ MIoT Pub/Sub client. import asyncio import json import logging -import os -import queue import random import re import ssl @@ -58,24 +56,24 @@ import threading from abc import ABC, abstractmethod from dataclasses import dataclass from enum import Enum, auto -from typing import Any, Callable, Optional, final +from typing import Any, Callable, Optional, final, Coroutine from paho.mqtt.client import ( MQTT_ERR_SUCCESS, MQTT_ERR_UNKNOWN, Client, - MQTTv5) + MQTTv5, + MQTTMessage) # pylint: disable=relative-beyond-top-level from .common import MIoTMatcher from .const import MIHOME_MQTT_KEEPALIVE from .miot_error import MIoTErrorCode, MIoTMipsError -from .miot_ev import MIoTEventLoop, TimeoutHandle _LOGGER = logging.getLogger(__name__) -class MipsMsgTypeOptions(Enum): +class _MipsMsgTypeOptions(Enum): """MIoT Pub/Sub message type.""" ID = 0 RET_TOPIC = auto() @@ -84,16 +82,16 @@ class MipsMsgTypeOptions(Enum): MAX = auto() -class MipsMessage: +class _MipsMessage: """MIoT Pub/Sub message.""" mid: int = 0 - msg_from: str = None - ret_topic: str = None - payload: str = None + msg_from: Optional[str] = None + ret_topic: Optional[str] = None + payload: Optional[str] = None @staticmethod - def unpack(data: bytes): - mips_msg = MipsMessage() + def unpack(data: bytes) -> '_MipsMessage': + mips_msg = _MipsMessage() data_len = len(data) data_start = 0 data_end = 0 @@ -104,15 +102,15 @@ class MipsMessage: unpack_data = data[data_end:data_end+unpack_len] # string end with \x00 match unpack_type: - case MipsMsgTypeOptions.ID.value: + case _MipsMsgTypeOptions.ID.value: mips_msg.mid = int.from_bytes( unpack_data, byteorder='little') - case MipsMsgTypeOptions.RET_TOPIC.value: + case _MipsMsgTypeOptions.RET_TOPIC.value: mips_msg.ret_topic = str( unpack_data.strip(b'\x00'), 'utf-8') - case MipsMsgTypeOptions.PAYLOAD.value: + case _MipsMsgTypeOptions.PAYLOAD.value: mips_msg.payload = str(unpack_data.strip(b'\x00'), 'utf-8') - case MipsMsgTypeOptions.FROM.value: + case _MipsMsgTypeOptions.FROM.value: mips_msg.msg_from = str( unpack_data.strip(b'\x00'), 'utf-8') case _: @@ -122,155 +120,73 @@ class MipsMessage: @staticmethod def pack( - mid: int, payload: str, msg_from: str = None, ret_topic: str = None + mid: int, + payload: str, + msg_from: Optional[str] = None, + ret_topic: Optional[str] = None ) -> bytes: if mid is None or payload is None: raise MIoTMipsError('invalid mid or payload') pack_msg: bytes = b'' # mid - pack_msg += struct.pack(' str: return f'{self.mid}, {self.msg_from}, {self.ret_topic}, {self.payload}' -class MipsCmdType(Enum): - """MIoT Pub/Sub command type.""" - CONNECT = 0 - DISCONNECT = auto() - DEINIT = auto() - SUB = auto() - UNSUB = auto() - CALL_API = auto() - REG_BROADCAST = auto() - UNREG_BROADCAST = auto() - - REG_MIPS_STATE = auto() - UNREG_MIPS_STATE = auto() - REG_DEVICE_STATE = auto() - UNREG_DEVICE_STATE = auto() - - @dataclass -class MipsCmd: - """MIoT Pub/Sub command.""" - type_: MipsCmdType - data: Any - - def __init__(self, type_: MipsCmdType, data: Any) -> None: - self.type_ = type_ - self.data = data - - -@dataclass -class MipsRequest: +class _MipsRequest: """MIoT Pub/Sub request.""" - mid: int = None - on_reply: Callable[[str, Any], None] = None - on_reply_ctx: Any = None - timer: TimeoutHandle = None + mid: int + on_reply: Callable[[str, Any], None] + on_reply_ctx: Any + timer: Optional[asyncio.TimerHandle] @dataclass -class MipsRequestData: - """MIoT Pub/Sub request data.""" - topic: str = None - payload: str = None - on_reply: Callable[[str, Any], None] = None - on_reply_ctx: Any = None - timeout_ms: int = None - - -@dataclass -class MipsSendBroadcastData: - """MIoT Pub/Sub send broadcast data.""" - topic: str = None - payload: str = None - - -@dataclass -class MipsIncomingApiCall: - """MIoT Pub/Sub incoming API call.""" - mid: int = None - ret_topic: str = None - timer: TimeoutHandle = None - - -@dataclass -class MipsApi: - """MIoT Pub/Sub API.""" - topic: str = None - """ - param1: session - param2: payload - param3: handler_ctx - """ - handler: Callable[[MipsIncomingApiCall, str, Any], None] = None - handler_ctx: Any = None - - -class MipsRegApi(MipsApi): - """.MIoT Pub/Sub register API.""" - - -@dataclass -class MipsReplyData: - """MIoT Pub/Sub reply data.""" - session: MipsIncomingApiCall = None - payload: str = None - - -@dataclass -class MipsBroadcast: +class _MipsBroadcast: """MIoT Pub/Sub broadcast.""" - topic: str = None + topic: str """ param 1: msg topic param 2: msg payload param 3: handle_ctx """ - handler: Callable[[str, str, Any], None] = None - handler_ctx: Any = None + handler: Callable[[str, str, Any], None] + handler_ctx: Any def __str__(self) -> str: return f'{self.topic}, {id(self.handler)}, {id(self.handler_ctx)}' -class MipsRegBroadcast(MipsBroadcast): - """MIoT Pub/Sub register broadcast.""" - - @dataclass -class MipsState: +class _MipsState: """MIoT Pub/Sub state.""" - key: str = None + key: str """ str: key bool: mips connect state """ - handler: Callable[[str, bool], asyncio.Future] = None - - -class MipsRegState(MipsState): - """MIoT Pub/Sub register state.""" + handler: Callable[[str, bool], Coroutine] class MIoTDeviceState(Enum): @@ -283,69 +199,66 @@ class MIoTDeviceState(Enum): @dataclass class MipsDeviceState: """MIoT Pub/Sub device state.""" - did: str = None + did: Optional[str] = None """handler str: did MIoTDeviceState: online/offline/disable Any: ctx """ - handler: Callable[[str, MIoTDeviceState, Any], None] = None + handler: Optional[Callable[[str, MIoTDeviceState, Any], None]] = None handler_ctx: Any = None -class MipsRegDeviceState(MipsDeviceState): - """MIoT Pub/Sub register device state.""" - - -class MipsClient(ABC): +class _MipsClient(ABC): """MIoT Pub/Sub client.""" # pylint: disable=unused-argument - MQTT_INTERVAL_MS = 1000 + MQTT_INTERVAL_S = 1 MIPS_QOS: int = 2 UINT32_MAX: int = 0xFFFFFFFF - MIPS_RECONNECT_INTERVAL_MIN: int = 30000 - MIPS_RECONNECT_INTERVAL_MAX: int = 600000 + MIPS_RECONNECT_INTERVAL_MIN: float = 30 + MIPS_RECONNECT_INTERVAL_MAX: float = 600 MIPS_SUB_PATCH: int = 300 - MIPS_SUB_INTERVAL: int = 1000 + MIPS_SUB_INTERVAL: float = 1 main_loop: asyncio.AbstractEventLoop - _logger: logging.Logger + _logger: Optional[logging.Logger] _client_id: str _host: str _port: int - _username: str - _password: str - _ca_file: str - _cert_file: str - _key_file: str + _username: Optional[str] + _password: Optional[str] + _ca_file: Optional[str] + _cert_file: Optional[str] + _key_file: Optional[str] + _tls_done: bool - _mqtt_logger: logging.Logger + _mqtt_logger: Optional[logging.Logger] _mqtt: Client _mqtt_fd: int - _mqtt_timer: TimeoutHandle + _mqtt_timer: Optional[asyncio.TimerHandle] _mqtt_state: bool _event_connect: asyncio.Event _event_disconnect: asyncio.Event - _mev: MIoTEventLoop - _mips_thread: threading.Thread - _mips_queue: queue.Queue - _cmd_event_fd: os.eventfd + _internal_loop: asyncio.AbstractEventLoop + _mips_thread: Optional[threading.Thread] _mips_reconnect_tag: bool - _mips_reconnect_interval: int - _mips_reconnect_timer: Optional[TimeoutHandle] - _mips_state_sub_map: dict[str, MipsState] + _mips_reconnect_interval: float + _mips_reconnect_timer: Optional[asyncio.TimerHandle] + _mips_state_sub_map: dict[str, _MipsState] + _mips_state_sub_map_lock: threading.Lock _mips_sub_pending_map: dict[str, int] - _mips_sub_pending_timer: Optional[TimeoutHandle] - - _on_mips_cmd: Callable[[MipsCmd], None] - _on_mips_message: Callable[[str, bytes], None] - _on_mips_connect: Callable[[int, dict], None] - _on_mips_disconnect: Callable[[int, dict], None] + _mips_sub_pending_timer: Optional[asyncio.TimerHandle] def __init__( - self, client_id: str, host: str, port: int, - username: str = None, password: str = None, - ca_file: str = None, cert_file: str = None, key_file: str = None, + self, + client_id: str, + host: str, + port: int, + username: Optional[str] = None, + password: Optional[str] = None, + ca_file: Optional[str] = None, + cert_file: Optional[str] = None, + key_file: Optional[str] = None, loop: Optional[asyncio.AbstractEventLoop] = None ) -> None: # MUST run with running loop @@ -359,6 +272,7 @@ class MipsClient(ABC): self._ca_file = ca_file self._cert_file = cert_file self._key_file = key_file + self._tls_done = False self._mqtt_logger = None self._mqtt_fd = -1 @@ -372,26 +286,15 @@ class MipsClient(ABC): # Mips init self._event_connect = asyncio.Event() self._event_disconnect = asyncio.Event() + self._mips_thread = None self._mips_reconnect_tag = False self._mips_reconnect_interval = 0 self._mips_reconnect_timer = None self._mips_state_sub_map = {} + self._mips_state_sub_map_lock = threading.Lock() self._mips_sub_pending_map = {} self._mips_sub_pending_timer = None - self._mev = MIoTEventLoop() - self._mips_queue = queue.Queue() - self._cmd_event_fd = os.eventfd(0, os.O_NONBLOCK) - self.mev_set_read_handler( - self._cmd_event_fd, self.__mips_cmd_read_handler, None) - self._mips_thread = threading.Thread(target=self.__mips_loop_thread) - self._mips_thread.daemon = True - self._mips_thread.name = self._client_id - self._mips_thread.start() - - self._on_mips_cmd = None - self._on_mips_message = None - self._on_mips_connect = None - self._on_mips_disconnect = None + # DO NOT start the thread yet. Do that on connect @property def client_id(self) -> str: @@ -415,29 +318,54 @@ class MipsClient(ABC): """ return self._mqtt and self._mqtt.is_connected() - @final - def mips_deinit(self) -> None: - self._mips_send_cmd(type_=MipsCmdType.DEINIT, data=None) + def connect(self, thread_name: Optional[str] = None) -> None: + """mips connect.""" + # Start mips thread + if self._mips_thread: + return + self._internal_loop = asyncio.new_event_loop() + self._mips_thread = threading.Thread(target=self.__mips_loop_thread) + self._mips_thread.daemon = True + self._mips_thread.name = ( + self._client_id if thread_name is None else thread_name) + self._mips_thread.start() + + async def connect_async(self) -> None: + """mips connect async.""" + self.connect() + await self._event_connect.wait() + + def disconnect(self) -> None: + """mips disconnect.""" + if not self._mips_thread: + return + self._internal_loop.call_soon_threadsafe(self.__mips_disconnect) self._mips_thread.join() self._mips_thread = None + self._internal_loop.close() + + async def disconnect_async(self) -> None: + """mips disconnect async.""" + self.disconnect() + await self._event_disconnect.wait() + + @final + def deinit(self) -> None: + self.disconnect() self._logger = None - self._client_id = None - self._host = None - self._port = None self._username = None self._password = None self._ca_file = None self._cert_file = None self._key_file = None + self._tls_done = False self._mqtt_logger = None - self._mips_state_sub_map = None - self._mips_sub_pending_map = None + with self._mips_state_sub_map_lock: + self._mips_state_sub_map.clear() + self._mips_sub_pending_map.clear() self._mips_sub_pending_timer = None - self._event_connect = None - self._event_disconnect = None - def update_mqtt_password(self, password: str) -> None: self._password = password self._mqtt.username_pw_set( @@ -466,166 +394,74 @@ class MipsClient(ABC): else: self._mqtt.disable_logger() - @final - def mips_connect(self) -> None: - """mips connect.""" - return self._mips_send_cmd(type_=MipsCmdType.CONNECT, data=None) - - @final - async def mips_connect_async(self) -> None: - """mips connect async.""" - self._mips_send_cmd(type_=MipsCmdType.CONNECT, data=None) - return await self._event_connect.wait() - - @final - def mips_disconnect(self) -> None: - """mips disconnect.""" - return self._mips_send_cmd(type_=MipsCmdType.DISCONNECT, data=None) - - @final - async def mips_disconnect_async(self) -> None: - """mips disconnect async.""" - self._mips_send_cmd(type_=MipsCmdType.DISCONNECT, data=None) - return await self._event_disconnect.wait() - @final def sub_mips_state( - self, key: str, handler: Callable[[str, bool], asyncio.Future] + self, key: str, handler: Callable[[str, bool], Coroutine] ) -> bool: """Subscribe mips state. NOTICE: callback to main loop thread + This will be called before the client is connected. + So use mutex instead of IPC. """ if isinstance(key, str) is False or handler is None: raise MIoTMipsError('invalid params') - return self._mips_send_cmd( - type_=MipsCmdType.REG_MIPS_STATE, - data=MipsRegState(key=key, handler=handler)) + state = _MipsState(key=key, handler=handler) + with self._mips_state_sub_map_lock: + self._mips_state_sub_map[key] = state + self.log_debug(f'mips register mips state, {key}') + return True @final def unsub_mips_state(self, key: str) -> bool: """Unsubscribe mips state.""" if isinstance(key, str) is False: raise MIoTMipsError('invalid params') - return self._mips_send_cmd( - type_=MipsCmdType.UNREG_MIPS_STATE, data=MipsRegState(key=key)) - - @final - def mev_set_timeout( - self, timeout_ms: int, handler: Callable[[Any], None], - handler_ctx: Any = None - ) -> Optional[TimeoutHandle]: - """set timeout. - NOTICE: Internal function, only mips threads are allowed to call - """ - if self._mev is None: - return None - return self._mev.set_timeout( - timeout_ms=timeout_ms, handler=handler, handler_ctx=handler_ctx) - - @final - def mev_clear_timeout(self, handle: TimeoutHandle) -> None: - """clear timeout. - NOTICE: Internal function, only mips threads are allowed to call - """ - if self._mev is None: - return - self._mev.clear_timeout(handle) - - @final - def mev_set_read_handler( - self, fd: int, handler: Callable[[Any], None], handler_ctx: Any - ) -> bool: - """set read handler. - NOTICE: Internal function, only mips threads are allowed to call - """ - if self._mev is None: - return False - return self._mev.set_read_handler( - fd=fd, handler=handler, handler_ctx=handler_ctx) - - @final - def mev_set_write_handler( - self, fd: int, handler: Callable[[Any], None], handler_ctx: Any - ) -> bool: - """set write handler. - NOTICE: Internal function, only mips threads are allowed to call - """ - if self._mev is None: - return False - return self._mev.set_write_handler( - fd=fd, handler=handler, handler_ctx=handler_ctx) - - @property - def on_mips_cmd(self) -> Callable[[MipsCmd], None]: - return self._on_mips_cmd - - @on_mips_cmd.setter - def on_mips_cmd(self, handler: Callable[[MipsCmd], None]) -> None: - """MUST set after __init__ done. - NOTICE thread safe, this function will be called at the **mips** thread - """ - self._on_mips_cmd = handler - - @property - def on_mips_message(self) -> Callable[[str, bytes], None]: - return self._on_mips_message - - @on_mips_message.setter - def on_mips_message(self, handler: Callable[[str, bytes], None]) -> None: - """MUST set after __init__ done. - NOTICE thread safe, this function will be called at the **mips** thread - """ - self._on_mips_message = handler - - @property - def on_mips_connect(self) -> Callable[[int, dict], None]: - return self._on_mips_connect - - @on_mips_connect.setter - def on_mips_connect(self, handler: Callable[[int, dict], None]) -> None: - """MUST set after __init__ done. - NOTICE thread safe, this function will be called at the - **main loop** thread - """ - self._on_mips_connect = handler - - @property - def on_mips_disconnect(self) -> Callable[[int, dict], None]: - return self._on_mips_disconnect - - @on_mips_disconnect.setter - def on_mips_disconnect(self, handler: Callable[[int, dict], None]) -> None: - """MUST set after __init__ done. - NOTICE thread safe, this function will be called at the - **main loop** thread - """ - self._on_mips_disconnect = handler + with self._mips_state_sub_map_lock: + del self._mips_state_sub_map[key] + self.log_debug(f'mips unregister mips state, {key}') + return True @abstractmethod def sub_prop( - self, did: str, handler: Callable[[dict, Any], None], - siid: int = None, piid: int = None, handler_ctx: Any = None + self, + did: str, + handler: Callable[[dict, Any], None], + siid: Optional[int] = None, + piid: Optional[int] = None, + handler_ctx: Any = None ) -> bool: ... @abstractmethod def unsub_prop( - self, did: str, siid: int = None, piid: int = None + self, + did: str, + siid: Optional[int] = None, + piid: Optional[int] = None ) -> bool: ... @abstractmethod def sub_event( - self, did: str, handler: Callable[[dict, Any], None], - siid: int = None, eiid: int = None, handler_ctx: Any = None + self, + did: str, + handler: Callable[[dict, Any], None], + siid: Optional[int] = None, + eiid: Optional[int] = None, + handler_ctx: Any = None ) -> bool: ... @abstractmethod def unsub_event( - self, did: str, siid: int = None, eiid: int = None + self, + did: str, + siid: Optional[int] = None, + eiid: Optional[int] = None ) -> bool: ... @abstractmethod async def get_dev_list_async( - self, payload: str = None, timeout_ms: int = 10000 + self, + payload: Optional[str] = None, + timeout_ms: int = 10000 ) -> dict[str, dict]: ... @abstractmethod @@ -637,13 +473,22 @@ class MipsClient(ABC): async def set_prop_async( self, did: str, siid: int, piid: int, value: Any, timeout_ms: int = 10000 - ) -> bool: ... + ) -> dict: ... @abstractmethod async def action_async( self, did: str, siid: int, aiid: int, in_list: list, timeout_ms: int = 10000 - ) -> tuple[bool, list]: ... + ) -> dict: ... + + @abstractmethod + def _on_mips_message(self, topic: str, payload: bytes) -> None: ... + + @abstractmethod + def _on_mips_connect(self, rc: int, props: dict) -> None: ... + + @abstractmethod + def _on_mips_disconnect(self, rc: int, props: dict) -> None: ... @final def _mips_sub_internal(self, topic: str) -> None: @@ -657,8 +502,8 @@ class MipsClient(ABC): if topic not in self._mips_sub_pending_map: self._mips_sub_pending_map[topic] = 0 if not self._mips_sub_pending_timer: - self._mips_sub_pending_timer = self.mev_set_timeout( - 10, self.__mips_sub_internal_pending_handler, topic) + self._mips_sub_pending_timer = self._internal_loop.call_later( + 0.01, self.__mips_sub_internal_pending_handler, topic) except Exception as err: # pylint: disable=broad-exception-caught # Catch all exception self.log_error(f'mips sub internal error, {topic}. {err}') @@ -707,75 +552,24 @@ class MipsClient(ABC): self.log_error(f'mips publish internal error, {err}') return False - @final - def _mips_send_cmd(self, type_: MipsCmdType, data: Any) -> bool: - if self._mips_queue is None or self._cmd_event_fd is None: - raise MIoTMipsError('send mips cmd disable') - # Put data to queue - self._mips_queue.put(MipsCmd(type_=type_, data=data)) - # Write event fd - os.eventfd_write(self._cmd_event_fd, 1) - # self.log_debug(f'send mips cmd, {type}, {data}') - return True - def __thread_check(self) -> None: if threading.current_thread() is not self._mips_thread: raise MIoTMipsError('illegal call') - def __mips_cmd_read_handler(self, ctx: Any) -> None: - fd_value = os.eventfd_read(self._cmd_event_fd) - if fd_value == 0: - return - while self._mips_queue.empty() is False: - mips_cmd: MipsCmd = self._mips_queue.get(block=False) - if mips_cmd.type_ == MipsCmdType.CONNECT: - self._mips_reconnect_tag = True - self.__mips_try_reconnect(immediately=True) - elif mips_cmd.type_ == MipsCmdType.DISCONNECT: - self._mips_reconnect_tag = False - self.__mips_disconnect() - elif mips_cmd.type_ == MipsCmdType.DEINIT: - self.log_info('mips client recv deinit cmd') - self.__mips_disconnect() - # Close cmd event fd - if self._cmd_event_fd: - self.mev_set_read_handler( - self._cmd_event_fd, None, None) - os.close(self._cmd_event_fd) - self._cmd_event_fd = None - if self._mips_queue: - self._mips_queue = None - # ev loop stop - if self._mev: - self._mev.loop_stop() - self._mev = None - break - elif mips_cmd.type_ == MipsCmdType.REG_MIPS_STATE: - state: MipsState = mips_cmd.data - self._mips_state_sub_map[state.key] = state - self.log_debug(f'mips register mips state, {state.key}') - elif mips_cmd.type_ == MipsCmdType.UNREG_MIPS_STATE: - state: MipsState = mips_cmd.data - del self._mips_state_sub_map[state.key] - self.log_debug(f'mips unregister mips state, {state.key}') - else: - if self._on_mips_cmd: - self._on_mips_cmd(mips_cmd=mips_cmd) + def __mqtt_read_handler(self) -> None: + self.__mqtt_loop_handler() - def __mqtt_read_handler(self, ctx: Any) -> None: - self.__mqtt_loop_handler(ctx=ctx) + def __mqtt_write_handler(self) -> None: + self._internal_loop.remove_writer(self._mqtt_fd) + self.__mqtt_loop_handler() - def __mqtt_write_handler(self, ctx: Any) -> None: - self.mev_set_write_handler(self._mqtt_fd, None, None) - self.__mqtt_loop_handler(ctx=ctx) - - def __mqtt_timer_handler(self, ctx: Any) -> None: - self.__mqtt_loop_handler(ctx=ctx) + def __mqtt_timer_handler(self) -> None: + self.__mqtt_loop_handler() if self._mqtt: - self._mqtt_timer = self.mev_set_timeout( - self.MQTT_INTERVAL_MS, self.__mqtt_timer_handler, None) + self._mqtt_timer = self._internal_loop.call_later( + self.MQTT_INTERVAL_S, self.__mqtt_timer_handler) - def __mqtt_loop_handler(self, ctx: Any) -> None: + def __mqtt_loop_handler(self) -> None: try: if self._mqtt: self._mqtt.loop_read() @@ -784,8 +578,8 @@ class MipsClient(ABC): if self._mqtt: self._mqtt.loop_misc() if self._mqtt and self._mqtt.want_write(): - self.mev_set_write_handler( - self._mqtt_fd, self.__mqtt_write_handler, None) + self._internal_loop.add_writer( + self._mqtt_fd, self.__mqtt_write_handler) except Exception as err: # pylint: disable=broad-exception-caught # Catch all exception self.log_error(f'__mqtt_loop_handler, {err}') @@ -797,25 +591,29 @@ class MipsClient(ABC): if self._username: self._mqtt.username_pw_set( username=self._username, password=self._password) - if ( - self._ca_file - and self._cert_file - and self._key_file - ): - self._mqtt.tls_set( - tls_version=ssl.PROTOCOL_TLS_CLIENT, - ca_certs=self._ca_file, - certfile=self._cert_file, - keyfile=self._key_file) - else: - self._mqtt.tls_set(tls_version=ssl.PROTOCOL_TLS_CLIENT) - self._mqtt.tls_insecure_set(True) + if not self._tls_done: + if ( + self._ca_file + and self._cert_file + and self._key_file + ): + self._mqtt.tls_set( + tls_version=ssl.PROTOCOL_TLS_CLIENT, + ca_certs=self._ca_file, + certfile=self._cert_file, + keyfile=self._key_file) + else: + self._mqtt.tls_set(tls_version=ssl.PROTOCOL_TLS_CLIENT) + self._mqtt.tls_insecure_set(True) + self._tls_done = True self._mqtt.on_connect = self.__on_connect self._mqtt.on_connect_fail = self.__on_connect_failed self._mqtt.on_disconnect = self.__on_disconnect self._mqtt.on_message = self.__on_message + # Connect to mips + self.__mips_start_connect_tries() # Run event loop - self._mev.loop_forever() + self._internal_loop.run_forever() self.log_info('mips_loop_thread exit!') def __on_connect(self, client, user_data, flags, rc, props) -> None: @@ -823,23 +621,23 @@ class MipsClient(ABC): return self.log_info(f'mips connect, {flags}, {rc}, {props}') self._mqtt_state = True - if self._on_mips_connect: - self.mev_set_timeout( - timeout_ms=0, - handler=lambda ctx: - self._on_mips_connect(rc, props)) - for item in self._mips_state_sub_map.values(): - if item.handler is None: - continue - self.main_loop.call_soon_threadsafe( - self.main_loop.create_task, - item.handler(item.key, True)) + self._internal_loop.call_soon( + self._on_mips_connect, rc, props) + with self._mips_state_sub_map_lock: + for item in self._mips_state_sub_map.values(): + if item.handler is None: + continue + self.main_loop.call_soon_threadsafe( + self.main_loop.create_task, + item.handler(item.key, True)) # Resolve future - self._event_connect.set() - self._event_disconnect.clear() + self.main_loop.call_soon_threadsafe( + self._event_connect.set) + self.main_loop.call_soon_threadsafe( + self._event_disconnect.clear) - def __on_connect_failed(self, client, user_data, flags, rc) -> None: - self.log_error(f'mips connect failed, {flags}, {rc}') + def __on_connect_failed(self, client: Client, user_data: Any) -> None: + self.log_error('mips connect failed') # Try to reconnect self.__mips_try_reconnect() @@ -848,53 +646,44 @@ class MipsClient(ABC): self.log_error(f'mips disconnect, {rc}, {props}') self._mqtt_state = False if self._mqtt_timer: - self.mev_clear_timeout(self._mqtt_timer) + self._mqtt_timer.cancel() self._mqtt_timer = None if self._mqtt_fd != -1: - self.mev_set_read_handler(self._mqtt_fd, None, None) - self.mev_set_write_handler(self._mqtt_fd, None, None) + self._internal_loop.remove_reader(self._mqtt_fd) + self._internal_loop.remove_writer(self._mqtt_fd) self._mqtt_fd = -1 # Clear retry sub if self._mips_sub_pending_timer: - self.mev_clear_timeout(self._mips_sub_pending_timer) + self._mips_sub_pending_timer.cancel() self._mips_sub_pending_timer = None self._mips_sub_pending_map = {} - if self._on_mips_disconnect: - self.mev_set_timeout( - timeout_ms=0, - handler=lambda ctx: - self._on_mips_disconnect(rc, props)) + self._internal_loop.call_soon( + self._on_mips_disconnect, rc, props) # Call state sub handler - for item in self._mips_state_sub_map.values(): - if item.handler is None: - continue - self.main_loop.call_soon_threadsafe( - self.main_loop.create_task, - item.handler(item.key, False)) + with self._mips_state_sub_map_lock: + for item in self._mips_state_sub_map.values(): + if item.handler is None: + continue + self.main_loop.call_soon_threadsafe( + self.main_loop.create_task, + item.handler(item.key, False)) # Try to reconnect self.__mips_try_reconnect() # Set event - self._event_disconnect.set() - self._event_connect.clear() + self.main_loop.call_soon_threadsafe( + self._event_disconnect.set) + self.main_loop.call_soon_threadsafe( + self._event_connect.clear) - def __on_message(self, client, user_data, msg) -> None: + def __on_message( + self, + client: Client, + user_data: Any, + msg: MQTTMessage + ) -> None: self._on_mips_message(topic=msg.topic, payload=msg.payload) - def __mips_try_reconnect(self, immediately: bool = False) -> None: - if self._mips_reconnect_timer: - self.mev_clear_timeout(self._mips_reconnect_timer) - self._mips_reconnect_timer = None - if not self._mips_reconnect_tag: - return - interval: int = 0 - if not immediately: - interval = self.__get_next_reconnect_time() - self.log_error( - 'mips try reconnect after %sms', interval) - self._mips_reconnect_timer = self.mev_set_timeout( - interval, self.__mips_connect, None) - def __mips_sub_internal_pending_handler(self, ctx: Any) -> None: subbed_count = 1 for topic in list(self._mips_sub_pending_map.keys()): @@ -916,25 +705,25 @@ class MipsClient(ABC): f'retry mips sub internal, {count}, {topic}, {result}, {mid}') if len(self._mips_sub_pending_map): - self._mips_sub_pending_timer = self.mev_set_timeout( + self._mips_sub_pending_timer = self._internal_loop.call_later( self.MIPS_SUB_INTERVAL, self.__mips_sub_internal_pending_handler, None) else: self._mips_sub_pending_timer = None - def __mips_connect(self, ctx: Any = None) -> None: + def __mips_connect(self) -> None: result = MQTT_ERR_UNKNOWN if self._mips_reconnect_timer: - self.mev_clear_timeout(self._mips_reconnect_timer) + self._mips_reconnect_timer.cancel() self._mips_reconnect_timer = None try: # Try clean mqtt fd before mqtt connect if self._mqtt_timer: - self.mev_clear_timeout(self._mqtt_timer) + self._mqtt_timer.cancel() self._mqtt_timer = None if self._mqtt_fd != -1: - self.mev_set_read_handler(self._mqtt_fd, None, None) - self.mev_set_write_handler(self._mqtt_fd, None, None) + self._internal_loop.remove_reader(self._mqtt_fd) + self._internal_loop.remove_writer(self._mqtt_fd) self._mqtt_fd = -1 result = self._mqtt.connect( host=self._host, port=self._port, @@ -944,33 +733,59 @@ class MipsClient(ABC): self.log_error('__mips_connect, connect error, %s', error) if result == MQTT_ERR_SUCCESS: - self._mqtt_fd = self._mqtt.socket() + socket = self._mqtt.socket() + if socket is None: + self.log_error( + '__mips_connect, connect success, but socket is None') + self.__mips_try_reconnect() + return + self._mqtt_fd = socket.fileno() self.log_debug(f'__mips_connect, _mqtt_fd, {self._mqtt_fd}') - self.mev_set_read_handler( - self._mqtt_fd, self.__mqtt_read_handler, None) + self._internal_loop.add_reader( + self._mqtt_fd, self.__mqtt_read_handler) if self._mqtt.want_write(): - self.mev_set_write_handler( - self._mqtt_fd, self.__mqtt_write_handler, None) - self._mqtt_timer = self.mev_set_timeout( - self.MQTT_INTERVAL_MS, self.__mqtt_timer_handler, None) + self._internal_loop.add_writer( + self._mqtt_fd, self.__mqtt_write_handler) + self._mqtt_timer = self._internal_loop.call_later( + self.MQTT_INTERVAL_S, self.__mqtt_timer_handler) else: self.log_error(f'__mips_connect error result, {result}') self.__mips_try_reconnect() - def __mips_disconnect(self) -> None: + def __mips_try_reconnect(self, immediately: bool = False) -> None: if self._mips_reconnect_timer: - self.mev_clear_timeout(self._mips_reconnect_timer) + self._mips_reconnect_timer.cancel() + self._mips_reconnect_timer = None + if not self._mips_reconnect_tag: + return + interval: float = 0 + if not immediately: + interval = self.__get_next_reconnect_time() + self.log_error( + 'mips try reconnect after %ss', interval) + self._mips_reconnect_timer = self._internal_loop.call_later( + interval, self.__mips_connect) + + def __mips_start_connect_tries(self) -> None: + self._mips_reconnect_tag = True + self.__mips_try_reconnect(immediately=True) + + def __mips_disconnect(self) -> None: + self._mips_reconnect_tag = False + if self._mips_reconnect_timer: + self._mips_reconnect_timer.cancel() self._mips_reconnect_timer = None if self._mqtt_timer: - self.mev_clear_timeout(self._mqtt_timer) + self._mqtt_timer.cancel() self._mqtt_timer = None if self._mqtt_fd != -1: - self.mev_set_read_handler(self._mqtt_fd, None, None) - self.mev_set_write_handler(self._mqtt_fd, None, None) + self._internal_loop.remove_reader(self._mqtt_fd) + self._internal_loop.remove_writer(self._mqtt_fd) self._mqtt_fd = -1 self._mqtt.disconnect() + self._internal_loop.stop() - def __get_next_reconnect_time(self) -> int: + def __get_next_reconnect_time(self) -> float: if self._mips_reconnect_interval == 0: self._mips_reconnect_interval = self.MIPS_RECONNECT_INTERVAL_MIN else: @@ -980,7 +795,7 @@ class MipsClient(ABC): return self._mips_reconnect_interval -class MipsCloudClient(MipsClient): +class MipsCloudClient(_MipsClient): """MIoT Pub/Sub Cloud Client.""" # pylint: disable=unused-argument # pylint: disable=inconsistent-quotes @@ -996,45 +811,25 @@ class MipsCloudClient(MipsClient): client_id=f'ha.{uuid}', host=f'{cloud_server}-ha.mqtt.io.mi.com', port=port, username=app_id, password=token, loop=loop) - self.on_mips_cmd = self.__on_mips_cmd_handler - self.on_mips_message = self.__on_mips_message_handler - self.on_mips_connect = self.__on_mips_connect_handler - self.on_mips_disconnect = self.__on_mips_disconnect_handler - - def deinit(self) -> None: - self.mips_deinit() - self._msg_matcher = None - self.on_mips_cmd = None - self.on_mips_message = None - self.on_mips_connect = None - - @final - def connect(self) -> None: - self.mips_connect() - - @final - async def connect_async(self) -> None: - await self.mips_connect_async() - @final def disconnect(self) -> None: - self.mips_disconnect() - self._msg_matcher = MIoTMatcher() - - @final - async def disconnect_async(self) -> None: - await self.mips_disconnect_async() + super().disconnect() self._msg_matcher = MIoTMatcher() def update_access_token(self, access_token: str) -> bool: if not isinstance(access_token, str): raise MIoTMipsError('invalid token') - return self.update_mqtt_password(password=access_token) + self.update_mqtt_password(password=access_token) + return True @final def sub_prop( - self, did: str, handler: Callable[[dict, Any], None], - siid: int = None, piid: int = None, handler_ctx: Any = None + self, + did: str, + handler: Callable[[dict, Any], None], + siid: Optional[int] = None, + piid: Optional[int] = None, + handler_ctx: Any = None ) -> bool: if not isinstance(did, str) or handler is None: raise MIoTMipsError('invalid params') @@ -1043,7 +838,7 @@ class MipsCloudClient(MipsClient): f'device/{did}/up/properties_changed/' f'{"#" if siid is None or piid is None else f"{siid}/{piid}"}') - def on_prop_msg(topic: str, payload: str, ctx: Any) -> bool: + def on_prop_msg(topic: str, payload: str, ctx: Any) -> None: try: msg: dict = json.loads(payload) except json.JSONDecodeError: @@ -1062,22 +857,31 @@ class MipsCloudClient(MipsClient): if handler: self.log_debug('on properties_changed, %s', payload) handler(msg['params'], ctx) - return self.__reg_broadcast( + return self.__reg_broadcast_external( topic=topic, handler=on_prop_msg, handler_ctx=handler_ctx) @final - def unsub_prop(self, did: str, siid: int = None, piid: int = None) -> bool: + def unsub_prop( + self, + did: str, + siid: Optional[int] = None, + piid: Optional[int] = None + ) -> bool: if not isinstance(did, str): raise MIoTMipsError('invalid params') topic: str = ( f'device/{did}/up/properties_changed/' f'{"#" if siid is None or piid is None else f"{siid}/{piid}"}') - return self.__unreg_broadcast(topic=topic) + return self.__unreg_broadcast_external(topic=topic) @final def sub_event( - self, did: str, handler: Callable[[dict, Any], None], - siid: int = None, eiid: int = None, handler_ctx: Any = None + self, + did: str, + handler: Callable[[dict, Any], None], + siid: Optional[int] = None, + eiid: Optional[int] = None, + handler_ctx: Any = None ) -> bool: if not isinstance(did, str) or handler is None: raise MIoTMipsError('invalid params') @@ -1086,7 +890,7 @@ class MipsCloudClient(MipsClient): f'device/{did}/up/event_occured/' f'{"#" if siid is None or eiid is None else f"{siid}/{eiid}"}') - def on_event_msg(topic: str, payload: str, ctx: Any) -> bool: + def on_event_msg(topic: str, payload: str, ctx: Any) -> None: try: msg: dict = json.loads(payload) except json.JSONDecodeError: @@ -1106,18 +910,23 @@ class MipsCloudClient(MipsClient): self.log_debug('on on_event_msg, %s', payload) msg['params']['from'] = 'cloud' handler(msg['params'], ctx) - return self.__reg_broadcast( + return self.__reg_broadcast_external( topic=topic, handler=on_event_msg, handler_ctx=handler_ctx) @final - def unsub_event(self, did: str, siid: int = None, eiid: int = None) -> bool: + def unsub_event( + self, + did: str, + siid: Optional[int] = None, + eiid: Optional[int] = None + ) -> bool: if not isinstance(did, str): raise MIoTMipsError('invalid params') # Spelling error: event_occured topic: str = ( f'device/{did}/up/event_occured/' f'{"#" if siid is None or eiid is None else f"{siid}/{eiid}"}') - return self.__unreg_broadcast(topic=topic) + return self.__unreg_broadcast_external(topic=topic) @final def sub_device_state( @@ -1145,7 +954,7 @@ class MipsCloudClient(MipsClient): handler( did, MIoTDeviceState.ONLINE if msg['event'] == 'online' else MIoTDeviceState.OFFLINE, ctx) - return self.__reg_broadcast( + return self.__reg_broadcast_external( topic=topic, handler=on_state_msg, handler_ctx=handler_ctx) @final @@ -1153,10 +962,10 @@ class MipsCloudClient(MipsClient): if not isinstance(did, str): raise MIoTMipsError('invalid params') topic: str = f'device/{did}/state/#' - return self.__unreg_broadcast(topic=topic) + return self.__unreg_broadcast_external(topic=topic) async def get_dev_list_async( - self, payload: str = None, timeout_ms: int = 10000 + self, payload: Optional[str] = None, timeout_ms: int = 10000 ) -> dict[str, dict]: raise NotImplementedError('please call in http client') @@ -1168,97 +977,95 @@ class MipsCloudClient(MipsClient): async def set_prop_async( self, did: str, siid: int, piid: int, value: Any, timeout_ms: int = 10000 - ) -> bool: + ) -> dict: raise NotImplementedError('please call in http client') async def action_async( self, did: str, siid: int, aiid: int, in_list: list, timeout_ms: int = 10000 - ) -> tuple[bool, list]: + ) -> dict: raise NotImplementedError('please call in http client') - def __on_mips_cmd_handler(self, mips_cmd: MipsCmd) -> None: - """ - NOTICE thread safe, this function will be called at the **mips** thread - """ - if mips_cmd.type_ == MipsCmdType.REG_BROADCAST: - reg_bc: MipsRegBroadcast = mips_cmd.data - if not self._msg_matcher.get(topic=reg_bc.topic): - sub_bc: MipsBroadcast = MipsBroadcast( - topic=reg_bc.topic, handler=reg_bc.handler, - handler_ctx=reg_bc.handler_ctx) - self._msg_matcher[reg_bc.topic] = sub_bc - self._mips_sub_internal(topic=reg_bc.topic) - else: - self.log_debug(f'mips cloud re-reg broadcast, {reg_bc.topic}') - elif mips_cmd.type_ == MipsCmdType.UNREG_BROADCAST: - unreg_bc: MipsRegBroadcast = mips_cmd.data - if self._msg_matcher.get(topic=unreg_bc.topic): - del self._msg_matcher[unreg_bc.topic] - self._mips_unsub_internal(topic=unreg_bc.topic) + def __reg_broadcast_external( + self, topic: str, handler: Callable[[str, str, Any], None], + handler_ctx: Any = None + ) -> bool: + self._internal_loop.call_soon_threadsafe( + self.__reg_broadcast, topic, handler, handler_ctx) + return True + + def __unreg_broadcast_external(self, topic: str) -> bool: + self._internal_loop.call_soon_threadsafe( + self.__unreg_broadcast, topic) + return True def __reg_broadcast( self, topic: str, handler: Callable[[str, str, Any], None], handler_ctx: Any = None - ) -> bool: - return self._mips_send_cmd( - type_=MipsCmdType.REG_BROADCAST, - data=MipsRegBroadcast( - topic=topic, handler=handler, handler_ctx=handler_ctx)) + ) -> None: + if not self._msg_matcher.get(topic=topic): + sub_bc: _MipsBroadcast = _MipsBroadcast( + topic=topic, handler=handler, + handler_ctx=handler_ctx) + self._msg_matcher[topic] = sub_bc + self._mips_sub_internal(topic=topic) + else: + self.log_debug(f'mips cloud re-reg broadcast, {topic}') - def __unreg_broadcast(self, topic: str) -> bool: - return self._mips_send_cmd( - type_=MipsCmdType.UNREG_BROADCAST, - data=MipsRegBroadcast(topic=topic)) + def __unreg_broadcast(self, topic: str) -> None: + if self._msg_matcher.get(topic=topic): + del self._msg_matcher[topic] + self._mips_unsub_internal(topic=topic) - def __on_mips_connect_handler(self, rc, props) -> None: + def _on_mips_connect(self, rc: int, props: dict) -> None: """sub topic.""" for topic, _ in list( self._msg_matcher.iter_all_nodes()): self._mips_sub_internal(topic=topic) - def __on_mips_disconnect_handler(self, rc, props) -> None: + def _on_mips_disconnect(self, rc: int, props: dict) -> None: """unsub topic.""" pass - def __on_mips_message_handler(self, topic: str, payload) -> None: + def _on_mips_message(self, topic: str, payload: bytes) -> None: """ NOTICE thread safe, this function will be called at the **mips** thread """ # broadcast - bc_list: list[MipsBroadcast] = list( + bc_list: list[_MipsBroadcast] = list( self._msg_matcher.iter_match(topic)) if not bc_list: return + # The message from the cloud is not packed. + payload_str: str = payload.decode('utf-8') # self.log_debug(f"on broadcast, {topic}, {payload}") for item in bc_list or []: if item.handler is None: continue # NOTICE: call threadsafe self.main_loop.call_soon_threadsafe( - item.handler, topic, payload, item.handler_ctx) + item.handler, topic, payload_str, item.handler_ctx) -class MipsLocalClient(MipsClient): +class MipsLocalClient(_MipsClient): """MIoT Pub/Sub Local Client.""" # pylint: disable=unused-argument # pylint: disable=inconsistent-quotes - MIPS_RECONNECT_INTERVAL_MIN: int = 6000 - MIPS_RECONNECT_INTERVAL_MAX: int = 60000 + MIPS_RECONNECT_INTERVAL_MIN: float = 6 + MIPS_RECONNECT_INTERVAL_MAX: float = 60 MIPS_SUB_PATCH: int = 1000 - MIPS_SUB_INTERVAL: int = 100 + MIPS_SUB_INTERVAL: float = 0.1 _did: str _group_id: str _home_name: str _mips_seed_id: int _reply_topic: str _dev_list_change_topic: str - _request_map: dict[str, MipsRequest] + _request_map: dict[str, _MipsRequest] _msg_matcher: MIoTMatcher - _device_state_sub_map: dict[str, MipsDeviceState] _get_prop_queue: dict[str, list] - _get_prop_timer: asyncio.TimerHandle - _on_dev_list_changed: Callable[[Any, list[str]], asyncio.Future] + _get_prop_timer: Optional[asyncio.TimerHandle] + _on_dev_list_changed: Optional[Callable[[Any, list[str]], Coroutine]] def __init__( self, did: str, host: str, group_id: str, @@ -1274,7 +1081,6 @@ class MipsLocalClient(MipsClient): self._dev_list_change_topic = f'{did}/appMsg/devListChange' self._request_map = {} self._msg_matcher = MIoTMatcher() - self._device_state_sub_map = {} self._get_prop_queue = {} self._get_prop_timer = None self._on_dev_list_changed = None @@ -1282,34 +1088,11 @@ class MipsLocalClient(MipsClient): super().__init__( client_id=did, host=host, port=port, ca_file=ca_file, cert_file=cert_file, key_file=key_file, loop=loop) - # MIPS local thread name use group_id - self._mips_thread.name = self._group_id - - self.on_mips_cmd = self.__on_mips_cmd_handler - self.on_mips_message = self.__on_mips_message_handler - self.on_mips_connect = self.__on_mips_connect_handler @property def group_id(self) -> str: return self._group_id - def deinit(self) -> None: - self.mips_deinit() - self._did = None - self._mips_seed_id = None - self._reply_topic = None - self._dev_list_change_topic = None - self._request_map = None - self._msg_matcher = None - self._device_state_sub_map = None - self._get_prop_queue = None - self._get_prop_timer = None - self._on_dev_list_changed = None - - self.on_mips_cmd = None - self.on_mips_message = None - self.on_mips_connect = None - def log_debug(self, msg, *args, **kwargs) -> None: if self._logger: self._logger.debug(f'{self._home_name}, '+msg, *args, **kwargs) @@ -1323,31 +1106,24 @@ class MipsLocalClient(MipsClient): self._logger.error(f'{self._home_name}, '+msg, *args, **kwargs) @final - def connect(self) -> None: - self.mips_connect() - - @final - async def connect_async(self) -> None: - await self.mips_connect_async() + def connect(self, thread_name: Optional[str] = None) -> None: + # MIPS local thread name use group_id + super().connect(self._group_id) @final def disconnect(self) -> None: - self.mips_disconnect() + super().disconnect() self._request_map = {} self._msg_matcher = MIoTMatcher() - self._device_state_sub_map = {} - - @final - async def disconnect_async(self) -> None: - await self.mips_disconnect_async() - self._request_map = {} - self._msg_matcher = MIoTMatcher() - self._device_state_sub_map = {} @final def sub_prop( - self, did: str, handler: Callable[[dict, Any], None], - siid: int = None, piid: int = None, handler_ctx: Any = None + self, + did: str, + handler: Callable[[dict, Any], None], + siid: Optional[int] = None, + piid: Optional[int] = None, + handler_ctx: Any = None ) -> bool: topic: str = ( f'appMsg/notify/iot/{did}/property/' @@ -1367,20 +1143,29 @@ class MipsLocalClient(MipsClient): if handler: self.log_debug('local, on properties_changed, %s', payload) handler(msg, ctx) - return self.__reg_broadcast( + return self.__reg_broadcast_external( topic=topic, handler=on_prop_msg, handler_ctx=handler_ctx) @final - def unsub_prop(self, did: str, siid: int = None, piid: int = None) -> bool: + def unsub_prop( + self, + did: str, + siid: Optional[int] = None, + piid: Optional[int] = None + ) -> bool: topic: str = ( f'appMsg/notify/iot/{did}/property/' f'{"#" if siid is None or piid is None else f"{siid}.{piid}"}') - return self.__unreg_broadcast(topic=topic) + return self.__unreg_broadcast_external(topic=topic) @final def sub_event( - self, did: str, handler: Callable[[dict, Any], None], - siid: int = None, eiid: int = None, handler_ctx: Any = None + self, + did: str, + handler: Callable[[dict, Any], None], + siid: Optional[int] = None, + eiid: Optional[int] = None, + handler_ctx: Any = None ) -> bool: topic: str = ( f'appMsg/notify/iot/{did}/event/' @@ -1400,15 +1185,20 @@ class MipsLocalClient(MipsClient): if handler: self.log_debug('local, on event_occurred, %s', payload) handler(msg, ctx) - return self.__reg_broadcast( + return self.__reg_broadcast_external( topic=topic, handler=on_event_msg, handler_ctx=handler_ctx) @final - def unsub_event(self, did: str, siid: int = None, eiid: int = None) -> bool: + def unsub_event( + self, + did: str, + siid: Optional[int] = None, + eiid: Optional[int] = None + ) -> bool: topic: str = ( f'appMsg/notify/iot/{did}/event/' f'{"#" if siid is None or eiid is None else f"{siid}.{eiid}"}') - return self.__unreg_broadcast(topic=topic) + return self.__unreg_broadcast_external(topic=topic) @final async def get_prop_safe_async( @@ -1426,7 +1216,9 @@ class MipsLocalClient(MipsClient): 'timeout_ms': timeout_ms }) if self._get_prop_timer is None: - self._get_prop_timer = self.main_loop.create_task( + self._get_prop_timer = self.main_loop.call_later( + 0.1, + self.main_loop.create_task, self.__get_prop_timer_handle()) return await fut @@ -1515,13 +1307,13 @@ class MipsLocalClient(MipsClient): @final async def get_dev_list_async( - self, payload: str = None, timeout_ms: int = 10000 + self, payload: Optional[str] = None, timeout_ms: int = 10000 ) -> dict[str, dict]: result_obj = await self.__request_async( topic='proxy/getDevList', payload=payload or '{}', timeout_ms=timeout_ms) if not result_obj or 'devList' not in result_obj: - return None + raise MIoTMipsError('invalid result') device_list = {} for did, info in result_obj['devList'].items(): name: str = info.get('name', None) @@ -1557,7 +1349,7 @@ class MipsLocalClient(MipsClient): payload='{}', timeout_ms=timeout_ms) if not result_obj or 'result' not in result_obj: - return None + raise MIoTMipsError('invalid result') return result_obj['result'] @final @@ -1579,79 +1371,73 @@ class MipsLocalClient(MipsClient): @final @property - def on_dev_list_changed(self) -> Callable[[Any, list[str]], asyncio.Future]: + def on_dev_list_changed( + self + ) -> Optional[Callable[[Any, list[str]], Coroutine]]: return self._on_dev_list_changed @final @on_dev_list_changed.setter def on_dev_list_changed( - self, func: Callable[[Any, list[str]], asyncio.Future] + self, func: Callable[[Any, list[str]], Coroutine] ) -> None: """run in main loop.""" self._on_dev_list_changed = func - @final - def __on_mips_cmd_handler(self, mips_cmd: MipsCmd) -> None: - if mips_cmd.type_ == MipsCmdType.CALL_API: - req_data: MipsRequestData = mips_cmd.data - req = MipsRequest() - req.mid = self.__gen_mips_id - req.on_reply = req_data.on_reply - req.on_reply_ctx = req_data.on_reply_ctx - pub_topic: str = f'master/{req_data.topic}' - result = self.__mips_publish( - topic=pub_topic, payload=req_data.payload, mid=req.mid, - ret_topic=self._reply_topic) - self.log_debug( - f'mips local call api, {result}, {req.mid}, {pub_topic}, ' - f'{req_data.payload}') + def __request( + self, topic: str, payload: str, + on_reply: Callable[[str, Any], None], + on_reply_ctx: Any = None, timeout_ms: int = 10000 + ) -> None: + req = _MipsRequest( + mid=self.__gen_mips_id, + on_reply=on_reply, + on_reply_ctx=on_reply_ctx, + timer=None) + pub_topic: str = f'master/{topic}' + result = self.__mips_publish( + topic=pub_topic, payload=payload, mid=req.mid, + ret_topic=self._reply_topic) + self.log_debug( + f'mips local call api, {result}, {req.mid}, {pub_topic}, ' + f'{payload}') - def on_request_timeout(req: MipsRequest): - self.log_error( - f'on mips request timeout, {req.mid}, {pub_topic}' - f', {req_data.payload}') - self._request_map.pop(str(req.mid), None) - req.on_reply( - '{"error":{"code":-10006, "message":"timeout"}}', - req.on_reply_ctx) - req.timer = self.mev_set_timeout( - req_data.timeout_ms, on_request_timeout, req) - self._request_map[str(req.mid)] = req - elif mips_cmd.type_ == MipsCmdType.REG_BROADCAST: - reg_bc: MipsRegBroadcast = mips_cmd.data - sub_topic: str = f'{self._did}/{reg_bc.topic}' - if not self._msg_matcher.get(sub_topic): - sub_bc: MipsBroadcast = MipsBroadcast( - topic=sub_topic, handler=reg_bc.handler, - handler_ctx=reg_bc.handler_ctx) - self._msg_matcher[sub_topic] = sub_bc - self._mips_sub_internal(topic=f'master/{reg_bc.topic}') - else: - self.log_debug(f'mips re-reg broadcast, {sub_topic}') - elif mips_cmd.type_ == MipsCmdType.UNREG_BROADCAST: - unreg_bc: MipsRegBroadcast = mips_cmd.data - # Central hub gateway needs to add prefix - unsub_topic: str = f'{self._did}/{unreg_bc.topic}' - if self._msg_matcher.get(unsub_topic): - del self._msg_matcher[unsub_topic] - self._mips_unsub_internal( - topic=re.sub(f'^{self._did}', 'master', unsub_topic)) - elif mips_cmd.type_ == MipsCmdType.REG_DEVICE_STATE: - reg_dev_state: MipsRegDeviceState = mips_cmd.data - self._device_state_sub_map[reg_dev_state.did] = reg_dev_state - self.log_debug( - f'mips local reg device state, {reg_dev_state.did}') - elif mips_cmd.type_ == MipsCmdType.UNREG_DEVICE_STATE: - unreg_dev_state: MipsRegDeviceState = mips_cmd.data - del self._device_state_sub_map[unreg_dev_state.did] - self.log_debug( - f'mips local unreg device state, {unreg_dev_state.did}') - else: + def on_request_timeout(req: _MipsRequest): self.log_error( - f'mips local recv unknown cmd, {mips_cmd.type_}, ' - f'{mips_cmd.data}') + f'on mips request timeout, {req.mid}, {pub_topic}' + f', {payload}') + self._request_map.pop(str(req.mid), None) + req.on_reply( + '{"error":{"code":-10006, "message":"timeout"}}', + req.on_reply_ctx) + req.timer = self._internal_loop.call_later( + timeout_ms/1000, on_request_timeout, req) + self._request_map[str(req.mid)] = req - def __on_mips_connect_handler(self, rc, props) -> None: + def __reg_broadcast( + self, topic: str, handler: Callable[[str, str, Any], None], + handler_ctx: Any + ) -> None: + sub_topic: str = f'{self._did}/{topic}' + if not self._msg_matcher.get(sub_topic): + sub_bc: _MipsBroadcast = _MipsBroadcast( + topic=sub_topic, handler=handler, + handler_ctx=handler_ctx) + self._msg_matcher[sub_topic] = sub_bc + self._mips_sub_internal(topic=f'master/{topic}') + else: + self.log_debug(f'mips re-reg broadcast, {sub_topic}') + + def __unreg_broadcast(self, topic) -> None: + # Central hub gateway needs to add prefix + unsub_topic: str = f'{self._did}/{topic}' + if self._msg_matcher.get(unsub_topic): + del self._msg_matcher[unsub_topic] + self._mips_unsub_internal( + topic=re.sub(f'^{self._did}', 'master', unsub_topic)) + + @final + def _on_mips_connect(self, rc: int, props: dict) -> None: self.log_debug('__on_mips_connect_handler') # Sub did/#, include reply topic self._mips_sub_internal(f'{self._did}/#') @@ -1665,24 +1451,30 @@ class MipsLocalClient(MipsClient): topic=re.sub(f'^{self._did}', 'master', topic)) @final - def __on_mips_message_handler(self, topic: str, payload: bytes) -> None: - mips_msg: MipsMessage = MipsMessage.unpack(payload) + def _on_mips_disconnect(self, rc: int, props: dict) -> None: + pass + + @final + def _on_mips_message(self, topic: str, payload: bytes) -> None: + mips_msg: _MipsMessage = _MipsMessage.unpack(payload) # self.log_debug( # f"mips local client, on_message, {topic} -> {mips_msg}") # Reply if topic == self._reply_topic: self.log_debug(f'on request reply, {mips_msg}') - req: MipsRequest = self._request_map.pop(str(mips_msg.mid), None) + req: Optional[_MipsRequest] = self._request_map.pop( + str(mips_msg.mid), None) if req: # Cancel timer - self.mev_clear_timeout(req.timer) + if req.timer: + req.timer.cancel() if req.on_reply: self.main_loop.call_soon_threadsafe( req.on_reply, mips_msg.payload or '{}', req.on_reply_ctx) return # Broadcast - bc_list: list[MipsBroadcast] = list(self._msg_matcher.iter_match( + bc_list: list[_MipsBroadcast] = list(self._msg_matcher.iter_match( topic=topic)) if bc_list: self.log_debug(f'on broadcast, {topic}, {mips_msg}') @@ -1695,6 +1487,9 @@ class MipsLocalClient(MipsClient): return # Device list change if topic == self._dev_list_change_topic: + if mips_msg.payload is None: + self.log_error('devListChange msg is None') + return payload_obj: dict = json.loads(mips_msg.payload) dev_list = payload_obj.get('devList', None) if not isinstance(dev_list, list) or not dev_list: @@ -1704,7 +1499,7 @@ class MipsLocalClient(MipsClient): if self._on_dev_list_changed: self.main_loop.call_soon_threadsafe( self.main_loop.create_task, - self._on_dev_list_changed(self, payload_obj['devList'])) + self._on_dev_list_changed(self, dev_list)) return self.log_debug( @@ -1717,45 +1512,45 @@ class MipsLocalClient(MipsClient): return mips_id def __mips_publish( - self, topic: str, payload: str | bytes, mid: int = None, - ret_topic: str = None, wait_for_publish: bool = False, - timeout_ms: int = 10000 + self, + topic: str, + payload: str, + mid: Optional[int] = None, + ret_topic: Optional[str] = None, + wait_for_publish: bool = False, + timeout_ms: int = 10000 ) -> bool: - mips_msg: bytes = MipsMessage.pack( + mips_msg: bytes = _MipsMessage.pack( mid=mid or self.__gen_mips_id, payload=payload, msg_from='local', ret_topic=ret_topic) return self._mips_publish_internal( topic=topic.strip(), payload=mips_msg, wait_for_publish=wait_for_publish, timeout_ms=timeout_ms) - def __request( + def __request_external( self, topic: str, payload: str, on_reply: Callable[[str, Any], None], on_reply_ctx: Any = None, timeout_ms: int = 10000 ) -> bool: if topic is None or payload is None or on_reply is None: raise MIoTMipsError('invalid params') - req_data: MipsRequestData = MipsRequestData() - req_data.topic = topic - req_data.payload = payload - req_data.on_reply = on_reply - req_data.on_reply_ctx = on_reply_ctx - req_data.timeout_ms = timeout_ms - return self._mips_send_cmd(type_=MipsCmdType.CALL_API, data=req_data) + self._internal_loop.call_soon_threadsafe( + self.__request, topic, payload, on_reply, on_reply_ctx, timeout_ms) + return True - def __reg_broadcast( + def __reg_broadcast_external( self, topic: str, handler: Callable[[str, str, Any], None], handler_ctx: Any ) -> bool: - return self._mips_send_cmd( - type_=MipsCmdType.REG_BROADCAST, - data=MipsRegBroadcast( - topic=topic, handler=handler, handler_ctx=handler_ctx)) + self._internal_loop.call_soon_threadsafe( + self.__reg_broadcast, + topic, handler, handler_ctx) + return True - def __unreg_broadcast(self, topic) -> bool: - return self._mips_send_cmd( - type_=MipsCmdType.UNREG_BROADCAST, - data=MipsRegBroadcast(topic=topic)) + def __unreg_broadcast_external(self, topic) -> bool: + self._internal_loop.call_soon_threadsafe( + self.__unreg_broadcast, topic) + return True @final async def __request_async( @@ -1767,7 +1562,7 @@ class MipsLocalClient(MipsClient): fut: asyncio.Future = ctx if fut: self.main_loop.call_soon_threadsafe(fut.set_result, payload) - if not self.__request( + if not self.__request_external( topic=topic, payload=payload, on_reply=on_msg_reply, diff --git a/test/conftest.py b/test/conftest.py index 9263402..64687f7 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -20,7 +20,6 @@ def load_py_file(): 'const.py', 'miot_cloud.py', 'miot_error.py', - 'miot_ev.py', 'miot_i18n.py', 'miot_lan.py', 'miot_mdns.py', diff --git a/test/test_ev.py b/test/test_ev.py deleted file mode 100644 index 6353fe8..0000000 --- a/test/test_ev.py +++ /dev/null @@ -1,55 +0,0 @@ -# -*- coding: utf-8 -*- -"""Unit test for miot_ev.py.""" -import os -import pytest - -# pylint: disable=import-outside-toplevel, disable=unused-argument - - -@pytest.mark.github -def test_mev_timer_and_fd(): - from miot.miot_ev import MIoTEventLoop, TimeoutHandle - - mev = MIoTEventLoop() - assert mev - event_fd: os.eventfd = os.eventfd(0, os.O_NONBLOCK) - assert event_fd - timer4: TimeoutHandle = None - - def event_handler(event_fd): - value: int = os.eventfd_read(event_fd) - if value == 1: - mev.clear_timeout(timer4) - print('cancel timer4') - elif value == 2: - print('event write twice in a row') - elif value == 3: - mev.set_read_handler(event_fd, None, None) - os.close(event_fd) - event_fd = None - print('close event fd') - - def timer1_handler(event_fd): - os.eventfd_write(event_fd, 1) - - def timer2_handler(event_fd): - os.eventfd_write(event_fd, 1) - os.eventfd_write(event_fd, 1) - - def timer3_handler(event_fd): - os.eventfd_write(event_fd, 3) - - def timer4_handler(event_fd): - raise ValueError('unreachable code') - - mev.set_read_handler( - event_fd, event_handler, event_fd) - - mev.set_timeout(500, timer1_handler, event_fd) - mev.set_timeout(1000, timer2_handler, event_fd) - mev.set_timeout(1500, timer3_handler, event_fd) - timer4 = mev.set_timeout(2000, timer4_handler, event_fd) - - mev.loop_forever() - # Loop will exit when there are no timers or fd handlers. - mev.loop_stop() From 5903c9a5a8e48d791f643b919f6e0d78b4beaa64 Mon Sep 17 00:00:00 2001 From: Paul Shawn <32349595+topsworld@users.noreply.github.com> Date: Mon, 13 Jan 2025 09:38:44 +0800 Subject: [PATCH 04/14] test: add miot cloud test case (#620) * test: add miot cloud test case * feat: improve miot cloud logic * feat: simplify oauth logic * test: improve miot cloud test case * fix: fix pylint error * feat: use random value replace uuid, random_did * fix: import error --- custom_components/xiaomi_home/config_flow.py | 23 +- .../xiaomi_home/miot/miot_cloud.py | 11 +- test/conftest.py | 38 ++ test/test_cloud.py | 485 ++++++++++++++++++ 4 files changed, 541 insertions(+), 16 deletions(-) create mode 100755 test/test_cloud.py diff --git a/custom_components/xiaomi_home/config_flow.py b/custom_components/xiaomi_home/config_flow.py index 8e48849..1c3f12c 100644 --- a/custom_components/xiaomi_home/config_flow.py +++ b/custom_components/xiaomi_home/config_flow.py @@ -426,14 +426,12 @@ class XiaomiMihomeConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): cloud_server=self._cloud_server, uuid=self._uuid, loop=self._main_loop) - state = hashlib.sha1( - f'd=ha.{self._uuid}'.encode('utf-8')).hexdigest() - self.hass.data[DOMAIN][self._virtual_did]['oauth_state'] = state self._cc_oauth_auth_url = miot_oauth.gen_auth_url( - redirect_url=self._oauth_redirect_url_full, state=state) + redirect_url=self._oauth_redirect_url_full) + self.hass.data[DOMAIN][self._virtual_did]['oauth_state'] = ( + miot_oauth.state) _LOGGER.info( - 'async_step_oauth, oauth_url: %s', - self._cc_oauth_auth_url) + 'async_step_oauth, oauth_url: %s', self._cc_oauth_auth_url) webhook_async_unregister( self.hass, webhook_id=self._virtual_did) webhook_async_register( @@ -1150,17 +1148,12 @@ class OptionsFlowHandler(config_entries.OptionsFlow): async def async_step_oauth(self, user_input=None): try: if self._cc_task_oauth is None: - state = hashlib.sha1( - f'd=ha.{self._entry_data["uuid"]}'.encode('utf-8') - ).hexdigest() - self.hass.data[DOMAIN][self._virtual_did]['oauth_state'] = state - self._miot_oauth.set_redirect_url( - redirect_url=self._oauth_redirect_url_full) self._cc_oauth_auth_url = self._miot_oauth.gen_auth_url( - redirect_url=self._oauth_redirect_url_full, state=state) + redirect_url=self._oauth_redirect_url_full) + self.hass.data[DOMAIN][self._virtual_did]['oauth_state'] = ( + self._miot_oauth.state) _LOGGER.info( - 'async_step_oauth, oauth_url: %s', - self._cc_oauth_auth_url) + 'async_step_oauth, oauth_url: %s', self._cc_oauth_auth_url) webhook_async_unregister( self.hass, webhook_id=self._virtual_did) webhook_async_register( diff --git a/custom_components/xiaomi_home/miot/miot_cloud.py b/custom_components/xiaomi_home/miot/miot_cloud.py index 4c076fe..e70930f 100644 --- a/custom_components/xiaomi_home/miot/miot_cloud.py +++ b/custom_components/xiaomi_home/miot/miot_cloud.py @@ -47,6 +47,7 @@ MIoT http client. """ import asyncio import base64 +import hashlib import json import logging import re @@ -76,6 +77,7 @@ class MIoTOauthClient: _client_id: int _redirect_url: str _device_id: str + _state: str def __init__( self, client_id: str, redirect_url: str, cloud_server: str, @@ -98,8 +100,14 @@ class MIoTOauthClient: else: self._oauth_host = f'{cloud_server}.{DEFAULT_OAUTH2_API_HOST}' self._device_id = f'ha.{uuid}' + self._state = hashlib.sha1( + f'd={self._device_id}'.encode('utf-8')).hexdigest() self._session = aiohttp.ClientSession(loop=self._main_loop) + @property + def state(self) -> str: + return self.state + async def deinit_async(self) -> None: if self._session and not self._session.closed: await self._session.close() @@ -136,7 +144,8 @@ class MIoTOauthClient: 'redirect_uri': redirect_url or self._redirect_url, 'client_id': self._client_id, 'response_type': 'code', - 'device_id': self._device_id + 'device_id': self._device_id, + 'state': self._state } if state: params['state'] = state diff --git a/test/conftest.py b/test/conftest.py index 64687f7..63464cd 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -1,16 +1,22 @@ # -*- coding: utf-8 -*- """Pytest fixtures.""" +import random import shutil import pytest from os import path, makedirs +from uuid import uuid4 TEST_ROOT_PATH: str = path.dirname(path.abspath(__file__)) TEST_FILES_PATH: str = path.join(TEST_ROOT_PATH, 'miot') TEST_CACHE_PATH: str = path.join(TEST_ROOT_PATH, 'test_cache') +TEST_OAUTH2_REDIRECT_URL: str = 'http://homeassistant.local:8123' TEST_LANG: str = 'zh-Hans' TEST_UID: str = '123456789' TEST_CLOUD_SERVER: str = 'cn' +DOMAIN_OAUTH2: str = 'oauth2_info' +DOMAIN_USER_INFO: str = 'user_info' + @pytest.fixture(scope='session', autouse=True) def load_py_file(): @@ -23,6 +29,7 @@ def load_py_file(): 'miot_i18n.py', 'miot_lan.py', 'miot_mdns.py', + 'miot_mips.py', 'miot_network.py', 'miot_spec.py', 'miot_storage.py'] @@ -59,6 +66,10 @@ def load_py_file(): yield + # NOTICE: All test files and data (tokens, device information, etc.) will + # be deleted after the test is completed. For some test cases that + # require caching data, you can comment out the following code. + if path.exists(TEST_FILES_PATH): shutil.rmtree(TEST_FILES_PATH) print('\nremoved test files, ', TEST_FILES_PATH) @@ -79,6 +90,11 @@ def test_cache_path() -> str: return TEST_CACHE_PATH +@pytest.fixture(scope='session') +def test_oauth2_redirect_url() -> str: + return TEST_OAUTH2_REDIRECT_URL + + @pytest.fixture(scope='session') def test_lang() -> str: return TEST_LANG @@ -89,6 +105,28 @@ def test_uid() -> str: return TEST_UID +@pytest.fixture(scope='session') +def test_random_did() -> str: + # Gen random did + return str(random.getrandbits(64)) + + +@pytest.fixture(scope='session') +def test_uuid() -> str: + # Gen uuid + return uuid4().hex + + @pytest.fixture(scope='session') def test_cloud_server() -> str: return TEST_CLOUD_SERVER + + +@pytest.fixture(scope='session') +def test_domain_oauth2() -> str: + return DOMAIN_OAUTH2 + + +@pytest.fixture(scope='session') +def test_domain_user_info() -> str: + return DOMAIN_USER_INFO diff --git a/test/test_cloud.py b/test/test_cloud.py new file mode 100755 index 0000000..acece12 --- /dev/null +++ b/test/test_cloud.py @@ -0,0 +1,485 @@ +# -*- coding: utf-8 -*- +"""Unit test for miot_cloud.py.""" +import asyncio +import time +import webbrowser +import pytest + +# pylint: disable=import-outside-toplevel, unused-argument + + +@pytest.mark.asyncio +@pytest.mark.dependency() +async def test_miot_oauth_async( + test_cache_path: str, + test_cloud_server: str, + test_oauth2_redirect_url: str, + test_domain_oauth2: str, + test_uuid: str +) -> dict: + from miot.const import OAUTH2_CLIENT_ID + from miot.miot_cloud import MIoTOauthClient + from miot.miot_storage import MIoTStorage + print('') # separate from previous output + + miot_storage = MIoTStorage(test_cache_path) + local_uuid = await miot_storage.load_async( + domain=test_domain_oauth2, name=f'{test_cloud_server}_uuid', type_=str) + uuid = str(local_uuid or test_uuid) + print(f'uuid: {uuid}') + miot_oauth = MIoTOauthClient( + client_id=OAUTH2_CLIENT_ID, + redirect_url=test_oauth2_redirect_url, + cloud_server=test_cloud_server, + uuid=uuid) + + oauth_info = None + load_info = await miot_storage.load_async( + domain=test_domain_oauth2, name=test_cloud_server, type_=dict) + if ( + isinstance(load_info, dict) + and 'access_token' in load_info + and 'expires_ts' in load_info + and load_info['expires_ts'] > int(time.time()) + ): + print(f'load oauth info, {load_info}') + oauth_info = load_info + if oauth_info is None: + # gen oauth url + auth_url: str = miot_oauth.gen_auth_url() + assert isinstance(auth_url, str) + print('auth url: ', auth_url) + # get code + webbrowser.open(auth_url) + code: str = input('input code: ') + assert code is not None + # get access_token + res_obj = await miot_oauth.get_access_token_async(code=code) + assert res_obj is not None + oauth_info = res_obj + print(f'get_access_token result: {res_obj}') + rc = await miot_storage.save_async( + test_domain_oauth2, test_cloud_server, oauth_info) + assert rc + print('save oauth info') + rc = await miot_storage.save_async( + test_domain_oauth2, f'{test_cloud_server}_uuid', uuid) + assert rc + print('save uuid') + + access_token = oauth_info.get('access_token', None) + assert isinstance(access_token, str) + print(f'access_token: {access_token}') + refresh_token = oauth_info.get('refresh_token', None) + assert isinstance(refresh_token, str) + print(f'refresh_token: {refresh_token}') + return oauth_info + + +@pytest.mark.asyncio +@pytest.mark.dependency(on=['test_miot_oauth_async']) +async def test_miot_oauth_refresh_token( + test_cache_path: str, + test_cloud_server: str, + test_oauth2_redirect_url: str, + test_domain_oauth2: str +): + from miot.const import OAUTH2_CLIENT_ID + from miot.miot_cloud import MIoTOauthClient + from miot.miot_storage import MIoTStorage + print('') # separate from previous output + + miot_storage = MIoTStorage(test_cache_path) + uuid = await miot_storage.load_async( + domain=test_domain_oauth2, name=f'{test_cloud_server}_uuid', type_=str) + assert isinstance(uuid, str) + oauth_info = await miot_storage.load_async( + domain=test_domain_oauth2, name=test_cloud_server, type_=dict) + assert isinstance(oauth_info, dict) + assert 'access_token' in oauth_info + assert 'refresh_token' in oauth_info + assert 'expires_ts' in oauth_info + remaining_time = oauth_info['expires_ts'] - int(time.time()) + print(f'token remaining valid time: {remaining_time}s') + # Refresh token + miot_oauth = MIoTOauthClient( + client_id=OAUTH2_CLIENT_ID, + redirect_url=test_oauth2_redirect_url, + cloud_server=test_cloud_server, + uuid=uuid) + refresh_token = oauth_info.get('refresh_token', None) + assert refresh_token + update_info = await miot_oauth.refresh_access_token_async( + refresh_token=refresh_token) + assert update_info + assert 'access_token' in update_info + assert 'refresh_token' in update_info + assert 'expires_ts' in update_info + remaining_time = update_info['expires_ts'] - int(time.time()) + assert remaining_time > 0 + print(f'refresh token, remaining valid time: {remaining_time}s') + # Save token + rc = await miot_storage.save_async( + test_domain_oauth2, test_cloud_server, update_info) + assert rc + print(f'refresh token success, {update_info}') + + +@pytest.mark.asyncio +@pytest.mark.dependency() +async def test_miot_cloud_get_nickname_async( + test_cache_path: str, + test_cloud_server: str, + test_domain_oauth2: str +): + from miot.const import OAUTH2_CLIENT_ID + from miot.miot_cloud import MIoTHttpClient + from miot.miot_storage import MIoTStorage + print('') # separate from previous output + + miot_storage = MIoTStorage(test_cache_path) + oauth_info = await miot_storage.load_async( + domain=test_domain_oauth2, name=test_cloud_server, type_=dict) + assert isinstance(oauth_info, dict) and 'access_token' in oauth_info + miot_http = MIoTHttpClient( + cloud_server=test_cloud_server, client_id=OAUTH2_CLIENT_ID, + access_token=oauth_info['access_token']) + + # Get nickname + user_info = await miot_http.get_user_info_async() + assert isinstance(user_info, dict) and 'miliaoNick' in user_info + nickname = user_info['miliaoNick'] + print(f'your nickname: {nickname}\n') + + +@pytest.mark.asyncio +@pytest.mark.dependency() +async def test_miot_cloud_get_uid_async( + test_cache_path: str, + test_cloud_server: str, + test_domain_oauth2: str, + test_domain_user_info: str +): + from miot.const import OAUTH2_CLIENT_ID + from miot.miot_cloud import MIoTHttpClient + from miot.miot_storage import MIoTStorage + print('') # separate from previous output + + miot_storage = MIoTStorage(test_cache_path) + oauth_info = await miot_storage.load_async( + domain=test_domain_oauth2, name=test_cloud_server, type_=dict) + assert isinstance(oauth_info, dict) and 'access_token' in oauth_info + miot_http = MIoTHttpClient( + cloud_server=test_cloud_server, client_id=OAUTH2_CLIENT_ID, + access_token=oauth_info['access_token']) + + uid = await miot_http.get_uid_async() + assert isinstance(uid, str) + print(f'your uid: {uid}\n') + # Save uid + rc = await miot_storage.save_async( + domain=test_domain_user_info, + name=f'uid_{test_cloud_server}', data=uid) + assert rc + + +@pytest.mark.asyncio +@pytest.mark.dependency() +async def test_miot_cloud_get_homeinfos_async( + test_cache_path: str, + test_cloud_server: str, + test_domain_oauth2: str, + test_domain_user_info: str +): + from miot.const import OAUTH2_CLIENT_ID + from miot.miot_cloud import MIoTHttpClient + from miot.miot_storage import MIoTStorage + print('') # separate from previous output + + miot_storage = MIoTStorage(test_cache_path) + oauth_info = await miot_storage.load_async( + domain=test_domain_oauth2, name=test_cloud_server, type_=dict) + assert isinstance(oauth_info, dict) and 'access_token' in oauth_info + miot_http = MIoTHttpClient( + cloud_server=test_cloud_server, client_id=OAUTH2_CLIENT_ID, + access_token=oauth_info['access_token']) + + # Get homeinfos + homeinfos = await miot_http.get_homeinfos_async() + assert isinstance(homeinfos, dict) + assert 'uid' in homeinfos and isinstance(homeinfos['uid'], str) + assert 'home_list' in homeinfos and isinstance( + homeinfos['home_list'], dict) + assert 'share_home_list' in homeinfos and isinstance( + homeinfos['share_home_list'], dict) + # Get uid + uid = homeinfos.get('uid', '') + # Compare uid with uid in storage + uid2 = await miot_storage.load_async( + domain=test_domain_user_info, + name=f'uid_{test_cloud_server}', type_=str) + assert uid == uid2 + print(f'your uid: {uid}\n') + # Get homes + home_list = homeinfos.get('home_list', {}) + print(f'your home_list: {home_list}\n') + # Get share homes + share_home_list = homeinfos.get('share_home_list', {}) + print(f'your share_home_list: {share_home_list}\n') + + +@pytest.mark.asyncio +@pytest.mark.dependency() +async def test_miot_cloud_get_devices_async( + test_cache_path: str, + test_cloud_server: str, + test_domain_oauth2: str, + test_domain_user_info: str +): + from miot.const import OAUTH2_CLIENT_ID + from miot.miot_cloud import MIoTHttpClient + from miot.miot_storage import MIoTStorage + print('') # separate from previous output + + miot_storage = MIoTStorage(test_cache_path) + oauth_info = await miot_storage.load_async( + domain=test_domain_oauth2, name=test_cloud_server, type_=dict) + assert isinstance(oauth_info, dict) and 'access_token' in oauth_info + miot_http = MIoTHttpClient( + cloud_server=test_cloud_server, client_id=OAUTH2_CLIENT_ID, + access_token=oauth_info['access_token']) + + # Get devices + devices = await miot_http.get_devices_async() + assert isinstance(devices, dict) + assert 'uid' in devices and isinstance(devices['uid'], str) + assert 'homes' in devices and isinstance(devices['homes'], dict) + assert 'devices' in devices and isinstance(devices['devices'], dict) + # Compare uid with uid in storage + uid = devices.get('uid', '') + uid2 = await miot_storage.load_async( + domain=test_domain_user_info, + name=f'uid_{test_cloud_server}', type_=str) + assert uid == uid2 + print(f'your uid: {uid}\n') + # Get homes + homes = devices['homes'] + print(f'your homes: {homes}\n') + # Get devices + devices = devices['devices'] + print(f'your devices count: {len(devices)}\n') + # Storage homes and devices + rc = await miot_storage.save_async( + domain=test_domain_user_info, + name=f'homes_{test_cloud_server}', data=homes) + assert rc + rc = await miot_storage.save_async( + domain=test_domain_user_info, + name=f'devices_{test_cloud_server}', data=devices) + assert rc + + +@pytest.mark.asyncio +@pytest.mark.dependency() +async def test_miot_cloud_get_devices_with_dids_async( + test_cache_path: str, + test_cloud_server: str, + test_domain_oauth2: str, + test_domain_user_info: str +): + from miot.const import OAUTH2_CLIENT_ID + from miot.miot_cloud import MIoTHttpClient + from miot.miot_storage import MIoTStorage + print('') # separate from previous output + + miot_storage = MIoTStorage(test_cache_path) + oauth_info = await miot_storage.load_async( + domain=test_domain_oauth2, name=test_cloud_server, type_=dict) + assert isinstance(oauth_info, dict) and 'access_token' in oauth_info + miot_http = MIoTHttpClient( + cloud_server=test_cloud_server, client_id=OAUTH2_CLIENT_ID, + access_token=oauth_info['access_token']) + + # Load devices + local_devices = await miot_storage.load_async( + domain=test_domain_user_info, + name=f'devices_{test_cloud_server}', type_=dict) + assert isinstance(local_devices, dict) + did_list = list(local_devices.keys()) + assert len(did_list) > 0 + # Get device with dids + test_list = did_list[:6] + devices_info = await miot_http.get_devices_with_dids_async( + dids=test_list) + assert isinstance(devices_info, dict) + print(f'test did list, {len(test_list)}, {test_list}\n') + print(f'test result: {len(devices_info)}, {list(devices_info.keys())}\n') + + +@pytest.mark.asyncio +@pytest.mark.dependency() +async def test_miot_cloud_get_prop_async( + test_cache_path: str, + test_cloud_server: str, + test_domain_oauth2: str, + test_domain_user_info: str +): + from miot.const import OAUTH2_CLIENT_ID + from miot.miot_cloud import MIoTHttpClient + from miot.miot_storage import MIoTStorage + print('') # separate from previous output + + miot_storage = MIoTStorage(test_cache_path) + oauth_info = await miot_storage.load_async( + domain=test_domain_oauth2, name=test_cloud_server, type_=dict) + assert isinstance(oauth_info, dict) and 'access_token' in oauth_info + miot_http = MIoTHttpClient( + cloud_server=test_cloud_server, client_id=OAUTH2_CLIENT_ID, + access_token=oauth_info['access_token']) + + # Load devices + local_devices = await miot_storage.load_async( + domain=test_domain_user_info, + name=f'devices_{test_cloud_server}', type_=dict) + assert isinstance(local_devices, dict) + did_list = list(local_devices.keys()) + assert len(did_list) > 0 + # Get prop + test_list = did_list[:6] + for did in test_list: + prop_value = await miot_http.get_prop_async(did=did, siid=2, piid=1) + device_name = local_devices[did]['name'] + print(f'{device_name}({did}), prop.2.1: {prop_value}\n') + + +@pytest.mark.asyncio +@pytest.mark.dependency() +async def test_miot_cloud_get_props_async( + test_cache_path: str, + test_cloud_server: str, + test_domain_oauth2: str, + test_domain_user_info: str +): + from miot.const import OAUTH2_CLIENT_ID + from miot.miot_cloud import MIoTHttpClient + from miot.miot_storage import MIoTStorage + print('') # separate from previous output + + miot_storage = MIoTStorage(test_cache_path) + oauth_info = await miot_storage.load_async( + domain=test_domain_oauth2, name=test_cloud_server, type_=dict) + assert isinstance(oauth_info, dict) and 'access_token' in oauth_info + miot_http = MIoTHttpClient( + cloud_server=test_cloud_server, client_id=OAUTH2_CLIENT_ID, + access_token=oauth_info['access_token']) + + # Load devices + local_devices = await miot_storage.load_async( + domain=test_domain_user_info, + name=f'devices_{test_cloud_server}', type_=dict) + assert isinstance(local_devices, dict) + did_list = list(local_devices.keys()) + assert len(did_list) > 0 + # Get props + test_list = did_list[:6] + prop_values = await miot_http.get_props_async(params=[ + {'did': did, 'siid': 2, 'piid': 1} for did in test_list]) + print(f'test did list, {len(test_list)}, {test_list}\n') + print(f'test result: {len(prop_values)}, {prop_values}\n') + + +@pytest.mark.skip(reason='skip danger operation') +@pytest.mark.asyncio +@pytest.mark.dependency() +async def test_miot_cloud_set_prop_async( + test_cache_path: str, + test_cloud_server: str, + test_domain_oauth2: str, + test_domain_user_info: str +): + """ + WARNING: This test case will control the actual device and is not enabled + by default. You can uncomment @pytest.mark.skip to enable it. + """ + from miot.const import OAUTH2_CLIENT_ID + from miot.miot_cloud import MIoTHttpClient + from miot.miot_storage import MIoTStorage + print('') # separate from previous output + + miot_storage = MIoTStorage(test_cache_path) + oauth_info = await miot_storage.load_async( + domain=test_domain_oauth2, name=test_cloud_server, type_=dict) + assert isinstance(oauth_info, dict) and 'access_token' in oauth_info + miot_http = MIoTHttpClient( + cloud_server=test_cloud_server, client_id=OAUTH2_CLIENT_ID, + access_token=oauth_info['access_token']) + + # Load devices + local_devices = await miot_storage.load_async( + domain=test_domain_user_info, + name=f'devices_{test_cloud_server}', type_=dict) + assert isinstance(local_devices, dict) + assert len(local_devices) > 0 + # Set prop + # Find central hub gateway, control its indicator light switch + # You can replace it with the device you want to control. + test_did = '' + for did, dev in local_devices.items(): + if dev['model'] == 'xiaomi.gateway.hub1': + test_did = did + break + assert test_did != '', 'no central hub gateway found' + result = await miot_http.set_prop_async(params=[{ + 'did': test_did, 'siid': 3, 'piid': 1, 'value': False}]) + print(f'test did, {test_did}, prop.3.1=False -> {result}\n') + await asyncio.sleep(1) + result = await miot_http.set_prop_async(params=[{ + 'did': test_did, 'siid': 3, 'piid': 1, 'value': True}]) + print(f'test did, {test_did}, prop.3.1=True -> {result}\n') + + +@pytest.mark.skip(reason='skip danger operation') +@pytest.mark.asyncio +@pytest.mark.dependency() +async def test_miot_cloud_action_async( + test_cache_path: str, + test_cloud_server: str, + test_domain_oauth2: str, + test_domain_user_info: str +): + """ + WARNING: This test case will control the actual device and is not enabled + by default. You can uncomment @pytest.mark.skip to enable it. + """ + from miot.const import OAUTH2_CLIENT_ID + from miot.miot_cloud import MIoTHttpClient + from miot.miot_storage import MIoTStorage + print('') # separate from previous output + + miot_storage = MIoTStorage(test_cache_path) + oauth_info = await miot_storage.load_async( + domain=test_domain_oauth2, name=test_cloud_server, type_=dict) + assert isinstance(oauth_info, dict) and 'access_token' in oauth_info + miot_http = MIoTHttpClient( + cloud_server=test_cloud_server, client_id=OAUTH2_CLIENT_ID, + access_token=oauth_info['access_token']) + + # Load devices + local_devices = await miot_storage.load_async( + domain=test_domain_user_info, + name=f'devices_{test_cloud_server}', type_=dict) + assert isinstance(local_devices, dict) + assert len(local_devices) > 0 + # Action + # Find central hub gateway, trigger its virtual events + # You can replace it with the device you want to control. + test_did = '' + for did, dev in local_devices.items(): + if dev['model'] == 'xiaomi.gateway.hub1': + test_did = did + break + assert test_did != '', 'no central hub gateway found' + result = await miot_http.action_async( + did=test_did, siid=4, aiid=1, + in_list=[{'piid': 1, 'value': 'hello world.'}]) + print(f'test did, {test_did}, action.4.1 -> {result}\n') From 045528fbf2e7d0835da9a974552bdc43c2915e54 Mon Sep 17 00:00:00 2001 From: Paul Shawn <32349595+topsworld@users.noreply.github.com> Date: Mon, 13 Jan 2025 10:54:18 +0800 Subject: [PATCH 05/14] style: using logging for test case log print (#636) * style: using logging for test case log print * fix: fix miot cloud test case resource error --- test/check_rule_format.py | 43 ++++++++++------ test/conftest.py | 29 +++++++++-- test/test_cloud.py | 103 ++++++++++++++++++++++---------------- test/test_common.py | 2 +- test/test_lan.py | 13 +++-- test/test_mdns.py | 12 +++-- test/test_network.py | 13 +++-- test/test_spec.py | 9 ++-- test/test_storage.py | 17 ++++--- 9 files changed, 153 insertions(+), 88 deletions(-) diff --git a/test/check_rule_format.py b/test/check_rule_format.py index 3c20afa..5075367 100644 --- a/test/check_rule_format.py +++ b/test/check_rule_format.py @@ -1,11 +1,14 @@ # -*- coding: utf-8 -*- """Test rule format.""" import json +import logging from os import listdir, path from typing import Optional import pytest import yaml +_LOGGER = logging.getLogger(__name__) + ROOT_PATH: str = path.dirname(path.abspath(__file__)) TRANS_RELATIVE_PATH: str = path.join( ROOT_PATH, '../custom_components/xiaomi_home/translations') @@ -27,10 +30,10 @@ def load_json_file(file_path: str) -> Optional[dict]: with open(file_path, 'r', encoding='utf-8') as file: return json.load(file) except FileNotFoundError: - print(file_path, 'is not found.') + _LOGGER.info('%s is not found.', file_path,) return None except json.JSONDecodeError: - print(file_path, 'is not a valid JSON file.') + _LOGGER.info('%s is not a valid JSON file.', file_path) return None @@ -44,10 +47,10 @@ def load_yaml_file(file_path: str) -> Optional[dict]: with open(file_path, 'r', encoding='utf-8') as file: return yaml.safe_load(file) except FileNotFoundError: - print(file_path, 'is not found.') + _LOGGER.info('%s is not found.', file_path) return None except yaml.YAMLError: - print(file_path, 'is not a valid YAML file.') + _LOGGER.info('%s, is not a valid YAML file.', file_path) return None @@ -116,37 +119,43 @@ def bool_trans(d: dict) -> bool: return False default_trans: dict = d['translate'].pop('default') if not default_trans: - print('default trans is empty') + _LOGGER.info('default trans is empty') return False default_keys: set[str] = set(default_trans.keys()) for key, trans in d['translate'].items(): trans_keys: set[str] = set(trans.keys()) if set(trans.keys()) != default_keys: - print('bool trans inconsistent', key, default_keys, trans_keys) + _LOGGER.info( + 'bool trans inconsistent, %s, %s, %s', + key, default_keys, trans_keys) return False return True def compare_dict_structure(dict1: dict, dict2: dict) -> bool: if not isinstance(dict1, dict) or not isinstance(dict2, dict): - print('invalid type') + _LOGGER.info('invalid type') return False if dict1.keys() != dict2.keys(): - print('inconsistent key values, ', dict1.keys(), dict2.keys()) + _LOGGER.info( + 'inconsistent key values, %s, %s', dict1.keys(), dict2.keys()) return False for key in dict1: if isinstance(dict1[key], dict) and isinstance(dict2[key], dict): if not compare_dict_structure(dict1[key], dict2[key]): - print('inconsistent key values, dict, ', key) + _LOGGER.info( + 'inconsistent key values, dict, %s', key) return False elif isinstance(dict1[key], list) and isinstance(dict2[key], list): if not all( isinstance(i, type(j)) for i, j in zip(dict1[key], dict2[key])): - print('inconsistent key values, list, ', key) + _LOGGER.info( + 'inconsistent key values, list, %s', key) return False elif not isinstance(dict1[key], type(dict2[key])): - print('inconsistent key values, type, ', key) + _LOGGER.info( + 'inconsistent key values, type, %s', key) return False return True @@ -239,7 +248,8 @@ def test_miot_lang_integrity(): compare_dict: dict = load_json_file( path.join(TRANS_RELATIVE_PATH, name)) if not compare_dict_structure(default_dict, compare_dict): - print('compare_dict_structure failed /translations, ', name) + _LOGGER.info( + 'compare_dict_structure failed /translations, %s', name) assert False # Check i18n files structure default_dict = load_json_file( @@ -248,7 +258,8 @@ def test_miot_lang_integrity(): compare_dict: dict = load_json_file( path.join(MIOT_I18N_RELATIVE_PATH, name)) if not compare_dict_structure(default_dict, compare_dict): - print('compare_dict_structure failed /miot/i18n, ', name) + _LOGGER.info( + 'compare_dict_structure failed /miot/i18n, %s', name) assert False @@ -284,10 +295,10 @@ def test_miot_data_sort(): def test_sort_spec_data(): sort_data: dict = sort_bool_trans(file_path=SPEC_BOOL_TRANS_FILE) save_json_file(file_path=SPEC_BOOL_TRANS_FILE, data=sort_data) - print(SPEC_BOOL_TRANS_FILE, 'formatted.') + _LOGGER.info('%s formatted.', SPEC_BOOL_TRANS_FILE) sort_data = sort_multi_lang(file_path=SPEC_MULTI_LANG_FILE) save_json_file(file_path=SPEC_MULTI_LANG_FILE, data=sort_data) - print(SPEC_MULTI_LANG_FILE, 'formatted.') + _LOGGER.info('%s formatted.', SPEC_MULTI_LANG_FILE) sort_data = sort_spec_filter(file_path=SPEC_FILTER_FILE) save_json_file(file_path=SPEC_FILTER_FILE, data=sort_data) - print(SPEC_FILTER_FILE, 'formatted.') + _LOGGER.info('%s formatted.', SPEC_FILTER_FILE) diff --git a/test/conftest.py b/test/conftest.py index 63464cd..48f0794 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- """Pytest fixtures.""" +import logging import random import shutil import pytest @@ -17,6 +18,21 @@ TEST_CLOUD_SERVER: str = 'cn' DOMAIN_OAUTH2: str = 'oauth2_info' DOMAIN_USER_INFO: str = 'user_info' +_LOGGER = logging.getLogger(__name__) + + +@pytest.fixture(scope='session', autouse=True) +def set_logger(): + logger = logging.getLogger() + logger.setLevel(logging.INFO) + console_handler = logging.StreamHandler() + console_handler.setLevel(logging.INFO) + formatter = logging.Formatter( + '%(asctime)s - %(name)s - %(levelname)s - %(message)s') + console_handler.setFormatter(formatter) + logger.addHandler(console_handler) + _LOGGER.info('set logger, %s', logger) + @pytest.fixture(scope='session', autouse=True) def load_py_file(): @@ -41,28 +57,28 @@ def load_py_file(): TEST_ROOT_PATH, '../custom_components/xiaomi_home/miot', file_name), path.join(TEST_FILES_PATH, file_name)) - print('\nloaded test py files, ', file_list) + _LOGGER.info('\nloaded test py files, %s', file_list) # Copy spec files to test folder shutil.copytree( src=path.join( TEST_ROOT_PATH, '../custom_components/xiaomi_home/miot/specs'), dst=path.join(TEST_FILES_PATH, 'specs'), dirs_exist_ok=True) - print('loaded spec test folder, specs') + _LOGGER.info('loaded spec test folder, specs') # Copy lan files to test folder shutil.copytree( src=path.join( TEST_ROOT_PATH, '../custom_components/xiaomi_home/miot/lan'), dst=path.join(TEST_FILES_PATH, 'lan'), dirs_exist_ok=True) - print('loaded lan test folder, lan') + _LOGGER.info('loaded lan test folder, lan') # Copy i18n files to test folder shutil.copytree( src=path.join( TEST_ROOT_PATH, '../custom_components/xiaomi_home/miot/i18n'), dst=path.join(TEST_FILES_PATH, 'i18n'), dirs_exist_ok=True) - print('loaded i18n test folder, i18n') + _LOGGER.info('loaded i18n test folder, i18n') yield @@ -127,6 +143,11 @@ def test_domain_oauth2() -> str: return DOMAIN_OAUTH2 +@pytest.fixture(scope='session') +def test_name_uuid() -> str: + return f'{TEST_CLOUD_SERVER}_uuid' + + @pytest.fixture(scope='session') def test_domain_user_info() -> str: return DOMAIN_USER_INFO diff --git a/test/test_cloud.py b/test/test_cloud.py index acece12..410420c 100755 --- a/test/test_cloud.py +++ b/test/test_cloud.py @@ -1,11 +1,13 @@ # -*- coding: utf-8 -*- """Unit test for miot_cloud.py.""" import asyncio +import logging import time import webbrowser import pytest # pylint: disable=import-outside-toplevel, unused-argument +_LOGGER = logging.getLogger(__name__) @pytest.mark.asyncio @@ -15,18 +17,18 @@ async def test_miot_oauth_async( test_cloud_server: str, test_oauth2_redirect_url: str, test_domain_oauth2: str, - test_uuid: str + test_uuid: str, + test_name_uuid: str ) -> dict: from miot.const import OAUTH2_CLIENT_ID from miot.miot_cloud import MIoTOauthClient from miot.miot_storage import MIoTStorage - print('') # separate from previous output miot_storage = MIoTStorage(test_cache_path) local_uuid = await miot_storage.load_async( - domain=test_domain_oauth2, name=f'{test_cloud_server}_uuid', type_=str) + domain=test_domain_oauth2, name=test_name_uuid, type_=str) uuid = str(local_uuid or test_uuid) - print(f'uuid: {uuid}') + _LOGGER.info('uuid: %s', uuid) miot_oauth = MIoTOauthClient( client_id=OAUTH2_CLIENT_ID, redirect_url=test_oauth2_redirect_url, @@ -42,13 +44,13 @@ async def test_miot_oauth_async( and 'expires_ts' in load_info and load_info['expires_ts'] > int(time.time()) ): - print(f'load oauth info, {load_info}') + _LOGGER.info('load oauth info, %s', load_info) oauth_info = load_info if oauth_info is None: # gen oauth url auth_url: str = miot_oauth.gen_auth_url() assert isinstance(auth_url, str) - print('auth url: ', auth_url) + _LOGGER.info('auth url: %s', auth_url) # get code webbrowser.open(auth_url) code: str = input('input code: ') @@ -57,22 +59,24 @@ async def test_miot_oauth_async( res_obj = await miot_oauth.get_access_token_async(code=code) assert res_obj is not None oauth_info = res_obj - print(f'get_access_token result: {res_obj}') + _LOGGER.info('get_access_token result: %s', res_obj) rc = await miot_storage.save_async( test_domain_oauth2, test_cloud_server, oauth_info) assert rc - print('save oauth info') + _LOGGER.info('save oauth info') rc = await miot_storage.save_async( - test_domain_oauth2, f'{test_cloud_server}_uuid', uuid) + test_domain_oauth2, test_name_uuid, uuid) assert rc - print('save uuid') + _LOGGER.info('save uuid') access_token = oauth_info.get('access_token', None) assert isinstance(access_token, str) - print(f'access_token: {access_token}') + _LOGGER.info('access_token: %s', access_token) refresh_token = oauth_info.get('refresh_token', None) assert isinstance(refresh_token, str) - print(f'refresh_token: {refresh_token}') + _LOGGER.info('refresh_token: %s', refresh_token) + + await miot_oauth.deinit_async() return oauth_info @@ -82,16 +86,16 @@ async def test_miot_oauth_refresh_token( test_cache_path: str, test_cloud_server: str, test_oauth2_redirect_url: str, - test_domain_oauth2: str + test_domain_oauth2: str, + test_name_uuid: str ): from miot.const import OAUTH2_CLIENT_ID from miot.miot_cloud import MIoTOauthClient from miot.miot_storage import MIoTStorage - print('') # separate from previous output miot_storage = MIoTStorage(test_cache_path) uuid = await miot_storage.load_async( - domain=test_domain_oauth2, name=f'{test_cloud_server}_uuid', type_=str) + domain=test_domain_oauth2, name=test_name_uuid, type_=str) assert isinstance(uuid, str) oauth_info = await miot_storage.load_async( domain=test_domain_oauth2, name=test_cloud_server, type_=dict) @@ -100,7 +104,7 @@ async def test_miot_oauth_refresh_token( assert 'refresh_token' in oauth_info assert 'expires_ts' in oauth_info remaining_time = oauth_info['expires_ts'] - int(time.time()) - print(f'token remaining valid time: {remaining_time}s') + _LOGGER.info('token remaining valid time: %ss', remaining_time) # Refresh token miot_oauth = MIoTOauthClient( client_id=OAUTH2_CLIENT_ID, @@ -117,12 +121,14 @@ async def test_miot_oauth_refresh_token( assert 'expires_ts' in update_info remaining_time = update_info['expires_ts'] - int(time.time()) assert remaining_time > 0 - print(f'refresh token, remaining valid time: {remaining_time}s') + _LOGGER.info('refresh token, remaining valid time: %ss', remaining_time) # Save token rc = await miot_storage.save_async( test_domain_oauth2, test_cloud_server, update_info) assert rc - print(f'refresh token success, {update_info}') + _LOGGER.info('refresh token success, %s', update_info) + + await miot_oauth.deinit_async() @pytest.mark.asyncio @@ -135,7 +141,6 @@ async def test_miot_cloud_get_nickname_async( from miot.const import OAUTH2_CLIENT_ID from miot.miot_cloud import MIoTHttpClient from miot.miot_storage import MIoTStorage - print('') # separate from previous output miot_storage = MIoTStorage(test_cache_path) oauth_info = await miot_storage.load_async( @@ -149,7 +154,9 @@ async def test_miot_cloud_get_nickname_async( user_info = await miot_http.get_user_info_async() assert isinstance(user_info, dict) and 'miliaoNick' in user_info nickname = user_info['miliaoNick'] - print(f'your nickname: {nickname}\n') + _LOGGER.info('your nickname: %s', nickname) + + await miot_http.deinit_async() @pytest.mark.asyncio @@ -163,7 +170,6 @@ async def test_miot_cloud_get_uid_async( from miot.const import OAUTH2_CLIENT_ID from miot.miot_cloud import MIoTHttpClient from miot.miot_storage import MIoTStorage - print('') # separate from previous output miot_storage = MIoTStorage(test_cache_path) oauth_info = await miot_storage.load_async( @@ -175,13 +181,15 @@ async def test_miot_cloud_get_uid_async( uid = await miot_http.get_uid_async() assert isinstance(uid, str) - print(f'your uid: {uid}\n') + _LOGGER.info('your uid: %s', uid) # Save uid rc = await miot_storage.save_async( domain=test_domain_user_info, name=f'uid_{test_cloud_server}', data=uid) assert rc + await miot_http.deinit_async() + @pytest.mark.asyncio @pytest.mark.dependency() @@ -194,7 +202,6 @@ async def test_miot_cloud_get_homeinfos_async( from miot.const import OAUTH2_CLIENT_ID from miot.miot_cloud import MIoTHttpClient from miot.miot_storage import MIoTStorage - print('') # separate from previous output miot_storage = MIoTStorage(test_cache_path) oauth_info = await miot_storage.load_async( @@ -219,13 +226,15 @@ async def test_miot_cloud_get_homeinfos_async( domain=test_domain_user_info, name=f'uid_{test_cloud_server}', type_=str) assert uid == uid2 - print(f'your uid: {uid}\n') + _LOGGER.info('your uid: %s', uid) # Get homes home_list = homeinfos.get('home_list', {}) - print(f'your home_list: {home_list}\n') + _LOGGER.info('your home_list: ,%s', home_list) # Get share homes share_home_list = homeinfos.get('share_home_list', {}) - print(f'your share_home_list: {share_home_list}\n') + _LOGGER.info('your share_home_list: %s', share_home_list) + + await miot_http.deinit_async() @pytest.mark.asyncio @@ -239,7 +248,6 @@ async def test_miot_cloud_get_devices_async( from miot.const import OAUTH2_CLIENT_ID from miot.miot_cloud import MIoTHttpClient from miot.miot_storage import MIoTStorage - print('') # separate from previous output miot_storage = MIoTStorage(test_cache_path) oauth_info = await miot_storage.load_async( @@ -261,13 +269,13 @@ async def test_miot_cloud_get_devices_async( domain=test_domain_user_info, name=f'uid_{test_cloud_server}', type_=str) assert uid == uid2 - print(f'your uid: {uid}\n') + _LOGGER.info('your uid: %s', uid) # Get homes homes = devices['homes'] - print(f'your homes: {homes}\n') + _LOGGER.info('your homes: %s', homes) # Get devices devices = devices['devices'] - print(f'your devices count: {len(devices)}\n') + _LOGGER.info('your devices count: %s', len(devices)) # Storage homes and devices rc = await miot_storage.save_async( domain=test_domain_user_info, @@ -278,6 +286,8 @@ async def test_miot_cloud_get_devices_async( name=f'devices_{test_cloud_server}', data=devices) assert rc + await miot_http.deinit_async() + @pytest.mark.asyncio @pytest.mark.dependency() @@ -290,7 +300,6 @@ async def test_miot_cloud_get_devices_with_dids_async( from miot.const import OAUTH2_CLIENT_ID from miot.miot_cloud import MIoTHttpClient from miot.miot_storage import MIoTStorage - print('') # separate from previous output miot_storage = MIoTStorage(test_cache_path) oauth_info = await miot_storage.load_async( @@ -312,8 +321,11 @@ async def test_miot_cloud_get_devices_with_dids_async( devices_info = await miot_http.get_devices_with_dids_async( dids=test_list) assert isinstance(devices_info, dict) - print(f'test did list, {len(test_list)}, {test_list}\n') - print(f'test result: {len(devices_info)}, {list(devices_info.keys())}\n') + _LOGGER.info('test did list, %s, %s', len(test_list), test_list) + _LOGGER.info( + 'test result: %s, %s', len(devices_info), list(devices_info.keys())) + + await miot_http.deinit_async() @pytest.mark.asyncio @@ -327,7 +339,6 @@ async def test_miot_cloud_get_prop_async( from miot.const import OAUTH2_CLIENT_ID from miot.miot_cloud import MIoTHttpClient from miot.miot_storage import MIoTStorage - print('') # separate from previous output miot_storage = MIoTStorage(test_cache_path) oauth_info = await miot_storage.load_async( @@ -349,7 +360,9 @@ async def test_miot_cloud_get_prop_async( for did in test_list: prop_value = await miot_http.get_prop_async(did=did, siid=2, piid=1) device_name = local_devices[did]['name'] - print(f'{device_name}({did}), prop.2.1: {prop_value}\n') + _LOGGER.info('%s(%s), prop.2.1: %s', device_name, did, prop_value) + + await miot_http.deinit_async() @pytest.mark.asyncio @@ -363,7 +376,6 @@ async def test_miot_cloud_get_props_async( from miot.const import OAUTH2_CLIENT_ID from miot.miot_cloud import MIoTHttpClient from miot.miot_storage import MIoTStorage - print('') # separate from previous output miot_storage = MIoTStorage(test_cache_path) oauth_info = await miot_storage.load_async( @@ -384,8 +396,11 @@ async def test_miot_cloud_get_props_async( test_list = did_list[:6] prop_values = await miot_http.get_props_async(params=[ {'did': did, 'siid': 2, 'piid': 1} for did in test_list]) - print(f'test did list, {len(test_list)}, {test_list}\n') - print(f'test result: {len(prop_values)}, {prop_values}\n') + + _LOGGER.info('test did list, %s, %s', len(test_list), test_list) + _LOGGER.info('test result, %s, %s', len(prop_values), prop_values) + + await miot_http.deinit_async() @pytest.mark.skip(reason='skip danger operation') @@ -404,7 +419,6 @@ async def test_miot_cloud_set_prop_async( from miot.const import OAUTH2_CLIENT_ID from miot.miot_cloud import MIoTHttpClient from miot.miot_storage import MIoTStorage - print('') # separate from previous output miot_storage = MIoTStorage(test_cache_path) oauth_info = await miot_storage.load_async( @@ -431,11 +445,13 @@ async def test_miot_cloud_set_prop_async( assert test_did != '', 'no central hub gateway found' result = await miot_http.set_prop_async(params=[{ 'did': test_did, 'siid': 3, 'piid': 1, 'value': False}]) - print(f'test did, {test_did}, prop.3.1=False -> {result}\n') + _LOGGER.info('test did, %s, prop.3.1=False -> %s', test_did, result) await asyncio.sleep(1) result = await miot_http.set_prop_async(params=[{ 'did': test_did, 'siid': 3, 'piid': 1, 'value': True}]) - print(f'test did, {test_did}, prop.3.1=True -> {result}\n') + _LOGGER.info('test did, %s, prop.3.1=True -> %s', test_did, result) + + await miot_http.deinit_async() @pytest.mark.skip(reason='skip danger operation') @@ -454,7 +470,6 @@ async def test_miot_cloud_action_async( from miot.const import OAUTH2_CLIENT_ID from miot.miot_cloud import MIoTHttpClient from miot.miot_storage import MIoTStorage - print('') # separate from previous output miot_storage = MIoTStorage(test_cache_path) oauth_info = await miot_storage.load_async( @@ -482,4 +497,6 @@ async def test_miot_cloud_action_async( result = await miot_http.action_async( did=test_did, siid=4, aiid=1, in_list=[{'piid': 1, 'value': 'hello world.'}]) - print(f'test did, {test_did}, action.4.1 -> {result}\n') + _LOGGER.info('test did, %s, action.4.1 -> %s', test_did, result) + + await miot_http.deinit_async() diff --git a/test/test_common.py b/test/test_common.py index a6d68bc..18a4736 100644 --- a/test/test_common.py +++ b/test/test_common.py @@ -18,7 +18,7 @@ def test_miot_matcher(): if not matcher.get(topic=f'test/+/{l2}'): matcher[f'test/+/{l2}'] = f'test/+/{l2}' # Match - match_result: list[(str, dict)] = list(matcher.iter_all_nodes()) + match_result: list[str] = list(matcher.iter_all_nodes()) assert len(match_result) == 120 match_result: list[str] = list(matcher.iter_match(topic='test/1/1')) assert len(match_result) == 3 diff --git a/test/test_lan.py b/test/test_lan.py index a6051c0..a2861cc 100755 --- a/test/test_lan.py +++ b/test/test_lan.py @@ -1,11 +1,14 @@ # -*- coding: utf-8 -*- """Unit test for miot_lan.py.""" +import logging from typing import Any import pytest import asyncio from zeroconf import IPVersion from zeroconf.asyncio import AsyncZeroconf +_LOGGER = logging.getLogger(__name__) + # pylint: disable=import-outside-toplevel, unused-argument @@ -67,7 +70,7 @@ async def test_lan_async(test_devices: dict): miot_network = MIoTNetwork() await miot_network.init_async() - print('miot_network, ', miot_network.network_info) + _LOGGER.info('miot_network, %s', miot_network.network_info) mips_service = MipsService( aiozc=AsyncZeroconf(ip_version=IPVersion.V4Only)) await mips_service.init_async() @@ -81,7 +84,7 @@ async def test_lan_async(test_devices: dict): await miot_lan.vote_for_lan_ctrl_async(key='test', vote=True) async def device_state_change(did: str, state: dict, ctx: Any): - print('device state change, ', did, state) + _LOGGER.info('device state change, %s, %s', did, state) if did != test_did: return if ( @@ -91,10 +94,10 @@ async def test_lan_async(test_devices: dict): # Test sub prop miot_lan.sub_prop( did=did, siid=3, piid=1, handler=lambda msg, ctx: - print(f'sub prop.3.1 msg, {did}={msg}')) + _LOGGER.info('sub prop.3.1 msg, %s=%s', did, msg)) miot_lan.sub_prop( did=did, handler=lambda msg, ctx: - print(f'sub all device msg, {did}={msg}')) + _LOGGER.info('sub all device msg, %s=%s', did, msg)) evt_push_available.set() else: # miot_lan.unsub_prop(did=did, siid=3, piid=1) @@ -102,7 +105,7 @@ async def test_lan_async(test_devices: dict): evt_push_unavailable.set() async def lan_state_change(state: bool): - print('lan state change, ', state) + _LOGGER.info('lan state change, %s', state) if not state: return miot_lan.update_devices(devices={ diff --git a/test/test_mdns.py b/test/test_mdns.py index ddf6a10..82cf477 100755 --- a/test/test_mdns.py +++ b/test/test_mdns.py @@ -1,9 +1,12 @@ # -*- coding: utf-8 -*- """Unit test for miot_mdns.py.""" +import logging import pytest from zeroconf import IPVersion from zeroconf.asyncio import AsyncZeroconf +_LOGGER = logging.getLogger(__name__) + # pylint: disable=import-outside-toplevel, unused-argument @@ -13,7 +16,7 @@ async def test_service_loop_async(): async def on_service_state_change( group_id: str, state: MipsServiceState, data: MipsServiceData): - print( + _LOGGER.info( 'on_service_state_change, %s, %s, %s', group_id, state, data) async with AsyncZeroconf(ip_version=IPVersion.V4Only) as aiozc: @@ -21,8 +24,9 @@ async def test_service_loop_async(): mips_service.sub_service_change('test', '*', on_service_state_change) await mips_service.init_async() services_detail = mips_service.get_services() - print('get all service, ', services_detail.keys()) + _LOGGER.info('get all service, %s', services_detail.keys()) for name, data in services_detail.items(): - print( - '\tinfo, ', name, data['did'], data['addresses'], data['port']) + _LOGGER.info( + '\tinfo, %s, %s, %s, %s', + name, data['did'], data['addresses'], data['port']) await mips_service.deinit_async() diff --git a/test/test_network.py b/test/test_network.py index aa81a4e..f59ddb2 100755 --- a/test/test_network.py +++ b/test/test_network.py @@ -1,8 +1,11 @@ # -*- coding: utf-8 -*- """Unit test for miot_network.py.""" +import logging import pytest import asyncio +_LOGGER = logging.getLogger(__name__) + # pylint: disable=import-outside-toplevel, unused-argument @@ -12,16 +15,16 @@ async def test_network_monitor_loop_async(): miot_net = MIoTNetwork() async def on_network_status_changed(status: bool): - print(f'on_network_status_changed, {status}') + _LOGGER.info('on_network_status_changed, %s', status) miot_net.sub_network_status(key='test', handler=on_network_status_changed) async def on_network_info_changed( status: InterfaceStatus, info: NetworkInfo): - print(f'on_network_info_changed, {status}, {info}') + _LOGGER.info('on_network_info_changed, %s, %s', status, info) miot_net.sub_network_info(key='test', handler=on_network_info_changed) - await miot_net.init_async(3) + await miot_net.init_async() await asyncio.sleep(3) - print(f'net status: {miot_net.network_status}') - print(f'net info: {miot_net.network_info}') + _LOGGER.info('net status: %s', miot_net.network_status) + _LOGGER.info('net info: %s', miot_net.network_info) await miot_net.deinit_async() diff --git a/test/test_spec.py b/test/test_spec.py index 57ccbb6..248e9d8 100755 --- a/test/test_spec.py +++ b/test/test_spec.py @@ -1,11 +1,14 @@ # -*- coding: utf-8 -*- """Unit test for miot_spec.py.""" import json +import logging import random import time from urllib.request import Request, urlopen import pytest +_LOGGER = logging.getLogger(__name__) + # pylint: disable=import-outside-toplevel, unused-argument @@ -79,10 +82,10 @@ async def test_spec_random_parse_async(test_cache_path, test_lang): storage = MIoTStorage(test_cache_path) spec_parser = MIoTSpecParser(lang=test_lang, storage=storage) await spec_parser.init_async() - start_ts: int = time.time()*1000 + start_ts = time.time()*1000 for index in test_urn_index: urn: str = test_urns[int(index)] result = await spec_parser.parse(urn=urn, skip_cache=True) assert result is not None - end_ts: int = time.time()*1000 - print(f'takes time, {test_count}, {end_ts-start_ts}') + end_ts = time.time()*1000 + _LOGGER.info('takes time, %s, %s', test_count, end_ts-start_ts) diff --git a/test/test_storage.py b/test/test_storage.py index 76ec510..ace0c53 100755 --- a/test/test_storage.py +++ b/test/test_storage.py @@ -1,9 +1,12 @@ # -*- coding: utf-8 -*- """Unit test for miot_storage.py.""" import asyncio +import logging from os import path import pytest +_LOGGER = logging.getLogger(__name__) + # pylint: disable=import-outside-toplevel, unused-argument @@ -101,7 +104,7 @@ async def test_multi_task_load_async(test_cache_path): for _ in range(task_count): task_list.append(asyncio.create_task(storage.load_async( domain=test_domain, name=name, type_=dict))) - print(f'\ntask count, {len(task_list)}') + _LOGGER.info('task count, %s', len(task_list)) result: list = await asyncio.gather(*task_list) assert None not in result @@ -178,28 +181,28 @@ async def test_user_config_async( config=config_update, replace=True) assert (config_replace := await storage.load_user_config_async( uid=test_uid, cloud_server=test_cloud_server)) == config_update - print('replace result, ', config_replace) + _LOGGER.info('replace result, %s', config_replace) # Test query query_keys = list(config_base.keys()) - print('query keys, ', query_keys) + _LOGGER.info('query keys, %s', query_keys) query_result = await storage.load_user_config_async( uid=test_uid, cloud_server=test_cloud_server, keys=query_keys) - print('query result 1, ', query_result) + _LOGGER.info('query result 1, %s', query_result) assert await storage.update_user_config_async( uid=test_uid, cloud_server=test_cloud_server, config=config_base, replace=True) query_result = await storage.load_user_config_async( uid=test_uid, cloud_server=test_cloud_server, keys=query_keys) - print('query result 2, ', query_result) + _LOGGER.info('query result 2, %s', query_result) query_result = await storage.load_user_config_async( uid=test_uid, cloud_server=test_cloud_server) - print('query result all, ', query_result) + _LOGGER.info('query result all, %s', query_result) # Remove config assert await storage.update_user_config_async( uid=test_uid, cloud_server=test_cloud_server, config=None) query_result = await storage.load_user_config_async( uid=test_uid, cloud_server=test_cloud_server) - print('remove result, ', query_result) + _LOGGER.info('remove result, %s', query_result) # Remove domain assert await storage.remove_domain_async(domain='miot_config') From 3b89536bda64887356d9e1a6c6c65e109d1b99ba Mon Sep 17 00:00:00 2001 From: Paul Shawn <32349595+topsworld@users.noreply.github.com> Date: Mon, 13 Jan 2025 11:23:53 +0800 Subject: [PATCH 06/14] fix: fix miot cloud and mdns error (#637) * fix: fix miot cloud state error * style: code format --- custom_components/xiaomi_home/miot/miot_cloud.py | 2 +- custom_components/xiaomi_home/miot/miot_mdns.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/xiaomi_home/miot/miot_cloud.py b/custom_components/xiaomi_home/miot/miot_cloud.py index e70930f..98d5204 100644 --- a/custom_components/xiaomi_home/miot/miot_cloud.py +++ b/custom_components/xiaomi_home/miot/miot_cloud.py @@ -106,7 +106,7 @@ class MIoTOauthClient: @property def state(self) -> str: - return self.state + return self._state async def deinit_async(self) -> None: if self._session and not self._session.closed: diff --git a/custom_components/xiaomi_home/miot/miot_mdns.py b/custom_components/xiaomi_home/miot/miot_mdns.py index a6b3002..ba661aa 100644 --- a/custom_components/xiaomi_home/miot/miot_mdns.py +++ b/custom_components/xiaomi_home/miot/miot_mdns.py @@ -117,7 +117,7 @@ class MipsServiceData: self.type = service_info.type self.server = service_info.server or '' # Parse profile - self.did = str(int.from_bytes(self.profile_bin[1:9])) + self.did = str(int.from_bytes(self.profile_bin[1:9], byteorder='big')) self.group_id = binascii.hexlify( self.profile_bin[9:17][::-1]).decode('utf-8') self.role = int(self.profile_bin[20] >> 4) From 72d8977e6ebdf0e5570fb1f4e3c7399fe52aba22 Mon Sep 17 00:00:00 2001 From: Paul Shawn <32349595+topsworld@users.noreply.github.com> Date: Mon, 13 Jan 2025 22:20:48 +0800 Subject: [PATCH 07/14] test: add test case for user cert (#638) --- test/conftest.py | 31 +++++-- test/test_cloud.py | 197 ++++++++++++++++++++++++++++++++------------- 2 files changed, 166 insertions(+), 62 deletions(-) diff --git a/test/conftest.py b/test/conftest.py index 48f0794..9e9160a 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -15,8 +15,7 @@ TEST_LANG: str = 'zh-Hans' TEST_UID: str = '123456789' TEST_CLOUD_SERVER: str = 'cn' -DOMAIN_OAUTH2: str = 'oauth2_info' -DOMAIN_USER_INFO: str = 'user_info' +DOMAIN_CLOUD_CACHE: str = 'cloud_cache' _LOGGER = logging.getLogger(__name__) @@ -139,8 +138,18 @@ def test_cloud_server() -> str: @pytest.fixture(scope='session') -def test_domain_oauth2() -> str: - return DOMAIN_OAUTH2 +def test_domain_cloud_cache() -> str: + return DOMAIN_CLOUD_CACHE + + +@pytest.fixture(scope='session') +def test_name_oauth2_info() -> str: + return f'{TEST_CLOUD_SERVER}_oauth2_info' + + +@pytest.fixture(scope='session') +def test_name_uid() -> str: + return f'{TEST_CLOUD_SERVER}_uid' @pytest.fixture(scope='session') @@ -149,5 +158,15 @@ def test_name_uuid() -> str: @pytest.fixture(scope='session') -def test_domain_user_info() -> str: - return DOMAIN_USER_INFO +def test_name_rd_did() -> str: + return f'{TEST_CLOUD_SERVER}_rd_did' + + +@pytest.fixture(scope='session') +def test_name_homes() -> str: + return f'{TEST_CLOUD_SERVER}_homes' + + +@pytest.fixture(scope='session') +def test_name_devices() -> str: + return f'{TEST_CLOUD_SERVER}_devices' diff --git a/test/test_cloud.py b/test/test_cloud.py index 410420c..f1c74b9 100755 --- a/test/test_cloud.py +++ b/test/test_cloud.py @@ -16,8 +16,9 @@ async def test_miot_oauth_async( test_cache_path: str, test_cloud_server: str, test_oauth2_redirect_url: str, - test_domain_oauth2: str, test_uuid: str, + test_domain_cloud_cache: str, + test_name_oauth2_info: str, test_name_uuid: str ) -> dict: from miot.const import OAUTH2_CLIENT_ID @@ -26,7 +27,7 @@ async def test_miot_oauth_async( miot_storage = MIoTStorage(test_cache_path) local_uuid = await miot_storage.load_async( - domain=test_domain_oauth2, name=test_name_uuid, type_=str) + domain=test_domain_cloud_cache, name=test_name_uuid, type_=str) uuid = str(local_uuid or test_uuid) _LOGGER.info('uuid: %s', uuid) miot_oauth = MIoTOauthClient( @@ -37,7 +38,7 @@ async def test_miot_oauth_async( oauth_info = None load_info = await miot_storage.load_async( - domain=test_domain_oauth2, name=test_cloud_server, type_=dict) + domain=test_domain_cloud_cache, name=test_name_oauth2_info, type_=dict) if ( isinstance(load_info, dict) and 'access_token' in load_info @@ -61,11 +62,11 @@ async def test_miot_oauth_async( oauth_info = res_obj _LOGGER.info('get_access_token result: %s', res_obj) rc = await miot_storage.save_async( - test_domain_oauth2, test_cloud_server, oauth_info) + test_domain_cloud_cache, test_name_oauth2_info, oauth_info) assert rc _LOGGER.info('save oauth info') rc = await miot_storage.save_async( - test_domain_oauth2, test_name_uuid, uuid) + test_domain_cloud_cache, test_name_uuid, uuid) assert rc _LOGGER.info('save uuid') @@ -86,7 +87,8 @@ async def test_miot_oauth_refresh_token( test_cache_path: str, test_cloud_server: str, test_oauth2_redirect_url: str, - test_domain_oauth2: str, + test_domain_cloud_cache: str, + test_name_oauth2_info: str, test_name_uuid: str ): from miot.const import OAUTH2_CLIENT_ID @@ -95,10 +97,10 @@ async def test_miot_oauth_refresh_token( miot_storage = MIoTStorage(test_cache_path) uuid = await miot_storage.load_async( - domain=test_domain_oauth2, name=test_name_uuid, type_=str) + domain=test_domain_cloud_cache, name=test_name_uuid, type_=str) assert isinstance(uuid, str) oauth_info = await miot_storage.load_async( - domain=test_domain_oauth2, name=test_cloud_server, type_=dict) + domain=test_domain_cloud_cache, name=test_name_oauth2_info, type_=dict) assert isinstance(oauth_info, dict) assert 'access_token' in oauth_info assert 'refresh_token' in oauth_info @@ -122,9 +124,9 @@ async def test_miot_oauth_refresh_token( remaining_time = update_info['expires_ts'] - int(time.time()) assert remaining_time > 0 _LOGGER.info('refresh token, remaining valid time: %ss', remaining_time) - # Save token + # Save oauth2 info rc = await miot_storage.save_async( - test_domain_oauth2, test_cloud_server, update_info) + test_domain_cloud_cache, test_name_oauth2_info, update_info) assert rc _LOGGER.info('refresh token success, %s', update_info) @@ -136,7 +138,8 @@ async def test_miot_oauth_refresh_token( async def test_miot_cloud_get_nickname_async( test_cache_path: str, test_cloud_server: str, - test_domain_oauth2: str + test_domain_cloud_cache: str, + test_name_oauth2_info: str ): from miot.const import OAUTH2_CLIENT_ID from miot.miot_cloud import MIoTHttpClient @@ -144,7 +147,7 @@ async def test_miot_cloud_get_nickname_async( miot_storage = MIoTStorage(test_cache_path) oauth_info = await miot_storage.load_async( - domain=test_domain_oauth2, name=test_cloud_server, type_=dict) + domain=test_domain_cloud_cache, name=test_name_oauth2_info, type_=dict) assert isinstance(oauth_info, dict) and 'access_token' in oauth_info miot_http = MIoTHttpClient( cloud_server=test_cloud_server, client_id=OAUTH2_CLIENT_ID, @@ -164,8 +167,9 @@ async def test_miot_cloud_get_nickname_async( async def test_miot_cloud_get_uid_async( test_cache_path: str, test_cloud_server: str, - test_domain_oauth2: str, - test_domain_user_info: str + test_domain_cloud_cache: str, + test_name_oauth2_info: str, + test_name_uid: str ): from miot.const import OAUTH2_CLIENT_ID from miot.miot_cloud import MIoTHttpClient @@ -173,7 +177,7 @@ async def test_miot_cloud_get_uid_async( miot_storage = MIoTStorage(test_cache_path) oauth_info = await miot_storage.load_async( - domain=test_domain_oauth2, name=test_cloud_server, type_=dict) + domain=test_domain_cloud_cache, name=test_name_oauth2_info, type_=dict) assert isinstance(oauth_info, dict) and 'access_token' in oauth_info miot_http = MIoTHttpClient( cloud_server=test_cloud_server, client_id=OAUTH2_CLIENT_ID, @@ -184,8 +188,7 @@ async def test_miot_cloud_get_uid_async( _LOGGER.info('your uid: %s', uid) # Save uid rc = await miot_storage.save_async( - domain=test_domain_user_info, - name=f'uid_{test_cloud_server}', data=uid) + domain=test_domain_cloud_cache, name=test_name_uid, data=uid) assert rc await miot_http.deinit_async() @@ -196,8 +199,9 @@ async def test_miot_cloud_get_uid_async( async def test_miot_cloud_get_homeinfos_async( test_cache_path: str, test_cloud_server: str, - test_domain_oauth2: str, - test_domain_user_info: str + test_domain_cloud_cache: str, + test_name_oauth2_info: str, + test_name_uid: str ): from miot.const import OAUTH2_CLIENT_ID from miot.miot_cloud import MIoTHttpClient @@ -205,7 +209,7 @@ async def test_miot_cloud_get_homeinfos_async( miot_storage = MIoTStorage(test_cache_path) oauth_info = await miot_storage.load_async( - domain=test_domain_oauth2, name=test_cloud_server, type_=dict) + domain=test_domain_cloud_cache, name=test_name_oauth2_info, type_=dict) assert isinstance(oauth_info, dict) and 'access_token' in oauth_info miot_http = MIoTHttpClient( cloud_server=test_cloud_server, client_id=OAUTH2_CLIENT_ID, @@ -223,8 +227,7 @@ async def test_miot_cloud_get_homeinfos_async( uid = homeinfos.get('uid', '') # Compare uid with uid in storage uid2 = await miot_storage.load_async( - domain=test_domain_user_info, - name=f'uid_{test_cloud_server}', type_=str) + domain=test_domain_cloud_cache, name=test_name_uid, type_=str) assert uid == uid2 _LOGGER.info('your uid: %s', uid) # Get homes @@ -242,8 +245,11 @@ async def test_miot_cloud_get_homeinfos_async( async def test_miot_cloud_get_devices_async( test_cache_path: str, test_cloud_server: str, - test_domain_oauth2: str, - test_domain_user_info: str + test_domain_cloud_cache: str, + test_name_oauth2_info: str, + test_name_uid: str, + test_name_homes: str, + test_name_devices: str ): from miot.const import OAUTH2_CLIENT_ID from miot.miot_cloud import MIoTHttpClient @@ -251,7 +257,7 @@ async def test_miot_cloud_get_devices_async( miot_storage = MIoTStorage(test_cache_path) oauth_info = await miot_storage.load_async( - domain=test_domain_oauth2, name=test_cloud_server, type_=dict) + domain=test_domain_cloud_cache, name=test_name_oauth2_info, type_=dict) assert isinstance(oauth_info, dict) and 'access_token' in oauth_info miot_http = MIoTHttpClient( cloud_server=test_cloud_server, client_id=OAUTH2_CLIENT_ID, @@ -266,8 +272,7 @@ async def test_miot_cloud_get_devices_async( # Compare uid with uid in storage uid = devices.get('uid', '') uid2 = await miot_storage.load_async( - domain=test_domain_user_info, - name=f'uid_{test_cloud_server}', type_=str) + domain=test_domain_cloud_cache, name=test_name_uid, type_=str) assert uid == uid2 _LOGGER.info('your uid: %s', uid) # Get homes @@ -278,12 +283,10 @@ async def test_miot_cloud_get_devices_async( _LOGGER.info('your devices count: %s', len(devices)) # Storage homes and devices rc = await miot_storage.save_async( - domain=test_domain_user_info, - name=f'homes_{test_cloud_server}', data=homes) + domain=test_domain_cloud_cache, name=test_name_homes, data=homes) assert rc rc = await miot_storage.save_async( - domain=test_domain_user_info, - name=f'devices_{test_cloud_server}', data=devices) + domain=test_domain_cloud_cache, name=test_name_devices, data=devices) assert rc await miot_http.deinit_async() @@ -294,8 +297,9 @@ async def test_miot_cloud_get_devices_async( async def test_miot_cloud_get_devices_with_dids_async( test_cache_path: str, test_cloud_server: str, - test_domain_oauth2: str, - test_domain_user_info: str + test_domain_cloud_cache: str, + test_name_oauth2_info: str, + test_name_devices: str ): from miot.const import OAUTH2_CLIENT_ID from miot.miot_cloud import MIoTHttpClient @@ -303,7 +307,7 @@ async def test_miot_cloud_get_devices_with_dids_async( miot_storage = MIoTStorage(test_cache_path) oauth_info = await miot_storage.load_async( - domain=test_domain_oauth2, name=test_cloud_server, type_=dict) + domain=test_domain_cloud_cache, name=test_name_oauth2_info, type_=dict) assert isinstance(oauth_info, dict) and 'access_token' in oauth_info miot_http = MIoTHttpClient( cloud_server=test_cloud_server, client_id=OAUTH2_CLIENT_ID, @@ -311,8 +315,7 @@ async def test_miot_cloud_get_devices_with_dids_async( # Load devices local_devices = await miot_storage.load_async( - domain=test_domain_user_info, - name=f'devices_{test_cloud_server}', type_=dict) + domain=test_domain_cloud_cache, name=test_name_devices, type_=dict) assert isinstance(local_devices, dict) did_list = list(local_devices.keys()) assert len(did_list) > 0 @@ -328,13 +331,96 @@ async def test_miot_cloud_get_devices_with_dids_async( await miot_http.deinit_async() +@pytest.mark.asyncio +async def test_miot_cloud_get_cert( + test_cache_path: str, + test_cloud_server: str, + test_random_did: str, + test_domain_cloud_cache: str, + test_name_oauth2_info: str, + test_name_uid: str, + test_name_rd_did: str +): + """ + NOTICE: Currently, only certificate acquisition in the CN region is + supported. + """ + from miot.const import OAUTH2_CLIENT_ID + from miot.miot_cloud import MIoTHttpClient + from miot.miot_storage import MIoTCert, MIoTStorage + + if test_cloud_server.lower() != 'cn': + _LOGGER.info('only support CN region') + return + + miot_storage = MIoTStorage(test_cache_path) + uid = await miot_storage.load_async( + domain=test_domain_cloud_cache, name=test_name_uid, type_=str) + assert isinstance(uid, str) + _LOGGER.info('your uid: %s', uid) + random_did = await miot_storage.load_async( + domain=test_domain_cloud_cache, name=test_name_rd_did, type_=str) + if not random_did: + random_did = test_random_did + rc = await miot_storage.save_async( + domain=test_domain_cloud_cache, name=test_name_rd_did, + data=random_did) + assert rc + assert isinstance(random_did, str) + _LOGGER.info('your random_did: %s', random_did) + oauth_info = await miot_storage.load_async( + domain=test_domain_cloud_cache, name=test_name_oauth2_info, type_=dict) + assert isinstance(oauth_info, dict) + assert 'access_token' in oauth_info + access_token = oauth_info['access_token'] + + # Get certificates + miot_cert = MIoTCert(storage=miot_storage, uid=uid, cloud_server='CN') + assert await miot_cert.verify_ca_cert_async(), 'invalid ca cert' + remaining_time: int = await miot_cert.user_cert_remaining_time_async() + if remaining_time > 0: + _LOGGER.info( + 'user cert is valid, remaining time, %ss', remaining_time) + _LOGGER.info(( + 'if you want to obtain it again, please delete the ' + 'key, csr, and cert files in %s.'), test_cache_path) + return + + miot_http = MIoTHttpClient( + cloud_server=test_cloud_server, + client_id=OAUTH2_CLIENT_ID, + access_token=access_token) + + user_key = miot_cert.gen_user_key() + assert isinstance(user_key, str) + _LOGGER.info('user_key str, %s', user_key) + user_csr = miot_cert.gen_user_csr(user_key=user_key, did=random_did) + assert isinstance(user_csr, str) + _LOGGER.info('user_csr str, %s', user_csr) + cert_str = await miot_http.get_central_cert_async(csr=user_csr) + assert isinstance(cert_str, str) + _LOGGER.info('user_cert str, %s', cert_str) + rc = await miot_cert.update_user_key_async(key=user_key) + assert rc + rc = await miot_cert.update_user_cert_async(cert=cert_str) + assert rc + # verify user certificates + remaining_time = await miot_cert.user_cert_remaining_time_async( + cert_data=cert_str.encode('utf-8'), did=random_did) + assert remaining_time > 0 + _LOGGER.info('user cert remaining time, %ss', remaining_time) + + await miot_http.deinit_async() + + @pytest.mark.asyncio @pytest.mark.dependency() async def test_miot_cloud_get_prop_async( test_cache_path: str, test_cloud_server: str, - test_domain_oauth2: str, - test_domain_user_info: str + test_domain_cloud_cache: str, + test_name_oauth2_info: str, + test_name_devices: str ): from miot.const import OAUTH2_CLIENT_ID from miot.miot_cloud import MIoTHttpClient @@ -342,7 +428,7 @@ async def test_miot_cloud_get_prop_async( miot_storage = MIoTStorage(test_cache_path) oauth_info = await miot_storage.load_async( - domain=test_domain_oauth2, name=test_cloud_server, type_=dict) + domain=test_domain_cloud_cache, name=test_name_oauth2_info, type_=dict) assert isinstance(oauth_info, dict) and 'access_token' in oauth_info miot_http = MIoTHttpClient( cloud_server=test_cloud_server, client_id=OAUTH2_CLIENT_ID, @@ -350,8 +436,7 @@ async def test_miot_cloud_get_prop_async( # Load devices local_devices = await miot_storage.load_async( - domain=test_domain_user_info, - name=f'devices_{test_cloud_server}', type_=dict) + domain=test_domain_cloud_cache, name=test_name_devices, type_=dict) assert isinstance(local_devices, dict) did_list = list(local_devices.keys()) assert len(did_list) > 0 @@ -370,8 +455,9 @@ async def test_miot_cloud_get_prop_async( async def test_miot_cloud_get_props_async( test_cache_path: str, test_cloud_server: str, - test_domain_oauth2: str, - test_domain_user_info: str + test_domain_cloud_cache: str, + test_name_oauth2_info: str, + test_name_devices: str ): from miot.const import OAUTH2_CLIENT_ID from miot.miot_cloud import MIoTHttpClient @@ -379,7 +465,7 @@ async def test_miot_cloud_get_props_async( miot_storage = MIoTStorage(test_cache_path) oauth_info = await miot_storage.load_async( - domain=test_domain_oauth2, name=test_cloud_server, type_=dict) + domain=test_domain_cloud_cache, name=test_name_oauth2_info, type_=dict) assert isinstance(oauth_info, dict) and 'access_token' in oauth_info miot_http = MIoTHttpClient( cloud_server=test_cloud_server, client_id=OAUTH2_CLIENT_ID, @@ -387,8 +473,7 @@ async def test_miot_cloud_get_props_async( # Load devices local_devices = await miot_storage.load_async( - domain=test_domain_user_info, - name=f'devices_{test_cloud_server}', type_=dict) + domain=test_domain_cloud_cache, name=test_name_devices, type_=dict) assert isinstance(local_devices, dict) did_list = list(local_devices.keys()) assert len(did_list) > 0 @@ -409,8 +494,9 @@ async def test_miot_cloud_get_props_async( async def test_miot_cloud_set_prop_async( test_cache_path: str, test_cloud_server: str, - test_domain_oauth2: str, - test_domain_user_info: str + test_domain_cloud_cache: str, + test_name_oauth2_info: str, + test_name_devices: str ): """ WARNING: This test case will control the actual device and is not enabled @@ -422,7 +508,7 @@ async def test_miot_cloud_set_prop_async( miot_storage = MIoTStorage(test_cache_path) oauth_info = await miot_storage.load_async( - domain=test_domain_oauth2, name=test_cloud_server, type_=dict) + domain=test_domain_cloud_cache, name=test_name_oauth2_info, type_=dict) assert isinstance(oauth_info, dict) and 'access_token' in oauth_info miot_http = MIoTHttpClient( cloud_server=test_cloud_server, client_id=OAUTH2_CLIENT_ID, @@ -430,8 +516,7 @@ async def test_miot_cloud_set_prop_async( # Load devices local_devices = await miot_storage.load_async( - domain=test_domain_user_info, - name=f'devices_{test_cloud_server}', type_=dict) + domain=test_domain_cloud_cache, name=test_name_devices, type_=dict) assert isinstance(local_devices, dict) assert len(local_devices) > 0 # Set prop @@ -460,8 +545,9 @@ async def test_miot_cloud_set_prop_async( async def test_miot_cloud_action_async( test_cache_path: str, test_cloud_server: str, - test_domain_oauth2: str, - test_domain_user_info: str + test_domain_cloud_cache: str, + test_name_oauth2_info: str, + test_name_devices: str ): """ WARNING: This test case will control the actual device and is not enabled @@ -473,7 +559,7 @@ async def test_miot_cloud_action_async( miot_storage = MIoTStorage(test_cache_path) oauth_info = await miot_storage.load_async( - domain=test_domain_oauth2, name=test_cloud_server, type_=dict) + domain=test_domain_cloud_cache, name=test_name_oauth2_info, type_=dict) assert isinstance(oauth_info, dict) and 'access_token' in oauth_info miot_http = MIoTHttpClient( cloud_server=test_cloud_server, client_id=OAUTH2_CLIENT_ID, @@ -481,8 +567,7 @@ async def test_miot_cloud_action_async( # Load devices local_devices = await miot_storage.load_async( - domain=test_domain_user_info, - name=f'devices_{test_cloud_server}', type_=dict) + domain=test_domain_cloud_cache, name=test_name_devices, type_=dict) assert isinstance(local_devices, dict) assert len(local_devices) > 0 # Action From e0eb06144fb8ae89d7896fb5177b8587412fd637 Mon Sep 17 00:00:00 2001 From: Paul Shawn <32349595+topsworld@users.noreply.github.com> Date: Mon, 13 Jan 2025 22:22:23 +0800 Subject: [PATCH 08/14] feat: support remove device (#622) * feat: support remove device * feat: simplify the unsub logic * feat: update notify after rm device --- custom_components/xiaomi_home/__init__.py | 40 +++++++++++++++++++ .../xiaomi_home/miot/miot_client.py | 24 +++++++++++ 2 files changed, 64 insertions(+) diff --git a/custom_components/xiaomi_home/__init__.py b/custom_components/xiaomi_home/__init__.py index 3b534e3..694154d 100644 --- a/custom_components/xiaomi_home/__init__.py +++ b/custom_components/xiaomi_home/__init__.py @@ -308,3 +308,43 @@ async def async_remove_entry( await miot_cert.remove_user_cert_async() await miot_cert.remove_user_key_async() return True + + +async def async_remove_config_entry_device( + hass: HomeAssistant, + config_entry: ConfigEntry, + device_entry: device_registry.DeviceEntry +) -> bool: + """Remove the device.""" + miot_client: MIoTClient = await get_miot_instance_async( + hass=hass, entry_id=config_entry.entry_id) + + if len(device_entry.identifiers) != 1: + _LOGGER.error( + 'remove device failed, invalid identifiers, %s, %s', + device_entry.id, device_entry.identifiers) + return False + identifiers = list(device_entry.identifiers)[0] + if identifiers[0] != DOMAIN: + _LOGGER.error( + 'remove device failed, invalid domain, %s, %s', + device_entry.id, device_entry.identifiers) + return False + device_info = identifiers[1].split('_') + if len(device_info) != 2: + _LOGGER.error( + 'remove device failed, invalid device info, %s, %s', + device_entry.id, device_entry.identifiers) + return False + did = device_info[1] + if did not in miot_client.device_list: + _LOGGER.error( + 'remove device failed, device not found, %s, %s', + device_entry.id, device_entry.identifiers) + return False + # Remove device + await miot_client.remove_device_async(did) + device_registry.async_get(hass).async_remove_device(device_entry.id) + _LOGGER.info( + 'remove device, %s, %s, %s', device_info[0], did, device_entry.id) + return True diff --git a/custom_components/xiaomi_home/miot/miot_client.py b/custom_components/xiaomi_home/miot/miot_client.py index 58fb504..203c377 100644 --- a/custom_components/xiaomi_home/miot/miot_client.py +++ b/custom_components/xiaomi_home/miot/miot_client.py @@ -848,6 +848,30 @@ class MIoTClient: _LOGGER.debug('client unsub device state, %s', did) return True + async def remove_device_async(self, did: str) -> None: + if did not in self._device_list_cache: + return + sub_from = self._sub_source_list.pop(did, None) + # Unsub + if sub_from: + if sub_from == 'cloud': + self._mips_cloud.unsub_prop(did=did) + self._mips_cloud.unsub_event(did=did) + elif sub_from == 'lan': + self._miot_lan.unsub_prop(did=did) + self._miot_lan.unsub_event(did=did) + elif sub_from in self._mips_local: + mips = self._mips_local[sub_from] + mips.unsub_prop(did=did) + mips.unsub_event(did=did) + # Storage + await self._storage.save_async( + domain='miot_devices', + name=f'{self._uid}_{self._cloud_server}', + data=self._device_list_cache) + # Update notify + self.__request_show_devices_changed_notify() + def __get_exec_error_with_rc(self, rc: int) -> str: err_msg: str = self._i18n.translate(key=f'error.common.{rc}') if not err_msg: From 1cdcb785b5c43bcb57f66a37ae492a43d1ff4e07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A1=94=E5=AD=90?= Date: Tue, 14 Jan 2025 09:19:28 +0800 Subject: [PATCH 09/14] feat: add power properties trans (#571) --- custom_components/xiaomi_home/miot/specs/specv2entity.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/custom_components/xiaomi_home/miot/specs/specv2entity.py b/custom_components/xiaomi_home/miot/specs/specv2entity.py index 9e36011..c5bdbea 100644 --- a/custom_components/xiaomi_home/miot/specs/specv2entity.py +++ b/custom_components/xiaomi_home/miot/specs/specv2entity.py @@ -431,6 +431,14 @@ SPEC_PROP_TRANS_MAP: dict[str, dict | str] = { 'unit_of_measurement': UnitOfEnergy.KILO_WATT_HOUR } }, + 'power': { + 'device_class': SensorDeviceClass.POWER, + 'entity': 'sensor', + 'optional': { + 'state_class': SensorStateClass.MEASUREMENT, + 'unit_of_measurement': UnitOfPower.WATT + } + }, 'total-battery': { 'device_class': SensorDeviceClass.ENERGY, 'entity': 'sensor', From 288194807675227de3abd75815c6c34abd88fe50 Mon Sep 17 00:00:00 2001 From: Feng Wang Date: Tue, 14 Jan 2025 16:59:35 +0800 Subject: [PATCH 10/14] feat: move web page to html (#627) * move web page to html * move loading into function * make the loading async * fix usage * Fix function naming * fix lint * fix lint * feat: use get_running_loop replace get_event_loop * feat: translate using the i18n module * docs: update zh-Hant translate content --------- Co-authored-by: topsworld --- custom_components/xiaomi_home/config_flow.py | 55 +++- .../xiaomi_home/miot/i18n/de.json | 16 ++ .../xiaomi_home/miot/i18n/en.json | 16 ++ .../xiaomi_home/miot/i18n/es.json | 16 ++ .../xiaomi_home/miot/i18n/fr.json | 16 ++ .../xiaomi_home/miot/i18n/ja.json | 16 ++ .../xiaomi_home/miot/i18n/nl.json | 16 ++ .../xiaomi_home/miot/i18n/pt-BR.json | 16 ++ .../xiaomi_home/miot/i18n/pt.json | 16 ++ .../xiaomi_home/miot/i18n/ru.json | 16 ++ .../xiaomi_home/miot/i18n/zh-Hans.json | 16 ++ .../xiaomi_home/miot/i18n/zh-Hant.json | 16 ++ .../xiaomi_home/miot/miot_error.py | 2 + .../miot/resource/oauth_redirect_page.html | 136 +++++++++ .../xiaomi_home/miot/web_pages.py | 258 ++---------------- 15 files changed, 386 insertions(+), 241 deletions(-) create mode 100644 custom_components/xiaomi_home/miot/resource/oauth_redirect_page.html diff --git a/custom_components/xiaomi_home/config_flow.py b/custom_components/xiaomi_home/config_flow.py index 1c3f12c..5b78c27 100644 --- a/custom_components/xiaomi_home/config_flow.py +++ b/custom_components/xiaomi_home/config_flow.py @@ -91,7 +91,8 @@ from .miot.miot_cloud import MIoTHttpClient, MIoTOauthClient from .miot.miot_storage import MIoTStorage, MIoTCert from .miot.miot_mdns import MipsService from .miot.web_pages import oauth_redirect_page -from .miot.miot_error import MIoTConfigError, MIoTError, MIoTOauthError +from .miot.miot_error import ( + MIoTConfigError, MIoTError, MIoTErrorCode, MIoTOauthError) from .miot.miot_i18n import MIoTI18n from .miot.miot_network import MIoTNetwork from .miot.miot_client import MIoTClient, get_miot_instance_async @@ -430,6 +431,8 @@ class XiaomiMihomeConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): redirect_url=self._oauth_redirect_url_full) self.hass.data[DOMAIN][self._virtual_did]['oauth_state'] = ( miot_oauth.state) + self.hass.data[DOMAIN][self._virtual_did]['i18n'] = ( + self._miot_i18n) _LOGGER.info( 'async_step_oauth, oauth_url: %s', self._cc_oauth_auth_url) webhook_async_unregister( @@ -1152,6 +1155,8 @@ class OptionsFlowHandler(config_entries.OptionsFlow): redirect_url=self._oauth_redirect_url_full) self.hass.data[DOMAIN][self._virtual_did]['oauth_state'] = ( self._miot_oauth.state) + self.hass.data[DOMAIN][self._virtual_did]['i18n'] = ( + self._miot_i18n) _LOGGER.info( 'async_step_oauth, oauth_url: %s', self._cc_oauth_auth_url) webhook_async_unregister( @@ -1967,29 +1972,61 @@ class OptionsFlowHandler(config_entries.OptionsFlow): async def _handle_oauth_webhook(hass, webhook_id, request): """Webhook to handle oauth2 callback.""" # pylint: disable=inconsistent-quotes + i18n: MIoTI18n = hass.data[DOMAIN][webhook_id].get('i18n', None) try: data = dict(request.query) if data.get('code', None) is None or data.get('state', None) is None: - raise MIoTConfigError('invalid oauth code') + raise MIoTConfigError( + 'invalid oauth code or state', + MIoTErrorCode.CODE_CONFIG_INVALID_INPUT) if data['state'] != hass.data[DOMAIN][webhook_id]['oauth_state']: raise MIoTConfigError( - f'invalid oauth state, ' - f'{hass.data[DOMAIN][webhook_id]["oauth_state"]}, ' - f'{data["state"]}') + f'inconsistent state, ' + f'{hass.data[DOMAIN][webhook_id]["oauth_state"]}!=' + f'{data["state"]}', MIoTErrorCode.CODE_CONFIG_INVALID_STATE) fut_oauth_code: asyncio.Future = hass.data[DOMAIN][webhook_id].pop( 'fut_oauth_code', None) fut_oauth_code.set_result(data['code']) _LOGGER.info('webhook code: %s', data['code']) + success_trans: dict = {} + if i18n: + success_trans = i18n.translate( + 'oauth2.success') or {} # type: ignore + # Delete + del hass.data[DOMAIN][webhook_id]['oauth_state'] + del hass.data[DOMAIN][webhook_id]['i18n'] return web.Response( - body=oauth_redirect_page( - hass.config.language, 'success'), content_type='text/html') + body=await oauth_redirect_page( + title=success_trans.get('title', 'Success'), + content=success_trans.get( + 'content', ( + 'Please close this page and return to the account ' + 'authentication page to click NEXT')), + button=success_trans.get('button', 'Close Page'), + success=True, + ), content_type='text/html') - except MIoTConfigError: + except Exception as err: # pylint: disable=broad-exception-caught + fail_trans: dict = {} + err_msg: str = str(err) + if i18n: + if isinstance(err, MIoTConfigError): + err_msg = i18n.translate( + f'oauth2.error_msg.{err.code.value}' + ) or err.message # type: ignore + fail_trans = i18n.translate('oauth2.fail') or {} # type: ignore return web.Response( - body=oauth_redirect_page(hass.config.language, 'fail'), + body=await oauth_redirect_page( + title=fail_trans.get('title', 'Authentication Failed'), + content=str(fail_trans.get('content', ( + '{error_msg}, Please close this page and return to the ' + 'account authentication page to click the authentication ' + 'link again.'))).replace('{error_msg}', err_msg), + button=fail_trans.get('button', 'Close Page'), + success=False), content_type='text/html') diff --git a/custom_components/xiaomi_home/miot/i18n/de.json b/custom_components/xiaomi_home/miot/i18n/de.json index 81fb203..9dce0e9 100644 --- a/custom_components/xiaomi_home/miot/i18n/de.json +++ b/custom_components/xiaomi_home/miot/i18n/de.json @@ -64,6 +64,22 @@ "net_unavailable": "Schnittstelle nicht verfügbar" } }, + "oauth2": { + "success": { + "title": "Authentifizierung erfolgreich", + "content": "Bitte schließen Sie diese Seite und kehren Sie zur Kontoauthentifizierungsseite zurück, um auf „Weiter“ zu klicken.", + "button": "Schließen" + }, + "fail": { + "title": "Authentifizierung fehlgeschlagen", + "content": "{error_msg}, bitte schließen Sie diese Seite und kehren Sie zur Kontoauthentifizierungsseite zurück, um den Authentifizierungslink erneut zu klicken.", + "button": "Schließen" + }, + "error_msg": { + "-10100": "Ungültige Antwortparameter ('code' oder 'state' Feld ist leer)", + "-10101": "Übergebenes 'state' Feld stimmt nicht überein" + } + }, "miot": { "client": { "invalid_oauth_info": "Ungültige Authentifizierungsinformationen, Cloud-Verbindung nicht verfügbar, bitte betreten Sie die Xiaomi Home-Integrationsseite und klicken Sie auf 'Optionen', um die Authentifizierung erneut durchzuführen", diff --git a/custom_components/xiaomi_home/miot/i18n/en.json b/custom_components/xiaomi_home/miot/i18n/en.json index 219b276..7cf0ecb 100644 --- a/custom_components/xiaomi_home/miot/i18n/en.json +++ b/custom_components/xiaomi_home/miot/i18n/en.json @@ -64,6 +64,22 @@ "net_unavailable": "Interface unavailable" } }, + "oauth2": { + "success": { + "title": "Authentication Successful", + "content": "Please close this page and return to the account authentication page to click 'Next'.", + "button": "Close" + }, + "fail": { + "title": "Authentication Failed", + "content": "{error_msg}, please close this page and return to the account authentication page to click the authentication link again.", + "button": "Close" + }, + "error_msg": { + "-10100": "Invalid response parameters ('code' or 'state' field is empty)", + "-10101": "Passed-in 'state' field mismatch" + } + }, "miot": { "client": { "invalid_oauth_info": "Authentication information is invalid, cloud link will be unavailable, please enter the Xiaomi Home integration page, click 'Options' to re-authenticate", diff --git a/custom_components/xiaomi_home/miot/i18n/es.json b/custom_components/xiaomi_home/miot/i18n/es.json index 49a6ea6..a71312f 100644 --- a/custom_components/xiaomi_home/miot/i18n/es.json +++ b/custom_components/xiaomi_home/miot/i18n/es.json @@ -64,6 +64,22 @@ "net_unavailable": "Interfaz no disponible" } }, + "oauth2": { + "success": { + "title": "Autenticación exitosa", + "content": "Por favor, cierre esta página y regrese a la página de autenticación de la cuenta para hacer clic en 'Siguiente'.", + "button": "Cerrar" + }, + "fail": { + "title": "Autenticación fallida", + "content": "{error_msg}, por favor, cierre esta página y regrese a la página de autenticación de la cuenta para hacer clic en el enlace de autenticación nuevamente.", + "button": "Cerrar" + }, + "error_msg": { + "-10100": "Parámetros de respuesta inválidos ('code' o 'state' está vacío)", + "-10101": "El campo 'state' proporcionado no coincide" + } + }, "miot": { "client": { "invalid_oauth_info": "La información de autenticación es inválida, la conexión en la nube no estará disponible, por favor, vaya a la página de integración de Xiaomi Home, haga clic en 'Opciones' para volver a autenticar", diff --git a/custom_components/xiaomi_home/miot/i18n/fr.json b/custom_components/xiaomi_home/miot/i18n/fr.json index 40feb65..e64b614 100644 --- a/custom_components/xiaomi_home/miot/i18n/fr.json +++ b/custom_components/xiaomi_home/miot/i18n/fr.json @@ -64,6 +64,22 @@ "net_unavailable": "Interface non disponible" } }, + "oauth2": { + "success": { + "title": "Authentification réussie", + "content": "Veuillez fermer cette page et revenir à la page d'authentification du compte pour cliquer sur 'Suivant'.", + "button": "Fermer" + }, + "fail": { + "title": "Échec de l'authentification", + "content": "{error_msg}, veuillez fermer cette page et revenir à la page d'authentification du compte pour cliquer à nouveau sur le lien d'authentification.", + "button": "Fermer" + }, + "error_msg": { + "-10100": "Paramètres de réponse invalides ('code' ou 'state' est vide)", + "-10101": "Le champ 'state' transmis ne correspond pas" + } + }, "miot": { "client": { "invalid_oauth_info": "Informations d'authentification non valides, le lien cloud ne sera pas disponible, veuillez accéder à la page d'intégration Xiaomi Home, cliquez sur \"Options\" pour vous réauthentifier", diff --git a/custom_components/xiaomi_home/miot/i18n/ja.json b/custom_components/xiaomi_home/miot/i18n/ja.json index 3ffc22a..087467c 100644 --- a/custom_components/xiaomi_home/miot/i18n/ja.json +++ b/custom_components/xiaomi_home/miot/i18n/ja.json @@ -64,6 +64,22 @@ "net_unavailable": "インターフェースが利用できません" } }, + "oauth2": { + "success": { + "title": "認証成功", + "content": "このページを閉じて、アカウント認証ページに戻り、「次へ」をクリックしてください。", + "button": "閉じる" + }, + "fail": { + "title": "認証失敗", + "content": "{error_msg}、このページを閉じて、アカウント認証ページに戻り、再度認証リンクをクリックしてください。", + "button": "閉じる" + }, + "error_msg": { + "-10100": "無効な応答パラメータ('code'または'state'フィールドが空です)", + "-10101": "渡された'state'フィールドが一致しません" + } + }, "miot": { "client": { "invalid_oauth_info": "認証情報が無効です。クラウドリンクは利用できません。Xiaomi Home統合ページに入り、[オプション]をクリックして再認証してください", diff --git a/custom_components/xiaomi_home/miot/i18n/nl.json b/custom_components/xiaomi_home/miot/i18n/nl.json index 101ff3a..d71e90e 100644 --- a/custom_components/xiaomi_home/miot/i18n/nl.json +++ b/custom_components/xiaomi_home/miot/i18n/nl.json @@ -64,6 +64,22 @@ "net_unavailable": "Interface niet beschikbaar" } }, + "oauth2": { + "success": { + "title": "Authenticatie geslaagd", + "content": "Sluit deze pagina en ga terug naar de accountauthenticatiepagina om op 'Volgende' te klikken.", + "button": "Sluiten" + }, + "fail": { + "title": "Authenticatie mislukt", + "content": "{error_msg}, sluit deze pagina en ga terug naar de accountauthenticatiepagina om opnieuw op de authenticatielink te klikken.", + "button": "Sluiten" + }, + "error_msg": { + "-10100": "Ongeldige antwoordparameters ('code' of 'state' veld is leeg)", + "-10101": "Doorgegeven 'state' veld komt niet overeen" + } + }, "miot": { "client": { "invalid_oauth_info": "Authenticatie-informatie is ongeldig, cloudverbinding zal niet beschikbaar zijn. Ga naar de Xiaomi Home-integratiepagina en klik op 'Opties' om opnieuw te verifiëren.", diff --git a/custom_components/xiaomi_home/miot/i18n/pt-BR.json b/custom_components/xiaomi_home/miot/i18n/pt-BR.json index 8e37ecb..0364f7d 100644 --- a/custom_components/xiaomi_home/miot/i18n/pt-BR.json +++ b/custom_components/xiaomi_home/miot/i18n/pt-BR.json @@ -64,6 +64,22 @@ "net_unavailable": "Interface indisponível" } }, + "oauth2": { + "success": { + "title": "Autenticação bem-sucedida", + "content": "Por favor, feche esta página e volte para a página de autenticação da conta para clicar em 'Próximo'.", + "button": "Fechar" + }, + "fail": { + "title": "Falha na autenticação", + "content": "{error_msg}, por favor, feche esta página e volte para a página de autenticação da conta para clicar no link de autenticação novamente.", + "button": "Fechar" + }, + "error_msg": { + "-10100": "Parâmetros de resposta inválidos ('code' ou 'state' está vazio)", + "-10101": "O campo 'state' fornecido não corresponde" + } + }, "miot": { "client": { "invalid_oauth_info": "Informações de autenticação inválidas, a conexão com a nuvem estará indisponível. Vá para a página de integração do Xiaomi Home e clique em 'Opções' para reautenticar.", diff --git a/custom_components/xiaomi_home/miot/i18n/pt.json b/custom_components/xiaomi_home/miot/i18n/pt.json index 08afe4d..d02180f 100644 --- a/custom_components/xiaomi_home/miot/i18n/pt.json +++ b/custom_components/xiaomi_home/miot/i18n/pt.json @@ -64,6 +64,22 @@ "net_unavailable": "Interface indisponível" } }, + "oauth2": { + "success": { + "title": "Autenticação bem-sucedida", + "content": "Por favor, feche esta página e volte para a página de autenticação da conta para clicar em 'Seguinte'.", + "button": "Fechar" + }, + "fail": { + "title": "Falha na autenticação", + "content": "{error_msg}, por favor, feche esta página e volte para a página de autenticação da conta para clicar no link de autenticação novamente.", + "button": "Fechar" + }, + "error_msg": { + "-10100": "Parâmetros de resposta inválidos ('code' ou 'state' está vazio)", + "-10101": "O campo 'state' fornecido não corresponde" + } + }, "miot": { "client": { "invalid_oauth_info": "Informações de autenticação inválidas, a conexão na nuvem ficará indisponível. Por favor, acesse a página de integração do Xiaomi Home e clique em 'Opções' para autenticar novamente.", diff --git a/custom_components/xiaomi_home/miot/i18n/ru.json b/custom_components/xiaomi_home/miot/i18n/ru.json index d018603..7065c39 100644 --- a/custom_components/xiaomi_home/miot/i18n/ru.json +++ b/custom_components/xiaomi_home/miot/i18n/ru.json @@ -64,6 +64,22 @@ "net_unavailable": "Интерфейс недоступен" } }, + "oauth2": { + "success": { + "title": "Аутентификация успешна", + "content": "Пожалуйста, закройте эту страницу и вернитесь на страницу аутентификации учетной записи, чтобы нажать 'Далее'.", + "button": "Закрыть" + }, + "fail": { + "title": "Аутентификация не удалась", + "content": "{error_msg}, пожалуйста, закройте эту страницу и вернитесь на страницу аутентификации учетной записи, чтобы снова нажать на ссылку аутентификации.", + "button": "Закрыть" + }, + "error_msg": { + "-10100": "Недействительные параметры ответа ('code' или 'state' поле пусто)", + "-10101": "Переданное поле 'state' не совпадает" + } + }, "miot": { "client": { "invalid_oauth_info": "Информация об аутентификации недействительна, облако будет недоступно, пожалуйста, войдите на страницу интеграции Xiaomi Home, нажмите 'Опции' для повторной аутентификации", diff --git a/custom_components/xiaomi_home/miot/i18n/zh-Hans.json b/custom_components/xiaomi_home/miot/i18n/zh-Hans.json index d8f7c8a..3d47d2a 100644 --- a/custom_components/xiaomi_home/miot/i18n/zh-Hans.json +++ b/custom_components/xiaomi_home/miot/i18n/zh-Hans.json @@ -64,6 +64,22 @@ "net_unavailable": "接口不可用" } }, + "oauth2": { + "success": { + "title": "认证成功", + "content": "请关闭此页面,返回账号认证页面点击“下一步”", + "button": "关闭" + }, + "fail": { + "title": "认证失败", + "content": "{error_msg},请关闭此页面,返回账号认证页面重新点击认链接进行认证。", + "button": "关闭" + }, + "error_msg": { + "-10100": "无效的响应参数(“code”或者“state”字段为空)", + "-10101": "传入“state”字段不一致" + } + }, "miot": { "client": { "invalid_oauth_info": "认证信息失效,云端链路将不可用,请进入 Xiaomi Home 集成页面,点击“选项”重新认证", diff --git a/custom_components/xiaomi_home/miot/i18n/zh-Hant.json b/custom_components/xiaomi_home/miot/i18n/zh-Hant.json index 73bfa98..3c541a7 100644 --- a/custom_components/xiaomi_home/miot/i18n/zh-Hant.json +++ b/custom_components/xiaomi_home/miot/i18n/zh-Hant.json @@ -64,6 +64,22 @@ "net_unavailable": "接口不可用" } }, + "oauth2": { + "success": { + "title": "認證成功", + "content": "請關閉此頁面,返回帳號認證頁面點擊“下一步”", + "button": "關閉" + }, + "fail": { + "title": "認證失敗", + "content": "{error_msg},請關閉此頁面,返回帳號認證頁面重新點擊認鏈接進行認證。", + "button": "關閉" + }, + "error_msg": { + "-10100": "無效的響應參數(“code”或者“state”字段為空)", + "-10101": "傳入的“state”字段不一致" + } + }, "miot": { "client": { "invalid_oauth_info": "認證信息失效,雲端鏈路將不可用,請進入 Xiaomi Home 集成頁面,點擊“選項”重新認證", diff --git a/custom_components/xiaomi_home/miot/miot_error.py b/custom_components/xiaomi_home/miot/miot_error.py index 6e65ad8..e32103e 100644 --- a/custom_components/xiaomi_home/miot/miot_error.py +++ b/custom_components/xiaomi_home/miot/miot_error.py @@ -72,6 +72,8 @@ class MIoTErrorCode(Enum): # MIoT ev error code, -10080 # Mips service error code, -10090 # Config flow error code, -10100 + CODE_CONFIG_INVALID_INPUT = -10100 + CODE_CONFIG_INVALID_STATE = -10101 # Options flow error code , -10110 # MIoT lan error code, -10120 CODE_LAN_UNAVAILABLE = -10120 diff --git a/custom_components/xiaomi_home/miot/resource/oauth_redirect_page.html b/custom_components/xiaomi_home/miot/resource/oauth_redirect_page.html new file mode 100644 index 0000000..1205f10 --- /dev/null +++ b/custom_components/xiaomi_home/miot/resource/oauth_redirect_page.html @@ -0,0 +1,136 @@ + + + + + + + + TITLE_PLACEHOLDER + + + + +
+ +
+ + 编组 + Created with Sketch. + + + + + + + + + + + + + + + + + +
+ + + + + + +
+ + + + \ No newline at end of file diff --git a/custom_components/xiaomi_home/miot/web_pages.py b/custom_components/xiaomi_home/miot/web_pages.py index e4cde5a..d6ffd9f 100644 --- a/custom_components/xiaomi_home/miot/web_pages.py +++ b/custom_components/xiaomi_home/miot/web_pages.py @@ -46,237 +46,31 @@ off Xiaomi or its affiliates' products. MIoT redirect web pages. """ -# pylint: disable=line-too-long +import os +import asyncio -def oauth_redirect_page(lang: str, status: str) -> str: +_template = '' + + +def _load_page_template(): + path = os.path.join( + os.path.dirname(os.path.abspath(__file__)), + 'resource/oauth_redirect_page.html') + with open(path, 'r', encoding='utf-8') as f: + global _template + _template = f.read() + + +async def oauth_redirect_page( + title: str, content: str, button: str, success: bool +) -> str: """Return oauth redirect page.""" - return ''' - - - - - - - - - - -
- -
- 编组 - Created with Sketch. - - - - - - - - - - - - - - - - -
- -
- -
- -
- -
- - -
- - - - ''' + if _template == '': + await asyncio.get_running_loop().run_in_executor( + None, _load_page_template) + web_page = _template.replace('TITLE_PLACEHOLDER', title) + web_page = web_page.replace('CONTENT_PLACEHOLDER', content) + web_page = web_page.replace('BUTTON_PLACEHOLDER', button) + web_page = web_page.replace( + 'STATUS_PLACEHOLDER', 'true' if success else 'false') + return web_page From 75e44f4f93bf52c6aa6bd1d6d5170b881bf8398d Mon Sep 17 00:00:00 2001 From: Paul Shawn <32349595+topsworld@users.noreply.github.com> Date: Tue, 14 Jan 2025 17:55:49 +0800 Subject: [PATCH 11/14] feat: change mips reconnect logic & add mips test case (#641) * test: add test case for mips * feat: change mips reconnect logic * fix: fix test_mdns type error --- .../xiaomi_home/miot/miot_mips.py | 96 +++++-- test/test_mdns.py | 9 +- test/test_mips.py | 264 ++++++++++++++++++ 3 files changed, 335 insertions(+), 34 deletions(-) create mode 100644 test/test_mips.py diff --git a/custom_components/xiaomi_home/miot/miot_mips.py b/custom_components/xiaomi_home/miot/miot_mips.py index 1cade87..865c44c 100644 --- a/custom_components/xiaomi_home/miot/miot_mips.py +++ b/custom_components/xiaomi_home/miot/miot_mips.py @@ -229,10 +229,9 @@ class _MipsClient(ABC): _ca_file: Optional[str] _cert_file: Optional[str] _key_file: Optional[str] - _tls_done: bool _mqtt_logger: Optional[logging.Logger] - _mqtt: Client + _mqtt: Optional[Client] _mqtt_fd: int _mqtt_timer: Optional[asyncio.TimerHandle] _mqtt_state: bool @@ -272,16 +271,12 @@ class _MipsClient(ABC): self._ca_file = ca_file self._cert_file = cert_file self._key_file = key_file - self._tls_done = False self._mqtt_logger = None self._mqtt_fd = -1 self._mqtt_timer = None self._mqtt_state = False - # mqtt init for API_VERSION2, - # callback_api_version=CallbackAPIVersion.VERSION2, - self._mqtt = Client(client_id=self._client_id, protocol=MQTTv5) - self._mqtt.enable_logger(logger=self._mqtt_logger) + self._mqtt = None # Mips init self._event_connect = asyncio.Event() @@ -316,7 +311,9 @@ class _MipsClient(ABC): Returns: bool: True: connected, False: disconnected """ - return self._mqtt and self._mqtt.is_connected() + if self._mqtt: + return self._mqtt.is_connected() + return False def connect(self, thread_name: Optional[str] = None) -> None: """mips connect.""" @@ -359,7 +356,22 @@ class _MipsClient(ABC): self._ca_file = None self._cert_file = None self._key_file = None - self._tls_done = False + self._mqtt_logger = None + with self._mips_state_sub_map_lock: + self._mips_state_sub_map.clear() + self._mips_sub_pending_map.clear() + self._mips_sub_pending_timer = None + + @final + async def deinit_async(self) -> None: + await self.disconnect_async() + + self._logger = None + self._username = None + self._password = None + self._ca_file = None + self._cert_file = None + self._key_file = None self._mqtt_logger = None with self._mips_state_sub_map_lock: self._mips_state_sub_map.clear() @@ -368,8 +380,9 @@ class _MipsClient(ABC): def update_mqtt_password(self, password: str) -> None: self._password = password - self._mqtt.username_pw_set( - username=self._username, password=self._password) + if self._mqtt: + self._mqtt.username_pw_set( + username=self._username, password=self._password) def log_debug(self, msg, *args, **kwargs) -> None: if self._logger: @@ -389,10 +402,12 @@ class _MipsClient(ABC): def enable_mqtt_logger( self, logger: Optional[logging.Logger] = None ) -> None: - if logger: - self._mqtt.enable_logger(logger=logger) - else: - self._mqtt.disable_logger() + self._mqtt_logger = logger + if self._mqtt: + if logger: + self._mqtt.enable_logger(logger=logger) + else: + self._mqtt.disable_logger() @final def sub_mips_state( @@ -587,25 +602,27 @@ class _MipsClient(ABC): def __mips_loop_thread(self) -> None: self.log_info('mips_loop_thread start') + # mqtt init for API_VERSION2, + # callback_api_version=CallbackAPIVersion.VERSION2, + self._mqtt = Client(client_id=self._client_id, protocol=MQTTv5) + self._mqtt.enable_logger(logger=self._mqtt_logger) # Set mqtt config if self._username: self._mqtt.username_pw_set( username=self._username, password=self._password) - if not self._tls_done: - if ( - self._ca_file - and self._cert_file - and self._key_file - ): - self._mqtt.tls_set( - tls_version=ssl.PROTOCOL_TLS_CLIENT, - ca_certs=self._ca_file, - certfile=self._cert_file, - keyfile=self._key_file) - else: - self._mqtt.tls_set(tls_version=ssl.PROTOCOL_TLS_CLIENT) - self._mqtt.tls_insecure_set(True) - self._tls_done = True + if ( + self._ca_file + and self._cert_file + and self._key_file + ): + self._mqtt.tls_set( + tls_version=ssl.PROTOCOL_TLS_CLIENT, + ca_certs=self._ca_file, + certfile=self._cert_file, + keyfile=self._key_file) + else: + self._mqtt.tls_set(tls_version=ssl.PROTOCOL_TLS_CLIENT) + self._mqtt.tls_insecure_set(True) self._mqtt.on_connect = self.__on_connect self._mqtt.on_connect_fail = self.__on_connect_failed self._mqtt.on_disconnect = self.__on_disconnect @@ -617,6 +634,9 @@ class _MipsClient(ABC): self.log_info('mips_loop_thread exit!') def __on_connect(self, client, user_data, flags, rc, props) -> None: + if not self._mqtt: + _LOGGER.error('__on_connect, but mqtt is None') + return if not self._mqtt.is_connected(): return self.log_info(f'mips connect, {flags}, {rc}, {props}') @@ -685,6 +705,10 @@ class _MipsClient(ABC): self._on_mips_message(topic=msg.topic, payload=msg.payload) def __mips_sub_internal_pending_handler(self, ctx: Any) -> None: + if not self._mqtt or not self._mqtt.is_connected(): + _LOGGER.error( + 'mips sub internal pending, but mqtt is None or disconnected') + return subbed_count = 1 for topic in list(self._mips_sub_pending_map.keys()): if subbed_count > self.MIPS_SUB_PATCH: @@ -712,6 +736,9 @@ class _MipsClient(ABC): self._mips_sub_pending_timer = None def __mips_connect(self) -> None: + if not self._mqtt: + _LOGGER.error('__mips_connect, but mqtt is None') + return result = MQTT_ERR_UNKNOWN if self._mips_reconnect_timer: self._mips_reconnect_timer.cancel() @@ -782,7 +809,14 @@ class _MipsClient(ABC): self._internal_loop.remove_reader(self._mqtt_fd) self._internal_loop.remove_writer(self._mqtt_fd) self._mqtt_fd = -1 - self._mqtt.disconnect() + # Clear retry sub + if self._mips_sub_pending_timer: + self._mips_sub_pending_timer.cancel() + self._mips_sub_pending_timer = None + self._mips_sub_pending_map = {} + if self._mqtt: + self._mqtt.disconnect() + self._mqtt = None self._internal_loop.stop() def __get_next_reconnect_time(self) -> float: diff --git a/test/test_mdns.py b/test/test_mdns.py index 82cf477..a0e148a 100755 --- a/test/test_mdns.py +++ b/test/test_mdns.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- """Unit test for miot_mdns.py.""" +import asyncio import logging import pytest from zeroconf import IPVersion @@ -12,10 +13,10 @@ _LOGGER = logging.getLogger(__name__) @pytest.mark.asyncio async def test_service_loop_async(): - from miot.miot_mdns import MipsService, MipsServiceData, MipsServiceState + from miot.miot_mdns import MipsService, MipsServiceState async def on_service_state_change( - group_id: str, state: MipsServiceState, data: MipsServiceData): + group_id: str, state: MipsServiceState, data: dict): _LOGGER.info( 'on_service_state_change, %s, %s, %s', group_id, state, data) @@ -23,8 +24,10 @@ async def test_service_loop_async(): mips_service = MipsService(aiozc) mips_service.sub_service_change('test', '*', on_service_state_change) await mips_service.init_async() + # Wait for service to discover + await asyncio.sleep(3) services_detail = mips_service.get_services() - _LOGGER.info('get all service, %s', services_detail.keys()) + _LOGGER.info('get all service, %s', list(services_detail.keys())) for name, data in services_detail.items(): _LOGGER.info( '\tinfo, %s, %s, %s, %s', diff --git a/test/test_mips.py b/test/test_mips.py new file mode 100644 index 0000000..d808f22 --- /dev/null +++ b/test/test_mips.py @@ -0,0 +1,264 @@ +# -*- coding: utf-8 -*- +"""Unit test for miot_mips.py. +NOTICE: When running this test case, you need to run test_cloud.py first to +obtain the token and certificate information, and at the same time avoid data +deletion. +""" +import ipaddress +from typing import Any, Tuple +import pytest +import asyncio +import logging + +_LOGGER = logging.getLogger(__name__) + + +# pylint: disable = import-outside-toplevel, unused-argument + +@pytest.mark.parametrize('central_info', [ + ('', 'Gateway did', 'Gateway ip', 8883), +]) +@pytest.mark.asyncio +async def test_mips_local_async( + test_cache_path: str, + test_domain_cloud_cache: str, + test_name_uid: str, + test_name_rd_did: str, + central_info: Tuple[str, str, str, int] +): + """ + NOTICE: + - Mips local is used to connect to the central gateway and is only + supported in the Chinese mainland region. + - Before running this test case, you need to run test_mdns.py first to + obtain the group_id, did, ip, and port of the hub, and then fill in this + information in the parametrize. you can enter multiple central connection + information items for separate tests. + - This test case requires running test_cloud.py first to obtain the + central connection certificate. + - This test case will control the indicator light switch of the central + gateway. + """ + from miot.miot_storage import MIoTStorage, MIoTCert + from miot.miot_mips import MipsLocalClient + + central_group_id: str = central_info[0] + assert isinstance(central_group_id, str) + central_did: str = central_info[1] + assert central_did.isdigit() + central_ip: str = central_info[2] + assert ipaddress.ip_address(central_ip) + central_port: int = central_info[3] + assert isinstance(central_port, int) + + miot_storage = MIoTStorage(test_cache_path) + uid = await miot_storage.load_async( + domain=test_domain_cloud_cache, name=test_name_uid, type_=str) + assert isinstance(uid, str) + random_did = await miot_storage.load_async( + domain=test_domain_cloud_cache, name=test_name_rd_did, type_=str) + assert isinstance(random_did, str) + miot_cert = MIoTCert(storage=miot_storage, uid=uid, cloud_server='CN') + assert miot_cert.ca_file + assert miot_cert.cert_file + assert miot_cert.key_file + _LOGGER.info( + 'cert info, %s, %s, %s', miot_cert.ca_file, miot_cert.cert_file, + miot_cert.key_file) + + mips_local = MipsLocalClient( + did=random_did, + host=central_ip, + group_id=central_group_id, + ca_file=miot_cert.ca_file, + cert_file=miot_cert.cert_file, + key_file=miot_cert.key_file, + port=central_port, + home_name='mips local test') + mips_local.enable_logger(logger=_LOGGER) + mips_local.enable_mqtt_logger(logger=_LOGGER) + + async def on_mips_state_changed_async(key: str, state: bool): + _LOGGER.info('on mips state changed, %s, %s', key, state) + + async def on_dev_list_changed_async( + mips: MipsLocalClient, did_list: list[str] + ): + _LOGGER.info('dev list changed, %s', did_list) + + def on_prop_changed(payload: dict, ctx: Any): + _LOGGER.info('prop changed, %s=%s', ctx, payload) + + def on_event_occurred(payload: dict, ctx: Any): + _LOGGER.info('event occurred, %s=%s', ctx, payload) + + # Reg mips state + mips_local.sub_mips_state( + key='mips_local', handler=on_mips_state_changed_async) + mips_local.on_dev_list_changed = on_dev_list_changed_async + # Connect + await mips_local.connect_async() + await asyncio.sleep(0.5) + # Get device list + device_list = await mips_local.get_dev_list_async() + assert isinstance(device_list, dict) + _LOGGER.info( + 'get_dev_list, %d, %s', len(device_list), list(device_list.keys())) + # Sub Prop + mips_local.sub_prop( + did=central_did, handler=on_prop_changed, + handler_ctx=f'{central_did}.*') + # Sub Event + mips_local.sub_event( + did=central_did, handler=on_event_occurred, + handler_ctx=f'{central_did}.*') + # Get/set prop + test_siid = 3 + test_piid = 1 + # mips_local.sub_prop( + # did=central_did, siid=test_siid, piid=test_piid, + # handler=on_prop_changed, + # handler_ctx=f'{central_did}.{test_siid}.{test_piid}') + result1 = await mips_local.get_prop_async( + did=central_did, siid=test_siid, piid=test_piid) + assert isinstance(result1, bool) + _LOGGER.info('get prop.%s.%s, value=%s', test_siid, test_piid, result1) + result2 = await mips_local.set_prop_async( + did=central_did, siid=test_siid, piid=test_piid, value=not result1) + _LOGGER.info( + 'set prop.%s.%s=%s, result=%s', + test_siid, test_piid, not result1, result2) + assert isinstance(result2, dict) + result3 = await mips_local.get_prop_async( + did=central_did, siid=test_siid, piid=test_piid) + assert isinstance(result3, bool) + _LOGGER.info('get prop.%s.%s, value=%s', test_siid, test_piid, result3) + # Action + test_siid = 4 + test_aiid = 1 + in_list = [{'piid': 1, 'value': 'hello world.'}] + result4 = await mips_local.action_async( + did=central_did, siid=test_siid, aiid=test_aiid, + in_list=in_list) + assert isinstance(result4, dict) + _LOGGER.info( + 'action.%s.%s=%s, result=%s', test_siid, test_piid, in_list, result4) + # Disconnect + await mips_local.disconnect_async() + await mips_local.deinit_async() + + +@pytest.mark.asyncio +async def test_mips_cloud_async( + test_cache_path: str, + test_name_uuid: str, + test_cloud_server: str, + test_domain_cloud_cache: str, + test_name_oauth2_info: str, + test_name_devices: str +): + """ + NOTICE: + - This test case requires running test_cloud.py first to obtain the + central connection certificate. + - This test case will control the indicator light switch of the central + gateway. + """ + from miot.const import OAUTH2_CLIENT_ID + from miot.miot_storage import MIoTStorage + from miot.miot_mips import MipsCloudClient + from miot.miot_cloud import MIoTHttpClient + + miot_storage = MIoTStorage(test_cache_path) + uuid = await miot_storage.load_async( + domain=test_domain_cloud_cache, name=test_name_uuid, type_=str) + assert isinstance(uuid, str) + oauth_info = await miot_storage.load_async( + domain=test_domain_cloud_cache, name=test_name_oauth2_info, type_=dict) + assert isinstance(oauth_info, dict) and 'access_token' in oauth_info + access_token = oauth_info['access_token'] + _LOGGER.info('connect info, %s, %s', uuid, access_token) + mips_cloud = MipsCloudClient( + uuid=uuid, + cloud_server=test_cloud_server, + app_id=OAUTH2_CLIENT_ID, + token=access_token) + mips_cloud.enable_logger(logger=_LOGGER) + mips_cloud.enable_mqtt_logger(logger=_LOGGER) + miot_http = MIoTHttpClient( + cloud_server=test_cloud_server, + client_id=OAUTH2_CLIENT_ID, + access_token=access_token) + + async def on_mips_state_changed_async(key: str, state: bool): + _LOGGER.info('on mips state changed, %s, %s', key, state) + + def on_prop_changed(payload: dict, ctx: Any): + _LOGGER.info('prop changed, %s=%s', ctx, payload) + + def on_event_occurred(payload: dict, ctx: Any): + _LOGGER.info('event occurred, %s=%s', ctx, payload) + + await mips_cloud.connect_async() + await asyncio.sleep(0.5) + + # Sub mips state + mips_cloud.sub_mips_state( + key='mips_cloud', handler=on_mips_state_changed_async) + # Load devices + local_devices = await miot_storage.load_async( + domain=test_domain_cloud_cache, name=test_name_devices, type_=dict) + assert isinstance(local_devices, dict) + central_did = '' + for did, info in local_devices.items(): + if info['model'] != 'xiaomi.gateway.hub1': + continue + central_did = did + break + if central_did: + # Sub Prop + mips_cloud.sub_prop( + did=central_did, handler=on_prop_changed, + handler_ctx=f'{central_did}.*') + # Sub Event + mips_cloud.sub_event( + did=central_did, handler=on_event_occurred, + handler_ctx=f'{central_did}.*') + # Get/set prop + test_siid = 3 + test_piid = 1 + # mips_cloud.sub_prop( + # did=central_did, siid=test_siid, piid=test_piid, + # handler=on_prop_changed, + # handler_ctx=f'{central_did}.{test_siid}.{test_piid}') + result1 = await miot_http.get_prop_async( + did=central_did, siid=test_siid, piid=test_piid) + assert isinstance(result1, bool) + _LOGGER.info('get prop.%s.%s, value=%s', test_siid, test_piid, result1) + result2 = await miot_http.set_prop_async(params=[{ + 'did': central_did, 'siid': test_siid, 'piid': test_piid, + 'value': not result1}]) + _LOGGER.info( + 'set prop.%s.%s=%s, result=%s', + test_siid, test_piid, not result1, result2) + assert isinstance(result2, list) + result3 = await miot_http.get_prop_async( + did=central_did, siid=test_siid, piid=test_piid) + assert isinstance(result3, bool) + _LOGGER.info('get prop.%s.%s, value=%s', test_siid, test_piid, result3) + # Action + test_siid = 4 + test_aiid = 1 + in_list = [{'piid': 1, 'value': 'hello world.'}] + result4 = await miot_http.action_async( + did=central_did, siid=test_siid, aiid=test_aiid, + in_list=in_list) + assert isinstance(result4, dict) + _LOGGER.info( + 'action.%s.%s=%s, result=%s', + test_siid, test_piid, in_list, result4) + await asyncio.sleep(1) + # Disconnect + await mips_cloud.disconnect_async() + await mips_cloud.deinit_async() + await miot_http.deinit_async() From bf116e13a427d8e7dd86a5ccff2cca8736261edd Mon Sep 17 00:00:00 2001 From: wilds Date: Fri, 17 Jan 2025 06:17:05 +0100 Subject: [PATCH 12/14] feat: support italian translation (#183) * added italian translation * feat: updated translation/it.json * feat: add missing key in Italian translation * feat: fix missing key in Italian translation --- custom_components/xiaomi_home/miot/const.py | 1 + .../xiaomi_home/miot/i18n/it.json | 152 ++++++++++++ .../xiaomi_home/miot/specs/bool_trans.json | 22 +- .../xiaomi_home/miot/specs/multi_lang.json | 24 +- .../xiaomi_home/translations/it.json | 222 ++++++++++++++++++ 5 files changed, 419 insertions(+), 2 deletions(-) create mode 100644 custom_components/xiaomi_home/miot/i18n/it.json create mode 100644 custom_components/xiaomi_home/translations/it.json diff --git a/custom_components/xiaomi_home/miot/const.py b/custom_components/xiaomi_home/miot/const.py index c586992..275b297 100644 --- a/custom_components/xiaomi_home/miot/const.py +++ b/custom_components/xiaomi_home/miot/const.py @@ -103,6 +103,7 @@ INTEGRATION_LANGUAGES = { 'en': 'English', 'es': 'Español', 'fr': 'Français', + 'it': 'Italiano', 'ja': '日本語', 'nl': 'Nederlands', 'pt': 'Português', diff --git a/custom_components/xiaomi_home/miot/i18n/it.json b/custom_components/xiaomi_home/miot/i18n/it.json new file mode 100644 index 0000000..7dd652a --- /dev/null +++ b/custom_components/xiaomi_home/miot/i18n/it.json @@ -0,0 +1,152 @@ +{ + "config": { + "other": { + "devices": "Dispositivi", + "found_central_gateway": ", Trovato Gateway Hub Centrale Locale", + "without_room": "Nessuna stanza assegnata", + "no_display": "Non visualizzare" + }, + "control_mode": { + "auto": "Auto", + "cloud": "Cloud" + }, + "statistics_logic": { + "or": "Logica OR", + "and": "Logica AND" + }, + "filter_mode": { + "exclude": "Escludere", + "include": "Includere" + }, + "connect_type": { + "0": "WiFi", + "1": "Dispositivo yunyi", + "2": "Dispositivo Cloud", + "3": "ZigBee", + "4": "webSocket", + "5": "Dispositivo Virtuale", + "6": "BLE", + "7": "AP Locale", + "8": "WiFi+BLE", + "9": "Altro", + "10": "Plug-in Funzionale", + "11": "Rete Cellulare", + "12": "Cavo", + "13": "NB-IoT", + "14": "Accesso cloud di terze parti", + "15": "Dispositivo di controllo remoto a infrarossi", + "16": "BLE-Mesh", + "17": "Gruppo di Dispositivi Virtuali", + "18": "Sottodispositivo Gateway", + "19": "Sottodispositivo Gateway di livello di sicurezza", + "22": "PLC", + "23": "Solo Cavo", + "24": "Matter", + "25": "WiFi+Rete Cellulare" + }, + "room_name_rule": { + "none": "Non sincronizzare", + "home_room": "Nome Casa e Nome Stanza (Camera da Letto Xiaomi Home)", + "room": "Nome Stanza (Camera da Letto)", + "home": "Nome Casa (Xiaomi Home)" + }, + "option_status": { + "enable": "Abilita", + "disable": "Disabilita" + }, + "device_state": { + "add": "Aggiungere", + "del": "Non disponibile", + "offline": "Offline" + }, + "lan_ctrl_config": { + "notice_net_dup": "\r\n**[Avviso]** Rilevate più schede di rete che potrebbero essere connesse alla stessa rete. Si prega di prestare attenzione alla selezione.", + "net_unavailable": "Interfaccia non disponibile" + } + }, + "oauth2": { + "success": { + "title": "Autenticazione Avvenuta", + "content": "Chiudere questa pagina e tornare alla pagina di autenticazione dell'account per fare clic su 'Avanti'.", + "button": "Chiudi" + }, + "fail": { + "title": "Autenticazione Fallita", + "content": "{error_msg}, chiudere questa pagina e tornare alla pagina di autenticazione dell'account per fare clic nuovamente sul link di autenticazione.", + "button": "Chiudi" + }, + "error_msg": { + "-10100": "Parametri di risposta non validi (i campi 'code' o 'state' sono vuoti)", + "-10101": "Campo 'state' passato non corrispondente" + } + }, + "miot": { + "client": { + "invalid_oauth_info": "Le informazioni di autenticazione non sono valide, il collegamento al cloud non sarà disponibile, si prega di accedere alla pagina di integrazione Xiaomi Home e cliccare 'Opzioni' per ri-autenticarsi", + "invalid_device_cache": "Le informazioni memorizzate nella cache del dispositivo sono anomale, si prega di accedere alla pagina di integrazione Xiaomi Home e cliccare 'Opzioni->Aggiorna elenco dispositivi' per aggiornare la cache locale", + "invalid_cert_info": "Certificato utente non valido, il collegamento centrale locale non sarà disponibile, si prega di accedere alla pagina di integrazione Xiaomi Home e cliccare 'Opzioni' per ri-autenticarsi", + "device_cloud_error": "Si è verificata un'eccezione durante l'ottenimento delle informazioni del dispositivo dal cloud, si prega di controllare la connessione alla rete locale", + "xiaomi_home_error_title": "Errore di Integrazione Xiaomi Home", + "xiaomi_home_error": "Rilevato errore per **{nick_name}({uid}, {cloud_server})**, si prega di accedere alla pagina delle opzioni per riconfigurare.\n\n**Messaggio di errore**: \n{message}", + "device_list_changed_title": "Modifiche all'elenco dispositivi Xiaomi Home", + "device_list_changed": "Rilevato cambiamento nelle informazioni del dispositivo per **{nick_name}({uid}, {cloud_server})**, si prega di accedere alla pagina delle opzioni di integrazione, cliccare `Opzioni->Aggiorna elenco dispositivi` per aggiornare le informazioni locali dei dispositivi.\n\nStato corrente della rete: {network_status}\n{message}\n", + "device_list_add": "\n**{count} nuovi dispositivi:** \n{message}", + "device_list_del": "\n**{count} dispositivi non disponibili:** \n{message}", + "device_list_offline": "\n**{count} dispositivi offline:** \n{message}", + "network_status_online": "Online", + "network_status_offline": "Offline", + "device_exec_error": "Errore di esecuzione" + } + }, + "error": { + "common": { + "-10000": "Errore sconosciuto", + "-10001": "Servizio non disponibile", + "-10002": "Parametro non valido", + "-10003": "Risorse insufficienti", + "-10004": "Errore interno", + "-10005": "Permessi insufficienti", + "-10006": "Timeout di esecuzione", + "-10007": "Dispositivo offline o inesistente", + "-10020": "Non autorizzato (OAuth2)", + "-10030": "Token non valido (HTTP)", + "-10040": "Formato messaggio non valido", + "-10050": "Certificato non valido", + "-704000000": "Errore sconosciuto", + "-704010000": "Non autorizzato (il dispositivo potrebbe essere stato eliminato)", + "-704014006": "Descrizione del dispositivo non trovata", + "-704030013": "Proprietà non leggibile", + "-704030023": "Proprietà non scrivibile", + "-704030033": "Proprietà non sottoscrivibile", + "-704040002": "Servizio inesistente", + "-704040003": "Proprietà inesistente", + "-704040004": "Evento inesistente", + "-704040005": "Azione inesistente", + "-704040999": "Funzione non online", + "-704042001": "Dispositivo inesistente", + "-704042011": "Dispositivo offline", + "-704053036": "Timeout operazione del dispositivo", + "-704053100": "Il dispositivo non può eseguire questa operazione nello stato attuale", + "-704083036": "Timeout operazione del dispositivo", + "-704090001": "Dispositivo inesistente", + "-704220008": "ID non valido", + "-704220025": "Conteggio parametri azione non corrispondente", + "-704220035": "Errore del parametro azione", + "-704220043": "Errore valore proprietà", + "-704222034": "Errore valore di ritorno dell'azione", + "-705004000": "Errore sconosciuto", + "-705004501": "Errore sconosciuto", + "-705201013": "Proprietà non leggibile", + "-705201015": "Errore di esecuzione azione", + "-705201023": "Proprietà non scrivibile", + "-705201033": "Proprietà non sottoscrivibile", + "-706012000": "Errore sconosciuto", + "-706012013": "Proprietà non leggibile", + "-706012015": "Errore di esecuzione azione", + "-706012023": "Proprietà non scrivibile", + "-706012033": "Proprietà non sottoscrivibile", + "-706012043": "Errore valore proprietà", + "-706014006": "Descrizione del dispositivo non trovata" + } + } +} diff --git a/custom_components/xiaomi_home/miot/specs/bool_trans.json b/custom_components/xiaomi_home/miot/specs/bool_trans.json index 0bf193f..4bee0b4 100644 --- a/custom_components/xiaomi_home/miot/specs/bool_trans.json +++ b/custom_components/xiaomi_home/miot/specs/bool_trans.json @@ -78,6 +78,10 @@ "true": "Vrai", "false": "Faux" }, + "it": { + "true": "Vero", + "false": "Falso" + }, "ja": { "true": "真", "false": "偽" @@ -124,6 +128,10 @@ "true": "Ouvert", "false": "Fermer" }, + "it": { + "true": "Aperto", + "false": "Chiuso" + }, "ja": { "true": "開く", "false": "閉じる" @@ -170,6 +178,10 @@ "true": "Oui", "false": "Non" }, + "it": { + "true": "Si", + "false": "No" + }, "ja": { "true": "はい", "false": "いいえ" @@ -216,6 +228,10 @@ "true": "Mouvement détecté", "false": "Aucun mouvement détecté" }, + "it": { + "true": "Movimento Rilevato", + "false": "Nessun Movimento Rilevato" + }, "ja": { "true": "動きを検知", "false": "動きが検出されません" @@ -262,6 +278,10 @@ "true": "Contact", "false": "Pas de contact" }, + "it": { + "true": "Contatto", + "false": "Nessun Contatto" + }, "ja": { "true": "接触", "false": "非接触" @@ -292,4 +312,4 @@ } } } -} \ No newline at end of file +} diff --git a/custom_components/xiaomi_home/miot/specs/multi_lang.json b/custom_components/xiaomi_home/miot/specs/multi_lang.json index 5e48bcc..8cd4284 100644 --- a/custom_components/xiaomi_home/miot/specs/multi_lang.json +++ b/custom_components/xiaomi_home/miot/specs/multi_lang.json @@ -88,6 +88,28 @@ "service:004:event:001": "Événement virtuel survenu", "service:004:property:001": "Nom de l'événement" }, + "it": { + "service:001": "Informazioni sul Dispositivo", + "service:001:property:003": "ID Dispositivo", + "service:001:property:005": "Numero di Serie (SN)", + "service:002": "Gateway", + "service:002:event:001": "Rete Modificata", + "service:002:event:002": "Rete Modificata", + "service:002:property:001": "Metodo di Accesso", + "service:002:property:001:valuelist:000": "Cablato", + "service:002:property:001:valuelist:001": "Wireless 5G", + "service:002:property:001:valuelist:002": "Wireless 2.4G", + "service:002:property:002": "Indirizzo IP", + "service:002:property:003": "Nome Rete WiFi", + "service:002:property:004": "Ora Attuale", + "service:002:property:005": "Indirizzo MAC del Server DHCP", + "service:003": "Luce Indicatore", + "service:003:property:001": "Interruttore", + "service:004": "Servizio Virtuale", + "service:004:action:001": "Genera Evento Virtuale", + "service:004:event:001": "Evento Virtuale Avvenuto", + "service:004:property:001": "Nome Evento" + }, "ja": { "service:001": "デバイス情報", "service:001:property:003": "デバイスID", @@ -169,4 +191,4 @@ "service:017:action:001": "右键确认" } } -} \ No newline at end of file +} diff --git a/custom_components/xiaomi_home/translations/it.json b/custom_components/xiaomi_home/translations/it.json new file mode 100644 index 0000000..066ec12 --- /dev/null +++ b/custom_components/xiaomi_home/translations/it.json @@ -0,0 +1,222 @@ +{ + "config": { + "flow_title": "Integrazione Xiaomi Home", + "step": { + "eula": { + "title": "Avviso sui Rischi", + "description": "1. Le informazioni del tuo utente Xiaomi e le informazioni del dispositivo saranno memorizzate nel sistema Home Assistant. **Xiaomi non può garantire la sicurezza del meccanismo di archiviazione di Home Assistant**. Sei responsabile per prevenire il furto delle tue informazioni.\r\n2. Questa integrazione è mantenuta dalla comunità open-source. Potrebbero esserci problemi di stabilità o altri problemi. In caso di problemi o bug con questa integrazione, **dovresti cercare aiuto dalla comunità open-source piuttosto che contattare il servizio clienti Xiaomi**.\r\n3. È necessaria una certa abilità tecnica per mantenere il tuo ambiente operativo locale. L'integrazione non è user-friendly per i principianti.\r\n4. Si prega di leggere il file README prima di iniziare.\n\n5. Per garantire un uso stabile dell'integrazione e prevenire l'abuso dell'interfaccia, **questa integrazione può essere utilizzata solo in Home Assistant. Per i dettagli, consulta il LICENSE**.", + "data": { + "eula": "Sono consapevole dei rischi sopra indicati e sono disposto ad assumermi volontariamente qualsiasi rischio associato all'uso dell'integrazione." + } + }, + "auth_config": { + "title": "Configurazione di base", + "description": "### Regione di Login\r\nSeleziona la regione del tuo account Xiaomi. Puoi trovarla nell'APP Xiaomi Home > Profilo (nel menu in basso) > Impostazioni aggiuntive > Informazioni su Xiaomi Home.\r\n### Lingua\r\nSeleziona la lingua dei nomi dei dispositivi e delle entità. Alcune frasi senza traduzione verranno visualizzate in inglese.\r\n### URL di reindirizzamento OAuth2\r\nL'indirizzo di reindirizzamento dell'autenticazione OAuth2 è **[http://homeassistant.local:8123](http://homeassistant.local:8123)**. Home Assistant deve trovarsi nella stessa rete locale del terminale operativo corrente (ad esempio, il computer personale) e il terminale operativo deve poter accedere alla home page di Home Assistant tramite questo indirizzo. Altrimenti, l'autenticazione del login potrebbe fallire.\r\n### Nota\r\n- Per gli utenti con centinaia o più dispositivi Mi Home, l'aggiunta iniziale dell'integrazione richiederà del tempo. Si prega di essere pazienti.\r\n- Se Home Assistant è in esecuzione in un ambiente Docker, assicurarsi che la modalità di rete Docker sia impostata su host, altrimenti la funzionalità di controllo locale potrebbe non funzionare correttamente.\r\n- La funzionalità di controllo locale dell'integrazione ha alcune dipendenze. Si prega di leggere attentamente il README.", + "data": { + "cloud_server": "Regione di Login", + "integration_language": "Lingua", + "oauth_redirect_url": "URL di reindirizzamento OAuth2", + "network_detect_config": "Configurazione di rete integrata" + } + }, + "network_detect_config": { + "title": "Configurazione di Rete Integrata", + "description": "## Introduzione all'uso\r\n### Indirizzo di Rilevamento della Rete\r\nUtilizzato per verificare se la rete funziona correttamente. Se non impostato, verrà utilizzato l'indirizzo di default del sistema. Se il controllo dell'indirizzo predefinito fallisce, puoi provare a inserire un indirizzo personalizzato.\r\n- Puoi inserire più indirizzi di rilevamento, separati da virgole, come `8.8.8.8,https://www.bing.com`\r\n- Se è un indirizzo IP, il rilevamento verrà eseguito tramite ping. Se è un indirizzo HTTP(s), il rilevamento verrà eseguito tramite richiesta HTTP GET.\r\n- Se desideri ripristinare l'indirizzo di rilevamento predefinito del sistema, inserisci una virgola `,` e fai clic su 'Avanti'.\r\n- **Questa configurazione è globale e le modifiche influenzeranno altre istanze di integrazione. Si prega di modificare con cautela.**\r\n### Controlla le Dipendenze di Rete\r\nControlla una per una le seguenti dipendenze di rete per vedere se sono accessibili. Se gli indirizzi correlati non sono accessibili, causerà problemi di integrazione.\r\n- Indirizzo di Autenticazione OAuth2: `https://account.xiaomi.com/oauth2/authorize`.\r\n- Indirizzo API HTTP di Xiaomi: `https://{http_host}/app/v2/ha/oauth/get_token`.\r\n- Indirizzo API SPEC di Xiaomi: `https://miot-spec.org/miot-spec-v2/template/list/device`.\r\n- Indirizzo del Broker MQTT di Xiaomi: `mqtts://{cloud_server}-ha.mqtt.io.mi.com:8883`.", + "data": { + "network_detect_addr": "Indirizzo di Rilevamento della Rete", + "check_network_deps": "Controlla le Dipendenze di Rete" + } + }, + "oauth_error": { + "title": "Errore di Login", + "description": "Clicca AVANTI per riprovare." + }, + "homes_select": { + "title": "Seleziona Famiglia e Dispositivo", + "description": "## Introduzione\r\n### Importa la Famiglia del Dispositivo\r\nL'integrazione aggiungerà i dispositivi dalla famiglia selezionata.\r\n### Modalità di Sincronizzazione del Nome della Stanza\r\nQuando si sincronizzano i dispositivi dall'app Mi Home a Home Assistant, la denominazione dell'area in Home Assistant seguirà le regole indicate di seguito. Si noti che il processo di sincronizzazione non modificherà le impostazioni di famiglia e stanza nell'app Mi Home.\r\n- Non sincronizzare: Il dispositivo non verrà aggiunto a nessuna area.\r\n- Altre opzioni: L'area a cui viene aggiunto il dispositivo verrà denominata come la famiglia o il nome della stanza nell'app Mi Home.\r\n### Impostazioni Avanzate\r\nMostra le impostazioni avanzate per modificare le opzioni di configurazione professionale dell'integrazione.\r\n\r\n \r\n### {nick_name} Ciao! Seleziona la famiglia a cui desideri aggiungere il dispositivo.", + "data": { + "home_infos": "Importa la Famiglia del Dispositivo", + "area_name_rule": "Modalità di Sincronizzazione del Nome della Stanza", + "advanced_options": "Impostazioni Avanzate" + } + }, + "advanced_options": { + "title": "Impostazioni Avanzate", + "description": "## Introduzione\r\n### A meno che tu non abbia chiaro il significato delle seguenti opzioni, si prega di mantenere le impostazioni predefinite.\r\n### Filtra Dispositivi\r\nSupporta il filtraggio dei dispositivi per nome della stanza e tipo di dispositivo, e supporta anche il filtraggio delle dimensioni del dispositivo.\r\n### Modalità di Controllo\r\n- Automatico: Quando è disponibile un gateway hub centrale Xiaomi nella rete locale, Home Assistant darà priorità all'invio dei comandi di controllo dei dispositivi tramite il gateway hub centrale per ottenere il controllo locale. Se non è presente un gateway hub centrale nella rete locale, tenterà di inviare comandi di controllo tramite il protocollo OT di Xiaomi per ottenere il controllo locale. Solo quando le condizioni di controllo locale sopra indicate non sono soddisfatte, i comandi di controllo del dispositivo verranno inviati tramite il cloud.\r\n- Cloud: Tutti i comandi di controllo vengono inviati tramite il cloud.\r\n### Modalità di Debug delle Azioni\r\nPer i metodi definiti dal dispositivo MIoT-Spec-V2, oltre a generare entità di notifica, verrà generata anche un'entità di casella di input di testo. È possibile utilizzarla per inviare comandi di controllo al dispositivo durante il debug.\r\n### Nascondi Entità Generate Non Standard\r\nNasconde le entità generate da istanze non standard MIoT-Spec-V2 con nomi che iniziano con \"*\".\r\n### Mostra Notifiche di Cambio di Stato del Dispositivo\r\nMostra notifiche dettagliate sui cambiamenti di stato del dispositivo, mostrando solo le notifiche selezionate.", + "data": { + "devices_filter": "Filtra Dispositivi", + "ctrl_mode": "Modalità di Controllo", + "action_debug": "Modalità di Debug delle Azioni", + "hide_non_standard_entities": "Nascondi Entità Generate Non Standard", + "display_devices_changed_notify": "Mostra Notifiche di Cambio di Stato del Dispositivo" + } + }, + "devices_filter": { + "title": "Filtra Dispositivi", + "description": "## Istruzioni per l'uso\r\nSupporta il filtraggio dei dispositivi per nome della stanza, tipo di accesso al dispositivo e modello del dispositivo, e supporta anche il filtraggio delle dimensioni del dispositivo. La logica di filtraggio è la seguente:\r\n- Prima, secondo la logica statistica, ottieni l'unione o l'intersezione di tutti gli elementi inclusi, poi ottieni l'intersezione o l'unione degli elementi esclusi e infine sottrai il [risultato riassuntivo incluso] dal [risultato riassuntivo escluso] per ottenere il [risultato del filtro].\r\n- Se non vengono selezionati elementi inclusi, significa che tutti sono inclusi.\r\n### Modalità di Filtro\r\n- Escludi: Rimuovi gli elementi indesiderati.\r\n- Includi: Includi gli elementi desiderati.\r\n### Logica Statistica\r\n- Logica AND: Prendi l'intersezione di tutti gli elementi nella stessa modalità.\r\n- Logica OR: Prendi l'unione di tutti gli elementi nella stessa modalità.\r\n\r\nPuoi anche andare alla pagina [Configurazione > Aggiorna Elenco Dispositivi] dell'elemento di integrazione e controllare [Filtra Dispositivi] per rifiltrare.", + "data": { + "room_filter_mode": "Filtra Stanze della Famiglia", + "room_list": "Stanze della Famiglia", + "type_filter_mode": "Filtra Tipo di Connessione del Dispositivo", + "type_list": "Tipo di Connessione del Dispositivo", + "model_filter_mode": "Filtra Modello del Dispositivo", + "model_list": "Modello del Dispositivo", + "devices_filter_mode": "Filtra Dispositivi", + "device_list": "Elenco Dispositivi", + "statistics_logic": "Logica Statistica" + } + } + }, + "progress": { + "oauth": "### {link_left}Clicca qui per accedere{link_right}\r\n(Verrai reindirizzato automaticamente alla pagina successiva dopo un accesso riuscito)" + }, + "error": { + "eula_not_agree": "Si prega di leggere l'avviso sui rischi.", + "get_token_error": "Impossibile recuperare le informazioni di autorizzazione per il login (token OAuth).", + "get_homeinfo_error": "Impossibile recuperare le informazioni della casa.", + "mdns_discovery_error": "Eccezione del servizio di scoperta dei dispositivi locali.", + "get_cert_error": "Impossibile recuperare il certificato del gateway centrale.", + "no_family_selected": "Nessuna casa selezionata.", + "no_devices": "La casa selezionata non ha dispositivi. Si prega di scegliere una casa che contiene dispositivi e continuare.", + "no_filter_devices": "I dispositivi filtrati sono vuoti. Si prega di selezionare criteri di filtro validi e continuare.", + "no_central_device": "[Modalità Gateway Hub Centrale] richiede un gateway hub centrale Xiaomi disponibile nella rete locale in cui esiste Home Assistant. Si prega di verificare se la casa selezionata soddisfa il requisito.", + "invalid_network_addr": "Rilevato indirizzo IP o indirizzo HTTP non valido, si prega di inserire un indirizzo valido.", + "invalid_ip_addr": "Rilevato indirizzo IP non raggiungibile, si prega di inserire un indirizzo IP valido.", + "invalid_http_addr": "Rilevato indirizzo HTTP non raggiungibile, si prega di inserire un indirizzo HTTP valido.", + "invalid_default_addr": "Indirizzo di rilevamento della rete predefinito non raggiungibile, si prega di verificare la configurazione della rete o utilizzare un indirizzo di rilevamento della rete personalizzato.", + "unreachable_oauth2_host": "Impossibile raggiungere l'indirizzo di autenticazione OAuth2, si prega di verificare la configurazione della rete.", + "unreachable_http_host": "Impossibile raggiungere l'indirizzo API HTTP di Xiaomi, si prega di verificare la configurazione della rete.", + "unreachable_spec_host": "Impossibile raggiungere l'indirizzo API SPEC di Xiaomi, si prega di verificare la configurazione della rete.", + "unreachable_mqtt_broker": "Impossibile raggiungere l'indirizzo del broker MQTT di Xiaomi, si prega di verificare la configurazione della rete." + }, + "abort": { + "ha_uuid_get_failed": "Impossibile ottenere l'UUID di Home Assistant.", + "network_connect_error": "Configurazione fallita. La connessione di rete è anomala. Si prega di controllare la configurazione della rete del dispositivo.", + "already_configured": "La configurazione per questo utente è già completata. Si prega di andare alla pagina dell'integrazione e cliccare sul pulsante CONFIGURA per le modifiche.", + "invalid_auth_info": "Le informazioni di autenticazione sono scadute. Si prega di andare alla pagina dell'integrazione e cliccare sul pulsante CONFIGURA per ri-autenticarsi.", + "config_flow_error": "Errore di configurazione dell'integrazione: {error}." + } + }, + "options": { + "step": { + "auth_config": { + "title": "Configurazione dell'Autenticazione", + "description": "Le informazioni di autenticazione locale sono scadute. Si prega di riavviare il processo di autenticazione.\r\n### Regione di Login Corrente: {cloud_server}\r\n### URL di reindirizzamento OAuth2\r\nL'indirizzo di reindirizzamento dell'autenticazione OAuth2 è **[http://homeassistant.local:8123](http://homeassistant.local:8123)**. Home Assistant deve trovarsi nella stessa rete locale del terminale operativo corrente (ad esempio, il computer personale) e il terminale operativo deve poter accedere alla home page di Home Assistant tramite questo indirizzo. Altrimenti, l'autenticazione del login potrebbe fallire.", + "data": { + "oauth_redirect_url": "URL di reindirizzamento OAuth2" + } + }, + "oauth_error": { + "title": "Si è verificato un errore durante il login.", + "description": "Clicca AVANTI per riprovare." + }, + "config_options": { + "title": "Opzioni di Configurazione", + "description": "### Ciao, {nick_name}\r\n\r\nID Xiaomi: {uid}\r\nRegione di Login Corrente: {cloud_server}\r\n\r\nSeleziona le opzioni che desideri configurare, poi clicca AVANTI.", + "data": { + "integration_language": "Lingua dell'Integrazione", + "update_user_info": "Aggiorna le informazioni dell'utente", + "update_devices": "Aggiorna l'elenco dei dispositivi", + "action_debug": "Modalità debug per azione", + "hide_non_standard_entities": "Nascondi entità create non standard", + "display_devices_changed_notify": "Mostra notifiche di cambio stato del dispositivo", + "update_trans_rules": "Aggiorna le regole di conversione delle entità", + "update_lan_ctrl_config": "Aggiorna configurazione del controllo LAN", + "network_detect_config": "Configurazione di Rete Integrata" + } + }, + "update_user_info": { + "title": "Aggiorna il Nickname dell'Utente", + "description": "Ciao {nick_name}, puoi modificare il tuo nickname personalizzato qui sotto.", + "data": { + "nick_name": "Nickname" + } + }, + "homes_select": { + "title": "Seleziona Nuovamente Casa e Dispositivi", + "description": "## Istruzioni per l'uso\r\n### Importa dispositivi da casa\r\nL'integrazione aggiungerà dispositivi dalle case selezionate.\r\n### Filtra Dispositivi\r\nSupporta il filtraggio dei dispositivi per nome della stanza, tipo di accesso al dispositivo e modello del dispositivo, e supporta anche il filtraggio delle dimensioni del dispositivo. **{local_count}** dispositivi sono stati filtrati.\r\n### Modalità di Controllo\r\n- Automatico: Quando è disponibile un gateway hub centrale Xiaomi nella rete locale, Home Assistant darà priorità all'invio dei comandi di controllo dei dispositivi tramite il gateway hub centrale per ottenere il controllo locale. Se non è presente un gateway hub centrale nella rete locale, tenterà di inviare comandi di controllo tramite la funzione di controllo LAN di Xiaomi. Solo quando le condizioni di controllo locale sopra indicate non sono soddisfatte, i comandi di controllo del dispositivo verranno inviati tramite il cloud.\r\n- Cloud: Tutti i comandi di controllo vengono inviati tramite il cloud.", + "data": { + "home_infos": "Importa dispositivi da casa", + "devices_filter": "Filtra dispositivi", + "ctrl_mode": "Modalità di controllo" + } + }, + "devices_filter": { + "title": "Filtra Dispositivi", + "description": "## Istruzioni per l'uso\r\nSupporta il filtraggio dei dispositivi per nome della stanza, tipo di accesso al dispositivo e modello del dispositivo, e supporta anche il filtraggio delle dimensioni del dispositivo. La logica di filtraggio è la seguente:\r\n- Prima, secondo la logica statistica, ottieni l'unione o l'intersezione di tutti gli elementi inclusi, poi ottieni l'intersezione o l'unione degli elementi esclusi e infine sottrai il [risultato riassuntivo incluso] dal [risultato riassuntivo escluso] per ottenere il [risultato del filtro].\r\n- Se non vengono selezionati elementi inclusi, significa che tutti sono inclusi.\r\n### Modalità di Filtro\r\n- Escludi: Rimuovi gli elementi indesiderati.\r\n- Includi: Includi gli elementi desiderati.\r\n### Logica Statistica\r\n- Logica AND: Prendi l'intersezione di tutti gli elementi nella stessa modalità.\r\n- Logica OR: Prendi l'unione di tutti gli elementi nella stessa modalità.\r\n\r\nPuoi anche andare alla pagina [Configurazione > Aggiorna Elenco Dispositivi] dell'elemento di integrazione e controllare [Filtra Dispositivi] per rifiltrare.", + "data": { + "room_filter_mode": "Filtra Stanze della Famiglia", + "room_list": "Stanze della Famiglia", + "type_filter_mode": "Filtra Tipo di Connessione del Dispositivo", + "type_list": "Tipo di Connessione del Dispositivo", + "model_filter_mode": "Filtra Modello del Dispositivo", + "model_list": "Modello del Dispositivo", + "devices_filter_mode": "Filtra Dispositivi", + "device_list": "Elenco Dispositivi", + "statistics_logic": "Logica Statistica" + } + }, + "update_trans_rules": { + "title": "Aggiorna le Regole di Trasformazione delle Entità", + "description": "## Istruzioni per l'uso\r\n- Aggiorna le informazioni delle entità dei dispositivi nell'istanza dell'integrazione corrente, incluse la configurazione multilingue MIoT-Spec-V2, la traduzione booleana e il filtro dei modelli.\r\n- **Avviso**: Questa è una configurazione globale e aggiornerà la cache locale. Influenzando tutte le istanze di integrazione.\r\n- Questa operazione richiederà del tempo, si prega di essere pazienti. Seleziona \"Conferma Aggiornamento\" e clicca \"Avanti\" per iniziare l'aggiornamento di **{urn_count}** regole, altrimenti salta l'aggiornamento.", + "data": { + "confirm": "Conferma l'aggiornamento" + } + }, + "update_lan_ctrl_config": { + "title": "Aggiorna configurazione del controllo LAN", + "description": "## Istruzioni per l'uso\r\nAggiorna le configurazioni per la funzione di controllo LAN di Xiaomi. Quando il cloud e il gateway centrale non possono controllare i dispositivi, l'integrazione tenterà di controllare i dispositivi tramite la LAN. Se nessuna scheda di rete è selezionata, la funzione di controllo LAN non avrà effetto.\r\n- Solo i dispositivi compatibili con MIoT-Spec-V2 nella LAN sono supportati. Alcuni dispositivi prodotti prima del 2020 potrebbero non supportare il controllo LAN o l'abbonamento LAN.\r\n- Seleziona la/le scheda/e di rete nella stessa rete dei dispositivi da controllare. È possibile selezionare più schede di rete. Se Home Assistant ha due o più connessioni alla rete locale a causa della selezione multipla delle schede di rete, si consiglia di selezionare quella con la migliore connessione di rete, altrimenti potrebbe avere un effetto negativo sui dispositivi.\r\n- Se ci sono dispositivi terminali (altoparlanti Xiaomi con schermo, telefono cellulare, ecc.) nella LAN che supportano il controllo locale, abilitare l'abbonamento LAN potrebbe causare anomalie nell'automazione locale e nei dispositivi.\r\n- **Avviso**: Questa è una configurazione globale. Influenzando tutte le istanze di integrazione. Usala con cautela.\r\n{notice_net_dup}", + "data": { + "net_interfaces": "Si prega di selezionare la scheda di rete da utilizzare", + "enable_subscribe": "Abilita Sottoscrizione LAN" + } + }, + "network_detect_config": { + "title": "Configurazione di Rete Integrata", + "description": "## Introduzione all'uso\r\n### Indirizzo di Rilevamento della Rete\r\nUtilizzato per verificare se la rete funziona correttamente. Se non impostato, verrà utilizzato l'indirizzo di default del sistema. Se il controllo dell'indirizzo predefinito fallisce, puoi provare a inserire un indirizzo personalizzato.\r\n- Puoi inserire più indirizzi di rilevamento, separati da virgole, come `8.8.8.8,https://www.bing.com`\r\n- Se è un indirizzo IP, il rilevamento verrà eseguito tramite ping. Se è un indirizzo HTTP(s), il rilevamento verrà eseguito tramite richiesta HTTP GET.\r\n- Se desideri ripristinare l'indirizzo di rilevamento predefinito del sistema, inserisci una virgola `,` e fai clic su 'Avanti'.\r\n- **Questa configurazione è globale e le modifiche influenzeranno altre istanze di integrazione. Si prega di modificare con cautela.**\r\n### Controlla le Dipendenze di Rete\r\nControlla una per una le seguenti dipendenze di rete per vedere se sono accessibili. Se gli indirizzi correlati non sono accessibili, causerà problemi di integrazione.\r\n- Indirizzo di Autenticazione OAuth2: `https://account.xiaomi.com/oauth2/authorize`.\r\n- Indirizzo API HTTP di Xiaomi: `https://{http_host}/app/v2/ha/oauth/get_token`.\r\n- Indirizzo API SPEC di Xiaomi: `https://miot-spec.org/miot-spec-v2/template/list/device`.\r\n- Indirizzo del Broker MQTT di Xiaomi: `mqtts://{cloud_server}-ha.mqtt.io.mi.com:8883`.", + "data": { + "network_detect_addr": "Indirizzo di Rilevamento della Rete", + "check_network_deps": "Controlla le Dipendenze di Rete" + } + }, + "config_confirm": { + "title": "Conferma Configurazione", + "description": "Ciao **{nick_name}**, si prega di confermare le informazioni di configurazione più recenti e poi fare clic su INVIA.\r\nL'integrazione verrà ricaricata utilizzando la configurazione aggiornata.\r\n\r\nLingua dell'Integrazione: \t{lang_new}\r\nSoprannome: \t{nick_name_new}\r\nModalità di debug per azione: \t{action_debug}\r\nNascondi entità create non standard: \t{hide_non_standard_entities}\r\nMostra notifiche di cambio stato del dispositivo:\t{display_devices_changed_notify}\r\nCambiamenti del Dispositivo: \tAggiungi **{devices_add}** dispositivi, Rimuovi **{devices_remove}** dispositivi\r\nCambiamenti delle regole di trasformazione: \tCi sono un totale di **{trans_rules_count}** regole, e aggiornate **{trans_rules_count_success}** regole", + "data": { + "confirm": "Conferma la modifica" + } + } + }, + "progress": { + "oauth": "### {link_left}Clicca qui per riaccedere{link_right}" + }, + "error": { + "not_auth": "Non autenticato. Si prega di fare clic sul link di autenticazione per autenticare l'identità dell'utente.", + "get_token_error": "Impossibile recuperare le informazioni di autorizzazione all'accesso (token OAuth).", + "get_homeinfo_error": "Impossibile recuperare le informazioni sulla casa.", + "get_cert_error": "Impossibile recuperare il certificato del gateway hub centrale.", + "no_devices": "Non ci sono dispositivi nella casa selezionata. Si prega di selezionare una casa con dispositivi e continuare.", + "no_filter_devices": "I dispositivi filtrati sono vuoti. Si prega di selezionare criteri di filtro validi e continuare.", + "no_family_selected": "Nessuna casa selezionata.", + "no_central_device": "[Modalità Gateway Hub Centrale] richiede un gateway hub centrale Xiaomi disponibile nella rete locale in cui esiste Home Assistant. Si prega di verificare se la casa selezionata soddisfa il requisito.", + "mdns_discovery_error": "Eccezione nel servizio di rilevamento dei dispositivi locali.", + "update_config_error": "Impossibile aggiornare le informazioni di configurazione.", + "not_confirm": "Le modifiche non sono confermate. Si prega di confermare la modifica prima di inviare.", + "invalid_network_addr": "Rilevato indirizzo IP o indirizzo HTTP non valido, si prega di inserire un indirizzo valido.", + "invalid_ip_addr": "Rilevato indirizzo IP non raggiungibile, si prega di inserire un indirizzo IP valido.", + "invalid_http_addr": "Rilevato indirizzo HTTP non raggiungibile, si prega di inserire un indirizzo HTTP valido.", + "invalid_default_addr": "Indirizzo di rilevamento della rete predefinito non raggiungibile, si prega di verificare la configurazione della rete o utilizzare un indirizzo di rilevamento della rete personalizzato.", + "unreachable_oauth2_host": "Impossibile raggiungere l'indirizzo di autenticazione OAuth2, si prega di verificare la configurazione della rete.", + "unreachable_http_host": "Impossibile raggiungere l'indirizzo API HTTP di Xiaomi, si prega di verificare la configurazione della rete.", + "unreachable_spec_host": "Impossibile raggiungere l'indirizzo API SPEC di Xiaomi, si prega di verificare la configurazione della rete.", + "unreachable_mqtt_broker": "Impossibile raggiungere l'indirizzo del broker MQTT di Xiaomi, si prega di verificare la configurazione della rete." + }, + "abort": { + "network_connect_error": "Configurazione fallita. La connessione di rete è anomala. Si prega di controllare la configurazione della rete del dispositivo.", + "options_flow_error": "Errore di riconfigurazione dell'integrazione: {error}", + "re_add": "Si prega di riaggiungere l'integrazione. Messaggio di errore: {error}", + "storage_error": "Eccezione del modulo di archiviazione dell'integrazione. Si prega di riprovare o riaggiungere l'integrazione: {error}", + "inconsistent_account": "Le informazioni dell'account sono incoerenti." + } + } +} From ef56448dbbeb053795a2657111baa633e620c002 Mon Sep 17 00:00:00 2001 From: Paul Shawn <32349595+topsworld@users.noreply.github.com> Date: Fri, 17 Jan 2025 18:14:31 +0800 Subject: [PATCH 13/14] refactor: refactor miot device and spec (#592) * fix: fix miot_device type error * fix: fix type error * feat: remove spec cache storage * feat: update std_lib and multi_lang logic * feat: update entity value-range * feat: update value-list logic * feat: update prop.format_ logic * fix: fix miot cloud log error * fix: fix fan entity * style: ignore type error * style: rename spec_filter func name * feat: move bool_trans from storage to spec * feat: move sepc_filter from storage to spec, use the YAML format file * feat: same prop supports multiple sub * feat: same event supports multiple sub * fix: fix device remove error * feat: add func slugify_did * fix: fix multi lang error * feat: update action debug logic * feat: ignore normal disconnect log * feat: support binary mode * feat: change miot spec name type define * style: ignore i18n tranlate type error * fix: fix pylint warning * fix: miot storage type error * feat: support binary display mode configure * feat: set default sensor state_class * fix: fix sensor entity type error * fix: fix __init__ type error * feat: update test case logic * fix: github actions add dependencies lib * fix: fix some type error * feat: update device list changed notify logic --- .github/workflows/test.yaml | 2 +- custom_components/xiaomi_home/__init__.py | 52 +- .../xiaomi_home/binary_sensor.py | 7 +- custom_components/xiaomi_home/climate.py | 95 +- custom_components/xiaomi_home/config_flow.py | 35 +- custom_components/xiaomi_home/cover.py | 44 +- custom_components/xiaomi_home/event.py | 4 +- custom_components/xiaomi_home/fan.py | 94 +- custom_components/xiaomi_home/humidifier.py | 42 +- custom_components/xiaomi_home/light.py | 77 +- custom_components/xiaomi_home/miot/common.py | 81 + .../xiaomi_home/miot/i18n/de.json | 4 + .../xiaomi_home/miot/i18n/en.json | 4 + .../xiaomi_home/miot/i18n/es.json | 4 + .../xiaomi_home/miot/i18n/fr.json | 4 + .../xiaomi_home/miot/i18n/it.json | 6 +- .../xiaomi_home/miot/i18n/ja.json | 4 + .../xiaomi_home/miot/i18n/nl.json | 4 + .../xiaomi_home/miot/i18n/pt-BR.json | 4 + .../xiaomi_home/miot/i18n/pt.json | 4 + .../xiaomi_home/miot/i18n/ru.json | 4 + .../xiaomi_home/miot/i18n/zh-Hans.json | 4 + .../xiaomi_home/miot/i18n/zh-Hant.json | 4 + .../xiaomi_home/miot/miot_client.py | 69 +- .../xiaomi_home/miot/miot_cloud.py | 2 +- .../xiaomi_home/miot/miot_device.py | 280 ++- .../xiaomi_home/miot/miot_mips.py | 3 +- .../xiaomi_home/miot/miot_spec.py | 1628 +++++++++++------ .../xiaomi_home/miot/miot_storage.py | 339 +--- .../xiaomi_home/miot/specs/bool_trans.json | 315 ---- .../xiaomi_home/miot/specs/bool_trans.yaml | 246 +++ .../xiaomi_home/miot/specs/multi_lang.json | 194 -- .../xiaomi_home/miot/specs/spec_filter.json | 68 - .../xiaomi_home/miot/specs/spec_filter.yaml | 43 + custom_components/xiaomi_home/notify.py | 10 +- custom_components/xiaomi_home/number.py | 6 +- custom_components/xiaomi_home/select.py | 3 +- custom_components/xiaomi_home/sensor.py | 22 +- custom_components/xiaomi_home/text.py | 19 +- .../xiaomi_home/translations/de.json | 4 +- .../xiaomi_home/translations/en.json | 4 +- .../xiaomi_home/translations/es.json | 4 +- .../xiaomi_home/translations/fr.json | 4 +- .../xiaomi_home/translations/it.json | 8 +- .../xiaomi_home/translations/ja.json | 4 +- .../xiaomi_home/translations/nl.json | 4 +- .../xiaomi_home/translations/pt-BR.json | 4 +- .../xiaomi_home/translations/pt.json | 4 +- .../xiaomi_home/translations/ru.json | 4 +- .../xiaomi_home/translations/zh-Hans.json | 4 +- .../xiaomi_home/translations/zh-Hant.json | 4 +- custom_components/xiaomi_home/vacuum.py | 22 +- custom_components/xiaomi_home/water_heater.py | 50 +- test/check_rule_format.py | 74 +- 54 files changed, 2050 insertions(+), 1978 deletions(-) delete mode 100644 custom_components/xiaomi_home/miot/specs/bool_trans.json create mode 100644 custom_components/xiaomi_home/miot/specs/bool_trans.yaml delete mode 100644 custom_components/xiaomi_home/miot/specs/multi_lang.json delete mode 100644 custom_components/xiaomi_home/miot/specs/spec_filter.json create mode 100644 custom_components/xiaomi_home/miot/specs/spec_filter.yaml diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 481762c..34f8734 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -19,7 +19,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install pytest pytest-asyncio pytest-dependency zeroconf paho.mqtt psutil cryptography + pip install pytest pytest-asyncio pytest-dependency zeroconf paho.mqtt psutil cryptography slugify - name: Check rule format with pytest run: | diff --git a/custom_components/xiaomi_home/__init__.py b/custom_components/xiaomi_home/__init__.py index 694154d..fb1b5c7 100644 --- a/custom_components/xiaomi_home/__init__.py +++ b/custom_components/xiaomi_home/__init__.py @@ -54,6 +54,7 @@ from homeassistant.core import HomeAssistant from homeassistant.components import persistent_notification from homeassistant.helpers import device_registry, entity_registry +from .miot.common import slugify_did from .miot.miot_storage import ( DeviceManufacturer, MIoTStorage, MIoTCert) from .miot.miot_spec import ( @@ -92,7 +93,7 @@ async def async_setup_entry( """Send messages in Notifications dialog box.""" if title: persistent_notification.async_create( - hass=hass, message=message, + hass=hass, message=message or '', title=title, notification_id=notify_id) else: persistent_notification.async_dismiss( @@ -125,9 +126,8 @@ async def async_setup_entry( miot_devices: list[MIoTDevice] = [] er = entity_registry.async_get(hass=hass) for did, info in miot_client.device_list.items(): - spec_instance: MIoTSpecInstance = await spec_parser.parse( - urn=info['urn']) - if spec_instance is None: + spec_instance = await spec_parser.parse(urn=info['urn']) + if not isinstance(spec_instance, MIoTSpecInstance): _LOGGER.error('spec content is None, %s, %s', did, info) continue device: MIoTDevice = MIoTDevice( @@ -155,7 +155,8 @@ async def async_setup_entry( for entity in filter_entities: device.entity_list[platform].remove(entity) entity_id = device.gen_service_entity_id( - ha_domain=platform, siid=entity.spec.iid) + ha_domain=platform, + siid=entity.spec.iid) # type: ignore if er.async_get(entity_id_or_uuid=entity_id): er.async_remove(entity_id=entity_id) if platform in device.prop_list: @@ -208,12 +209,7 @@ async def async_setup_entry( if er.async_get(entity_id_or_uuid=entity_id): er.async_remove(entity_id=entity_id) # Action debug - if miot_client.action_debug: - if 'notify' in device.action_list: - # Add text entity for debug action - device.action_list['action_text'] = ( - device.action_list['notify']) - else: + if not miot_client.action_debug: # Remove text entity for debug action for action in device.action_list.get('notify', []): entity_id = device.gen_action_entity_id( @@ -221,6 +217,21 @@ async def async_setup_entry( siid=action.service.iid, aiid=action.iid) if er.async_get(entity_id_or_uuid=entity_id): er.async_remove(entity_id=entity_id) + # Binary sensor display + if not miot_client.display_binary_bool: + for prop in device.prop_list.get('binary_sensor', []): + entity_id = device.gen_prop_entity_id( + ha_domain='binary_sensor', spec_name=prop.name, + siid=prop.service.iid, piid=prop.iid) + if er.async_get(entity_id_or_uuid=entity_id): + er.async_remove(entity_id=entity_id) + if not miot_client.display_binary_text: + for prop in device.prop_list.get('binary_sensor', []): + entity_id = device.gen_prop_entity_id( + ha_domain='sensor', spec_name=prop.name, + siid=prop.service.iid, piid=prop.iid) + if er.async_get(entity_id_or_uuid=entity_id): + er.async_remove(entity_id=entity_id) hass.data[DOMAIN]['devices'][config_entry.entry_id] = miot_devices await hass.config_entries.async_forward_entry_setups( @@ -237,7 +248,7 @@ async def async_setup_entry( device_entry = dr.async_get_device( identifiers={( DOMAIN, - MIoTDevice.gen_did_tag( + slugify_did( cloud_server=config_entry.data['cloud_server'], did=did))}, connections=None) @@ -330,21 +341,10 @@ async def async_remove_config_entry_device( 'remove device failed, invalid domain, %s, %s', device_entry.id, device_entry.identifiers) return False - device_info = identifiers[1].split('_') - if len(device_info) != 2: - _LOGGER.error( - 'remove device failed, invalid device info, %s, %s', - device_entry.id, device_entry.identifiers) - return False - did = device_info[1] - if did not in miot_client.device_list: - _LOGGER.error( - 'remove device failed, device not found, %s, %s', - device_entry.id, device_entry.identifiers) - return False + # Remove device - await miot_client.remove_device_async(did) + await miot_client.remove_device2_async(did_tag=identifiers[1]) device_registry.async_get(hass).async_remove_device(device_entry.id) _LOGGER.info( - 'remove device, %s, %s, %s', device_info[0], did, device_entry.id) + 'remove device, %s, %s', identifiers[1], device_entry.id) return True diff --git a/custom_components/xiaomi_home/binary_sensor.py b/custom_components/xiaomi_home/binary_sensor.py index 9ec6e83..aca45d8 100644 --- a/custom_components/xiaomi_home/binary_sensor.py +++ b/custom_components/xiaomi_home/binary_sensor.py @@ -68,9 +68,10 @@ async def async_setup_entry( new_entities = [] for miot_device in device_list: - for prop in miot_device.prop_list.get('binary_sensor', []): - new_entities.append(BinarySensor( - miot_device=miot_device, spec=prop)) + if miot_device.miot_client.display_binary_bool: + for prop in miot_device.prop_list.get('binary_sensor', []): + new_entities.append(BinarySensor( + miot_device=miot_device, spec=prop)) if new_entities: async_add_entities(new_entities) diff --git a/custom_components/xiaomi_home/climate.py b/custom_components/xiaomi_home/climate.py index bd4cfe3..fb3dc45 100644 --- a/custom_components/xiaomi_home/climate.py +++ b/custom_components/xiaomi_home/climate.py @@ -156,64 +156,56 @@ class AirConditioner(MIoTServiceEntity, ClimateEntity): _LOGGER.error( 'unknown on property, %s', self.entity_id) elif prop.name == 'mode': - if ( - not isinstance(prop.value_list, list) - or not prop.value_list - ): + if not prop.value_list: _LOGGER.error( 'invalid mode value_list, %s', self.entity_id) continue self._hvac_mode_map = {} - for item in prop.value_list: - if item['name'].lower() in {'off', 'idle'}: - self._hvac_mode_map[item['value']] = HVACMode.OFF - elif item['name'].lower() in {'auto'}: - self._hvac_mode_map[item['value']] = HVACMode.AUTO - elif item['name'].lower() in {'cool'}: - self._hvac_mode_map[item['value']] = HVACMode.COOL - elif item['name'].lower() in {'heat'}: - self._hvac_mode_map[item['value']] = HVACMode.HEAT - elif item['name'].lower() in {'dry'}: - self._hvac_mode_map[item['value']] = HVACMode.DRY - elif item['name'].lower() in {'fan'}: - self._hvac_mode_map[item['value']] = HVACMode.FAN_ONLY + for item in prop.value_list.items: + if item.name in {'off', 'idle'}: + self._hvac_mode_map[item.value] = HVACMode.OFF + elif item.name in {'auto'}: + self._hvac_mode_map[item.value] = HVACMode.AUTO + elif item.name in {'cool'}: + self._hvac_mode_map[item.value] = HVACMode.COOL + elif item.name in {'heat'}: + self._hvac_mode_map[item.value] = HVACMode.HEAT + elif item.name in {'dry'}: + self._hvac_mode_map[item.value] = HVACMode.DRY + elif item.name in {'fan'}: + self._hvac_mode_map[item.value] = HVACMode.FAN_ONLY self._attr_hvac_modes = list(self._hvac_mode_map.values()) self._prop_mode = prop elif prop.name == 'target-temperature': - if not isinstance(prop.value_range, dict): + if not prop.value_range: _LOGGER.error( 'invalid target-temperature value_range format, %s', self.entity_id) continue - self._attr_min_temp = prop.value_range['min'] - self._attr_max_temp = prop.value_range['max'] - self._attr_target_temperature_step = prop.value_range['step'] + self._attr_min_temp = prop.value_range.min_ + self._attr_max_temp = prop.value_range.max_ + self._attr_target_temperature_step = prop.value_range.step self._attr_temperature_unit = prop.external_unit self._attr_supported_features |= ( ClimateEntityFeature.TARGET_TEMPERATURE) self._prop_target_temp = prop elif prop.name == 'target-humidity': - if not isinstance(prop.value_range, dict): + if not prop.value_range: _LOGGER.error( 'invalid target-humidity value_range format, %s', self.entity_id) continue - self._attr_min_humidity = prop.value_range['min'] - self._attr_max_humidity = prop.value_range['max'] + self._attr_min_humidity = prop.value_range.min_ + self._attr_max_humidity = prop.value_range.max_ self._attr_supported_features |= ( ClimateEntityFeature.TARGET_HUMIDITY) self._prop_target_humi = prop elif prop.name == 'fan-level': - if ( - not isinstance(prop.value_list, list) - or not prop.value_list - ): + if not prop.value_list: _LOGGER.error( 'invalid fan-level value_list, %s', self.entity_id) continue - self._fan_mode_map = { - item['value']: item['description'] - for item in prop.value_list} + self._fan_mode_map = prop.value_list.to_map() self._attr_fan_modes = list(self._fan_mode_map.values()) self._attr_supported_features |= ClimateEntityFeature.FAN_MODE self._prop_fan_level = prop @@ -269,8 +261,8 @@ class AirConditioner(MIoTServiceEntity, ClimateEntity): elif self.get_prop_value(prop=self._prop_on) is False: await self.set_property_async(prop=self._prop_on, value=True) # set mode - mode_value = self.get_map_value( - map_=self._hvac_mode_map, description=hvac_mode) + mode_value = self.get_map_key( + map_=self._hvac_mode_map, value=hvac_mode) if ( mode_value is None or not await self.set_property_async( @@ -339,8 +331,8 @@ class AirConditioner(MIoTServiceEntity, ClimateEntity): async def async_set_fan_mode(self, fan_mode): """Set new target fan mode.""" - mode_value = self.get_map_value( - map_=self._fan_mode_map, description=fan_mode) + mode_value = self.get_map_key( + map_=self._fan_mode_map, value=fan_mode) if mode_value is None or not await self.set_property_async( prop=self._prop_fan_level, value=mode_value): raise RuntimeError( @@ -376,9 +368,9 @@ class AirConditioner(MIoTServiceEntity, ClimateEntity): """Return the hvac mode. e.g., heat, cool mode.""" if self.get_prop_value(prop=self._prop_on) is False: return HVACMode.OFF - return self.get_map_description( + return self.get_map_key( map_=self._hvac_mode_map, - key=self.get_prop_value(prop=self._prop_mode)) + value=self.get_prop_value(prop=self._prop_mode)) @property def fan_mode(self) -> Optional[str]: @@ -386,7 +378,7 @@ class AirConditioner(MIoTServiceEntity, ClimateEntity): Requires ClimateEntityFeature.FAN_MODE. """ - return self.get_map_description( + return self.get_map_value( map_=self._fan_mode_map, key=self.get_prop_value(prop=self._prop_fan_level)) @@ -446,8 +438,8 @@ class AirConditioner(MIoTServiceEntity, ClimateEntity): }.get(v_ac_state['M'], None) if mode: self.set_prop_value( - prop=self._prop_mode, value=self.get_map_value( - map_=self._hvac_mode_map, description=mode)) + prop=self._prop_mode, value=self.get_map_key( + map_=self._hvac_mode_map, value=mode)) # T: target temperature if 'T' in v_ac_state and self._prop_target_temp: self.set_prop_value(prop=self._prop_target_temp, @@ -517,29 +509,24 @@ class Heater(MIoTServiceEntity, ClimateEntity): ClimateEntityFeature.TURN_OFF) self._prop_on = prop elif prop.name == 'target-temperature': - if not isinstance(prop.value_range, dict): + if not prop.value_range: _LOGGER.error( 'invalid target-temperature value_range format, %s', self.entity_id) continue - self._attr_min_temp = prop.value_range['min'] - self._attr_max_temp = prop.value_range['max'] - self._attr_target_temperature_step = prop.value_range['step'] + self._attr_min_temp = prop.value_range.min_ + self._attr_max_temp = prop.value_range.max_ + self._attr_target_temperature_step = prop.value_range.step self._attr_temperature_unit = prop.external_unit self._attr_supported_features |= ( ClimateEntityFeature.TARGET_TEMPERATURE) self._prop_target_temp = prop elif prop.name == 'heat-level': - if ( - not isinstance(prop.value_list, list) - or not prop.value_list - ): + if not prop.value_list: _LOGGER.error( 'invalid heat-level value_list, %s', self.entity_id) continue - self._heat_level_map = { - item['value']: item['description'] - for item in prop.value_list} + self._heat_level_map = prop.value_list.to_map() self._attr_preset_modes = list(self._heat_level_map.values()) self._attr_supported_features |= ( ClimateEntityFeature.PRESET_MODE) @@ -582,8 +569,8 @@ class Heater(MIoTServiceEntity, ClimateEntity): """Set the preset mode.""" await self.set_property_async( self._prop_heat_level, - value=self.get_map_value( - map_=self._heat_level_map, description=preset_mode)) + value=self.get_map_key( + map_=self._heat_level_map, value=preset_mode)) @property def target_temperature(self) -> Optional[float]: @@ -613,7 +600,7 @@ class Heater(MIoTServiceEntity, ClimateEntity): @property def preset_mode(self) -> Optional[str]: return ( - self.get_map_description( + self.get_map_value( map_=self._heat_level_map, key=self.get_prop_value(prop=self._prop_heat_level)) if self._prop_heat_level else None) diff --git a/custom_components/xiaomi_home/config_flow.py b/custom_components/xiaomi_home/config_flow.py index 5b78c27..7c0d20a 100644 --- a/custom_components/xiaomi_home/config_flow.py +++ b/custom_components/xiaomi_home/config_flow.py @@ -124,6 +124,7 @@ class XiaomiMihomeConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): _area_name_rule: str _action_debug: bool _hide_non_standard_entities: bool + _display_binary_mode: list[str] _display_devices_changed_notify: list[str] _cloud_server: str @@ -158,6 +159,7 @@ class XiaomiMihomeConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._area_name_rule = self.DEFAULT_AREA_NAME_RULE self._action_debug = False self._hide_non_standard_entities = False + self._display_binary_mode = ['bool'] self._display_devices_changed_notify = ['add', 'del', 'offline'] self._auth_info = {} self._nick_name = DEFAULT_NICK_NAME @@ -473,6 +475,7 @@ class XiaomiMihomeConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): await self._miot_oauth.deinit_async() self._miot_oauth = None return self.async_show_progress_done(next_step_id='homes_select') + # pylint: disable=unexpected-keyword-arg return self.async_show_progress( step_id='oauth', progress_action='oauth', @@ -481,7 +484,7 @@ class XiaomiMihomeConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): f'', 'link_right': '' }, - progress_task=self._cc_task_oauth, + progress_task=self._cc_task_oauth, # type: ignore ) async def __check_oauth_async(self) -> None: @@ -727,6 +730,8 @@ class XiaomiMihomeConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 'action_debug', self._action_debug) self._hide_non_standard_entities = user_input.get( 'hide_non_standard_entities', self._hide_non_standard_entities) + self._display_binary_mode = user_input.get( + 'display_binary_mode', self._display_binary_mode) self._display_devices_changed_notify = user_input.get( 'display_devices_changed_notify', self._display_devices_changed_notify) @@ -749,6 +754,12 @@ class XiaomiMihomeConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 'hide_non_standard_entities', default=self._hide_non_standard_entities # type: ignore ): bool, + vol.Required( + 'display_binary_mode', + default=self._display_binary_mode # type: ignore + ): cv.multi_select( + self._miot_i18n.translate( + key='config.binary_mode')), # type: ignore vol.Required( 'display_devices_changed_notify', default=self._display_devices_changed_notify # type: ignore @@ -931,6 +942,7 @@ class XiaomiMihomeConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 'action_debug': self._action_debug, 'hide_non_standard_entities': self._hide_non_standard_entities, + 'display_binary_mode': self._display_binary_mode, 'display_devices_changed_notify': self._display_devices_changed_notify }) @@ -972,6 +984,7 @@ class OptionsFlowHandler(config_entries.OptionsFlow): _devices_filter: dict _action_debug: bool _hide_non_standard_entities: bool + _display_binary_mode: list[str] _display_devs_notify: list[str] _oauth_redirect_url_full: str @@ -986,6 +999,7 @@ class OptionsFlowHandler(config_entries.OptionsFlow): _nick_name_new: Optional[str] _action_debug_new: bool _hide_non_standard_entities_new: bool + _display_binary_mode_new: list[str] _update_user_info: bool _update_devices: bool _update_trans_rules: bool @@ -1024,6 +1038,8 @@ class OptionsFlowHandler(config_entries.OptionsFlow): self._action_debug = self._entry_data.get('action_debug', False) self._hide_non_standard_entities = self._entry_data.get( 'hide_non_standard_entities', False) + self._display_binary_mode = self._entry_data.get( + 'display_binary_mode', ['text']) self._display_devs_notify = self._entry_data.get( 'display_devices_changed_notify', ['add', 'del', 'offline']) self._home_selected_list = list( @@ -1042,6 +1058,7 @@ class OptionsFlowHandler(config_entries.OptionsFlow): self._nick_name_new = None self._action_debug_new = False self._hide_non_standard_entities_new = False + self._display_binary_mode_new = [] self._update_user_info = False self._update_devices = False self._update_trans_rules = False @@ -1196,7 +1213,7 @@ class OptionsFlowHandler(config_entries.OptionsFlow): err, traceback.format_exc()) self._cc_config_rc = str(err) return self.async_show_progress_done(next_step_id='oauth_error') - + # pylint: disable=unexpected-keyword-arg return self.async_show_progress( step_id='oauth', progress_action='oauth', @@ -1205,7 +1222,7 @@ class OptionsFlowHandler(config_entries.OptionsFlow): f'', 'link_right': '' }, - progress_task=self._cc_task_oauth, + progress_task=self._cc_task_oauth, # type: ignore ) async def __check_oauth_async(self) -> None: @@ -1308,6 +1325,12 @@ class OptionsFlowHandler(config_entries.OptionsFlow): 'hide_non_standard_entities', default=self._hide_non_standard_entities # type: ignore ): bool, + vol.Required( + 'display_binary_mode', + default=self._display_binary_mode # type: ignore + ): cv.multi_select( + self._miot_i18n.translate( + 'config.binary_mode')), # type: ignore vol.Required( 'update_trans_rules', default=self._update_trans_rules # type: ignore @@ -1336,6 +1359,8 @@ class OptionsFlowHandler(config_entries.OptionsFlow): 'action_debug', self._action_debug) self._hide_non_standard_entities_new = user_input.get( 'hide_non_standard_entities', self._hide_non_standard_entities) + self._display_binary_mode_new = user_input.get( + 'display_binary_mode', self._display_binary_mode) self._display_devs_notify = user_input.get( 'display_devices_changed_notify', self._display_devs_notify) self._update_trans_rules = user_input.get( @@ -1939,6 +1964,10 @@ class OptionsFlowHandler(config_entries.OptionsFlow): self._entry_data['hide_non_standard_entities'] = ( self._hide_non_standard_entities_new) self._need_reload = True + if set(self._display_binary_mode) != set(self._display_binary_mode_new): + self._entry_data['display_binary_mode'] = ( + self._display_binary_mode_new) + self._need_reload = True # Update display_devices_changed_notify self._entry_data['display_devices_changed_notify'] = ( self._display_devs_notify) diff --git a/custom_components/xiaomi_home/cover.py b/custom_components/xiaomi_home/cover.py index d8236c7..78a6a02 100644 --- a/custom_components/xiaomi_home/cover.py +++ b/custom_components/xiaomi_home/cover.py @@ -132,53 +132,47 @@ class Cover(MIoTServiceEntity, CoverEntity): # properties for prop in entity_data.props: if prop.name == 'motor-control': - if ( - not isinstance(prop.value_list, list) - or not prop.value_list - ): + if not prop.value_list: _LOGGER.error( 'motor-control value_list is None, %s', self.entity_id) continue - for item in prop.value_list: - if item['name'].lower() in ['open']: + for item in prop.value_list.items: + if item.name in {'open'}: self._attr_supported_features |= ( CoverEntityFeature.OPEN) - self._prop_motor_value_open = item['value'] - elif item['name'].lower() in ['close']: + self._prop_motor_value_open = item.value + elif item.name in {'close'}: self._attr_supported_features |= ( CoverEntityFeature.CLOSE) - self._prop_motor_value_close = item['value'] - elif item['name'].lower() in ['pause']: + self._prop_motor_value_close = item.value + elif item.name in {'pause'}: self._attr_supported_features |= ( CoverEntityFeature.STOP) - self._prop_motor_value_pause = item['value'] + self._prop_motor_value_pause = item.value self._prop_motor_control = prop elif prop.name == 'status': - if ( - not isinstance(prop.value_list, list) - or not prop.value_list - ): + if not prop.value_list: _LOGGER.error( 'status value_list is None, %s', self.entity_id) continue - for item in prop.value_list: - if item['name'].lower() in ['opening', 'open']: - self._prop_status_opening = item['value'] - elif item['name'].lower() in ['closing', 'close']: - self._prop_status_closing = item['value'] - elif item['name'].lower() in ['stop', 'pause']: - self._prop_status_stop = item['value'] + for item in prop.value_list.items: + if item.name in {'opening', 'open'}: + self._prop_status_opening = item.value + elif item.name in {'closing', 'close'}: + self._prop_status_closing = item.value + elif item.name in {'stop', 'pause'}: + self._prop_status_stop = item.value self._prop_status = prop elif prop.name == 'current-position': self._prop_current_position = prop elif prop.name == 'target-position': - if not isinstance(prop.value_range, dict): + if not prop.value_range: _LOGGER.error( 'invalid target-position value_range format, %s', self.entity_id) continue - self._prop_position_value_min = prop.value_range['min'] - self._prop_position_value_max = prop.value_range['max'] + self._prop_position_value_min = prop.value_range.min_ + self._prop_position_value_max = prop.value_range.max_ self._prop_position_value_range = ( self._prop_position_value_max - self._prop_position_value_min) diff --git a/custom_components/xiaomi_home/event.py b/custom_components/xiaomi_home/event.py index 7892290..85fbf33 100644 --- a/custom_components/xiaomi_home/event.py +++ b/custom_components/xiaomi_home/event.py @@ -85,6 +85,8 @@ class Event(MIoTEventEntity, EventEntity): # Set device_class self._attr_device_class = spec.device_class - def on_event_occurred(self, name: str, arguments: list[dict[int, Any]]): + def on_event_occurred( + self, name: str, arguments: dict[str, Any] | None = None + ) -> None: """An event is occurred.""" self._trigger_event(event_type=name, event_attributes=arguments) diff --git a/custom_components/xiaomi_home/fan.py b/custom_components/xiaomi_home/fan.py index 90220db..a28b989 100644 --- a/custom_components/xiaomi_home/fan.py +++ b/custom_components/xiaomi_home/fan.py @@ -87,7 +87,7 @@ async def async_setup_entry( class Fan(MIoTServiceEntity, FanEntity): """Fan entities for Xiaomi Home.""" # pylint: disable=unused-argument - _prop_on: Optional[MIoTSpecProperty] + _prop_on: MIoTSpecProperty _prop_fan_level: Optional[MIoTSpecProperty] _prop_mode: Optional[MIoTSpecProperty] _prop_horizontal_swing: Optional[MIoTSpecProperty] @@ -100,7 +100,7 @@ class Fan(MIoTServiceEntity, FanEntity): _speed_step: int _speed_names: Optional[list] _speed_name_map: Optional[dict[int, str]] - _mode_list: Optional[dict[Any, Any]] + _mode_map: Optional[dict[Any, Any]] def __init__( self, miot_device: MIoTDevice, entity_data: MIoTEntityData @@ -111,7 +111,7 @@ class Fan(MIoTServiceEntity, FanEntity): self._attr_current_direction = None self._attr_supported_features = FanEntityFeature(0) - self._prop_on = None + # _prop_on is required self._prop_fan_level = None self._prop_mode = None self._prop_horizontal_swing = None @@ -124,7 +124,7 @@ class Fan(MIoTServiceEntity, FanEntity): self._speed_names = [] self._speed_name_map = {} - self._mode_list = None + self._mode_map = None # properties for prop in entity_data.props: @@ -133,42 +133,34 @@ class Fan(MIoTServiceEntity, FanEntity): self._attr_supported_features |= FanEntityFeature.TURN_OFF self._prop_on = prop elif prop.name == 'fan-level': - if isinstance(prop.value_range, dict): + if prop.value_range: # Fan level with value-range - self._speed_min = prop.value_range['min'] - self._speed_max = prop.value_range['max'] - self._speed_step = prop.value_range['step'] + self._speed_min = prop.value_range.min_ + self._speed_max = prop.value_range.max_ + self._speed_step = prop.value_range.step self._attr_speed_count = int(( self._speed_max - self._speed_min)/self._speed_step)+1 self._attr_supported_features |= FanEntityFeature.SET_SPEED self._prop_fan_level = prop elif ( self._prop_fan_level is None - and isinstance(prop.value_list, list) and prop.value_list ): # Fan level with value-list # Fan level with value-range is prior to fan level with # value-list when a fan has both fan level properties. - self._speed_name_map = { - item['value']: item['description'] - for item in prop.value_list} + self._speed_name_map = prop.value_list.to_map() self._speed_names = list(self._speed_name_map.values()) - self._attr_speed_count = len(prop.value_list) + self._attr_speed_count = len(self._speed_names) self._attr_supported_features |= FanEntityFeature.SET_SPEED self._prop_fan_level = prop elif prop.name == 'mode': - if ( - not isinstance(prop.value_list, list) - or not prop.value_list - ): + if not prop.value_list: _LOGGER.error( 'mode value_list is None, %s', self.entity_id) continue - self._mode_list = { - item['value']: item['description'] - for item in prop.value_list} - self._attr_preset_modes = list(self._mode_list.values()) + self._mode_map = prop.value_list.to_map() + self._attr_preset_modes = list(self._mode_map.values()) self._attr_supported_features |= FanEntityFeature.PRESET_MODE self._prop_mode = prop elif prop.name == 'horizontal-swing': @@ -178,16 +170,11 @@ class Fan(MIoTServiceEntity, FanEntity): if prop.format_ == 'bool': self._prop_wind_reverse_forward = False self._prop_wind_reverse_reverse = True - elif ( - isinstance(prop.value_list, list) - and prop.value_list - ): - for item in prop.value_list: - if item['name'].lower() in {'foreward'}: - self._prop_wind_reverse_forward = item['value'] - elif item['name'].lower() in { - 'reversal', 'reverse'}: - self._prop_wind_reverse_reverse = item['value'] + elif prop.value_list: + for item in prop.value_list.items: + if item.name in {'foreward'}: + self._prop_wind_reverse_forward = item.value + self._prop_wind_reverse_reverse = item.value if ( self._prop_wind_reverse_forward is None or self._prop_wind_reverse_reverse is None @@ -199,21 +186,9 @@ class Fan(MIoTServiceEntity, FanEntity): self._attr_supported_features |= FanEntityFeature.DIRECTION self._prop_wind_reverse = prop - def __get_mode_description(self, key: int) -> Optional[str]: - if self._mode_list is None: - return None - return self._mode_list.get(key, None) - - def __get_mode_value(self, description: str) -> Optional[int]: - if self._mode_list is None: - return None - for key, value in self._mode_list.items(): - if value == description: - return key - return None - async def async_turn_on( - self, percentage: int = None, preset_mode: str = None, **kwargs: Any + self, percentage: Optional[int] = None, + preset_mode: Optional[str] = None, **kwargs: Any ) -> None: """Turn the fan on. @@ -225,12 +200,12 @@ class Fan(MIoTServiceEntity, FanEntity): # percentage if percentage: if self._speed_names: - speed = percentage_to_ordered_list_item( - self._speed_names, percentage) - speed_value = self.get_map_value( - map_=self._speed_name_map, description=speed) await self.set_property_async( - prop=self._prop_fan_level, value=speed_value) + prop=self._prop_fan_level, + value=self.get_map_value( + map_=self._speed_name_map, + key=percentage_to_ordered_list_item( + self._speed_names, percentage))) else: await self.set_property_async( prop=self._prop_fan_level, @@ -241,7 +216,8 @@ class Fan(MIoTServiceEntity, FanEntity): if preset_mode: await self.set_property_async( self._prop_mode, - value=self.__get_mode_value(description=preset_mode)) + value=self.get_map_key( + map_=self._mode_map, value=preset_mode)) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the fan off.""" @@ -255,12 +231,12 @@ class Fan(MIoTServiceEntity, FanEntity): """Set the percentage of the fan speed.""" if percentage > 0: if self._speed_names: - speed = percentage_to_ordered_list_item( - self._speed_names, percentage) - speed_value = self.get_map_value( - map_=self._speed_name_map, description=speed) await self.set_property_async( - prop=self._prop_fan_level, value=speed_value) + prop=self._prop_fan_level, + value=self.get_map_value( + map_=self._speed_name_map, + key=percentage_to_ordered_list_item( + self._speed_names, percentage))) else: await self.set_property_async( prop=self._prop_fan_level, @@ -277,7 +253,8 @@ class Fan(MIoTServiceEntity, FanEntity): """Set the preset mode.""" await self.set_property_async( self._prop_mode, - value=self.__get_mode_value(description=preset_mode)) + value=self.get_map_key( + map_=self._mode_map, value=preset_mode)) async def async_set_direction(self, direction: str) -> None: """Set the direction of the fan.""" @@ -306,7 +283,8 @@ class Fan(MIoTServiceEntity, FanEntity): """Return the current preset mode, e.g., auto, smart, eco, favorite.""" return ( - self.__get_mode_description( + self.get_map_value( + map_=self._mode_map, key=self.get_prop_value(prop=self._prop_mode)) if self._prop_mode else None) diff --git a/custom_components/xiaomi_home/humidifier.py b/custom_components/xiaomi_home/humidifier.py index 9739da4..1bcd5c8 100644 --- a/custom_components/xiaomi_home/humidifier.py +++ b/custom_components/xiaomi_home/humidifier.py @@ -97,7 +97,7 @@ class Humidifier(MIoTServiceEntity, HumidifierEntity): _prop_target_humidity: Optional[MIoTSpecProperty] _prop_humidity: Optional[MIoTSpecProperty] - _mode_list: dict[Any, Any] + _mode_map: dict[Any, Any] def __init__( self, miot_device: MIoTDevice, entity_data: MIoTEntityData @@ -110,7 +110,7 @@ class Humidifier(MIoTServiceEntity, HumidifierEntity): self._prop_mode = None self._prop_target_humidity = None self._prop_humidity = None - self._mode_list = None + self._mode_map = None # properties for prop in entity_data.props: @@ -119,28 +119,23 @@ class Humidifier(MIoTServiceEntity, HumidifierEntity): self._prop_on = prop # target-humidity elif prop.name == 'target-humidity': - if not isinstance(prop.value_range, dict): + if not prop.value_range: _LOGGER.error( 'invalid target-humidity value_range format, %s', self.entity_id) continue - self._attr_min_humidity = prop.value_range['min'] - self._attr_max_humidity = prop.value_range['max'] + self._attr_min_humidity = prop.value_range.min_ + self._attr_max_humidity = prop.value_range.max_ self._prop_target_humidity = prop # mode elif prop.name == 'mode': - if ( - not isinstance(prop.value_list, list) - or not prop.value_list - ): + if not prop.value_list: _LOGGER.error( 'mode value_list is None, %s', self.entity_id) continue - self._mode_list = { - item['value']: item['description'] - for item in prop.value_list} + self._mode_map = prop.value_list.to_map() self._attr_available_modes = list( - self._mode_list.values()) + self._mode_map.values()) self._attr_supported_features |= HumidifierEntityFeature.MODES self._prop_mode = prop # relative-humidity @@ -163,7 +158,8 @@ class Humidifier(MIoTServiceEntity, HumidifierEntity): async def async_set_mode(self, mode: str) -> None: """Set new target preset mode.""" await self.set_property_async( - prop=self._prop_mode, value=self.__get_mode_value(description=mode)) + prop=self._prop_mode, + value=self.get_map_key(map_=self._mode_map, value=mode)) @property def is_on(self) -> Optional[bool]: @@ -183,20 +179,6 @@ class Humidifier(MIoTServiceEntity, HumidifierEntity): @property def mode(self) -> Optional[str]: """Return the current preset mode.""" - return self.__get_mode_description( + return self.get_map_value( + map_=self._mode_map, key=self.get_prop_value(prop=self._prop_mode)) - - def __get_mode_description(self, key: int) -> Optional[str]: - """Convert mode value to description.""" - if self._mode_list is None: - return None - return self._mode_list.get(key, None) - - def __get_mode_value(self, description: str) -> Optional[int]: - """Convert mode description to value.""" - if self._mode_list is None: - return None - for key, value in self._mode_list.items(): - if value == description: - return key - return None diff --git a/custom_components/xiaomi_home/light.py b/custom_components/xiaomi_home/light.py index 666464e..1667662 100644 --- a/custom_components/xiaomi_home/light.py +++ b/custom_components/xiaomi_home/light.py @@ -96,14 +96,14 @@ class Light(MIoTServiceEntity, LightEntity): """Light entities for Xiaomi Home.""" # pylint: disable=unused-argument _VALUE_RANGE_MODE_COUNT_MAX = 30 - _prop_on: Optional[MIoTSpecProperty] + _prop_on: MIoTSpecProperty _prop_brightness: Optional[MIoTSpecProperty] _prop_color_temp: Optional[MIoTSpecProperty] _prop_color: Optional[MIoTSpecProperty] _prop_mode: Optional[MIoTSpecProperty] _brightness_scale: Optional[tuple[int, int]] - _mode_list: Optional[dict[Any, Any]] + _mode_map: Optional[dict[Any, Any]] def __init__( self, miot_device: MIoTDevice, entity_data: MIoTEntityData @@ -122,7 +122,7 @@ class Light(MIoTServiceEntity, LightEntity): self._prop_color = None self._prop_mode = None self._brightness_scale = None - self._mode_list = None + self._mode_map = None # properties for prop in entity_data.props: @@ -131,20 +131,17 @@ class Light(MIoTServiceEntity, LightEntity): self._prop_on = prop # brightness if prop.name == 'brightness': - if isinstance(prop.value_range, dict): + if prop.value_range: self._brightness_scale = ( - prop.value_range['min'], prop.value_range['max']) + prop.value_range.min_, prop.value_range.max_) self._prop_brightness = prop elif ( - self._mode_list is None - and isinstance(prop.value_list, list) + self._mode_map is None and prop.value_list ): # For value-list brightness - self._mode_list = { - item['value']: item['description'] - for item in prop.value_list} - self._attr_effect_list = list(self._mode_list.values()) + self._mode_map = prop.value_list.to_map() + self._attr_effect_list = list(self._mode_map.values()) self._attr_supported_features |= LightEntityFeature.EFFECT self._prop_mode = prop else: @@ -153,13 +150,13 @@ class Light(MIoTServiceEntity, LightEntity): continue # color-temperature if prop.name == 'color-temperature': - if not isinstance(prop.value_range, dict): + if not prop.value_range: _LOGGER.info( 'invalid color-temperature value_range format, %s', self.entity_id) continue - self._attr_min_color_temp_kelvin = prop.value_range['min'] - self._attr_max_color_temp_kelvin = prop.value_range['max'] + self._attr_min_color_temp_kelvin = prop.value_range.min_ + self._attr_max_color_temp_kelvin = prop.value_range.max_ self._attr_supported_color_modes.add(ColorMode.COLOR_TEMP) self._attr_color_mode = ColorMode.COLOR_TEMP self._prop_color_temp = prop @@ -171,20 +168,15 @@ class Light(MIoTServiceEntity, LightEntity): # mode if prop.name == 'mode': mode_list = None - if ( - isinstance(prop.value_list, list) - and prop.value_list - ): - mode_list = { - item['value']: item['description'] - for item in prop.value_list} - elif isinstance(prop.value_range, dict): + if prop.value_list: + mode_list = prop.value_list.to_map() + elif prop.value_range: mode_list = {} if ( int(( - prop.value_range['max'] - - prop.value_range['min'] - ) / prop.value_range['step']) + prop.value_range.max_ + - prop.value_range.min_ + ) / prop.value_range.step) > self._VALUE_RANGE_MODE_COUNT_MAX ): _LOGGER.info( @@ -192,13 +184,13 @@ class Light(MIoTServiceEntity, LightEntity): self.entity_id, prop.name, prop.value_range) else: for value in range( - prop.value_range['min'], - prop.value_range['max'], - prop.value_range['step']): + prop.value_range.min_, + prop.value_range.max_, + prop.value_range.step): mode_list[value] = f'mode {value}' if mode_list: - self._mode_list = mode_list - self._attr_effect_list = list(self._mode_list.values()) + self._mode_map = mode_list + self._attr_effect_list = list(self._mode_map.values()) self._attr_supported_features |= LightEntityFeature.EFFECT self._prop_mode = prop else: @@ -213,21 +205,6 @@ class Light(MIoTServiceEntity, LightEntity): self._attr_supported_color_modes.add(ColorMode.ONOFF) self._attr_color_mode = ColorMode.ONOFF - def __get_mode_description(self, key: int) -> Optional[str]: - """Convert mode value to description.""" - if self._mode_list is None: - return None - return self._mode_list.get(key, None) - - def __get_mode_value(self, description: str) -> Optional[int]: - """Convert mode description to value.""" - if self._mode_list is None: - return None - for key, value in self._mode_list.items(): - if value == description: - return key - return None - @property def is_on(self) -> Optional[bool]: """Return if the light is on.""" @@ -264,7 +241,8 @@ class Light(MIoTServiceEntity, LightEntity): @property def effect(self) -> Optional[str]: """Return the current mode.""" - return self.__get_mode_description( + return self.get_map_value( + map_=self._mode_map, key=self.get_prop_value(prop=self._prop_mode)) async def async_turn_on(self, **kwargs) -> None: @@ -275,7 +253,7 @@ class Light(MIoTServiceEntity, LightEntity): result: bool = False # on # Dirty logic for lumi.gateway.mgl03 indicator light - value_on = True if self._prop_on.format_ == 'bool' else 1 + value_on = True if self._prop_on.format_ == bool else 1 result = await self.set_property_async( prop=self._prop_on, value=value_on) # brightness @@ -303,11 +281,12 @@ class Light(MIoTServiceEntity, LightEntity): if ATTR_EFFECT in kwargs: result = await self.set_property_async( prop=self._prop_mode, - value=self.__get_mode_value(description=kwargs[ATTR_EFFECT])) + value=self.get_map_key( + map_=self._mode_map, value=kwargs[ATTR_EFFECT])) return result async def async_turn_off(self, **kwargs) -> None: """Turn the light off.""" # Dirty logic for lumi.gateway.mgl03 indicator light - value_on = False if self._prop_on.format_ == 'bool' else 0 + value_on = False if self._prop_on.format_ == bool else 0 return await self.set_property_async(prop=self._prop_on, value=value_on) diff --git a/custom_components/xiaomi_home/miot/common.py b/custom_components/xiaomi_home/miot/common.py index 0ee4f1d..21c5439 100644 --- a/custom_components/xiaomi_home/miot/common.py +++ b/custom_components/xiaomi_home/miot/common.py @@ -45,13 +45,17 @@ off Xiaomi or its affiliates' products. Common utilities. """ +import asyncio import json from os import path import random from typing import Any, Optional import hashlib +from urllib.parse import urlencode +from urllib.request import Request, urlopen from paho.mqtt.matcher import MQTTMatcher import yaml +from slugify import slugify MIOT_ROOT_PATH: str = path.dirname(path.abspath(__file__)) @@ -83,10 +87,22 @@ def randomize_int(value: int, ratio: float) -> int: """Randomize an integer value.""" return int(value * (1 - ratio + random.random()*2*ratio)) + def randomize_float(value: float, ratio: float) -> float: """Randomize a float value.""" return value * (1 - ratio + random.random()*2*ratio) + +def slugify_name(name: str, separator: str = '_') -> str: + """Slugify a name.""" + return slugify(name, separator=separator) + + +def slugify_did(cloud_server: str, did: str) -> str: + """Slugify a device id.""" + return slugify(f'{cloud_server}_{did}', separator='_') + + class MIoTMatcher(MQTTMatcher): """MIoT Pub/Sub topic matcher.""" @@ -105,3 +121,68 @@ class MIoTMatcher(MQTTMatcher): return self[topic] except KeyError: return None + + +class MIoTHttp: + """MIoT Common HTTP API.""" + @staticmethod + def get( + url: str, params: Optional[dict] = None, headers: Optional[dict] = None + ) -> Optional[str]: + full_url = url + if params: + encoded_params = urlencode(params) + full_url = f'{url}?{encoded_params}' + request = Request(full_url, method='GET', headers=headers or {}) + content: Optional[bytes] = None + with urlopen(request) as response: + content = response.read() + return str(content, 'utf-8') if content else None + + @staticmethod + def get_json( + url: str, params: Optional[dict] = None, headers: Optional[dict] = None + ) -> Optional[dict]: + response = MIoTHttp.get(url, params, headers) + return json.loads(response) if response else None + + @staticmethod + def post( + url: str, data: Optional[dict] = None, headers: Optional[dict] = None + ) -> Optional[str]: + pass + + @staticmethod + def post_json( + url: str, data: Optional[dict] = None, headers: Optional[dict] = None + ) -> Optional[dict]: + response = MIoTHttp.post(url, data, headers) + return json.loads(response) if response else None + + @staticmethod + async def get_async( + url: str, params: Optional[dict] = None, headers: Optional[dict] = None, + loop: Optional[asyncio.AbstractEventLoop] = None + ) -> Optional[str]: + # TODO: Use aiohttp + ev_loop = loop or asyncio.get_running_loop() + return await ev_loop.run_in_executor( + None, MIoTHttp.get, url, params, headers) + + @staticmethod + async def get_json_async( + url: str, params: Optional[dict] = None, headers: Optional[dict] = None, + loop: Optional[asyncio.AbstractEventLoop] = None + ) -> Optional[dict]: + ev_loop = loop or asyncio.get_running_loop() + return await ev_loop.run_in_executor( + None, MIoTHttp.get_json, url, params, headers) + + @ staticmethod + async def post_async( + url: str, data: Optional[dict] = None, headers: Optional[dict] = None, + loop: Optional[asyncio.AbstractEventLoop] = None + ) -> Optional[str]: + ev_loop = loop or asyncio.get_running_loop() + return await ev_loop.run_in_executor( + None, MIoTHttp.post, url, data, headers) diff --git a/custom_components/xiaomi_home/miot/i18n/de.json b/custom_components/xiaomi_home/miot/i18n/de.json index 9dce0e9..05ae2bf 100644 --- a/custom_components/xiaomi_home/miot/i18n/de.json +++ b/custom_components/xiaomi_home/miot/i18n/de.json @@ -54,6 +54,10 @@ "enable": "aktivieren", "disable": "deaktivieren" }, + "binary_mode": { + "text": "Textsensor-Entität", + "bool": "Binärsensor-Entität" + }, "device_state": { "add": "hinzufügen", "del": "nicht verfügbar", diff --git a/custom_components/xiaomi_home/miot/i18n/en.json b/custom_components/xiaomi_home/miot/i18n/en.json index 7cf0ecb..47187ad 100644 --- a/custom_components/xiaomi_home/miot/i18n/en.json +++ b/custom_components/xiaomi_home/miot/i18n/en.json @@ -54,6 +54,10 @@ "enable": "Enable", "disable": "Disable" }, + "binary_mode": { + "text": "Text Sensor Entity", + "bool": "Binary Sensor Entity" + }, "device_state": { "add": "Add", "del": "Unavailable", diff --git a/custom_components/xiaomi_home/miot/i18n/es.json b/custom_components/xiaomi_home/miot/i18n/es.json index a71312f..c6f78df 100644 --- a/custom_components/xiaomi_home/miot/i18n/es.json +++ b/custom_components/xiaomi_home/miot/i18n/es.json @@ -54,6 +54,10 @@ "enable": "habilitar", "disable": "deshabilitar" }, + "binary_mode": { + "text": "Entidad del sensor de texto", + "bool": "Entidad del sensor binario" + }, "device_state": { "add": "agregar", "del": "no disponible", diff --git a/custom_components/xiaomi_home/miot/i18n/fr.json b/custom_components/xiaomi_home/miot/i18n/fr.json index e64b614..2789cc6 100644 --- a/custom_components/xiaomi_home/miot/i18n/fr.json +++ b/custom_components/xiaomi_home/miot/i18n/fr.json @@ -54,6 +54,10 @@ "enable": "activer", "disable": "désactiver" }, + "binary_mode": { + "text": "Entité du capteur de texte", + "bool": "Entité du capteur binaire" + }, "device_state": { "add": "Ajouter", "del": "Supprimer", diff --git a/custom_components/xiaomi_home/miot/i18n/it.json b/custom_components/xiaomi_home/miot/i18n/it.json index 7dd652a..8ec19d3 100644 --- a/custom_components/xiaomi_home/miot/i18n/it.json +++ b/custom_components/xiaomi_home/miot/i18n/it.json @@ -54,6 +54,10 @@ "enable": "Abilita", "disable": "Disabilita" }, + "binary_mode": { + "text": "Entità Sensore Testo", + "bool": "Entità Sensore Binario" + }, "device_state": { "add": "Aggiungere", "del": "Non disponibile", @@ -149,4 +153,4 @@ "-706014006": "Descrizione del dispositivo non trovata" } } -} +} \ No newline at end of file diff --git a/custom_components/xiaomi_home/miot/i18n/ja.json b/custom_components/xiaomi_home/miot/i18n/ja.json index 087467c..a32d997 100644 --- a/custom_components/xiaomi_home/miot/i18n/ja.json +++ b/custom_components/xiaomi_home/miot/i18n/ja.json @@ -54,6 +54,10 @@ "enable": "有効", "disable": "無効" }, + "binary_mode": { + "text": "テキストセンサーエンティティ", + "bool": "バイナリセンサーエンティティ" + }, "device_state": { "add": "追加", "del": "利用不可", diff --git a/custom_components/xiaomi_home/miot/i18n/nl.json b/custom_components/xiaomi_home/miot/i18n/nl.json index d71e90e..5417348 100644 --- a/custom_components/xiaomi_home/miot/i18n/nl.json +++ b/custom_components/xiaomi_home/miot/i18n/nl.json @@ -54,6 +54,10 @@ "enable": "Inschakelen", "disable": "Uitschakelen" }, + "binary_mode": { + "text": "Tekstsensor-entiteit", + "bool": "Binairesensor-entiteit" + }, "device_state": { "add": "Toevoegen", "del": "Niet beschikbaar", diff --git a/custom_components/xiaomi_home/miot/i18n/pt-BR.json b/custom_components/xiaomi_home/miot/i18n/pt-BR.json index 0364f7d..553a90c 100644 --- a/custom_components/xiaomi_home/miot/i18n/pt-BR.json +++ b/custom_components/xiaomi_home/miot/i18n/pt-BR.json @@ -54,6 +54,10 @@ "enable": "habilitado", "disable": "desabilitado" }, + "binary_mode": { + "text": "Entidade do sensor de texto", + "bool": "Entidade do sensor binário" + }, "device_state": { "add": "adicionar", "del": "indisponível", diff --git a/custom_components/xiaomi_home/miot/i18n/pt.json b/custom_components/xiaomi_home/miot/i18n/pt.json index d02180f..6466994 100644 --- a/custom_components/xiaomi_home/miot/i18n/pt.json +++ b/custom_components/xiaomi_home/miot/i18n/pt.json @@ -54,6 +54,10 @@ "enable": "Habilitar", "disable": "Desabilitar" }, + "binary_mode": { + "text": "Entidade do sensor de texto", + "bool": "Entidade do sensor binário" + }, "device_state": { "add": "Adicionar", "del": "Indisponível", diff --git a/custom_components/xiaomi_home/miot/i18n/ru.json b/custom_components/xiaomi_home/miot/i18n/ru.json index 7065c39..b342ca1 100644 --- a/custom_components/xiaomi_home/miot/i18n/ru.json +++ b/custom_components/xiaomi_home/miot/i18n/ru.json @@ -54,6 +54,10 @@ "enable": "Включить", "disable": "Отключить" }, + "binary_mode": { + "text": "Сущность текстового датчика", + "bool": "Сущность бинарного датчика" + }, "device_state": { "add": "Добавить", "del": "Недоступно", diff --git a/custom_components/xiaomi_home/miot/i18n/zh-Hans.json b/custom_components/xiaomi_home/miot/i18n/zh-Hans.json index 3d47d2a..ed69254 100644 --- a/custom_components/xiaomi_home/miot/i18n/zh-Hans.json +++ b/custom_components/xiaomi_home/miot/i18n/zh-Hans.json @@ -54,6 +54,10 @@ "enable": "启用", "disable": "禁用" }, + "binary_mode": { + "text": "文本传感器实体", + "bool": "二进制传感器实体" + }, "device_state": { "add": "新增", "del": "不可用", diff --git a/custom_components/xiaomi_home/miot/i18n/zh-Hant.json b/custom_components/xiaomi_home/miot/i18n/zh-Hant.json index 3c541a7..c354733 100644 --- a/custom_components/xiaomi_home/miot/i18n/zh-Hant.json +++ b/custom_components/xiaomi_home/miot/i18n/zh-Hant.json @@ -54,6 +54,10 @@ "enable": "啟用", "disable": "禁用" }, + "binary_mode": { + "text": "文本傳感器實體", + "bool": "二進制傳感器實體" + }, "device_state": { "add": "新增", "del": "不可用", diff --git a/custom_components/xiaomi_home/miot/miot_client.py b/custom_components/xiaomi_home/miot/miot_client.py index 203c377..5f69062 100644 --- a/custom_components/xiaomi_home/miot/miot_client.py +++ b/custom_components/xiaomi_home/miot/miot_client.py @@ -59,7 +59,7 @@ from homeassistant.core import HomeAssistant from homeassistant.components import zeroconf # pylint: disable=relative-beyond-top-level -from .common import MIoTMatcher +from .common import MIoTMatcher, slugify_did from .const import ( DEFAULT_CTRL_MODE, DEFAULT_INTEGRATION_LANGUAGE, DEFAULT_NICK_NAME, DOMAIN, MIHOME_CERT_EXPIRE_MARGIN, NETWORK_REFRESH_INTERVAL, @@ -150,7 +150,7 @@ class MIoTClient: # Device list update timestamp _device_list_update_ts: int - _sub_source_list: dict[str] + _sub_source_list: dict[str, str] _sub_tree: MIoTMatcher _sub_device_state: dict[str, MipsDeviceState] @@ -169,6 +169,10 @@ class MIoTClient: _show_devices_changed_notify_timer: Optional[asyncio.TimerHandle] # Display devices changed notify _display_devs_notify: list[str] + _display_notify_content_hash: Optional[int] + # Display binary mode + _display_binary_text: bool + _display_binary_bool: bool def __init__( self, @@ -235,6 +239,11 @@ class MIoTClient: self._display_devs_notify = entry_data.get( 'display_devices_changed_notify', ['add', 'del', 'offline']) + self._display_notify_content_hash = None + self._display_binary_text = 'text' in entry_data.get( + 'display_binary_mode', ['text']) + self._display_binary_bool = 'bool' in entry_data.get( + 'display_binary_mode', ['text']) async def init_async(self) -> None: # Load user config and check @@ -469,6 +478,14 @@ class MIoTClient: def display_devices_changed_notify(self) -> list[str]: return self._display_devs_notify + @property + def display_binary_text(self) -> bool: + return self._display_binary_text + + @property + def display_binary_bool(self) -> bool: + return self._display_binary_bool + @display_devices_changed_notify.setter def display_devices_changed_notify(self, value: list[str]) -> None: if set(value) == set(self._display_devs_notify): @@ -543,7 +560,8 @@ class MIoTClient: return True except Exception as err: self.__show_client_error_notify( - message=self._i18n.translate('miot.client.invalid_oauth_info'), + message=self._i18n.translate( + 'miot.client.invalid_oauth_info'), # type: ignore notify_key='oauth_info') _LOGGER.error( 'refresh oauth info error (%s, %s), %s, %s', @@ -586,7 +604,8 @@ class MIoTClient: return True except MIoTClientError as error: self.__show_client_error_notify( - message=self._i18n.translate('miot.client.invalid_cert_info'), + message=self._i18n.translate( + 'miot.client.invalid_cert_info'), # type: ignore notify_key='user_cert') _LOGGER.error( 'refresh user cert error, %s, %s', @@ -872,8 +891,16 @@ class MIoTClient: # Update notify self.__request_show_devices_changed_notify() + async def remove_device2_async(self, did_tag: str) -> None: + for did in self._device_list_cache: + d_tag = slugify_did(cloud_server=self._cloud_server, did=did) + if did_tag == d_tag: + await self.remove_device_async(did) + break + def __get_exec_error_with_rc(self, rc: int) -> str: - err_msg: str = self._i18n.translate(key=f'error.common.{rc}') + err_msg: str = self._i18n.translate( + key=f'error.common.{rc}') # type: ignore if not err_msg: err_msg = f'{self._i18n.translate(key="error.common.-10000")}, ' err_msg += f'code={rc}' @@ -1280,7 +1307,7 @@ class MIoTClient: if not cache_list: self.__show_client_error_notify( message=self._i18n.translate( - 'miot.client.invalid_device_cache'), + 'miot.client.invalid_device_cache'), # type: ignore notify_key='device_cache') raise MIoTClientError('load device list from cache error') else: @@ -1368,7 +1395,8 @@ class MIoTClient: home_ids=list(self._entry_data.get('home_selected', {}).keys())) if not result and 'devices' not in result: self.__show_client_error_notify( - message=self._i18n.translate('miot.client.device_cloud_error'), + message=self._i18n.translate( + 'miot.client.device_cloud_error'), # type: ignore notify_key='device_cloud') return else: @@ -1725,13 +1753,14 @@ class MIoTClient: @final def __show_client_error_notify( - self, message: str, notify_key: str = '' + self, message: Optional[str], notify_key: str = '' ) -> None: if message: + self._persistence_notify( f'{DOMAIN}{self._uid}{self._cloud_server}{notify_key}error', self._i18n.translate( - key='miot.client.xiaomi_home_error_title'), + key='miot.client.xiaomi_home_error_title'), # type: ignore self._i18n.translate( key='miot.client.xiaomi_home_error', replace={ @@ -1739,8 +1768,7 @@ class MIoTClient: 'nick_name', DEFAULT_NICK_NAME), 'uid': self._uid, 'cloud_server': self._cloud_server, - 'message': message - })) + 'message': message})) # type: ignore else: self._persistence_notify( f'{DOMAIN}{self._uid}{self._cloud_server}{notify_key}error', @@ -1806,27 +1834,34 @@ class MIoTClient: key='miot.client.device_list_add', replace={ 'count': count_add, - 'message': message_add}) + 'message': message_add}) # type: ignore if 'del' in self._display_devs_notify and count_del: message += self._i18n.translate( key='miot.client.device_list_del', replace={ 'count': count_del, - 'message': message_del}) + 'message': message_del}) # type: ignore if 'offline' in self._display_devs_notify and count_offline: message += self._i18n.translate( key='miot.client.device_list_offline', replace={ 'count': count_offline, - 'message': message_offline}) + 'message': message_offline}) # type: ignore if message != '': + msg_hash = hash(message) + if msg_hash == self._display_notify_content_hash: + # Notify content no change, return + _LOGGER.debug( + 'device list changed notify content no change, return') + return network_status = self._i18n.translate( key='miot.client.network_status_online' if self._network.network_status else 'miot.client.network_status_offline') self._persistence_notify( self.__gen_notify_key('dev_list_changed'), - self._i18n.translate('miot.client.device_list_changed_title'), + self._i18n.translate( + 'miot.client.device_list_changed_title'), # type: ignore self._i18n.translate( key='miot.client.device_list_changed', replace={ @@ -1835,8 +1870,8 @@ class MIoTClient: 'uid': self._uid, 'cloud_server': self._cloud_server, 'network_status': network_status, - 'message': message - })) + 'message': message})) # type: ignore + self._display_notify_content_hash = msg_hash _LOGGER.debug( 'show device list changed notify, add %s, del %s, offline %s', count_add, count_del, count_offline) diff --git a/custom_components/xiaomi_home/miot/miot_cloud.py b/custom_components/xiaomi_home/miot/miot_cloud.py index 98d5204..59d0b50 100644 --- a/custom_components/xiaomi_home/miot/miot_cloud.py +++ b/custom_components/xiaomi_home/miot/miot_cloud.py @@ -744,7 +744,7 @@ class MIoTHttpClient: prop_obj['fut'].set_result(None) if props_req: _LOGGER.info( - 'get prop from cloud failed, %s, %s', len(key), props_req) + 'get prop from cloud failed, %s', props_req) if self._get_prop_list: self._get_prop_timer = self._main_loop.call_later( diff --git a/custom_components/xiaomi_home/miot/miot_device.py b/custom_components/xiaomi_home/miot/miot_device.py index 353b28f..991e2b1 100644 --- a/custom_components/xiaomi_home/miot/miot_device.py +++ b/custom_components/xiaomi_home/miot/miot_device.py @@ -75,7 +75,7 @@ from homeassistant.const import ( ) from homeassistant.helpers.entity import DeviceInfo from homeassistant.components.switch import SwitchDeviceClass -from homeassistant.util import slugify + # pylint: disable=relative-beyond-top-level from .specs.specv2entity import ( @@ -85,6 +85,7 @@ from .specs.specv2entity import ( SPEC_PROP_TRANS_MAP, SPEC_SERVICE_TRANS_MAP ) +from .common import slugify_name, slugify_did from .const import DOMAIN from .miot_client import MIoTClient from .miot_error import MIoTClientError, MIoTDeviceError @@ -94,7 +95,9 @@ from .miot_spec import ( MIoTSpecEvent, MIoTSpecInstance, MIoTSpecProperty, - MIoTSpecService + MIoTSpecService, + MIoTSpecValueList, + MIoTSpecValueRange ) _LOGGER = logging.getLogger(__name__) @@ -142,9 +145,12 @@ class MIoTDevice: _room_id: str _room_name: str - _suggested_area: str + _suggested_area: Optional[str] - _device_state_sub_list: dict[str, Callable[[str, MIoTDeviceState], None]] + _sub_id: int + _device_state_sub_list: dict[str, dict[ + str, Callable[[str, MIoTDeviceState], None]]] + _value_sub_list: dict[str, dict[str, Callable[[dict, Any], None]]] _entity_list: dict[str, list[MIoTEntityData]] _prop_list: dict[str, list[MIoTSpecProperty]] @@ -153,7 +159,7 @@ class MIoTDevice: def __init__( self, miot_client: MIoTClient, - device_info: dict[str, str], + device_info: dict[str, Any], spec_instance: MIoTSpecInstance ) -> None: self.miot_client = miot_client @@ -183,7 +189,9 @@ class MIoTDevice: case _: self._suggested_area = None + self._sub_id = 0 self._device_state_sub_list = {} + self._value_sub_list = {} self._entity_list = {} self._prop_list = {} self._event_list = {} @@ -234,36 +242,76 @@ class MIoTDevice: def sub_device_state( self, key: str, handler: Callable[[str, MIoTDeviceState], None] - ) -> bool: - self._device_state_sub_list[key] = handler - return True + ) -> int: + self._sub_id += 1 + if key in self._device_state_sub_list: + self._device_state_sub_list[key][str(self._sub_id)] = handler + else: + self._device_state_sub_list[key] = {str(self._sub_id): handler} + return self._sub_id - def unsub_device_state(self, key: str) -> bool: - self._device_state_sub_list.pop(key, None) - return True + def unsub_device_state(self, key: str, sub_id: int) -> None: + sub_list = self._device_state_sub_list.get(key, None) + if sub_list: + sub_list.pop(str(sub_id), None) + if not sub_list: + self._device_state_sub_list.pop(key, None) def sub_property( - self, handler: Callable[[dict, Any], None], siid: int = None, - piid: int = None, handler_ctx: Any = None - ) -> bool: - return self.miot_client.sub_prop( - did=self._did, handler=handler, siid=siid, piid=piid, - handler_ctx=handler_ctx) + self, handler: Callable[[dict, Any], None], siid: int, piid: int + ) -> int: + key: str = f'p.{siid}.{piid}' - def unsub_property(self, siid: int = None, piid: int = None) -> bool: - return self.miot_client.unsub_prop(did=self._did, siid=siid, piid=piid) + def _on_prop_changed(params: dict, ctx: Any) -> None: + for handler in self._value_sub_list[key].values(): + handler(params, ctx) + + self._sub_id += 1 + if key in self._value_sub_list: + self._value_sub_list[key][str(self._sub_id)] = handler + else: + self._value_sub_list[key] = {str(self._sub_id): handler} + self.miot_client.sub_prop( + did=self._did, handler=_on_prop_changed, siid=siid, piid=piid) + return self._sub_id + + def unsub_property(self, siid: int, piid: int, sub_id: int) -> None: + key: str = f'p.{siid}.{piid}' + + sub_list = self._value_sub_list.get(key, None) + if sub_list: + sub_list.pop(str(sub_id), None) + if not sub_list: + self.miot_client.unsub_prop(did=self._did, siid=siid, piid=piid) + self._value_sub_list.pop(key, None) def sub_event( - self, handler: Callable[[dict, Any], None], siid: int = None, - eiid: int = None, handler_ctx: Any = None - ) -> bool: - return self.miot_client.sub_event( - did=self._did, handler=handler, siid=siid, eiid=eiid, - handler_ctx=handler_ctx) + self, handler: Callable[[dict, Any], None], siid: int, eiid: int + ) -> int: + key: str = f'e.{siid}.{eiid}' - def unsub_event(self, siid: int = None, eiid: int = None) -> bool: - return self.miot_client.unsub_event( - did=self._did, siid=siid, eiid=eiid) + def _on_event_occurred(params: dict, ctx: Any) -> None: + for handler in self._value_sub_list[key].values(): + handler(params, ctx) + + self._sub_id += 1 + if key in self._value_sub_list: + self._value_sub_list[key][str(self._sub_id)] = handler + else: + self._value_sub_list[key] = {str(self._sub_id): handler} + self.miot_client.sub_event( + did=self._did, handler=_on_event_occurred, siid=siid, eiid=eiid) + return self._sub_id + + def unsub_event(self, siid: int, eiid: int, sub_id: int) -> None: + key: str = f'e.{siid}.{eiid}' + + sub_list = self._value_sub_list.get(key, None) + if sub_list: + sub_list.pop(str(sub_id), None) + if not sub_list: + self.miot_client.unsub_event(did=self._did, siid=siid, eiid=eiid) + self._value_sub_list.pop(key, None) @property def device_info(self) -> DeviceInfo: @@ -287,11 +335,8 @@ class MIoTDevice: @property def did_tag(self) -> str: - return slugify(f'{self.miot_client.cloud_server}_{self._did}') - - @staticmethod - def gen_did_tag(cloud_server: str, did: str) -> str: - return slugify(f'{cloud_server}_{did}') + return slugify_did( + cloud_server=self.miot_client.cloud_server, did=self._did) def gen_device_entity_id(self, ha_domain: str) -> str: return ( @@ -308,21 +353,24 @@ class MIoTDevice: ) -> str: return ( f'{ha_domain}.{self._model_strs[0][:9]}_{self.did_tag}_' - f'{self._model_strs[-1][:20]}_{slugify(spec_name)}_p_{siid}_{piid}') + f'{self._model_strs[-1][:20]}_{slugify_name(spec_name)}' + f'_p_{siid}_{piid}') def gen_event_entity_id( self, ha_domain: str, spec_name: str, siid: int, eiid: int ) -> str: return ( f'{ha_domain}.{self._model_strs[0][:9]}_{self.did_tag}_' - f'{self._model_strs[-1][:20]}_{slugify(spec_name)}_e_{siid}_{eiid}') + f'{self._model_strs[-1][:20]}_{slugify_name(spec_name)}' + f'_e_{siid}_{eiid}') def gen_action_entity_id( self, ha_domain: str, spec_name: str, siid: int, aiid: int ) -> str: return ( f'{ha_domain}.{self._model_strs[0][:9]}_{self.did_tag}_' - f'{self._model_strs[-1][:20]}_{slugify(spec_name)}_a_{siid}_{aiid}') + f'{self._model_strs[-1][:20]}_{slugify_name(spec_name)}' + f'_a_{siid}_{aiid}') @property def name(self) -> str: @@ -341,14 +389,20 @@ class MIoTDevice: self._entity_list[entity_data.platform].append(entity_data) def append_prop(self, prop: MIoTSpecProperty) -> None: + if not prop.platform: + return self._prop_list.setdefault(prop.platform, []) self._prop_list[prop.platform].append(prop) def append_event(self, event: MIoTSpecEvent) -> None: + if not event.platform: + return self._event_list.setdefault(event.platform, []) self._event_list[event.platform].append(event) def append_action(self, action: MIoTSpecAction) -> None: + if not action.platform: + return self._action_list.setdefault(action.platform, []) self._action_list[action.platform].append(action) @@ -507,7 +561,7 @@ class MIoTDevice: if prop_access != (SPEC_PROP_TRANS_MAP[ 'entities'][platform]['access']): return None - if prop.format_ not in SPEC_PROP_TRANS_MAP[ + if prop.format_.__name__ not in SPEC_PROP_TRANS_MAP[ 'entities'][platform]['format']: return None if prop.unit: @@ -560,9 +614,9 @@ class MIoTDevice: # general conversion if not prop.platform: if prop.writable: - if prop.format_ == 'str': + if prop.format_ == str: prop.platform = 'text' - elif prop.format_ == 'bool': + elif prop.format_ == bool: prop.platform = 'switch' prop.device_class = SwitchDeviceClass.SWITCH elif prop.value_list: @@ -573,9 +627,11 @@ class MIoTDevice: # Irregular property will not be transformed. pass elif prop.readable or prop.notifiable: - prop.platform = 'sensor' - if prop.platform: - self.append_prop(prop=prop) + if prop.format_ == bool: + prop.platform = 'binary_sensor' + else: + prop.platform = 'sensor' + self.append_prop(prop=prop) # STEP 3.2: event conversion for event in service.events: if event.platform: @@ -703,10 +759,11 @@ class MIoTDevice: def __on_device_state_changed( self, did: str, state: MIoTDeviceState, ctx: Any ) -> None: - self._online = state - for key, handler in self._device_state_sub_list.items(): - self.miot_client.main_loop.call_soon_threadsafe( - handler, key, state) + self._online = state == MIoTDeviceState.ONLINE + for key, sub_list in self._device_state_sub_list.items(): + for handler in sub_list.values(): + self.miot_client.main_loop.call_soon_threadsafe( + handler, key, state) class MIoTServiceEntity(Entity): @@ -718,8 +775,11 @@ class MIoTServiceEntity(Entity): _main_loop: asyncio.AbstractEventLoop _prop_value_map: dict[MIoTSpecProperty, Any] + _state_sub_id: int + _value_sub_ids: dict[str, int] - _event_occurred_handler: Callable[[MIoTSpecEvent, dict], None] + _event_occurred_handler: Optional[ + Callable[[MIoTSpecEvent, dict], None]] _prop_changed_subs: dict[ MIoTSpecProperty, Callable[[MIoTSpecProperty, Any], None]] @@ -738,13 +798,15 @@ class MIoTServiceEntity(Entity): self.entity_data = entity_data self._main_loop = miot_device.miot_client.main_loop self._prop_value_map = {} + self._state_sub_id = 0 + self._value_sub_ids = {} # Gen entity id - if isinstance(entity_data.spec, MIoTSpecInstance): + if isinstance(self.entity_data.spec, MIoTSpecInstance): self.entity_id = miot_device.gen_device_entity_id(DOMAIN) self._attr_name = f' {self.entity_data.spec.description_trans}' - elif isinstance(entity_data.spec, MIoTSpecService): + elif isinstance(self.entity_data.spec, MIoTSpecService): self.entity_id = miot_device.gen_service_entity_id( - DOMAIN, siid=entity_data.spec.iid) + DOMAIN, siid=self.entity_data.spec.iid) self._attr_name = ( f'{"* "if self.entity_data.spec.proprietary else " "}' f'{self.entity_data.spec.description_trans}') @@ -763,7 +825,9 @@ class MIoTServiceEntity(Entity): self.entity_id) @property - def event_occurred_handler(self) -> Callable[[MIoTSpecEvent, dict], None]: + def event_occurred_handler( + self + ) -> Optional[Callable[[MIoTSpecEvent, dict], None]]: return self._event_occurred_handler @event_occurred_handler.setter @@ -784,25 +848,27 @@ class MIoTServiceEntity(Entity): self._prop_changed_subs.pop(prop, None) @property - def device_info(self) -> dict: + def device_info(self) -> Optional[DeviceInfo]: return self.miot_device.device_info async def async_added_to_hass(self) -> None: state_id = 's.0' if isinstance(self.entity_data.spec, MIoTSpecService): state_id = f's.{self.entity_data.spec.iid}' - self.miot_device.sub_device_state( + self._state_sub_id = self.miot_device.sub_device_state( key=state_id, handler=self.__on_device_state_changed) # Sub prop for prop in self.entity_data.props: if not prop.notifiable and not prop.readable: continue - self.miot_device.sub_property( + key = f'p.{prop.service.iid}.{prop.iid}' + self._value_sub_ids[key] = self.miot_device.sub_property( handler=self.__on_properties_changed, siid=prop.service.iid, piid=prop.iid) # Sub event for event in self.entity_data.events: - self.miot_device.sub_event( + key = f'e.{event.service.iid}.{event.iid}' + self._value_sub_ids[key] = self.miot_device.sub_event( handler=self.__on_event_occurred, siid=event.service.iid, eiid=event.iid) @@ -817,30 +883,39 @@ class MIoTServiceEntity(Entity): state_id = 's.0' if isinstance(self.entity_data.spec, MIoTSpecService): state_id = f's.{self.entity_data.spec.iid}' - self.miot_device.unsub_device_state(key=state_id) + self.miot_device.unsub_device_state( + key=state_id, sub_id=self._state_sub_id) # Unsub prop for prop in self.entity_data.props: if not prop.notifiable and not prop.readable: continue - self.miot_device.unsub_property( - siid=prop.service.iid, piid=prop.iid) + sub_id = self._value_sub_ids.pop( + f'p.{prop.service.iid}.{prop.iid}', None) + if sub_id: + self.miot_device.unsub_property( + siid=prop.service.iid, piid=prop.iid, sub_id=sub_id) # Unsub event for event in self.entity_data.events: - self.miot_device.unsub_event( - siid=event.service.iid, eiid=event.iid) + sub_id = self._value_sub_ids.pop( + f'e.{event.service.iid}.{event.iid}', None) + if sub_id: + self.miot_device.unsub_event( + siid=event.service.iid, eiid=event.iid, sub_id=sub_id) - def get_map_description(self, map_: dict[int, Any], key: int) -> Any: + def get_map_value( + self, map_: dict[int, Any], key: int + ) -> Any: if map_ is None: return None return map_.get(key, None) - def get_map_value( - self, map_: dict[int, Any], description: Any + def get_map_key( + self, map_: dict[int, Any], value: Any ) -> Optional[int]: if map_ is None: return None - for key, value in map_.items(): - if value == description: + for key, value_ in map_.items(): + if value_ == value: return key return None @@ -999,11 +1074,12 @@ class MIoTPropertyEntity(Entity): service: MIoTSpecService _main_loop: asyncio.AbstractEventLoop - # {'min':int, 'max':int, 'step': int} - _value_range: dict[str, int] + _value_range: Optional[MIoTSpecValueRange] # {Any: Any} - _value_list: dict[Any, Any] + _value_list: Optional[MIoTSpecValueList] _value: Any + _state_sub_id: int + _value_sub_id: int _pending_write_ha_state_timer: Optional[asyncio.TimerHandle] @@ -1015,12 +1091,10 @@ class MIoTPropertyEntity(Entity): self.service = spec.service self._main_loop = miot_device.miot_client.main_loop self._value_range = spec.value_range - if spec.value_list: - self._value_list = { - item['value']: item['description'] for item in spec.value_list} - else: - self._value_list = None + self._value_list = spec.value_list self._value = None + self._state_sub_id = 0 + self._value_sub_id = 0 self._pending_write_ha_state_timer = None # Gen entity_id self.entity_id = self.miot_device.gen_prop_entity_id( @@ -1042,16 +1116,16 @@ class MIoTPropertyEntity(Entity): self._value_list) @property - def device_info(self) -> dict: + def device_info(self) -> Optional[DeviceInfo]: return self.miot_device.device_info async def async_added_to_hass(self) -> None: # Sub device state changed - self.miot_device.sub_device_state( + self._state_sub_id = self.miot_device.sub_device_state( key=f'{ self.service.iid}.{self.spec.iid}', handler=self.__on_device_state_changed) # Sub value changed - self.miot_device.sub_property( + self._value_sub_id = self.miot_device.sub_property( handler=self.__on_value_changed, siid=self.service.iid, piid=self.spec.iid) # Refresh value @@ -1063,22 +1137,21 @@ class MIoTPropertyEntity(Entity): self._pending_write_ha_state_timer.cancel() self._pending_write_ha_state_timer = None self.miot_device.unsub_device_state( - key=f'{ self.service.iid}.{self.spec.iid}') + key=f'{ self.service.iid}.{self.spec.iid}', + sub_id=self._state_sub_id) self.miot_device.unsub_property( - siid=self.service.iid, piid=self.spec.iid) + siid=self.service.iid, piid=self.spec.iid, + sub_id=self._value_sub_id) - def get_vlist_description(self, value: Any) -> str: + def get_vlist_description(self, value: Any) -> Optional[str]: if not self._value_list: return None - return self._value_list.get(value, None) + return self._value_list.get_description_by_value(value) def get_vlist_value(self, description: str) -> Any: if not self._value_list: return None - for key, value in self._value_list.items(): - if value == description: - return key - return None + return self._value_list.get_value_by_description(description) async def set_property_async(self, value: Any) -> bool: if not self.spec.writable: @@ -1148,9 +1221,10 @@ class MIoTEventEntity(Entity): service: MIoTSpecService _main_loop: asyncio.AbstractEventLoop - _value: Any _attr_event_types: list[str] _arguments_map: dict[int, str] + _state_sub_id: int + _value_sub_id: int def __init__(self, miot_device: MIoTDevice, spec: MIoTSpecEvent) -> None: if miot_device is None or spec is None or spec.service is None: @@ -1159,7 +1233,6 @@ class MIoTEventEntity(Entity): self.spec = spec self.service = spec.service self._main_loop = miot_device.miot_client.main_loop - self._value = None # Gen entity_id self.entity_id = self.miot_device.gen_event_entity_id( ha_domain=DOMAIN, spec_name=spec.name, @@ -1177,6 +1250,8 @@ class MIoTEventEntity(Entity): self._arguments_map = {} for prop in spec.argument: self._arguments_map[prop.iid] = prop.description_trans + self._state_sub_id = 0 + self._value_sub_id = 0 _LOGGER.info( 'new miot event entity, %s, %s, %s, %s, %s', @@ -1184,29 +1259,31 @@ class MIoTEventEntity(Entity): spec.device_class, self.entity_id) @property - def device_info(self) -> dict: + def device_info(self) -> Optional[DeviceInfo]: return self.miot_device.device_info async def async_added_to_hass(self) -> None: # Sub device state changed - self.miot_device.sub_device_state( + self._state_sub_id = self.miot_device.sub_device_state( key=f'event.{ self.service.iid}.{self.spec.iid}', handler=self.__on_device_state_changed) # Sub value changed - self.miot_device.sub_event( + self._value_sub_id = self.miot_device.sub_event( handler=self.__on_event_occurred, siid=self.service.iid, eiid=self.spec.iid) async def async_will_remove_from_hass(self) -> None: self.miot_device.unsub_device_state( - key=f'event.{ self.service.iid}.{self.spec.iid}') + key=f'event.{ self.service.iid}.{self.spec.iid}', + sub_id=self._state_sub_id) self.miot_device.unsub_event( - siid=self.service.iid, eiid=self.spec.iid) + siid=self.service.iid, eiid=self.spec.iid, + sub_id=self._value_sub_id) @abstractmethod def on_event_occurred( - self, name: str, arguments: list[dict[int, Any]] - ): ... + self, name: str, arguments: dict[str, Any] | None = None + ) -> None: ... def __on_event_occurred(self, params: dict, ctx: Any) -> None: _LOGGER.debug('event occurred, %s', params) @@ -1253,11 +1330,11 @@ class MIoTActionEntity(Entity): miot_device: MIoTDevice spec: MIoTSpecAction service: MIoTSpecService - action_platform: str _main_loop: asyncio.AbstractEventLoop _in_map: dict[int, MIoTSpecProperty] _out_map: dict[int, MIoTSpecProperty] + _state_sub_id: int def __init__(self, miot_device: MIoTDevice, spec: MIoTSpecAction) -> None: if miot_device is None or spec is None or spec.service is None: @@ -1265,8 +1342,8 @@ class MIoTActionEntity(Entity): self.miot_device = miot_device self.spec = spec self.service = spec.service - self.action_platform = 'action' self._main_loop = miot_device.miot_client.main_loop + self._state_sub_id = 0 # Gen entity_id self.entity_id = self.miot_device.gen_action_entity_id( ha_domain=DOMAIN, spec_name=spec.name, @@ -1286,19 +1363,22 @@ class MIoTActionEntity(Entity): spec.device_class, self.entity_id) @property - def device_info(self) -> dict: + def device_info(self) -> Optional[DeviceInfo]: return self.miot_device.device_info async def async_added_to_hass(self) -> None: - self.miot_device.sub_device_state( - key=f'{self.action_platform}.{ self.service.iid}.{self.spec.iid}', + self._state_sub_id = self.miot_device.sub_device_state( + key=f'a.{ self.service.iid}.{self.spec.iid}', handler=self.__on_device_state_changed) async def async_will_remove_from_hass(self) -> None: self.miot_device.unsub_device_state( - key=f'{self.action_platform}.{ self.service.iid}.{self.spec.iid}') + key=f'a.{ self.service.iid}.{self.spec.iid}', + sub_id=self._state_sub_id) - async def action_async(self, in_list: list = None) -> Optional[list]: + async def action_async( + self, in_list: Optional[list] = None + ) -> Optional[list]: try: return await self.miot_device.miot_client.action_async( did=self.miot_device.did, diff --git a/custom_components/xiaomi_home/miot/miot_mips.py b/custom_components/xiaomi_home/miot/miot_mips.py index 865c44c..b1bb65b 100644 --- a/custom_components/xiaomi_home/miot/miot_mips.py +++ b/custom_components/xiaomi_home/miot/miot_mips.py @@ -663,7 +663,8 @@ class _MipsClient(ABC): def __on_disconnect(self, client, user_data, rc, props) -> None: if self._mqtt_state: - self.log_error(f'mips disconnect, {rc}, {props}') + (self.log_info if rc == 0 else self.log_error)( + f'mips disconnect, {rc}, {props}') self._mqtt_state = False if self._mqtt_timer: self._mqtt_timer.cancel() diff --git a/custom_components/xiaomi_home/miot/miot_spec.py b/custom_components/xiaomi_home/miot/miot_spec.py index 33022a1..67fde7b 100644 --- a/custom_components/xiaomi_home/miot/miot_spec.py +++ b/custom_components/xiaomi_home/miot/miot_spec.py @@ -46,342 +46,200 @@ off Xiaomi or its affiliates' products. MIoT-Spec-V2 parser. """ import asyncio -import json +import os import platform import time -from typing import Any, Optional -from urllib.parse import urlencode -from urllib.request import Request, urlopen +from typing import Any, Optional, Type, Union import logging +from slugify import slugify + # pylint: disable=relative-beyond-top-level from .const import DEFAULT_INTEGRATION_LANGUAGE, SPEC_STD_LIB_EFFECTIVE_TIME +from .common import MIoTHttp, load_yaml_file from .miot_error import MIoTSpecError -from .miot_storage import ( - MIoTStorage, - SpecBoolTranslation, - SpecFilter, - SpecMultiLang) +from .miot_storage import MIoTStorage _LOGGER = logging.getLogger(__name__) -class MIoTSpecBase: - """MIoT SPEC base class.""" - iid: int - type_: str - description: str - description_trans: Optional[str] - proprietary: bool - need_filter: bool - name: Optional[str] +class MIoTSpecValueRange: + """MIoT SPEC value range class.""" + min_: int + max_: int + step: int - # External params - platform: str - device_class: Any - state_class: Any - icon: str - external_unit: Any + def __init__(self, value_range: Union[dict, list]) -> None: + if isinstance(value_range, dict): + self.load(value_range) + elif isinstance(value_range, list): + self.from_spec(value_range) + else: + raise MIoTSpecError('invalid value range format') - spec_id: str + def load(self, value_range: dict) -> None: + if ( + 'min' not in value_range + or 'max' not in value_range + or 'step' not in value_range + ): + raise MIoTSpecError('invalid value range') + self.min_ = value_range['min'] + self.max_ = value_range['max'] + self.step = value_range['step'] - def __init__(self, spec: dict) -> None: - self.iid = spec['iid'] - self.type_ = spec['type'] - self.description = spec['description'] - - self.description_trans = spec.get('description_trans', None) - self.proprietary = spec.get('proprietary', False) - self.need_filter = spec.get('need_filter', False) - self.name = spec.get('name', None) - - self.platform = None - self.device_class = None - self.state_class = None - self.icon = None - self.external_unit = None - - self.spec_id = hash(f'{self.type_}.{self.iid}') - - def __hash__(self) -> int: - return self.spec_id - - def __eq__(self, value: object) -> bool: - return self.spec_id == value.spec_id - - -class MIoTSpecProperty(MIoTSpecBase): - """MIoT SPEC property class.""" - format_: str - precision: int - unit: str - - value_range: list - value_list: list[dict] - - _access: list - _writable: bool - _readable: bool - _notifiable: bool - - service: MIoTSpecBase - - def __init__( - self, spec: dict, service: MIoTSpecBase = None, - format_: str = None, access: list = None, - unit: str = None, value_range: list = None, - value_list: list[dict] = None, precision: int = 0 - ) -> None: - super().__init__(spec=spec) - self.service = service - self.format_ = format_ - self.access = access - self.unit = unit - self.value_range = value_range - self.value_list = value_list - self.precision = precision - - self.spec_id = hash( - f'p.{self.name}.{self.service.iid}.{self.iid}') - - @property - def access(self) -> list: - return self._access - - @access.setter - def access(self, value: list) -> None: - self._access = value - if isinstance(value, list): - self._writable = 'write' in value - self._readable = 'read' in value - self._notifiable = 'notify' in value - - @property - def writable(self) -> bool: - return self._writable - - @property - def readable(self) -> bool: - return self._readable - - @property - def notifiable(self): - return self._notifiable - - def value_format(self, value: Any) -> Any: - if value is None: - return None - if self.format_ == 'int': - return int(value) - if self.format_ == 'float': - return round(value, self.precision) - if self.format_ == 'bool': - return bool(value in [True, 1, 'true', '1']) - return value + def from_spec(self, value_range: list) -> None: + if len(value_range) != 3: + raise MIoTSpecError('invalid value range') + self.min_ = value_range[0] + self.max_ = value_range[1] + self.step = value_range[2] def dump(self) -> dict: return { - 'type': self.type_, - 'name': self.name, - 'iid': self.iid, - 'description': self.description, - 'description_trans': self.description_trans, - 'proprietary': self.proprietary, - 'need_filter': self.need_filter, - 'format': self.format_, - 'access': self._access, - 'unit': self.unit, - 'value_range': self.value_range, - 'value_list': self.value_list, - 'precision': self.precision + 'min': self.min_, + 'max': self.max_, + 'step': self.step } - -class MIoTSpecEvent(MIoTSpecBase): - """MIoT SPEC event class.""" - argument: list[MIoTSpecProperty] - service: MIoTSpecBase - - def __init__( - self, spec: dict, service: MIoTSpecBase = None, - argument: list[MIoTSpecProperty] = None - ) -> None: - super().__init__(spec=spec) - self.argument = argument - self.service = service - - self.spec_id = hash( - f'e.{self.name}.{self.service.iid}.{self.iid}') - - def dump(self) -> dict: - return { - 'type': self.type_, - 'name': self.name, - 'iid': self.iid, - 'description': self.description, - 'description_trans': self.description_trans, - 'proprietary': self.proprietary, - 'need_filter': self.need_filter, - 'argument': [prop.iid for prop in self.argument], - } + def __str__(self) -> str: + return f'[{self.min_}, {self.max_}, {self.step}' -class MIoTSpecAction(MIoTSpecBase): - """MIoT SPEC action class.""" - in_: list[MIoTSpecProperty] - out: list[MIoTSpecProperty] - service: MIoTSpecBase - - def __init__( - self, spec: dict, service: MIoTSpecBase = None, - in_: list[MIoTSpecProperty] = None, - out: list[MIoTSpecProperty] = None - ) -> None: - super().__init__(spec=spec) - self.in_ = in_ - self.out = out - self.service = service - - self.spec_id = hash( - f'a.{self.name}.{self.service.iid}.{self.iid}') - - def dump(self) -> dict: - return { - 'type': self.type_, - 'name': self.name, - 'iid': self.iid, - 'description': self.description, - 'description_trans': self.description_trans, - 'proprietary': self.proprietary, - 'need_filter': self.need_filter, - 'in': [prop.iid for prop in self.in_], - 'out': [prop.iid for prop in self.out] - } - - -class MIoTSpecService(MIoTSpecBase): - """MIoT SPEC service class.""" - properties: list[MIoTSpecProperty] - events: list[MIoTSpecEvent] - actions: list[MIoTSpecAction] - - def __init__(self, spec: dict) -> None: - super().__init__(spec=spec) - self.properties = [] - self.events = [] - self.actions = [] - - def dump(self) -> dict: - return { - 'type': self.type_, - 'name': self.name, - 'iid': self.iid, - 'description': self.description, - 'description_trans': self.description_trans, - 'proprietary': self.proprietary, - 'properties': [prop.dump() for prop in self.properties], - 'need_filter': self.need_filter, - 'events': [event.dump() for event in self.events], - 'actions': [action.dump() for action in self.actions], - } - - -# @dataclass -class MIoTSpecInstance: - """MIoT SPEC instance class.""" - urn: str +class MIoTSpecValueListItem: + """MIoT SPEC value list item class.""" + # NOTICE: bool type without name name: str - # urn_name: str + # Value + value: Any + # Descriptions after multilingual conversion. description: str - description_trans: str - services: list[MIoTSpecService] - # External params - platform: str - device_class: Any - icon: str + def __init__(self, item: dict) -> None: + self.load(item) - def __init__( - self, urn: str = None, name: str = None, - description: str = None, description_trans: str = None - ) -> None: - self.urn = urn - self.name = name - self.description = description - self.description_trans = description_trans - self.services = [] + def load(self, item: dict) -> None: + if 'value' not in item or 'description' not in item: + raise MIoTSpecError('invalid value list item, %s') - def load(self, specs: dict) -> 'MIoTSpecInstance': - self.urn = specs['urn'] - self.name = specs['name'] - self.description = specs['description'] - self.description_trans = specs['description_trans'] - self.services = [] - for service in specs['services']: - spec_service = MIoTSpecService(spec=service) - for prop in service['properties']: - spec_prop = MIoTSpecProperty( - spec=prop, - service=spec_service, - format_=prop['format'], - access=prop['access'], - unit=prop['unit'], - value_range=prop['value_range'], - value_list=prop['value_list'], - precision=prop.get('precision', 0)) - spec_service.properties.append(spec_prop) - for event in service['events']: - spec_event = MIoTSpecEvent( - spec=event, service=spec_service) - arg_list: list[MIoTSpecProperty] = [] - for piid in event['argument']: - for prop in spec_service.properties: - if prop.iid == piid: - arg_list.append(prop) - break - spec_event.argument = arg_list - spec_service.events.append(spec_event) - for action in service['actions']: - spec_action = MIoTSpecAction( - spec=action, service=spec_service, in_=action['in']) - in_list: list[MIoTSpecProperty] = [] - for piid in action['in']: - for prop in spec_service.properties: - if prop.iid == piid: - in_list.append(prop) - break - spec_action.in_ = in_list - out_list: list[MIoTSpecProperty] = [] - for piid in action['out']: - for prop in spec_service.properties: - if prop.iid == piid: - out_list.append(prop) - break - spec_action.out = out_list - spec_service.actions.append(spec_action) - self.services.append(spec_service) - return self + self.name = item.get('name', None) + self.value = item['value'] + self.description = item['description'] + + @staticmethod + def from_spec(item: dict) -> 'MIoTSpecValueListItem': + if ( + 'name' not in item + or 'value' not in item + or 'description' not in item + ): + raise MIoTSpecError('invalid value list item, %s') + # Slugify name and convert to lower-case. + cache = { + 'name': slugify(text=item['name'], separator='_').lower(), + 'value': item['value'], + 'description': item['description'] + } + return MIoTSpecValueListItem(cache) def dump(self) -> dict: return { - 'urn': self.urn, 'name': self.name, - 'description': self.description, - 'description_trans': self.description_trans, - 'services': [service.dump() for service in self.services] + 'value': self.value, + 'description': self.description } + def __str__(self) -> str: + return f'{self.name}: {self.value} - {self.description}' -class SpecStdLib: + +class MIoTSpecValueList: + """MIoT SPEC value list class.""" + # pylint: disable=inconsistent-quotes + items: list[MIoTSpecValueListItem] + + def __init__(self, value_list: list[dict]) -> None: + if not isinstance(value_list, list): + raise MIoTSpecError('invalid value list format') + self.items = [] + self.load(value_list) + + @property + def names(self) -> list[str]: + return [item.name for item in self.items] + + @property + def values(self) -> list[Any]: + return [item.value for item in self.items] + + @property + def descriptions(self) -> list[str]: + return [item.description for item in self.items] + + @staticmethod + def from_spec(value_list: list[dict]) -> 'MIoTSpecValueList': + result = MIoTSpecValueList([]) + dup_desc: dict[str, int] = {} + for item in value_list: + # Handle duplicate descriptions. + count = 0 + if item['description'] in dup_desc: + count = dup_desc[item['description']] + count += 1 + dup_desc[item['description']] = count + if count > 1: + item['name'] = f'{item["name"]}_{count}' + item['description'] = f'{item["description"]}_{count}' + + result.items.append(MIoTSpecValueListItem.from_spec(item)) + return result + + def load(self, value_list: list[dict]) -> None: + for item in value_list: + self.items.append(MIoTSpecValueListItem(item)) + + def to_map(self) -> dict: + return {item.value: item.description for item in self.items} + + def get_value_by_description(self, description: str) -> Any: + for item in self.items: + if item.description == description: + return item.value + return None + + def get_description_by_value(self, value: Any) -> Optional[str]: + for item in self.items: + if item.value == value: + return item.description + return None + + def dump(self) -> list: + return [item.dump() for item in self.items] + + +class _SpecStdLib: """MIoT-Spec-V2 standard library.""" + # pylint: disable=inconsistent-quotes _lang: str - _spec_std_lib: Optional[dict[str, dict[str, dict[str, str]]]] + _devices: dict[str, dict[str, str]] + _services: dict[str, dict[str, str]] + _properties: dict[str, dict[str, str]] + _events: dict[str, dict[str, str]] + _actions: dict[str, dict[str, str]] + _values: dict[str, dict[str, str]] def __init__(self, lang: str) -> None: self._lang = lang + self._devices = {} + self._services = {} + self._properties = {} + self._events = {} + self._actions = {} + self._values = {} + self._spec_std_lib = None - def init(self, std_lib: dict[str, dict[str, str]]) -> None: + def load(self, std_lib: dict[str, dict[str, dict[str, str]]]) -> None: if ( not isinstance(std_lib, dict) or 'devices' not in std_lib @@ -392,246 +250,80 @@ class SpecStdLib: or 'values' not in std_lib ): return - self._spec_std_lib = std_lib - - def deinit(self) -> None: - self._spec_std_lib = None + self._devices = std_lib['devices'] + self._services = std_lib['services'] + self._properties = std_lib['properties'] + self._events = std_lib['events'] + self._actions = std_lib['actions'] + self._values = std_lib['values'] def device_translate(self, key: str) -> Optional[str]: - if not self._spec_std_lib or key not in self._spec_std_lib['devices']: + if not self._devices or key not in self._devices: return None - if self._lang not in self._spec_std_lib['devices'][key]: - return self._spec_std_lib['devices'][key].get( + if self._lang not in self._devices[key]: + return self._devices[key].get( DEFAULT_INTEGRATION_LANGUAGE, None) - return self._spec_std_lib['devices'][key][self._lang] + return self._devices[key][self._lang] def service_translate(self, key: str) -> Optional[str]: - if not self._spec_std_lib or key not in self._spec_std_lib['services']: + if not self._services or key not in self._services: return None - if self._lang not in self._spec_std_lib['services'][key]: - return self._spec_std_lib['services'][key].get( + if self._lang not in self._services[key]: + return self._services[key].get( DEFAULT_INTEGRATION_LANGUAGE, None) - return self._spec_std_lib['services'][key][self._lang] + return self._services[key][self._lang] def property_translate(self, key: str) -> Optional[str]: - if ( - not self._spec_std_lib - or key not in self._spec_std_lib['properties'] - ): + if not self._properties or key not in self._properties: return None - if self._lang not in self._spec_std_lib['properties'][key]: - return self._spec_std_lib['properties'][key].get( + if self._lang not in self._properties[key]: + return self._properties[key].get( DEFAULT_INTEGRATION_LANGUAGE, None) - return self._spec_std_lib['properties'][key][self._lang] + return self._properties[key][self._lang] def event_translate(self, key: str) -> Optional[str]: - if not self._spec_std_lib or key not in self._spec_std_lib['events']: + if not self._events or key not in self._events: return None - if self._lang not in self._spec_std_lib['events'][key]: - return self._spec_std_lib['events'][key].get( + if self._lang not in self._events[key]: + return self._events[key].get( DEFAULT_INTEGRATION_LANGUAGE, None) - return self._spec_std_lib['events'][key][self._lang] + return self._events[key][self._lang] def action_translate(self, key: str) -> Optional[str]: - if not self._spec_std_lib or key not in self._spec_std_lib['actions']: + if not self._actions or key not in self._actions: return None - if self._lang not in self._spec_std_lib['actions'][key]: - return self._spec_std_lib['actions'][key].get( + if self._lang not in self._actions[key]: + return self._actions[key].get( DEFAULT_INTEGRATION_LANGUAGE, None) - return self._spec_std_lib['actions'][key][self._lang] + return self._actions[key][self._lang] def value_translate(self, key: str) -> Optional[str]: - if not self._spec_std_lib or key not in self._spec_std_lib['values']: + if not self._values or key not in self._values: return None - if self._lang not in self._spec_std_lib['values'][key]: - return self._spec_std_lib['values'][key].get( + if self._lang not in self._values[key]: + return self._values[key].get( DEFAULT_INTEGRATION_LANGUAGE, None) - return self._spec_std_lib['values'][key][self._lang] + return self._values[key][self._lang] - def dump(self) -> dict[str, dict[str, str]]: - return self._spec_std_lib + def dump(self) -> dict[str, dict[str, dict[str, str]]]: + return { + 'devices': self._devices, + 'services': self._services, + 'properties': self._properties, + 'events': self._events, + 'actions': self._actions, + 'values': self._values + } + async def refresh_async(self) -> bool: + std_lib_new = await self.__request_from_cloud_async() + if std_lib_new: + self.load(std_lib_new) + return True + return False -class MIoTSpecParser: - """MIoT SPEC parser.""" - # pylint: disable=inconsistent-quotes - VERSION: int = 1 - DOMAIN: str = 'miot_specs' - _lang: str - _storage: MIoTStorage - _main_loop: asyncio.AbstractEventLoop - - _init_done: bool - _ram_cache: dict - - _std_lib: SpecStdLib - _bool_trans: SpecBoolTranslation - _multi_lang: SpecMultiLang - _spec_filter: SpecFilter - - def __init__( - self, lang: str = DEFAULT_INTEGRATION_LANGUAGE, - storage: MIoTStorage = None, - loop: Optional[asyncio.AbstractEventLoop] = None - ) -> None: - self._lang = lang - self._storage = storage - self._main_loop = loop or asyncio.get_running_loop() - - self._init_done = False - self._ram_cache = {} - - self._std_lib = SpecStdLib(lang=self._lang) - self._bool_trans = SpecBoolTranslation( - lang=self._lang, loop=self._main_loop) - self._multi_lang = SpecMultiLang(lang=self._lang, loop=self._main_loop) - self._spec_filter = SpecFilter(loop=self._main_loop) - - async def init_async(self) -> None: - if self._init_done is True: - return - await self._bool_trans.init_async() - await self._multi_lang.init_async() - await self._spec_filter.init_async() - std_lib_cache: dict = None - if self._storage: - std_lib_cache: dict = await self._storage.load_async( - domain=self.DOMAIN, name='spec_std_lib', type_=dict) - if ( - isinstance(std_lib_cache, dict) - and 'data' in std_lib_cache - and 'ts' in std_lib_cache - and isinstance(std_lib_cache['ts'], int) - and int(time.time()) - std_lib_cache['ts'] < - SPEC_STD_LIB_EFFECTIVE_TIME - ): - # Use the cache if the update time is less than 14 day - _LOGGER.debug( - 'use local spec std cache, ts->%s', std_lib_cache['ts']) - self._std_lib.init(std_lib_cache['data']) - self._init_done = True - return - # Update spec std lib - spec_lib_new = await self.__request_spec_std_lib_async() - if spec_lib_new: - self._std_lib.init(spec_lib_new) - if self._storage: - if not await self._storage.save_async( - domain=self.DOMAIN, name='spec_std_lib', - data={ - 'data': self._std_lib.dump(), - 'ts': int(time.time()) - } - ): - _LOGGER.error('save spec std lib failed') - else: - if std_lib_cache: - self._std_lib.init(std_lib_cache['data']) - _LOGGER.error('get spec std lib failed, use local cache') - else: - _LOGGER.error('get spec std lib failed') - self._init_done = True - - async def deinit_async(self) -> None: - self._init_done = False - self._std_lib.deinit() - await self._bool_trans.deinit_async() - await self._multi_lang.deinit_async() - await self._spec_filter.deinit_async() - self._ram_cache.clear() - - async def parse( - self, urn: str, skip_cache: bool = False, - ) -> MIoTSpecInstance: - """MUST await init first !!!""" - if not skip_cache: - cache_result = await self.__cache_get(urn=urn) - if isinstance(cache_result, dict): - _LOGGER.debug('get from cache, %s', urn) - return MIoTSpecInstance().load(specs=cache_result) - # Retry three times - for index in range(3): - try: - return await self.__parse(urn=urn) - except Exception as err: # pylint: disable=broad-exception-caught - _LOGGER.error( - 'parse error, retry, %d, %s, %s', index, urn, err) - return None - - async def refresh_async(self, urn_list: list[str]) -> int: - """MUST await init first !!!""" - if not urn_list: - return False - spec_std_new: dict = await self.__request_spec_std_lib_async() - if spec_std_new: - self._std_lib.init(spec_std_new) - if self._storage: - if not await self._storage.save_async( - domain=self.DOMAIN, name='spec_std_lib', - data={ - 'data': self._std_lib.dump(), - 'ts': int(time.time()) - } - ): - _LOGGER.error('save spec std lib failed') - else: - raise MIoTSpecError('get spec std lib failed') - success_count = 0 - for index in range(0, len(urn_list), 5): - batch = urn_list[index:index+5] - task_list = [self._main_loop.create_task( - self.parse(urn=urn, skip_cache=True)) for urn in batch] - results = await asyncio.gather(*task_list) - success_count += sum(1 for result in results if result is not None) - return success_count - - def __http_get( - self, url: str, params: dict = None, headers: dict = None - ) -> dict: - if params: - encoded_params = urlencode(params) - full_url = f'{url}?{encoded_params}' - else: - full_url = url - request = Request(full_url, method='GET', headers=headers or {}) - content: bytes = None - with urlopen(request) as response: - content = response.read() - return ( - json.loads(str(content, 'utf-8')) - if content is not None else None) - - async def __http_get_async( - self, url: str, params: dict = None, headers: dict = None - ) -> dict: - return await self._main_loop.run_in_executor( - None, self.__http_get, url, params, headers) - - async def __cache_get(self, urn: str) -> Optional[dict]: - if self._storage is not None: - if platform.system() == 'Windows': - urn = urn.replace(':', '_') - return await self._storage.load_async( - domain=self.DOMAIN, name=f'{urn}_{self._lang}', type_=dict) - return self._ram_cache.get(urn, None) - - async def __cache_set(self, urn: str, data: dict) -> bool: - if self._storage is not None: - if platform.system() == 'Windows': - urn = urn.replace(':', '_') - return await self._storage.save_async( - domain=self.DOMAIN, name=f'{urn}_{self._lang}', data=data) - self._ram_cache[urn] = data - return True - - def __spec_format2dtype(self, format_: str) -> str: - # 'string'|'bool'|'uint8'|'uint16'|'uint32'| - # 'int8'|'int16'|'int32'|'int64'|'float' - return {'string': 'str', 'bool': 'bool', 'float': 'float'}.get( - format_, 'int') - - async def __request_spec_std_lib_async(self) -> Optional[SpecStdLib]: - std_libs: dict = None + async def __request_from_cloud_async(self) -> Optional[dict]: + std_libs: Optional[dict] = None for index in range(3): try: tasks: list = [] @@ -659,7 +351,7 @@ class MIoTSpecParser: for name in [ 'device', 'service', 'property', 'event', 'action', 'property_value']: - tasks.append(self.__http_get_async( + tasks.append(MIoTHttp.get_json_async( 'https://cdn.cnbj1.fds.api.mi-img.com/res-conf/' f'xiaomi-home/std_ex_{name}.json')) results = await asyncio.gather(*tasks) @@ -719,7 +411,7 @@ class MIoTSpecParser: return None async def __get_property_value(self) -> dict: - reply = await self.__http_get_async( + reply = await MIoTHttp.get_json_async( url='https://miot-spec.org/miot-spec-v2' '/normalization/list/property_value') if reply is None or 'result' not in reply: @@ -743,7 +435,7 @@ class MIoTSpecParser: return result async def __get_template_list(self, url: str) -> dict: - reply = await self.__http_get_async(url=url) + reply = await MIoTHttp.get_json_async(url=url) if reply is None or 'result' not in reply: raise MIoTSpecError(f'get service failed, {url}') result: dict = {} @@ -767,20 +459,809 @@ class MIoTSpecParser: result[item['type']] = item['description'] return result - async def __get_instance(self, urn: str) -> dict: - return await self.__http_get_async( - url='https://miot-spec.org/miot-spec-v2/instance', - params={'type': urn}) - async def __get_translation(self, urn: str) -> dict: - return await self.__http_get_async( +class _MIoTSpecBase: + """MIoT SPEC base class.""" + iid: int + type_: str + description: str + description_trans: str + proprietary: bool + need_filter: bool + name: str + + # External params + platform: Optional[str] + device_class: Any + state_class: Any + icon: Optional[str] + external_unit: Any + expression: Optional[str] + + spec_id: int + + def __init__(self, spec: dict) -> None: + self.iid = spec['iid'] + self.type_ = spec['type'] + self.description = spec['description'] + + self.description_trans = spec.get('description_trans', None) + self.proprietary = spec.get('proprietary', False) + self.need_filter = spec.get('need_filter', False) + self.name = spec.get('name', 'xiaomi') + + self.platform = None + self.device_class = None + self.state_class = None + self.icon = None + self.external_unit = None + self.expression = None + + self.spec_id = hash(f'{self.type_}.{self.iid}') + + def __hash__(self) -> int: + return self.spec_id + + def __eq__(self, value) -> bool: + return self.spec_id == value.spec_id + + +class MIoTSpecProperty(_MIoTSpecBase): + """MIoT SPEC property class.""" + unit: Optional[str] + precision: int + + _format_: Type + _value_range: Optional[MIoTSpecValueRange] + _value_list: Optional[MIoTSpecValueList] + + _access: list + _writable: bool + _readable: bool + _notifiable: bool + + service: 'MIoTSpecService' + + def __init__( + self, + spec: dict, + service: 'MIoTSpecService', + format_: str, + access: list, + unit: Optional[str] = None, + value_range: Optional[dict] = None, + value_list: Optional[list[dict]] = None, + precision: Optional[int] = None + ) -> None: + super().__init__(spec=spec) + self.service = service + self.format_ = format_ + self.access = access + self.unit = unit + self.value_range = value_range + self.value_list = value_list + self.precision = precision or 1 + + self.spec_id = hash( + f'p.{self.name}.{self.service.iid}.{self.iid}') + + @property + def format_(self) -> Type: + return self._format_ + + @format_.setter + def format_(self, value: str) -> None: + self._format_ = { + 'string': str, + 'str': str, + 'bool': bool, + 'float': float}.get( + value, int) + + @property + def access(self) -> list: + return self._access + + @access.setter + def access(self, value: list) -> None: + self._access = value + if isinstance(value, list): + self._writable = 'write' in value + self._readable = 'read' in value + self._notifiable = 'notify' in value + + @property + def writable(self) -> bool: + return self._writable + + @property + def readable(self) -> bool: + return self._readable + + @property + def notifiable(self): + return self._notifiable + + @property + def value_range(self) -> Optional[MIoTSpecValueRange]: + return self._value_range + + @value_range.setter + def value_range(self, value: Union[dict, list, None]) -> None: + """Set value-range, precision.""" + if not value: + self._value_range = None + return + self._value_range = MIoTSpecValueRange(value_range=value) + if isinstance(value, list): + self.precision = len(str(value[2]).split( + '.')[1].rstrip('0')) if '.' in str(value[2]) else 0 + + @property + def value_list(self) -> Optional[MIoTSpecValueList]: + return self._value_list + + @value_list.setter + def value_list( + self, value: Union[list[dict], MIoTSpecValueList, None] + ) -> None: + if not value: + self._value_list = None + return + if isinstance(value, list): + self._value_list = MIoTSpecValueList(value_list=value) + elif isinstance(value, MIoTSpecValueList): + self._value_list = value + + def value_format(self, value: Any) -> Any: + if value is None: + return None + if self.format_ == int: + return int(value) + if self.format_ == float: + return round(value, self.precision) + if self.format_ == bool: + return bool(value in [True, 1, 'True', 'true', '1']) + return value + + def dump(self) -> dict: + return { + 'type': self.type_, + 'name': self.name, + 'iid': self.iid, + 'description': self.description, + 'description_trans': self.description_trans, + 'proprietary': self.proprietary, + 'need_filter': self.need_filter, + 'format': self.format_.__name__, + 'access': self._access, + 'unit': self.unit, + 'value_range': ( + self._value_range.dump() if self._value_range else None), + 'value_list': self._value_list.dump() if self._value_list else None, + 'precision': self.precision + } + + +class MIoTSpecEvent(_MIoTSpecBase): + """MIoT SPEC event class.""" + argument: list[MIoTSpecProperty] + service: 'MIoTSpecService' + + def __init__( + self, spec: dict, service: 'MIoTSpecService', + argument: Optional[list[MIoTSpecProperty]] = None + ) -> None: + super().__init__(spec=spec) + self.argument = argument or [] + self.service = service + + self.spec_id = hash( + f'e.{self.name}.{self.service.iid}.{self.iid}') + + def dump(self) -> dict: + return { + 'type': self.type_, + 'name': self.name, + 'iid': self.iid, + 'description': self.description, + 'description_trans': self.description_trans, + 'proprietary': self.proprietary, + 'argument': [prop.iid for prop in self.argument], + 'need_filter': self.need_filter + } + + +class MIoTSpecAction(_MIoTSpecBase): + """MIoT SPEC action class.""" + in_: list[MIoTSpecProperty] + out: list[MIoTSpecProperty] + service: 'MIoTSpecService' + + def __init__( + self, spec: dict, service: 'MIoTSpecService', + in_: Optional[list[MIoTSpecProperty]] = None, + out: Optional[list[MIoTSpecProperty]] = None + ) -> None: + super().__init__(spec=spec) + self.in_ = in_ or [] + self.out = out or [] + self.service = service + + self.spec_id = hash( + f'a.{self.name}.{self.service.iid}.{self.iid}') + + def dump(self) -> dict: + return { + 'type': self.type_, + 'name': self.name, + 'iid': self.iid, + 'description': self.description, + 'description_trans': self.description_trans, + 'in': [prop.iid for prop in self.in_], + 'out': [prop.iid for prop in self.out], + 'proprietary': self.proprietary, + 'need_filter': self.need_filter + } + + +class MIoTSpecService(_MIoTSpecBase): + """MIoT SPEC service class.""" + properties: list[MIoTSpecProperty] + events: list[MIoTSpecEvent] + actions: list[MIoTSpecAction] + + def __init__(self, spec: dict) -> None: + super().__init__(spec=spec) + self.properties = [] + self.events = [] + self.actions = [] + + def dump(self) -> dict: + return { + 'type': self.type_, + 'name': self.name, + 'iid': self.iid, + 'description': self.description, + 'description_trans': self.description_trans, + 'proprietary': self.proprietary, + 'properties': [prop.dump() for prop in self.properties], + 'events': [event.dump() for event in self.events], + 'actions': [action.dump() for action in self.actions], + 'need_filter': self.need_filter + } + + +# @dataclass +class MIoTSpecInstance: + """MIoT SPEC instance class.""" + urn: str + name: str + # urn_name: str + description: str + description_trans: str + services: list[MIoTSpecService] + + # External params + platform: str + device_class: Any + icon: str + + def __init__( + self, urn: str, name: str, description: str, description_trans: str + ) -> None: + self.urn = urn + self.name = name + self.description = description + self.description_trans = description_trans + self.services = [] + + @staticmethod + def load(specs: dict) -> 'MIoTSpecInstance': + instance = MIoTSpecInstance( + urn=specs['urn'], + name=specs['name'], + description=specs['description'], + description_trans=specs['description_trans']) + for service in specs['services']: + spec_service = MIoTSpecService(spec=service) + for prop in service['properties']: + spec_prop = MIoTSpecProperty( + spec=prop, + service=spec_service, + format_=prop['format'], + access=prop['access'], + unit=prop['unit'], + value_range=prop['value_range'], + value_list=prop['value_list'], + precision=prop.get('precision', None)) + spec_service.properties.append(spec_prop) + for event in service['events']: + spec_event = MIoTSpecEvent( + spec=event, service=spec_service) + arg_list: list[MIoTSpecProperty] = [] + for piid in event['argument']: + for prop in spec_service.properties: + if prop.iid == piid: + arg_list.append(prop) + break + spec_event.argument = arg_list + spec_service.events.append(spec_event) + for action in service['actions']: + spec_action = MIoTSpecAction( + spec=action, service=spec_service, in_=action['in']) + in_list: list[MIoTSpecProperty] = [] + for piid in action['in']: + for prop in spec_service.properties: + if prop.iid == piid: + in_list.append(prop) + break + spec_action.in_ = in_list + out_list: list[MIoTSpecProperty] = [] + for piid in action['out']: + for prop in spec_service.properties: + if prop.iid == piid: + out_list.append(prop) + break + spec_action.out = out_list + spec_service.actions.append(spec_action) + instance.services.append(spec_service) + return instance + + def dump(self) -> dict: + return { + 'urn': self.urn, + 'name': self.name, + 'description': self.description, + 'description_trans': self.description_trans, + 'services': [service.dump() for service in self.services] + } + + +class _MIoTSpecMultiLang: + """MIoT SPEC multi lang class.""" + # pylint: disable=broad-exception-caught + _DOMAIN: str = 'miot_specs_multi_lang' + _lang: str + _storage: MIoTStorage + _main_loop: asyncio.AbstractEventLoop + + _custom_cache: dict[str, dict] + _current_data: Optional[dict[str, str]] + + def __init__( + self, lang: Optional[str], + storage: MIoTStorage, + loop: Optional[asyncio.AbstractEventLoop] = None + ) -> None: + self._lang = lang or DEFAULT_INTEGRATION_LANGUAGE + self._storage = storage + self._main_loop = loop or asyncio.get_running_loop() + + self._custom_cache = {} + self._current_data = None + + async def set_spec_async(self, urn: str) -> None: + if urn in self._custom_cache: + self._current_data = self._custom_cache[urn] + return + + trans_cache: dict[str, str] = {} + trans_cloud: dict = {} + trans_local: dict = {} + # Get multi lang from cloud + try: + trans_cloud = await self.__get_multi_lang_async(urn) + if self._lang == 'zh-Hans': + # Simplified Chinese + trans_cache = trans_cloud.get('zh_cn', {}) + elif self._lang == 'zh-Hant': + # Traditional Chinese, zh_hk or zh_tw + trans_cache = trans_cloud.get('zh_hk', {}) + if not trans_cache: + trans_cache = trans_cloud.get('zh_tw', {}) + else: + trans_cache = trans_cloud.get(self._lang, {}) + except Exception as err: + trans_cloud = {} + _LOGGER.info('get multi lang from cloud failed, %s, %s', urn, err) + # Get multi lang from local + try: + trans_local = await self._storage.load_async( + domain=self._DOMAIN, name=urn, type_=dict) # type: ignore + if ( + isinstance(trans_local, dict) + and self._lang in trans_local + ): + trans_cache.update(trans_local[self._lang]) + except Exception as err: + trans_local = {} + _LOGGER.info('get multi lang from local failed, %s, %s', urn, err) + # Default language + if not trans_cache: + if trans_cloud and DEFAULT_INTEGRATION_LANGUAGE in trans_cloud: + trans_cache = trans_cloud[DEFAULT_INTEGRATION_LANGUAGE] + if trans_local and DEFAULT_INTEGRATION_LANGUAGE in trans_local: + trans_cache.update( + trans_local[DEFAULT_INTEGRATION_LANGUAGE]) + trans_data: dict[str, str] = {} + for tag, value in trans_cache.items(): + if value is None or value.strip() == '': + continue + # The dict key is like: + # 'service:002:property:001:valuelist:000' or + # 'service:002:property:001' or 'service:002' + strs: list = tag.split(':') + strs_len = len(strs) + if strs_len == 2: + trans_data[f's:{int(strs[1])}'] = value + elif strs_len == 4: + type_ = 'p' if strs[2] == 'property' else ( + 'a' if strs[2] == 'action' else 'e') + trans_data[ + f'{type_}:{int(strs[1])}:{int(strs[3])}' + ] = value + elif strs_len == 6: + trans_data[ + f'v:{int(strs[1])}:{int(strs[3])}:{int(strs[5])}' + ] = value + + self._custom_cache[urn] = trans_data + self._current_data = trans_data + + def translate(self, key: str) -> Optional[str]: + if not self._current_data: + return None + return self._current_data.get(key, None) + + async def __get_multi_lang_async(self, urn: str) -> dict: + res_trans = await MIoTHttp.get_json_async( url='https://miot-spec.org/instance/v2/multiLanguage', params={'urn': urn}) + if ( + not isinstance(res_trans, dict) + or 'data' not in res_trans + or not isinstance(res_trans['data'], dict) + ): + raise MIoTSpecError('invalid translation data') + return res_trans['data'] + + +class _SpecBoolTranslation: + """ + Boolean value translation. + """ + _BOOL_TRANS_FILE = 'specs/bool_trans.yaml' + _main_loop: asyncio.AbstractEventLoop + _lang: str + _data: Optional[dict[str, list]] + _data_default: Optional[list[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 + self._data_default = 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_yaml_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 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) -> Optional[list[dict]]: + """ + MUST call init_async() before calling this method. + [ + {'value': True, 'description': 'True'}, + {'value': False, 'description': 'False'} + ] + """ + if not self._data or urn not in self._data: + return self._data_default + return self._data[urn] + + +class _SpecFilter: + """ + MIoT-Spec-V2 filter for entity conversion. + """ + _SPEC_FILTER_FILE = 'specs/spec_filter.yaml' + _main_loop: asyncio.AbstractEventLoop + _data: Optional[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_yaml_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 + + async def set_spec_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 set_spec_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 set_spec_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 set_spec_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 set_spec_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 MIoTSpecParser: + """MIoT SPEC parser.""" + # pylint: disable=inconsistent-quotes + VERSION: int = 1 + _DOMAIN: str = 'miot_specs' + _lang: str + _storage: MIoTStorage + _main_loop: asyncio.AbstractEventLoop + + _std_lib: _SpecStdLib + _multi_lang: _MIoTSpecMultiLang + _bool_trans: _SpecBoolTranslation + _spec_filter: _SpecFilter + + _init_done: bool + + def __init__( + self, lang: Optional[str], + storage: MIoTStorage, + loop: Optional[asyncio.AbstractEventLoop] = None + ) -> None: + self._lang = lang or DEFAULT_INTEGRATION_LANGUAGE + self._storage = storage + self._main_loop = loop or asyncio.get_running_loop() + self._std_lib = _SpecStdLib(lang=self._lang) + self._multi_lang = _MIoTSpecMultiLang( + lang=self._lang, storage=self._storage, loop=self._main_loop) + self._bool_trans = _SpecBoolTranslation( + lang=self._lang, loop=self._main_loop) + self._spec_filter = _SpecFilter(loop=self._main_loop) + + self._init_done = False + + async def init_async(self) -> None: + if self._init_done is True: + return + await self._bool_trans.init_async() + await self._spec_filter.init_async() + std_lib_cache = await self._storage.load_async( + domain=self._DOMAIN, name='spec_std_lib', type_=dict) + if ( + isinstance(std_lib_cache, dict) + and 'data' in std_lib_cache + and 'ts' in std_lib_cache + and isinstance(std_lib_cache['ts'], int) + and int(time.time()) - std_lib_cache['ts'] < + SPEC_STD_LIB_EFFECTIVE_TIME + ): + # Use the cache if the update time is less than 14 day + _LOGGER.debug( + 'use local spec std cache, ts->%s', std_lib_cache['ts']) + self._std_lib.load(std_lib_cache['data']) + self._init_done = True + return + # Update spec std lib + if await self._std_lib.refresh_async(): + if not await self._storage.save_async( + domain=self._DOMAIN, name='spec_std_lib', + data={ + 'data': self._std_lib.dump(), + 'ts': int(time.time()) + } + ): + _LOGGER.error('save spec std lib failed') + else: + if isinstance(std_lib_cache, dict) and 'data' in std_lib_cache: + self._std_lib.load(std_lib_cache['data']) + _LOGGER.info('get spec std lib failed, use local cache') + else: + _LOGGER.error('load spec std lib failed') + self._init_done = True + + async def deinit_async(self) -> None: + self._init_done = False + # self._std_lib.deinit() + await self._bool_trans.deinit_async() + await self._spec_filter.deinit_async() + + async def parse( + self, urn: str, skip_cache: bool = False, + ) -> Optional[MIoTSpecInstance]: + """MUST await init first !!!""" + if not skip_cache: + cache_result = await self.__cache_get(urn=urn) + if isinstance(cache_result, dict): + _LOGGER.debug('get from cache, %s', urn) + return MIoTSpecInstance.load(specs=cache_result) + # Retry three times + for index in range(3): + try: + return await self.__parse(urn=urn) + except Exception as err: # pylint: disable=broad-exception-caught + _LOGGER.error( + 'parse error, retry, %d, %s, %s', index, urn, err) + return None + + async def refresh_async(self, urn_list: list[str]) -> int: + """MUST await init first !!!""" + if not urn_list: + return False + if await self._std_lib.refresh_async(): + if not await self._storage.save_async( + domain=self._DOMAIN, name='spec_std_lib', + data={ + 'data': self._std_lib.dump(), + 'ts': int(time.time()) + } + ): + _LOGGER.error('save spec std lib failed') + else: + raise MIoTSpecError('get spec std lib failed') + success_count = 0 + for index in range(0, len(urn_list), 5): + batch = urn_list[index:index+5] + task_list = [self._main_loop.create_task( + self.parse(urn=urn, skip_cache=True)) for urn in batch] + results = await asyncio.gather(*task_list) + success_count += sum(1 for result in results if result is not None) + return success_count + + async def __cache_get(self, urn: str) -> Optional[dict]: + if platform.system() == 'Windows': + urn = urn.replace(':', '_') + return await self._storage.load_async( + domain=self._DOMAIN, + name=f'{urn}_{self._lang}', + type_=dict) # type: ignore + + async def __cache_set(self, urn: str, data: dict) -> bool: + if platform.system() == 'Windows': + urn = urn.replace(':', '_') + return await self._storage.save_async( + domain=self._DOMAIN, name=f'{urn}_{self._lang}', data=data) + + async def __get_instance(self, urn: str) -> Optional[dict]: + return await MIoTHttp.get_json_async( + url='https://miot-spec.org/miot-spec-v2/instance', + params={'type': urn}) async def __parse(self, urn: str) -> MIoTSpecInstance: _LOGGER.debug('parse urn, %s', urn) # Load spec instance - instance: dict = await self.__get_instance(urn=urn) + instance = await self.__get_instance(urn=urn) if ( not isinstance(instance, dict) or 'type' not in instance @@ -788,69 +1269,12 @@ class MIoTSpecParser: or 'services' not in instance ): raise MIoTSpecError(f'invalid urn instance, {urn}') - translation: dict = {} - try: - # Load multiple language configuration. - res_trans = await self.__get_translation(urn=urn) - if ( - not isinstance(res_trans, dict) - or 'data' not in res_trans - or not isinstance(res_trans['data'], dict) - ): - raise MIoTSpecError('invalid translation data') - urn_strs: list[str] = urn.split(':') - urn_key: str = ':'.join(urn_strs[:6]) - trans_data: dict[str, str] = None - if self._lang == 'zh-Hans': - # Simplified Chinese - trans_data = res_trans['data'].get('zh_cn', {}) - elif self._lang == 'zh-Hant': - # Traditional Chinese, zh_hk or zh_tw - trans_data = res_trans['data'].get('zh_hk', {}) - if not trans_data: - trans_data = res_trans['data'].get('zh_tw', {}) - else: - trans_data = res_trans['data'].get(self._lang, {}) - # Load local multiple language configuration. - multi_lang: dict = await self._multi_lang.translate_async( - urn_key=urn_key) - if multi_lang: - trans_data.update(multi_lang) - if not trans_data: - trans_data = res_trans['data'].get( - DEFAULT_INTEGRATION_LANGUAGE, {}) - if not trans_data: - raise MIoTSpecError( - f'the language is not supported, {self._lang}') - else: - _LOGGER.error( - 'the language is not supported, %s, try using the ' - 'default language, %s, %s', - self._lang, DEFAULT_INTEGRATION_LANGUAGE, urn) - for tag, value in trans_data.items(): - if value is None or value.strip() == '': - continue - # The dict key is like: - # 'service:002:property:001:valuelist:000' or - # 'service:002:property:001' or 'service:002' - strs: list = tag.split(':') - strs_len = len(strs) - if strs_len == 2: - translation[f's:{int(strs[1])}'] = value - elif strs_len == 4: - type_ = 'p' if strs[2] == 'property' else ( - 'a' if strs[2] == 'action' else 'e') - translation[ - f'{type_}:{int(strs[1])}:{int(strs[3])}' - ] = value - elif strs_len == 6: - translation[ - f'v:{int(strs[1])}:{int(strs[3])}:{int(strs[5])}' - ] = value - except MIoTSpecError as e: - _LOGGER.error('get translation error, %s, %s', urn, e) - # Spec filter - self._spec_filter.filter_spec(urn_key=urn_key) + urn_strs: list[str] = urn.split(':') + urn_key: str = ':'.join(urn_strs[:6]) + # Set translation cache + await self._multi_lang.set_spec_async(urn=urn) + # Set spec filter + await self._spec_filter.set_spec_spec(urn_key=urn_key) # Parse device type spec_instance: MIoTSpecInstance = MIoTSpecInstance( urn=urn, name=urn_strs[3], @@ -880,7 +1304,7 @@ class MIoTSpecParser: if type_strs[1] != 'miot-spec-v2': spec_service.proprietary = True spec_service.description_trans = ( - translation.get(f's:{service["iid"]}', None) + self._multi_lang.translate(f's:{service["iid"]}') or self._std_lib.service_translate(key=':'.join(type_strs[:5])) or service['description'] or spec_service.name @@ -899,7 +1323,7 @@ class MIoTSpecParser: spec_prop: MIoTSpecProperty = MIoTSpecProperty( spec=property_, service=spec_service, - format_=self.__spec_format2dtype(property_['format']), + format_=property_['format'], access=property_['access'], unit=property_.get('unit', None)) spec_prop.name = p_type_strs[3] @@ -911,41 +1335,35 @@ class MIoTSpecParser: if p_type_strs[1] != 'miot-spec-v2': spec_prop.proprietary = spec_service.proprietary or True spec_prop.description_trans = ( - translation.get( - f'p:{service["iid"]}:{property_["iid"]}', None) + self._multi_lang.translate( + f'p:{service["iid"]}:{property_["iid"]}') or self._std_lib.property_translate( key=':'.join(p_type_strs[:5])) or property_['description'] or spec_prop.name) if 'value-range' in property_: - spec_prop.value_range = { - 'min': property_['value-range'][0], - 'max': property_['value-range'][1], - 'step': property_['value-range'][2] - } - spec_prop.precision = len(str( - property_['value-range'][2]).split( - '.')[1].rstrip('0')) if '.' in str( - property_['value-range'][2]) else 0 + spec_prop.value_range = property_['value-range'] elif 'value-list' in property_: v_list: list[dict] = property_['value-list'] for index, v in enumerate(v_list): + if v['description'].strip() == '': + v['description'] = f'v_{v["value"]}' v['name'] = v['description'] v['description'] = ( - translation.get( + self._multi_lang.translate( f'v:{service["iid"]}:{property_["iid"]}:' - f'{index}', None) + f'{index}') or self._std_lib.value_translate( key=f'{type_strs[:5]}|{p_type_strs[3]}|' f'{v["description"]}') - or v['name'] - ) - spec_prop.value_list = v_list + or v['name']) + spec_prop.value_list = MIoTSpecValueList.from_spec(v_list) elif property_['format'] == 'bool': v_tag = ':'.join(p_type_strs[:5]) - v_descriptions: dict = ( + v_descriptions = ( await self._bool_trans.translate_async(urn=v_tag)) if v_descriptions: + # bool without value-list.name spec_prop.value_list = v_descriptions spec_service.properties.append(spec_prop) # Parse service event @@ -969,8 +1387,8 @@ class MIoTSpecParser: if e_type_strs[1] != 'miot-spec-v2': spec_event.proprietary = spec_service.proprietary or True spec_event.description_trans = ( - translation.get( - f'e:{service["iid"]}:{event["iid"]}', None) + self._multi_lang.translate( + f'e:{service["iid"]}:{event["iid"]}') or self._std_lib.event_translate( key=':'.join(e_type_strs[:5])) or event['description'] @@ -1005,8 +1423,8 @@ class MIoTSpecParser: if a_type_strs[1] != 'miot-spec-v2': spec_action.proprietary = spec_service.proprietary or True spec_action.description_trans = ( - translation.get( - f'a:{service["iid"]}:{action["iid"]}', None) + self._multi_lang.translate( + f'a:{service["iid"]}:{action["iid"]}') or self._std_lib.action_translate( key=':'.join(a_type_strs[:5])) or action['description'] diff --git a/custom_components/xiaomi_home/miot/miot_storage.py b/custom_components/xiaomi_home/miot/miot_storage.py index 85b25c9..3767365 100644 --- a/custom_components/xiaomi_home/miot/miot_storage.py +++ b/custom_components/xiaomi_home/miot/miot_storage.py @@ -58,7 +58,6 @@ from enum import Enum, auto from pathlib import Path from typing import Any, 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 @@ -66,13 +65,13 @@ 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 .common import MIoTHttp from .miot_error import MIoTCertError, MIoTError, MIoTStorageError _LOGGER = logging.getLogger(__name__) @@ -93,10 +92,10 @@ class MIoTStorage: User data will be stored in the `.storage` directory of Home Assistant. """ - _main_loop: asyncio.AbstractEventLoop = None + _main_loop: asyncio.AbstractEventLoop _file_future: dict[str, tuple[MIoTStorageType, asyncio.Future]] - _root_path: str = None + _root_path: str def __init__( self, root_path: str, @@ -140,7 +139,7 @@ class MIoTStorage: if r_data is None: _LOGGER.error('load error, empty file, %s', full_path) return None - data_bytes: bytes = None + data_bytes: bytes # Hash check if with_hash_check: if len(r_data) <= 32: @@ -209,17 +208,17 @@ class MIoTStorage: 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: bytes + if isinstance(data, bytes): w_bytes = data - elif type_ == str: + elif isinstance(data, str): w_bytes = data.encode('utf-8') - elif type_ in [dict, list]: + elif isinstance(data, (dict, list)): w_bytes = json.dumps(data).encode('utf-8') else: _LOGGER.error( - 'save error, unsupported data type, %s', type_.__name__) + 'save error, unsupported data type, %s', + type(data).__name__) return False with open(full_path, 'wb') as w_file: w_file.write(w_bytes) @@ -353,7 +352,8 @@ class MIoTStorage: 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) + full_path=full_path, type_=bytes, + with_hash_check=False) # type: ignore async def load_file_async( self, domain: str, name_with_suffix: str @@ -371,7 +371,7 @@ class MIoTStorage: None, self.__load, full_path, bytes, False) if not fut.done(): self.__add_file_future(full_path, MIoTStorageType.LOAD_FILE, fut) - return await fut + return await fut # type: ignore def remove_file(self, domain: str, name_with_suffix: str) -> bool: full_path = os.path.join(self._root_path, domain, name_with_suffix) @@ -438,7 +438,7 @@ class MIoTStorage: 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) + local_config.update(config) # type: ignore return self.save( domain=config_domain, name=config_name, data=local_config) @@ -474,27 +474,31 @@ class MIoTStorage: 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) + local_config.update(config) # type: ignore 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: + if isinstance(keys, list) 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 {} + name=config_name, type_=dict)) + if not isinstance(local_config, dict): + return {} if keys is None: return local_config - return {key: local_config.get(key, None) for key in keys} + return { + key: local_config[key] for key in keys + if key in local_config} async def load_user_config_async( self, uid: str, cloud_server: str, keys: Optional[list[str]] = None - ) -> dict[str, Any]: + ) -> dict: """Load user configuration. Args: @@ -505,13 +509,15 @@ class MIoTStorage: Returns: dict[str, Any]: query result """ - if keys is not None and len(keys) == 0: + if isinstance(keys, list) 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 {} + domain=config_domain, name=config_name, type_=dict)) + if not isinstance(local_config, dict): + return {} if keys is None: return local_config return { @@ -519,7 +525,8 @@ class MIoTStorage: if key in local_config} def gen_storage_path( - self, domain: str = None, name_with_suffix: str = None + self, domain: Optional[str] = None, + name_with_suffix: Optional[str] = None ) -> str: """Generate file path.""" result = self._root_path @@ -609,9 +616,8 @@ class MIoTCert: if cert_data is None: return 0 # Check user cert - user_cert: x509.Certificate = None try: - user_cert = x509.load_pem_x509_certificate( + user_cert: x509.Certificate = x509.load_pem_x509_certificate( cert_data, default_backend()) cert_info = {} for attribute in user_cert.subject: @@ -669,7 +675,8 @@ class MIoTCert: NameOID.COMMON_NAME, f'mips.{self._uid}.{did_hash}.2'), ])) csr = builder.sign( - private_key, algorithm=None, backend=default_backend()) + private_key, algorithm=None, # type: ignore + backend=default_backend()) return csr.public_bytes(serialization.Encoding.PEM).decode('utf-8') async def load_user_key_async(self) -> Optional[str]: @@ -719,250 +726,6 @@ class MIoTCert: 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' @@ -976,12 +739,11 @@ class DeviceManufacturer: ) -> None: self._main_loop = loop or asyncio.get_event_loop() self._storage = storage - self._data = None + self._data = {} 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 ( @@ -995,8 +757,15 @@ class DeviceManufacturer: _LOGGER.debug('load manufacturer data success') return - data_cloud = await self._main_loop.run_in_executor( - None, self.__get_manufacturer_data) + data_cloud = None + try: + data_cloud = await MIoTHttp.get_json_async( + url='https://cdn.cnbj1.fds.api.mi-img.com/res-conf/xiaomi-home/' + 'manufacturer.json', + loop=self._main_loop) + except Exception as err: # pylint: disable=broad-exception-caught + _LOGGER.error('get manufacturer info failed, %s', err) + if data_cloud: await self._storage.save_async( domain=self.DOMAIN, name='manufacturer', @@ -1004,32 +773,16 @@ class DeviceManufacturer: self._data = data_cloud _LOGGER.debug('update manufacturer data success') else: - if data_cache: - self._data = data_cache.get('data', None) + if isinstance(data_cache, dict): + self._data = data_cache.get('data', {}) _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 + self._data.clear() 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 diff --git a/custom_components/xiaomi_home/miot/specs/bool_trans.json b/custom_components/xiaomi_home/miot/specs/bool_trans.json deleted file mode 100644 index 4bee0b4..0000000 --- a/custom_components/xiaomi_home/miot/specs/bool_trans.json +++ /dev/null @@ -1,315 +0,0 @@ -{ - "data": { - "urn:miot-spec-v2:property:air-cooler:000000EB": "open_close", - "urn:miot-spec-v2:property:alarm:00000012": "open_close", - "urn:miot-spec-v2:property:anion:00000025": "open_close", - "urn:miot-spec-v2:property:anti-fake:00000130": "yes_no", - "urn:miot-spec-v2:property:arrhythmia:000000B4": "yes_no", - "urn:miot-spec-v2:property:auto-cleanup:00000124": "open_close", - "urn:miot-spec-v2:property:auto-deodorization:00000125": "open_close", - "urn:miot-spec-v2:property:auto-keep-warm:0000002B": "open_close", - "urn:miot-spec-v2:property:automatic-feeding:000000F0": "open_close", - "urn:miot-spec-v2:property:blow:000000CD": "open_close", - "urn:miot-spec-v2:property:card-insertion-state:00000106": "yes_no", - "urn:miot-spec-v2:property:contact-state:0000007C": "contact_state", - "urn:miot-spec-v2:property:current-physical-control-lock:00000099": "open_close", - "urn:miot-spec-v2:property:delay:0000014F": "yes_no", - "urn:miot-spec-v2:property:deodorization:000000C6": "open_close", - "urn:miot-spec-v2:property:dns-auto-mode:000000DC": "open_close", - "urn:miot-spec-v2:property:driving-status:000000B9": "yes_no", - "urn:miot-spec-v2:property:dryer:00000027": "open_close", - "urn:miot-spec-v2:property:eco:00000024": "open_close", - "urn:miot-spec-v2:property:glimmer-full-color:00000089": "open_close", - "urn:miot-spec-v2:property:guard-mode:000000B6": "open_close", - "urn:miot-spec-v2:property:heater:00000026": "open_close", - "urn:miot-spec-v2:property:heating:000000C7": "open_close", - "urn:miot-spec-v2:property:horizontal-swing:00000017": "open_close", - "urn:miot-spec-v2:property:hot-water-recirculation:0000011C": "open_close", - "urn:miot-spec-v2:property:image-distortion-correction:0000010F": "open_close", - "urn:miot-spec-v2:property:local-storage:0000011E": "yes_no", - "urn:miot-spec-v2:property:motion-detection:00000056": "open_close", - "urn:miot-spec-v2:property:motion-state:0000007D": "motion_state", - "urn:miot-spec-v2:property:motion-tracking:0000008A": "open_close", - "urn:miot-spec-v2:property:motor-reverse:00000072": "yes_no", - "urn:miot-spec-v2:property:mute:00000040": "open_close", - "urn:miot-spec-v2:property:off-delay:00000053": "open_close", - "urn:miot-spec-v2:property:on:00000006": "open_close", - "urn:miot-spec-v2:property:physical-controls-locked:0000001D": "open_close", - "urn:miot-spec-v2:property:plasma:00000132": "yes_no", - "urn:miot-spec-v2:property:preheat:00000103": "open_close", - "urn:miot-spec-v2:property:seating-state:000000B8": "yes_no", - "urn:miot-spec-v2:property:silent-execution:000000FB": "yes_no", - "urn:miot-spec-v2:property:sleep-aid-mode:0000010B": "open_close", - "urn:miot-spec-v2:property:sleep-mode:00000028": "open_close", - "urn:miot-spec-v2:property:snore-state:0000012A": "yes_no", - "urn:miot-spec-v2:property:soft-wind:000000CF": "open_close", - "urn:miot-spec-v2:property:speed-control:000000E8": "open_close", - "urn:miot-spec-v2:property:submersion-state:0000007E": "yes_no", - "urn:miot-spec-v2:property:time-watermark:00000087": "open_close", - "urn:miot-spec-v2:property:un-straight-blowing:00000100": "open_close", - "urn:miot-spec-v2:property:uv:00000029": "open_close", - "urn:miot-spec-v2:property:valve-switch:000000FE": "open_close", - "urn:miot-spec-v2:property:ventilation:000000CE": "open_close", - "urn:miot-spec-v2:property:vertical-swing:00000018": "open_close", - "urn:miot-spec-v2:property:wake-up-mode:00000107": "open_close", - "urn:miot-spec-v2:property:water-pump:000000F2": "open_close", - "urn:miot-spec-v2:property:watering:000000CC": "open_close", - "urn:miot-spec-v2:property:wdr-mode:00000088": "open_close", - "urn:miot-spec-v2:property:wet:0000002A": "open_close", - "urn:miot-spec-v2:property:wifi-band-combine:000000E0": "open_close", - "urn:miot-spec-v2:property:wifi-ssid-hidden:000000E3": "yes_no", - "urn:miot-spec-v2:property:wind-reverse:00000117": "yes_no" - }, - "translate": { - "default": { - "de": { - "true": "Wahr", - "false": "Falsch" - }, - "en": { - "true": "True", - "false": "False" - }, - "es": { - "true": "Verdadero", - "false": "Falso" - }, - "fr": { - "true": "Vrai", - "false": "Faux" - }, - "it": { - "true": "Vero", - "false": "Falso" - }, - "ja": { - "true": "真", - "false": "偽" - }, - "nl": { - "true": "True", - "false": "False" - }, - "pt": { - "true": "True", - "false": "False" - }, - "pt-BR": { - "true": "True", - "false": "False" - }, - "ru": { - "true": "Истина", - "false": "Ложь" - }, - "zh-Hans": { - "true": "真", - "false": "假" - }, - "zh-Hant": { - "true": "真", - "false": "假" - } - }, - "open_close": { - "de": { - "true": "Öffnen", - "false": "Schließen" - }, - "en": { - "true": "Open", - "false": "Close" - }, - "es": { - "true": "Abierto", - "false": "Cerrado" - }, - "fr": { - "true": "Ouvert", - "false": "Fermer" - }, - "it": { - "true": "Aperto", - "false": "Chiuso" - }, - "ja": { - "true": "開く", - "false": "閉じる" - }, - "nl": { - "true": "Open", - "false": "Dicht" - }, - "pt": { - "true": "Aberto", - "false": "Fechado" - }, - "pt-BR": { - "true": "Aberto", - "false": "Fechado" - }, - "ru": { - "true": "Открыть", - "false": "Закрыть" - }, - "zh-Hans": { - "true": "开启", - "false": "关闭" - }, - "zh-Hant": { - "true": "開啟", - "false": "關閉" - } - }, - "yes_no": { - "de": { - "true": "Ja", - "false": "Nein" - }, - "en": { - "true": "Yes", - "false": "No" - }, - "es": { - "true": "Sí", - "false": "No" - }, - "fr": { - "true": "Oui", - "false": "Non" - }, - "it": { - "true": "Si", - "false": "No" - }, - "ja": { - "true": "はい", - "false": "いいえ" - }, - "nl": { - "true": "Ja", - "false": "Nee" - }, - "pt": { - "true": "Sim", - "false": "Não" - }, - "pt-BR": { - "true": "Sim", - "false": "Não" - }, - "ru": { - "true": "Да", - "false": "Нет" - }, - "zh-Hans": { - "true": "是", - "false": "否" - }, - "zh-Hant": { - "true": "是", - "false": "否" - } - }, - "motion_state": { - "de": { - "true": "Bewegung erkannt", - "false": "Keine Bewegung erkannt" - }, - "en": { - "true": "Motion Detected", - "false": "No Motion Detected" - }, - "es": { - "true": "Movimiento detectado", - "false": "No se detecta movimiento" - }, - "fr": { - "true": "Mouvement détecté", - "false": "Aucun mouvement détecté" - }, - "it": { - "true": "Movimento Rilevato", - "false": "Nessun Movimento Rilevato" - }, - "ja": { - "true": "動きを検知", - "false": "動きが検出されません" - }, - "nl": { - "true": "Contact", - "false": "Geen contact" - }, - "pt": { - "true": "Contato", - "false": "Sem contato" - }, - "pt-BR": { - "true": "Contato", - "false": "Sem contato" - }, - "ru": { - "true": "Обнаружено движение", - "false": "Движение не обнаружено" - }, - "zh-Hans": { - "true": "有人", - "false": "无人" - }, - "zh-Hant": { - "true": "有人", - "false": "無人" - } - }, - "contact_state": { - "de": { - "true": "Kontakt", - "false": "Kein Kontakt" - }, - "en": { - "true": "Contact", - "false": "No Contact" - }, - "es": { - "true": "Contacto", - "false": "Sin contacto" - }, - "fr": { - "true": "Contact", - "false": "Pas de contact" - }, - "it": { - "true": "Contatto", - "false": "Nessun Contatto" - }, - "ja": { - "true": "接触", - "false": "非接触" - }, - "nl": { - "true": "Contact", - "false": "Geen contact" - }, - "pt": { - "true": "Contato", - "false": "Sem contato" - }, - "pt-BR": { - "true": "Contato", - "false": "Sem contato" - }, - "ru": { - "true": "Контакт", - "false": "Нет контакта" - }, - "zh-Hans": { - "true": "接触", - "false": "分离" - }, - "zh-Hant": { - "true": "接觸", - "false": "分離" - } - } - } -} diff --git a/custom_components/xiaomi_home/miot/specs/bool_trans.yaml b/custom_components/xiaomi_home/miot/specs/bool_trans.yaml new file mode 100644 index 0000000..7bb3483 --- /dev/null +++ b/custom_components/xiaomi_home/miot/specs/bool_trans.yaml @@ -0,0 +1,246 @@ +data: + urn:miot-spec-v2:property:air-cooler:000000EB: open_close + urn:miot-spec-v2:property:alarm:00000012: open_close + urn:miot-spec-v2:property:anion:00000025: open_close + urn:miot-spec-v2:property:anti-fake:00000130: yes_no + urn:miot-spec-v2:property:arrhythmia:000000B4: yes_no + urn:miot-spec-v2:property:auto-cleanup:00000124: open_close + urn:miot-spec-v2:property:auto-deodorization:00000125: open_close + urn:miot-spec-v2:property:auto-keep-warm:0000002B: open_close + urn:miot-spec-v2:property:automatic-feeding:000000F0: open_close + urn:miot-spec-v2:property:blow:000000CD: open_close + urn:miot-spec-v2:property:card-insertion-state:00000106: yes_no + urn:miot-spec-v2:property:contact-state:0000007C: contact_state + urn:miot-spec-v2:property:current-physical-control-lock:00000099: open_close + urn:miot-spec-v2:property:delay:0000014F: yes_no + urn:miot-spec-v2:property:deodorization:000000C6: open_close + urn:miot-spec-v2:property:dns-auto-mode:000000DC: open_close + urn:miot-spec-v2:property:driving-status:000000B9: yes_no + urn:miot-spec-v2:property:dryer:00000027: open_close + urn:miot-spec-v2:property:eco:00000024: open_close + urn:miot-spec-v2:property:glimmer-full-color:00000089: open_close + urn:miot-spec-v2:property:guard-mode:000000B6: open_close + urn:miot-spec-v2:property:heater:00000026: open_close + urn:miot-spec-v2:property:heating:000000C7: open_close + urn:miot-spec-v2:property:horizontal-swing:00000017: open_close + urn:miot-spec-v2:property:hot-water-recirculation:0000011C: open_close + urn:miot-spec-v2:property:image-distortion-correction:0000010F: open_close + urn:miot-spec-v2:property:local-storage:0000011E: yes_no + urn:miot-spec-v2:property:motion-detection:00000056: open_close + urn:miot-spec-v2:property:motion-state:0000007D: motion_state + urn:miot-spec-v2:property:motion-tracking:0000008A: open_close + urn:miot-spec-v2:property:motor-reverse:00000072: yes_no + urn:miot-spec-v2:property:mute:00000040: open_close + urn:miot-spec-v2:property:off-delay:00000053: open_close + urn:miot-spec-v2:property:on:00000006: open_close + urn:miot-spec-v2:property:physical-controls-locked:0000001D: open_close + urn:miot-spec-v2:property:plasma:00000132: yes_no + urn:miot-spec-v2:property:preheat:00000103: open_close + urn:miot-spec-v2:property:seating-state:000000B8: yes_no + urn:miot-spec-v2:property:silent-execution:000000FB: yes_no + urn:miot-spec-v2:property:sleep-aid-mode:0000010B: open_close + urn:miot-spec-v2:property:sleep-mode:00000028: open_close + urn:miot-spec-v2:property:snore-state:0000012A: yes_no + urn:miot-spec-v2:property:soft-wind:000000CF: open_close + urn:miot-spec-v2:property:speed-control:000000E8: open_close + urn:miot-spec-v2:property:submersion-state:0000007E: yes_no + urn:miot-spec-v2:property:time-watermark:00000087: open_close + urn:miot-spec-v2:property:un-straight-blowing:00000100: open_close + urn:miot-spec-v2:property:uv:00000029: open_close + urn:miot-spec-v2:property:valve-switch:000000FE: open_close + urn:miot-spec-v2:property:ventilation:000000CE: open_close + urn:miot-spec-v2:property:vertical-swing:00000018: open_close + urn:miot-spec-v2:property:wake-up-mode:00000107: open_close + urn:miot-spec-v2:property:water-pump:000000F2: open_close + urn:miot-spec-v2:property:watering:000000CC: open_close + urn:miot-spec-v2:property:wdr-mode:00000088: open_close + urn:miot-spec-v2:property:wet:0000002A: open_close + urn:miot-spec-v2:property:wifi-band-combine:000000E0: open_close + urn:miot-spec-v2:property:wifi-ssid-hidden:000000E3: yes_no + urn:miot-spec-v2:property:wind-reverse:00000117: yes_no +translate: + contact_state: + de: + 'false': Kein Kontakt + 'true': Kontakt + en: + 'false': No Contact + 'true': Contact + es: + 'false': Sin contacto + 'true': Contacto + fr: + 'false': Pas de contact + 'true': Contact + it: + 'false': Nessun contatto + 'true': Contatto + ja: + 'false': 非接触 + 'true': 接触 + nl: + 'false': Geen contact + 'true': Contact + pt: + 'false': Sem contato + 'true': Contato + pt-BR: + 'false': Sem contato + 'true': Contato + ru: + 'false': Нет контакта + 'true': Контакт + zh-Hans: + 'false': 分离 + 'true': 接触 + zh-Hant: + 'false': 分離 + 'true': 接觸 + default: + de: + 'false': Falsch + 'true': Wahr + en: + 'false': 'False' + 'true': 'True' + es: + 'false': Falso + 'true': Verdadero + fr: + 'false': Faux + 'true': Vrai + it: + 'false': Falso + 'true': Vero + ja: + 'false': 偽 + 'true': 真 + nl: + 'false': 'False' + 'true': 'True' + pt: + 'false': 'False' + 'true': 'True' + pt-BR: + 'false': 'False' + 'true': 'True' + ru: + 'false': Ложь + 'true': Истина + zh-Hans: + 'false': 假 + 'true': 真 + zh-Hant: + 'false': 假 + 'true': 真 + motion_state: + de: + 'false': Keine Bewegung erkannt + 'true': Bewegung erkannt + en: + 'false': No Motion Detected + 'true': Motion Detected + es: + 'false': No se detecta movimiento + 'true': Movimiento detectado + fr: + 'false': Aucun mouvement détecté + 'true': Mouvement détecté + it: + 'false': Nessun Movimento Rilevato + 'true': Movimento Rilevato + ja: + 'false': 動きが検出されません + 'true': 動きを検知 + nl: + 'false': Geen contact + 'true': Contact + pt: + 'false': Sem contato + 'true': Contato + pt-BR: + 'false': Sem contato + 'true': Contato + ru: + 'false': Движение не обнаружено + 'true': Обнаружено движение + zh-Hans: + 'false': 无人 + 'true': 有人 + zh-Hant: + 'false': 無人 + 'true': 有人 + open_close: + de: + 'false': Schließen + 'true': Öffnen + en: + 'false': Close + 'true': Open + es: + 'false': Cerrado + 'true': Abierto + fr: + 'false': Fermer + 'true': Ouvert + it: + 'false': Chiuso + 'true': Aperto + ja: + 'false': 閉じる + 'true': 開く + nl: + 'false': Dicht + 'true': Open + pt: + 'false': Fechado + 'true': Aberto + pt-BR: + 'false': Fechado + 'true': Aberto + ru: + 'false': Закрыть + 'true': Открыть + zh-Hans: + 'false': 关闭 + 'true': 开启 + zh-Hant: + 'false': 關閉 + 'true': 開啟 + yes_no: + de: + 'false': Nein + 'true': Ja + en: + 'false': 'No' + 'true': 'Yes' + es: + 'false': 'No' + 'true': Sí + fr: + 'false': Non + 'true': Oui + it: + 'false': 'No' + 'true': Si + ja: + 'false': いいえ + 'true': はい + nl: + 'false': Nee + 'true': Ja + pt: + 'false': Não + 'true': Sim + pt-BR: + 'false': Não + 'true': Sim + ru: + 'false': Нет + 'true': Да + zh-Hans: + 'false': 否 + 'true': 是 + zh-Hant: + 'false': 否 + 'true': 是 diff --git a/custom_components/xiaomi_home/miot/specs/multi_lang.json b/custom_components/xiaomi_home/miot/specs/multi_lang.json deleted file mode 100644 index 8cd4284..0000000 --- a/custom_components/xiaomi_home/miot/specs/multi_lang.json +++ /dev/null @@ -1,194 +0,0 @@ -{ - "urn:miot-spec-v2:device:gateway:0000A019:xiaomi-hub1": { - "de": { - "service:001": "Geräteinformationen", - "service:001:property:003": "Geräte-ID", - "service:001:property:005": "Seriennummer (SN)", - "service:002": "Gateway", - "service:002:event:001": "Netzwerk geändert", - "service:002:event:002": "Netzwerk geändert", - "service:002:property:001": "Zugriffsmethode", - "service:002:property:001:valuelist:000": "Kabelgebunden", - "service:002:property:001:valuelist:001": "5G Drahtlos", - "service:002:property:001:valuelist:002": "2.4G Drahtlos", - "service:002:property:002": "IP-Adresse", - "service:002:property:003": "WiFi-Netzwerkname", - "service:002:property:004": "Aktuelle Zeit", - "service:002:property:005": "DHCP-Server-MAC-Adresse", - "service:003": "Anzeigelampe", - "service:003:property:001": "Schalter", - "service:004": "Virtueller Dienst", - "service:004:action:001": "Virtuelles Ereignis erzeugen", - "service:004:event:001": "Virtuelles Ereignis aufgetreten", - "service:004:property:001": "Ereignisname" - }, - "en": { - "service:001": "Device Information", - "service:001:property:003": "Device ID", - "service:001:property:005": "Serial Number (SN)", - "service:002": "Gateway", - "service:002:event:001": "Network Changed", - "service:002:event:002": "Network Changed", - "service:002:property:001": "Access Method", - "service:002:property:001:valuelist:000": "Wired", - "service:002:property:001:valuelist:001": "5G Wireless", - "service:002:property:001:valuelist:002": "2.4G Wireless", - "service:002:property:002": "IP Address", - "service:002:property:003": "WiFi Network Name", - "service:002:property:004": "Current Time", - "service:002:property:005": "DHCP Server MAC Address", - "service:003": "Indicator Light", - "service:003:property:001": "Switch", - "service:004": "Virtual Service", - "service:004:action:001": "Generate Virtual Event", - "service:004:event:001": "Virtual Event Occurred", - "service:004:property:001": "Event Name" - }, - "es": { - "service:001": "Información del dispositivo", - "service:001:property:003": "ID del dispositivo", - "service:001:property:005": "Número de serie (SN)", - "service:002": "Puerta de enlace", - "service:002:event:001": "Cambio de red", - "service:002:event:002": "Cambio de red", - "service:002:property:001": "Método de acceso", - "service:002:property:001:valuelist:000": "Cableado", - "service:002:property:001:valuelist:001": "5G inalámbrico", - "service:002:property:001:valuelist:002": "2.4G inalámbrico", - "service:002:property:002": "Dirección IP", - "service:002:property:003": "Nombre de red WiFi", - "service:002:property:004": "Hora actual", - "service:002:property:005": "Dirección MAC del servidor DHCP", - "service:003": "Luz indicadora", - "service:003:property:001": "Interruptor", - "service:004": "Servicio virtual", - "service:004:action:001": "Generar evento virtual", - "service:004:event:001": "Ocurrió un evento virtual", - "service:004:property:001": "Nombre del evento" - }, - "fr": { - "service:001": "Informations sur l'appareil", - "service:001:property:003": "ID de l'appareil", - "service:001:property:005": "Numéro de série (SN)", - "service:002": "Passerelle", - "service:002:event:001": "Changement de réseau", - "service:002:event:002": "Changement de réseau", - "service:002:property:001": "Méthode d'accès", - "service:002:property:001:valuelist:000": "Câblé", - "service:002:property:001:valuelist:001": "Sans fil 5G", - "service:002:property:001:valuelist:002": "Sans fil 2.4G", - "service:002:property:002": "Adresse IP", - "service:002:property:003": "Nom du réseau WiFi", - "service:002:property:004": "Heure actuelle", - "service:002:property:005": "Adresse MAC du serveur DHCP", - "service:003": "Voyant lumineux", - "service:003:property:001": "Interrupteur", - "service:004": "Service virtuel", - "service:004:action:001": "Générer un événement virtuel", - "service:004:event:001": "Événement virtuel survenu", - "service:004:property:001": "Nom de l'événement" - }, - "it": { - "service:001": "Informazioni sul Dispositivo", - "service:001:property:003": "ID Dispositivo", - "service:001:property:005": "Numero di Serie (SN)", - "service:002": "Gateway", - "service:002:event:001": "Rete Modificata", - "service:002:event:002": "Rete Modificata", - "service:002:property:001": "Metodo di Accesso", - "service:002:property:001:valuelist:000": "Cablato", - "service:002:property:001:valuelist:001": "Wireless 5G", - "service:002:property:001:valuelist:002": "Wireless 2.4G", - "service:002:property:002": "Indirizzo IP", - "service:002:property:003": "Nome Rete WiFi", - "service:002:property:004": "Ora Attuale", - "service:002:property:005": "Indirizzo MAC del Server DHCP", - "service:003": "Luce Indicatore", - "service:003:property:001": "Interruttore", - "service:004": "Servizio Virtuale", - "service:004:action:001": "Genera Evento Virtuale", - "service:004:event:001": "Evento Virtuale Avvenuto", - "service:004:property:001": "Nome Evento" - }, - "ja": { - "service:001": "デバイス情報", - "service:001:property:003": "デバイスID", - "service:001:property:005": "シリアル番号 (SN)", - "service:002": "ゲートウェイ", - "service:002:event:001": "ネットワークが変更されました", - "service:002:event:002": "ネットワークが変更されました", - "service:002:property:001": "アクセス方法", - "service:002:property:001:valuelist:000": "有線", - "service:002:property:001:valuelist:001": "5G ワイヤレス", - "service:002:property:001:valuelist:002": "2.4G ワイヤレス", - "service:002:property:002": "IPアドレス", - "service:002:property:003": "WiFiネットワーク名", - "service:002:property:004": "現在の時間", - "service:002:property:005": "DHCPサーバーMACアドレス", - "service:003": "インジケータライト", - "service:003:property:001": "スイッチ", - "service:004": "バーチャルサービス", - "service:004:action:001": "バーチャルイベントを生成", - "service:004:event:001": "バーチャルイベントが発生しました", - "service:004:property:001": "イベント名" - }, - "ru": { - "service:001": "Информация об устройстве", - "service:001:property:003": "ID устройства", - "service:001:property:005": "Серийный номер (SN)", - "service:002": "Шлюз", - "service:002:event:001": "Сеть изменена", - "service:002:event:002": "Сеть изменена", - "service:002:property:001": "Метод доступа", - "service:002:property:001:valuelist:000": "Проводной", - "service:002:property:001:valuelist:001": "5G Беспроводной", - "service:002:property:001:valuelist:002": "2.4G Беспроводной", - "service:002:property:002": "IP Адрес", - "service:002:property:003": "Название WiFi сети", - "service:002:property:004": "Текущее время", - "service:002:property:005": "MAC адрес DHCP сервера", - "service:003": "Световой индикатор", - "service:003:property:001": "Переключатель", - "service:004": "Виртуальная служба", - "service:004:action:001": "Создать виртуальное событие", - "service:004:event:001": "Произошло виртуальное событие", - "service:004:property:001": "Название события" - }, - "zh-Hant": { - "service:001": "設備信息", - "service:001:property:003": "設備ID", - "service:001:property:005": "序號 (SN)", - "service:002": "網關", - "service:002:event:001": "網路發生變化", - "service:002:event:002": "網路發生變化", - "service:002:property:001": "接入方式", - "service:002:property:001:valuelist:000": "有線", - "service:002:property:001:valuelist:001": "5G 無線", - "service:002:property:001:valuelist:002": "2.4G 無線", - "service:002:property:002": "IP地址", - "service:002:property:003": "WiFi網路名稱", - "service:002:property:004": "當前時間", - "service:002:property:005": "DHCP伺服器MAC地址", - "service:003": "指示燈", - "service:003:property:001": "開關", - "service:004": "虛擬服務", - "service:004:action:001": "產生虛擬事件", - "service:004:event:001": "虛擬事件發生", - "service:004:property:001": "事件名稱" - } - }, - "urn:miot-spec-v2:device:switch:0000A003:lumi-acn040:1": { - "en": { - "service:011": "Right Button On and Off", - "service:011:property:001": "Right Button On and Off", - "service:015:action:001": "Left Button Identify", - "service:016:action:001": "Middle Button Identify", - "service:017:action:001": "Right Button Identify" - }, - "zh-Hans": { - "service:015:action:001": "左键确认", - "service:016:action:001": "中键确认", - "service:017:action:001": "右键确认" - } - } -} diff --git a/custom_components/xiaomi_home/miot/specs/spec_filter.json b/custom_components/xiaomi_home/miot/specs/spec_filter.json deleted file mode 100644 index 274fb34..0000000 --- a/custom_components/xiaomi_home/miot/specs/spec_filter.json +++ /dev/null @@ -1,68 +0,0 @@ -{ - "urn:miot-spec-v2:device:air-purifier:0000A007:zhimi-ma4": { - "properties": [ - "9.*", - "13.*", - "15.*" - ], - "services": [ - "10" - ] - }, - "urn:miot-spec-v2:device:curtain:0000A00C:lumi-hmcn01": { - "properties": [ - "5.1" - ], - "services": [ - "4", - "7", - "8" - ] - }, - "urn:miot-spec-v2:device:gateway:0000A019:xiaomi-hub1": { - "events": [ - "2.1" - ] - }, - "urn:miot-spec-v2:device:health-pot:0000A051:chunmi-a1": { - "services": [ - "5" - ] - }, - "urn:miot-spec-v2:device:light:0000A001:philips-strip3": { - "properties": [ - "2.2" - ], - "services": [ - "1", - "3" - ] - }, - "urn:miot-spec-v2:device:light:0000A001:yeelink-color2": { - "properties": [ - "3.*", - "2.5" - ] - }, - "urn:miot-spec-v2:device:light:0000A001:yeelink-dnlight2": { - "services": [ - "3" - ] - }, - "urn:miot-spec-v2:device:light:0000A001:yeelink-mbulb3": { - "services": [ - "3" - ] - }, - "urn:miot-spec-v2:device:motion-sensor:0000A014:xiaomi-pir1": { - "services": [ - "1", - "5" - ] - }, - "urn:miot-spec-v2:device:router:0000A036:xiaomi-rd03": { - "services": [ - "*" - ] - } -} \ No newline at end of file diff --git a/custom_components/xiaomi_home/miot/specs/spec_filter.yaml b/custom_components/xiaomi_home/miot/specs/spec_filter.yaml new file mode 100644 index 0000000..2102e48 --- /dev/null +++ b/custom_components/xiaomi_home/miot/specs/spec_filter.yaml @@ -0,0 +1,43 @@ +urn:miot-spec-v2:device:air-purifier:0000A007:zhimi-ma4: + properties: + - 9.* + - 13.* + - 15.* + services: + - '10' +urn:miot-spec-v2:device:curtain:0000A00C:lumi-hmcn01: + properties: + - '5.1' + services: + - '4' + - '7' + - '8' +urn:miot-spec-v2:device:gateway:0000A019:xiaomi-hub1: + events: + - '2.1' +urn:miot-spec-v2:device:health-pot:0000A051:chunmi-a1: + services: + - '5' +urn:miot-spec-v2:device:light:0000A001:philips-strip3: + properties: + - '2.2' + services: + - '1' + - '3' +urn:miot-spec-v2:device:light:0000A001:yeelink-color2: + properties: + - 3.* + - '2.5' +urn:miot-spec-v2:device:light:0000A001:yeelink-dnlight2: + services: + - '3' +urn:miot-spec-v2:device:light:0000A001:yeelink-mbulb3: + services: + - '3' +urn:miot-spec-v2:device:motion-sensor:0000A014:xiaomi-pir1: + services: + - '1' + - '5' +urn:miot-spec-v2:device:router:0000A036:xiaomi-rd03: + services: + - '*' diff --git a/custom_components/xiaomi_home/notify.py b/custom_components/xiaomi_home/notify.py index ba0844a..5cf3fd8 100644 --- a/custom_components/xiaomi_home/notify.py +++ b/custom_components/xiaomi_home/notify.py @@ -90,7 +90,7 @@ class Notify(MIoTActionEntity, NotifyEntity): super().__init__(miot_device=miot_device, spec=spec) self._attr_extra_state_attributes = {} action_in: str = ', '.join([ - f'{prop.description_trans}({prop.format_})' + f'{prop.description_trans}({prop.format_.__name__})' for prop in self.spec.in_]) self._attr_extra_state_attributes['action params'] = f'[{action_in}]' @@ -122,24 +122,24 @@ class Notify(MIoTActionEntity, NotifyEntity): return in_value: list[dict] = [] for index, prop in enumerate(self.spec.in_): - if prop.format_ == 'str': + if prop.format_ == str: if isinstance(in_list[index], (bool, int, float, str)): in_value.append( {'piid': prop.iid, 'value': str(in_list[index])}) continue - elif prop.format_ == 'bool': + elif prop.format_ == bool: if isinstance(in_list[index], (bool, int)): # yes, no, on, off, true, false and other bool types # will also be parsed as 0 and 1 of int. in_value.append( {'piid': prop.iid, 'value': bool(in_list[index])}) continue - elif prop.format_ == 'float': + elif prop.format_ == float: if isinstance(in_list[index], (int, float)): in_value.append( {'piid': prop.iid, 'value': in_list[index]}) continue - elif prop.format_ == 'int': + elif prop.format_ == int: if isinstance(in_list[index], int): in_value.append( {'piid': prop.iid, 'value': in_list[index]}) diff --git a/custom_components/xiaomi_home/number.py b/custom_components/xiaomi_home/number.py index 53bc09c..29bd6b7 100644 --- a/custom_components/xiaomi_home/number.py +++ b/custom_components/xiaomi_home/number.py @@ -92,9 +92,9 @@ class Number(MIoTPropertyEntity, NumberEntity): self._attr_icon = self.spec.icon # Set value range if self._value_range: - self._attr_native_min_value = self._value_range['min'] - self._attr_native_max_value = self._value_range['max'] - self._attr_native_step = self._value_range['step'] + self._attr_native_min_value = self._value_range.min_ + self._attr_native_max_value = self._value_range.max_ + self._attr_native_step = self._value_range.step @property def native_value(self) -> Optional[float]: diff --git a/custom_components/xiaomi_home/select.py b/custom_components/xiaomi_home/select.py index 4c9bad3..21b5e78 100644 --- a/custom_components/xiaomi_home/select.py +++ b/custom_components/xiaomi_home/select.py @@ -82,7 +82,8 @@ class Select(MIoTPropertyEntity, SelectEntity): def __init__(self, miot_device: MIoTDevice, spec: MIoTSpecProperty) -> None: """Initialize the Select.""" super().__init__(miot_device=miot_device, spec=spec) - self._attr_options = list(self._value_list.values()) + if self._value_list: + self._attr_options = self._value_list.descriptions async def async_select_option(self, option: str) -> None: """Change the selected option.""" diff --git a/custom_components/xiaomi_home/sensor.py b/custom_components/xiaomi_home/sensor.py index 39b3bdb..88b4bac 100644 --- a/custom_components/xiaomi_home/sensor.py +++ b/custom_components/xiaomi_home/sensor.py @@ -76,6 +76,12 @@ async def async_setup_entry( for prop in miot_device.prop_list.get('sensor', []): new_entities.append(Sensor(miot_device=miot_device, spec=prop)) + if miot_device.miot_client.display_binary_text: + for prop in miot_device.prop_list.get('binary_sensor', []): + if not prop.value_list: + continue + new_entities.append(Sensor(miot_device=miot_device, spec=prop)) + if new_entities: async_add_entities(new_entities) @@ -91,7 +97,7 @@ class Sensor(MIoTPropertyEntity, SensorEntity): self._attr_device_class = SensorDeviceClass.ENUM self._attr_icon = 'mdi:message-text' self._attr_native_unit_of_measurement = None - self._attr_options = list(self._value_list.values()) + self._attr_options = self._value_list.descriptions else: self._attr_device_class = spec.device_class if spec.external_unit: @@ -100,29 +106,29 @@ class Sensor(MIoTPropertyEntity, SensorEntity): # device_class is not empty but unit is empty. # Set the default unit according to device_class. unit_sets = DEVICE_CLASS_UNITS.get( - self._attr_device_class, None) + self._attr_device_class, None) # type: ignore self._attr_native_unit_of_measurement = list( unit_sets)[0] if unit_sets else None + # Set state_class + if spec.state_class: + self._attr_state_class = spec.state_class # Set icon if spec.icon: self._attr_icon = spec.icon - # Set state_class - if spec.state_class: - self._attr_state_class = spec.state_class @property def native_value(self) -> Any: """Return the current value of the sensor.""" if self._value_range and isinstance(self._value, (int, float)): if ( - self._value < self._value_range['min'] - or self._value > self._value_range['max'] + self._value < self._value_range.min_ + or self._value > self._value_range.max_ ): _LOGGER.info( '%s, data exception, out of range, %s, %s', self.entity_id, self._value, self._value_range) if self._value_list: - return self._value_list.get(self._value, None) + return self.get_vlist_description(self._value) if isinstance(self._value, str): return self._value[:255] return self._value diff --git a/custom_components/xiaomi_home/text.py b/custom_components/xiaomi_home/text.py index 8a6b9ae..ff6ac3e 100644 --- a/custom_components/xiaomi_home/text.py +++ b/custom_components/xiaomi_home/text.py @@ -76,9 +76,10 @@ async def async_setup_entry( for prop in miot_device.prop_list.get('text', []): new_entities.append(Text(miot_device=miot_device, spec=prop)) - for action in miot_device.action_list.get('action_text', []): - new_entities.append(ActionText( - miot_device=miot_device, spec=action)) + if miot_device.miot_client.action_debug: + for action in miot_device.action_list.get('notify', []): + new_entities.append(ActionText( + miot_device=miot_device, spec=action)) if new_entities: async_add_entities(new_entities) @@ -111,11 +112,9 @@ class ActionText(MIoTActionEntity, TextEntity): self._attr_extra_state_attributes = {} self._attr_native_value = '' action_in: str = ', '.join([ - f'{prop.description_trans}({prop.format_})' + f'{prop.description_trans}({prop.format_.__name__})' for prop in self.spec.in_]) self._attr_extra_state_attributes['action params'] = f'[{action_in}]' - # For action debug - self.action_platform = 'action_text' async def async_set_value(self, value: str) -> None: if not value: @@ -141,24 +140,24 @@ class ActionText(MIoTActionEntity, TextEntity): f'invalid action params, {value}') in_value: list[dict] = [] for index, prop in enumerate(self.spec.in_): - if prop.format_ == 'str': + if prop.format_ == str: if isinstance(in_list[index], (bool, int, float, str)): in_value.append( {'piid': prop.iid, 'value': str(in_list[index])}) continue - elif prop.format_ == 'bool': + elif prop.format_ == bool: if isinstance(in_list[index], (bool, int)): # yes, no, on, off, true, false and other bool types # will also be parsed as 0 and 1 of int. in_value.append( {'piid': prop.iid, 'value': bool(in_list[index])}) continue - elif prop.format_ == 'float': + elif prop.format_ == float: if isinstance(in_list[index], (int, float)): in_value.append( {'piid': prop.iid, 'value': in_list[index]}) continue - elif prop.format_ == 'int': + elif prop.format_ == int: if isinstance(in_list[index], int): in_value.append( {'piid': prop.iid, 'value': in_list[index]}) diff --git a/custom_components/xiaomi_home/translations/de.json b/custom_components/xiaomi_home/translations/de.json index 25dfd02..15f5c0f 100644 --- a/custom_components/xiaomi_home/translations/de.json +++ b/custom_components/xiaomi_home/translations/de.json @@ -42,12 +42,13 @@ }, "advanced_options": { "title": "Erweiterte Einstellungen", - "description": "## Gebrauchsanweisung\r\n### Wenn Sie die Bedeutung der folgenden Optionen nicht genau kennen, belassen Sie sie bitte auf den Standardeinstellungen.\r\n### Geräte filtern\r\nUnterstützt das Filtern von Geräten nach Raumnamen und Gerätetypen sowie das Filtern nach Gerätedimensionen.\r\n\r\n### Steuerungsmodus\r\n- Automatisch: Wenn ein verfügbarer Xiaomi-Hub im lokalen Netzwerk vorhanden ist, priorisiert Home Assistant das Senden von Steuerbefehlen über den Hub, um eine lokale Steuerung zu ermöglichen. Wenn kein Hub vorhanden ist, wird versucht, Steuerbefehle über das Xiaomi-OT-Protokoll zu senden. Nur wenn diese Bedingungen für die lokale Steuerung nicht erfüllt sind, werden die Befehle über die Cloud gesendet.\r\n- Cloud: Steuerbefehle werden ausschließlich über die Cloud gesendet.\r\n### Action-Debug-Modus\r\nFür Methoden, die von MIoT-Spec-V2-Geräten definiert werden, wird neben der Benachrichtigungsentität auch eine Texteingabe-Entität erstellt, mit der Sie während des Debuggens Steuerbefehle an das Gerät senden können.\r\n### Nicht standardmäßige Entitäten ausblenden\r\nBlendet Entitäten aus, die von nicht-standardmäßigen MIoT-Spec-V2-Instanzen generiert werden und deren Name mit „*“ beginnt.\r\n### Gerätestatusänderungen anzeigen\r\nDetaillierte Anzeige von Gerätestatusänderungen, es werden nur die ausgewählten Benachrichtigungen angezeigt.", + "description": "## Gebrauchsanweisung\r\n### Wenn Sie die Bedeutung der folgenden Optionen nicht genau kennen, belassen Sie sie bitte auf den Standardeinstellungen.\r\n### Geräte filtern\r\nUnterstützt das Filtern von Geräten nach Raumnamen und Gerätetypen sowie das Filtern nach Gerätedimensionen.\r\n\r\n### Steuerungsmodus\r\n- Automatisch: Wenn ein verfügbarer Xiaomi-Hub im lokalen Netzwerk vorhanden ist, priorisiert Home Assistant das Senden von Steuerbefehlen über den Hub, um eine lokale Steuerung zu ermöglichen. Wenn kein Hub vorhanden ist, wird versucht, Steuerbefehle über das Xiaomi-OT-Protokoll zu senden. Nur wenn diese Bedingungen für die lokale Steuerung nicht erfüllt sind, werden die Befehle über die Cloud gesendet.\r\n- Cloud: Steuerbefehle werden ausschließlich über die Cloud gesendet.\r\n### Action-Debug-Modus\r\nFür Methoden, die von MIoT-Spec-V2-Geräten definiert werden, wird neben der Benachrichtigungsentität auch eine Texteingabe-Entität erstellt, mit der Sie während des Debuggens Steuerbefehle an das Gerät senden können.\r\n### Nicht standardmäßige Entitäten ausblenden\r\nBlendet Entitäten aus, die von nicht-standardmäßigen MIoT-Spec-V2-Instanzen generiert werden und deren Name mit „*“ beginnt.\r\n### Binärsensor-Anzeigemodus\r\nZeigt Binärsensoren in Xiaomi Home als Textsensor-Entität oder Binärsensor-Entität an。\r\n### Gerätestatusänderungen anzeigen\r\nDetaillierte Anzeige von Gerätestatusänderungen, es werden nur die ausgewählten Benachrichtigungen angezeigt.", "data": { "devices_filter": "Geräte filtern", "ctrl_mode": "Steuerungsmodus", "action_debug": "Action-Debug-Modus", "hide_non_standard_entities": "Nicht standardmäßige Entitäten ausblenden", + "display_binary_mode": "Binärsensor-Anzeigemodus", "display_devices_changed_notify": "Gerätestatusänderungen anzeigen" } }, @@ -119,6 +120,7 @@ "update_devices": "Geräteliste aktualisieren", "action_debug": "Action-Debug-Modus", "hide_non_standard_entities": "Verstecke Nicht-Standard-Entitäten", + "display_binary_mode": "Binärsensor-Anzeigemodus", "display_devices_changed_notify": "Gerätestatusänderungen anzeigen", "update_trans_rules": "Entitätskonvertierungsregeln aktualisieren", "update_lan_ctrl_config": "LAN-Steuerungskonfiguration aktualisieren", diff --git a/custom_components/xiaomi_home/translations/en.json b/custom_components/xiaomi_home/translations/en.json index 0ee151c..7832fd1 100644 --- a/custom_components/xiaomi_home/translations/en.json +++ b/custom_components/xiaomi_home/translations/en.json @@ -42,12 +42,13 @@ }, "advanced_options": { "title": "Advanced Settings", - "description": "## Introduction\r\n### Unless you are very clear about the meaning of the following options, please keep the default settings.\r\n### Filter Devices\r\nSupports filtering devices by room name and device type, and also supports device dimension filtering.\r\n### Control Mode\r\n- Auto: When there is an available Xiaomi central hub gateway in the local area network, Home Assistant will prioritize sending device control commands through the central hub gateway to achieve local control. If there is no central hub gateway in the local area network, it will attempt to send control commands through Xiaomi OT protocol to achieve local control. Only when the above local control conditions are not met, the device control commands will be sent through the cloud.\r\n- Cloud: All control commands are sent through the cloud.\r\n### Action Debug Mode\r\nFor the methods defined by the device MIoT-Spec-V2, in addition to generating notification entities, a text input box entity will also be generated. You can use it to send control commands to the device during debugging.\r\n### Hide Non-Standard Generated Entities\r\nHide entities generated by non-standard MIoT-Spec-V2 instances with names starting with \"*\".\r\n### Display Device Status Change Notifications\r\nDisplay detailed device status change notifications, only showing the selected notifications.", + "description": "## Introduction\r\n### Unless you are very clear about the meaning of the following options, please keep the default settings.\r\n### Filter Devices\r\nSupports filtering devices by room name and device type, and also supports device dimension filtering.\r\n### Control Mode\r\n- Auto: When there is an available Xiaomi central hub gateway in the local area network, Home Assistant will prioritize sending device control commands through the central hub gateway to achieve local control. If there is no central hub gateway in the local area network, it will attempt to send control commands through Xiaomi OT protocol to achieve local control. Only when the above local control conditions are not met, the device control commands will be sent through the cloud.\r\n- Cloud: All control commands are sent through the cloud.\r\n### Action Debug Mode\r\nFor the methods defined by the device MIoT-Spec-V2, in addition to generating notification entities, a text input box entity will also be generated. You can use it to send control commands to the device during debugging.\r\n### Hide Non-Standard Generated Entities\r\nHide entities generated by non-standard MIoT-Spec-V2 instances with names starting with \"*\".\r\n### Binary Sensor Display Mode\r\nDisplay binary sensors in Xiaomi Home as text sensor entity or binary sensor entity。\r\n### Display Device Status Change Notifications\r\nDisplay detailed device status change notifications, only showing the selected notifications.", "data": { "devices_filter": "Filter Devices", "ctrl_mode": "Control Mode", "action_debug": "Action Debug Mode", "hide_non_standard_entities": "Hide Non-Standard Generated Entities", + "display_binary_mode": "Binary Sensor Display Mode", "display_devices_changed_notify": "Display Device Status Change Notifications" } }, @@ -119,6 +120,7 @@ "update_devices": "Update device list", "action_debug": "Debug mode for action", "hide_non_standard_entities": "Hide non-standard created entities", + "display_binary_mode": "Binary Sensor Display Mode", "display_devices_changed_notify": "Display device status change notifications", "update_trans_rules": "Update entity conversion rules", "update_lan_ctrl_config": "Update LAN control configuration", diff --git a/custom_components/xiaomi_home/translations/es.json b/custom_components/xiaomi_home/translations/es.json index e7b0c75..eb52c74 100644 --- a/custom_components/xiaomi_home/translations/es.json +++ b/custom_components/xiaomi_home/translations/es.json @@ -42,12 +42,13 @@ }, "advanced_options": { "title": "Opciones Avanzadas", - "description": "## Introducción\r\n### A menos que entienda claramente el significado de las siguientes opciones, manténgalas en su configuración predeterminada.\r\n### Filtrar dispositivos\r\nAdmite la filtración de dispositivos por nombre de habitación y tipo de dispositivo, y también admite la filtración por familia.\r\n### Modo de Control\r\n- Automático: Cuando hay una puerta de enlace central de Xiaomi disponible en la red local, Home Assistant enviará comandos de control de dispositivos a través de la puerta de enlace central para lograr la función de control local. Cuando no hay una puerta de enlace central en la red local, intentará enviar comandos de control a través del protocolo OT de Xiaomi para lograr la función de control local. Solo cuando no se cumplan las condiciones de control local anteriores, los comandos de control de dispositivos se enviarán a través de la nube.\r\n- Nube: Los comandos de control solo se envían a través de la nube.\r\n### Modo de Depuración de Acciones\r\nPara los métodos definidos por el dispositivo MIoT-Spec-V2, además de generar una entidad de notificación, también se generará una entidad de cuadro de texto que se puede utilizar para enviar comandos de control al dispositivo durante la depuración.\r\n### Ocultar Entidades Generadas No Estándar\r\nOcultar entidades generadas por instancias MIoT-Spec-V2 no estándar que comienzan con \"*\".\r\n### Mostrar notificaciones de cambio de estado del dispositivo\r\nMostrar notificaciones detalladas de cambio de estado del dispositivo, mostrando solo las notificaciones seleccionadas.", + "description": "## Introducción\r\n### A menos que entienda claramente el significado de las siguientes opciones, manténgalas en su configuración predeterminada.\r\n### Filtrar dispositivos\r\nAdmite la filtración de dispositivos por nombre de habitación y tipo de dispositivo, y también admite la filtración por familia.\r\n### Modo de Control\r\n- Automático: Cuando hay una puerta de enlace central de Xiaomi disponible en la red local, Home Assistant enviará comandos de control de dispositivos a través de la puerta de enlace central para lograr la función de control local. Cuando no hay una puerta de enlace central en la red local, intentará enviar comandos de control a través del protocolo OT de Xiaomi para lograr la función de control local. Solo cuando no se cumplan las condiciones de control local anteriores, los comandos de control de dispositivos se enviarán a través de la nube.\r\n- Nube: Los comandos de control solo se envían a través de la nube.\r\n### Modo de Depuración de Acciones\r\nPara los métodos definidos por el dispositivo MIoT-Spec-V2, además de generar una entidad de notificación, también se generará una entidad de cuadro de texto que se puede utilizar para enviar comandos de control al dispositivo durante la depuración.\r\n### Ocultar Entidades Generadas No Estándar\r\nOcultar entidades generadas por instancias MIoT-Spec-V2 no estándar que comienzan con \"*\".\r\n### Modo de visualización del sensor binario\r\nMuestra los sensores binarios en Xiaomi Home como entidad de sensor de texto o entidad de sensor binario。\r\n### Mostrar notificaciones de cambio de estado del dispositivo\r\nMostrar notificaciones detalladas de cambio de estado del dispositivo, mostrando solo las notificaciones seleccionadas.", "data": { "devices_filter": "Filtrar Dispositivos", "ctrl_mode": "Modo de Control", "action_debug": "Modo de Depuración de Acciones", "hide_non_standard_entities": "Ocultar Entidades Generadas No Estándar", + "display_binary_mode": "Modo de visualización del sensor binario", "display_devices_changed_notify": "Mostrar notificaciones de cambio de estado del dispositivo" } }, @@ -119,6 +120,7 @@ "update_devices": "Actualizar lista de dispositivos", "action_debug": "Modo de depuración de Action", "hide_non_standard_entities": "Ocultar entidades generadas no estándar", + "display_binary_mode": "Modo de visualización del sensor binario", "display_devices_changed_notify": "Mostrar notificaciones de cambio de estado del dispositivo", "update_trans_rules": "Actualizar reglas de conversión de entidad", "update_lan_ctrl_config": "Actualizar configuración de control LAN", diff --git a/custom_components/xiaomi_home/translations/fr.json b/custom_components/xiaomi_home/translations/fr.json index 63b9c44..07c2245 100644 --- a/custom_components/xiaomi_home/translations/fr.json +++ b/custom_components/xiaomi_home/translations/fr.json @@ -42,12 +42,13 @@ }, "advanced_options": { "title": "Paramètres Avancés", - "description": "## Introduction\r\n### Sauf si vous comprenez très bien la signification des options suivantes, veuillez les laisser par défaut.\r\n### Filtrer les appareils\r\nPrend en charge le filtrage des appareils en fonction du nom de la pièce et du type d'appareil, ainsi que le filtrage basé sur les appareils.\r\n### Mode de Contrôle\r\n- Automatique : Lorsqu'une passerelle Xiaomi est disponible dans le réseau local, Home Assistant enverra les commandes de contrôle des appareils via la passerelle pour permettre le contrôle local. Si aucune passerelle n'est disponible dans le réseau local, Home Assistant essaiera d'envoyer les commandes de contrôle des appareils via le protocole OT Xiaomi pour permettre le contrôle local. Seules si les conditions de contrôle local ci-dessus ne sont pas remplies, les commandes de contrôle des appareils seront envoyées via le cloud.\r\n- Cloud : Les commandes de contrôle des appareils sont envoyées uniquement via le cloud.\r\n### Mode de Débogage d’Actions\r\nPour les méthodes définies par les appareils MIoT-Spec-V2, en plus de générer une entité de notification, une entité de champ de texte sera également générée pour vous permettre d'envoyer des commandes de contrôle aux appareils lors du débogage.\r\n### Masquer les Entités Non Standard\r\nMasquer les entités générées par des instances MIoT-Spec-V2 non standard et commençant par \"*\".\r\n### Afficher les notifications de changement d'état de l'appareil\r\nAfficher les notifications détaillées de changement d'état de l'appareil, en affichant uniquement les notifications sélectionnées.", + "description": "## Introduction\r\n### Sauf si vous comprenez très bien la signification des options suivantes, veuillez les laisser par défaut.\r\n### Filtrer les appareils\r\nPrend en charge le filtrage des appareils en fonction du nom de la pièce et du type d'appareil, ainsi que le filtrage basé sur les appareils.\r\n### Mode de Contrôle\r\n- Automatique : Lorsqu'une passerelle Xiaomi est disponible dans le réseau local, Home Assistant enverra les commandes de contrôle des appareils via la passerelle pour permettre le contrôle local. Si aucune passerelle n'est disponible dans le réseau local, Home Assistant essaiera d'envoyer les commandes de contrôle des appareils via le protocole OT Xiaomi pour permettre le contrôle local. Seules si les conditions de contrôle local ci-dessus ne sont pas remplies, les commandes de contrôle des appareils seront envoyées via le cloud.\r\n- Cloud : Les commandes de contrôle des appareils sont envoyées uniquement via le cloud.\r\n### Mode de Débogage d’Actions\r\nPour les méthodes définies par les appareils MIoT-Spec-V2, en plus de générer une entité de notification, une entité de champ de texte sera également générée pour vous permettre d'envoyer des commandes de contrôle aux appareils lors du débogage.\r\n### Masquer les Entités Non Standard\r\nMasquer les entités générées par des instances MIoT-Spec-V2 non standard et commençant par \"*\".\r\n### Mode d'affichage du capteur binaire\r\nAffiche les capteurs binaires dans Xiaomi Home comme entité de capteur de texte ou entité de capteur binaire。\r\n### Afficher les notifications de changement d'état de l'appareil\r\nAfficher les notifications détaillées de changement d'état de l'appareil, en affichant uniquement les notifications sélectionnées.", "data": { "devices_filter": "Filtrer les Appareils", "ctrl_mode": "Mode de Contrôle", "action_debug": "Mode de Débogage d’Actions", "hide_non_standard_entities": "Masquer les Entités Non Standard", + "display_binary_mode": "Mode d'affichage du capteur binaire", "display_devices_changed_notify": "Afficher les notifications de changement d'état de l'appareil" } }, @@ -119,6 +120,7 @@ "update_devices": "Mettre à jour la liste des appareils", "action_debug": "Mode de débogage d'action", "hide_non_standard_entities": "Masquer les entités générées non standard", + "display_binary_mode": "Mode d'affichage du capteur binaire", "display_devices_changed_notify": "Afficher les notifications de changement d'état de l'appareil", "update_trans_rules": "Mettre à jour les règles de conversion d'entités", "update_lan_ctrl_config": "Mettre à jour la configuration de contrôle LAN", diff --git a/custom_components/xiaomi_home/translations/it.json b/custom_components/xiaomi_home/translations/it.json index 066ec12..b384103 100644 --- a/custom_components/xiaomi_home/translations/it.json +++ b/custom_components/xiaomi_home/translations/it.json @@ -16,7 +16,7 @@ "cloud_server": "Regione di Login", "integration_language": "Lingua", "oauth_redirect_url": "URL di reindirizzamento OAuth2", - "network_detect_config": "Configurazione di rete integrata" + "network_detect_config": "Configurazione di rete integrata" } }, "network_detect_config": { @@ -42,12 +42,13 @@ }, "advanced_options": { "title": "Impostazioni Avanzate", - "description": "## Introduzione\r\n### A meno che tu non abbia chiaro il significato delle seguenti opzioni, si prega di mantenere le impostazioni predefinite.\r\n### Filtra Dispositivi\r\nSupporta il filtraggio dei dispositivi per nome della stanza e tipo di dispositivo, e supporta anche il filtraggio delle dimensioni del dispositivo.\r\n### Modalità di Controllo\r\n- Automatico: Quando è disponibile un gateway hub centrale Xiaomi nella rete locale, Home Assistant darà priorità all'invio dei comandi di controllo dei dispositivi tramite il gateway hub centrale per ottenere il controllo locale. Se non è presente un gateway hub centrale nella rete locale, tenterà di inviare comandi di controllo tramite il protocollo OT di Xiaomi per ottenere il controllo locale. Solo quando le condizioni di controllo locale sopra indicate non sono soddisfatte, i comandi di controllo del dispositivo verranno inviati tramite il cloud.\r\n- Cloud: Tutti i comandi di controllo vengono inviati tramite il cloud.\r\n### Modalità di Debug delle Azioni\r\nPer i metodi definiti dal dispositivo MIoT-Spec-V2, oltre a generare entità di notifica, verrà generata anche un'entità di casella di input di testo. È possibile utilizzarla per inviare comandi di controllo al dispositivo durante il debug.\r\n### Nascondi Entità Generate Non Standard\r\nNasconde le entità generate da istanze non standard MIoT-Spec-V2 con nomi che iniziano con \"*\".\r\n### Mostra Notifiche di Cambio di Stato del Dispositivo\r\nMostra notifiche dettagliate sui cambiamenti di stato del dispositivo, mostrando solo le notifiche selezionate.", + "description": "## Introduzione\r\n### A meno che tu non abbia chiaro il significato delle seguenti opzioni, si prega di mantenere le impostazioni predefinite.\r\n### Filtra Dispositivi\r\nSupporta il filtraggio dei dispositivi per nome della stanza e tipo di dispositivo, e supporta anche il filtraggio delle dimensioni del dispositivo.\r\n### Modalità di Controllo\r\n- Automatico: Quando è disponibile un gateway hub centrale Xiaomi nella rete locale, Home Assistant darà priorità all'invio dei comandi di controllo dei dispositivi tramite il gateway hub centrale per ottenere il controllo locale. Se non è presente un gateway hub centrale nella rete locale, tenterà di inviare comandi di controllo tramite il protocollo OT di Xiaomi per ottenere il controllo locale. Solo quando le condizioni di controllo locale sopra indicate non sono soddisfatte, i comandi di controllo del dispositivo verranno inviati tramite il cloud.\r\n- Cloud: Tutti i comandi di controllo vengono inviati tramite il cloud.\r\n### Modalità di Debug delle Azioni\r\nPer i metodi definiti dal dispositivo MIoT-Spec-V2, oltre a generare entità di notifica, verrà generata anche un'entità di casella di input di testo. È possibile utilizzarla per inviare comandi di controllo al dispositivo durante il debug.\r\n### Nascondi Entità Generate Non Standard\r\nNasconde le entità generate da istanze non standard MIoT-Spec-V2 con nomi che iniziano con \"*\".\r\n### Modalità di visualizzazione del sensore binario\r\nVisualizza i sensori binari in Mi Home come entità del sensore di testo o entità del sensore binario。\r\n### Mostra Notifiche di Cambio di Stato del Dispositivo\r\nMostra notifiche dettagliate sui cambiamenti di stato del dispositivo, mostrando solo le notifiche selezionate.", "data": { "devices_filter": "Filtra Dispositivi", "ctrl_mode": "Modalità di Controllo", "action_debug": "Modalità di Debug delle Azioni", "hide_non_standard_entities": "Nascondi Entità Generate Non Standard", + "display_binary_mode": "Modalità di visualizzazione del sensore binario", "display_devices_changed_notify": "Mostra Notifiche di Cambio di Stato del Dispositivo" } }, @@ -119,6 +120,7 @@ "update_devices": "Aggiorna l'elenco dei dispositivi", "action_debug": "Modalità debug per azione", "hide_non_standard_entities": "Nascondi entità create non standard", + "display_binary_mode": "Modalità di visualizzazione del sensore binario", "display_devices_changed_notify": "Mostra notifiche di cambio stato del dispositivo", "update_trans_rules": "Aggiorna le regole di conversione delle entità", "update_lan_ctrl_config": "Aggiorna configurazione del controllo LAN", @@ -219,4 +221,4 @@ "inconsistent_account": "Le informazioni dell'account sono incoerenti." } } -} +} \ No newline at end of file diff --git a/custom_components/xiaomi_home/translations/ja.json b/custom_components/xiaomi_home/translations/ja.json index 2b07b06..2dda890 100644 --- a/custom_components/xiaomi_home/translations/ja.json +++ b/custom_components/xiaomi_home/translations/ja.json @@ -42,12 +42,13 @@ }, "advanced_options": { "title": "高度な設定オプション", - "description": "## 紹介\r\n### 以下のオプションの意味がよくわからない場合は、デフォルトのままにしてください。\r\n### デバイスのフィルタリング\r\n部屋名とデバイスタイプでデバイスをフィルタリングすることができます。デバイスの次元でフィルタリングすることもできます。\r\n### コントロールモード\r\n- 自動:ローカルネットワーク内に利用可能なXiaomi中央ゲートウェイがある場合、Home Assistantはデバイス制御命令を送信するために優先的に中央ゲートウェイを使用します。ローカルネットワークに中央ゲートウェイがない場合、Xiaomi OTプロトコルを使用してデバイス制御命令を送信し、ローカル制御機能を実現します。上記のローカル制御条件が満たされない場合のみ、デバイス制御命令はクラウドを介して送信されます。\r\n- クラウド:制御命令はクラウドを介してのみ送信されます。\r\n### Actionデバッグモード\r\nデバイスが定義するMIoT-Spec-V2のメソッドに対して、通知エンティティを生成するだけでなく、デバイスに制御命令を送信するためのテキスト入力ボックスエンティティも生成されます。デバッグ時にデバイスに制御命令を送信するために使用できます。\r\n### 非標準生成エンティティを隠す\r\n「*」で始まる名前の非標準MIoT-Spec-V2インスタンスによって生成されたエンティティを非表示にします。\r\n### デバイスの状態変化通知を表示\r\nデバイスの状態変化通知を詳細に表示し、選択された通知のみを表示します。", + "description": "## 紹介\r\n### 以下のオプションの意味がよくわからない場合は、デフォルトのままにしてください。\r\n### デバイスのフィルタリング\r\n部屋名とデバイスタイプでデバイスをフィルタリングすることができます。デバイスの次元でフィルタリングすることもできます。\r\n### コントロールモード\r\n- 自動:ローカルネットワーク内に利用可能なXiaomi中央ゲートウェイがある場合、Home Assistantはデバイス制御命令を送信するために優先的に中央ゲートウェイを使用します。ローカルネットワークに中央ゲートウェイがない場合、Xiaomi OTプロトコルを使用してデバイス制御命令を送信し、ローカル制御機能を実現します。上記のローカル制御条件が満たされない場合のみ、デバイス制御命令はクラウドを介して送信されます。\r\n- クラウド:制御命令はクラウドを介してのみ送信されます。\r\n### Actionデバッグモード\r\nデバイスが定義するMIoT-Spec-V2のメソッドに対して、通知エンティティを生成するだけでなく、デバイスに制御命令を送信するためのテキスト入力ボックスエンティティも生成されます。デバッグ時にデバイスに制御命令を送信するために使用できます。\r\n### 非標準生成エンティティを隠す\r\n「*」で始まる名前の非標準MIoT-Spec-V2インスタンスによって生成されたエンティティを非表示にします。\r\n### バイナリセンサー表示モード\r\nXiaomi Homeのバイナリセンサーをテキストセンサーエンティティまたはバイナリセンサーエンティティとして表示します。\r\n### デバイスの状態変化通知を表示\r\nデバイスの状態変化通知を詳細に表示し、選択された通知のみを表示します。", "data": { "devices_filter": "デバイスをフィルタリング", "ctrl_mode": "コントロールモード", "action_debug": "Actionデバッグモード", "hide_non_standard_entities": "非標準生成エンティティを隠す", + "display_binary_mode": "バイナリセンサー表示モード", "display_devices_changed_notify": "デバイスの状態変化通知を表示" } }, @@ -119,6 +120,7 @@ "update_devices": "デバイスリストを更新する", "action_debug": "Action デバッグモード", "hide_non_standard_entities": "非標準生成エンティティを非表示にする", + "display_binary_mode": "バイナリセンサー表示モード", "display_devices_changed_notify": "デバイスの状態変化通知を表示", "update_trans_rules": "エンティティ変換ルールを更新する", "update_lan_ctrl_config": "LAN制御構成を更新する", diff --git a/custom_components/xiaomi_home/translations/nl.json b/custom_components/xiaomi_home/translations/nl.json index 6e28936..c9d8e40 100644 --- a/custom_components/xiaomi_home/translations/nl.json +++ b/custom_components/xiaomi_home/translations/nl.json @@ -42,12 +42,13 @@ }, "advanced_options": { "title": "Geavanceerde Instellingen", - "description": "## Inleiding\r\n### Tenzij u zeer goed op de hoogte bent van de betekenis van de volgende opties, houdt u de standaardinstellingen.\r\n### Apparaten filteren\r\nOndersteunt het filteren van apparaten op basis van kamer- en apparaattypen, en ondersteunt ook apparaatdimensiefiltering.\r\n### Besturingsmodus\r\n- Automatisch: Wanneer er een beschikbare Xiaomi centrale hubgateway in het lokale netwerk is, zal Home Assistant eerst apparaatbesturingsinstructies via de centrale hubgateway verzenden om lokale controlefunctionaliteit te bereiken. Als er geen centrale hub in het lokale netwerk is, zal het proberen om besturingsinstructies via het Xiaomi OT-protocol te verzenden om lokale controlefunctionaliteit te bereiken. Alleen als de bovenstaande lokale controlevoorwaarden niet worden vervuld, worden apparaatbesturingsinstructies via de cloud verzonden.\r\n- Cloud: Besturingsinstructies worden alleen via de cloud verzonden.\r\n### Actie-debugmodus\r\nVoor methoden die zijn gedefinieerd in de MIoT-Spec-V2 van het apparaat, wordt naast het genereren van een meldingsentiteit ook een tekstinvoerveldentiteit gegenereerd. U kunt dit gebruiken om besturingsinstructies naar het apparaat te sturen tijdens het debuggen.\r\n### Niet-standaard entiteiten verbergen\r\nVerberg entiteiten die zijn gegenereerd door niet-standaard MIoT-Spec-V2-instanties die beginnen met \"*\".\r\n### Apparaatstatuswijzigingen weergeven\r\nGedetailleerde apparaatstatuswijzigingen weergeven, alleen de geselecteerde meldingen weergeven.", + "description": "## Inleiding\r\n### Tenzij u zeer goed op de hoogte bent van de betekenis van de volgende opties, houdt u de standaardinstellingen.\r\n### Apparaten filteren\r\nOndersteunt het filteren van apparaten op basis van kamer- en apparaattypen, en ondersteunt ook apparaatdimensiefiltering.\r\n### Besturingsmodus\r\n- Automatisch: Wanneer er een beschikbare Xiaomi centrale hubgateway in het lokale netwerk is, zal Home Assistant eerst apparaatbesturingsinstructies via de centrale hubgateway verzenden om lokale controlefunctionaliteit te bereiken. Als er geen centrale hub in het lokale netwerk is, zal het proberen om besturingsinstructies via het Xiaomi OT-protocol te verzenden om lokale controlefunctionaliteit te bereiken. Alleen als de bovenstaande lokale controlevoorwaarden niet worden vervuld, worden apparaatbesturingsinstructies via de cloud verzonden.\r\n- Cloud: Besturingsinstructies worden alleen via de cloud verzonden.\r\n### Actie-debugmodus\r\nVoor methoden die zijn gedefinieerd in de MIoT-Spec-V2 van het apparaat, wordt naast het genereren van een meldingsentiteit ook een tekstinvoerveldentiteit gegenereerd. U kunt dit gebruiken om besturingsinstructies naar het apparaat te sturen tijdens het debuggen.\r\n### Niet-standaard entiteiten verbergen\r\nVerberg entiteiten die zijn gegenereerd door niet-standaard MIoT-Spec-V2-instanties die beginnen met \"*\".\r\n### Binaire sensorweergavemodus\r\nToont binaire sensoren in Xiaomi Home als tekstsensor-entiteit of binairesensor-entiteit。\r\n### Apparaatstatuswijzigingen weergeven\r\nGedetailleerde apparaatstatuswijzigingen weergeven, alleen de geselecteerde meldingen weergeven.", "data": { "devices_filter": "Apparaten filteren", "ctrl_mode": "Besturingsmodus", "action_debug": "Actie-debugmodus", "hide_non_standard_entities": "Niet-standaard entiteiten verbergen", + "display_binary_mode": "Binaire sensorweergavemodus", "display_devices_changed_notify": "Apparaatstatuswijzigingen weergeven" } }, @@ -119,6 +120,7 @@ "update_devices": "Werk apparatenlijst bij", "action_debug": "Debugmodus voor actie", "hide_non_standard_entities": "Verberg niet-standaard gemaakte entiteiten", + "display_binary_mode": "Binaire sensorweergavemodus", "display_devices_changed_notify": "Apparaatstatuswijzigingen weergeven", "update_trans_rules": "Werk entiteitsconversieregels bij", "update_lan_ctrl_config": "Werk LAN controleconfiguratie bij", diff --git a/custom_components/xiaomi_home/translations/pt-BR.json b/custom_components/xiaomi_home/translations/pt-BR.json index 3adcd0d..12286d5 100644 --- a/custom_components/xiaomi_home/translations/pt-BR.json +++ b/custom_components/xiaomi_home/translations/pt-BR.json @@ -42,12 +42,13 @@ }, "advanced_options": { "title": "Configurações Avançadas", - "description": "## Introdução\r\n### A menos que você entenda claramente o significado das opções a seguir, mantenha as configurações padrão.\r\n### Filtrar Dispositivos\r\nSuporte para filtrar dispositivos por nome da sala e tipo de dispositivo, bem como filtragem por família.\r\n### Modo de Controle\r\n- Automático: Quando um gateway central Xiaomi disponível na rede local está disponível, o Home Assistant enviará comandos de controle de dispositivo através do gateway central para realizar a função de controle local. Quando não há gateway central na rede local, ele tentará enviar comandos de controle através do protocolo OT da Xiaomi para realizar a função de controle local. Somente quando as condições de controle local acima não forem atendidas, os comandos de controle do dispositivo serão enviados através da nuvem.\r\n- Nuvem: Os comandos de controle são enviados apenas através da nuvem.\r\n### Modo de Depuração de Ações\r\nPara métodos definidos pelo MIoT-Spec-V2 do dispositivo, além de gerar uma entidade de notificação, também será gerada uma entidade de caixa de texto para você enviar comandos de controle ao dispositivo durante a depuração.\r\n### Ocultar Entidades Geradas Não Padrão\r\nOcultar entidades geradas por instâncias MIoT-Spec-V2 não padrão que começam com \"*\".\r\n### Exibir notificações de mudança de status do dispositivo\r\nExibir notificações detalhadas de mudança de status do dispositivo, mostrando apenas as notificações selecionadas.", + "description": "## Introdução\r\n### A menos que você entenda claramente o significado das opções a seguir, mantenha as configurações padrão.\r\n### Filtrar Dispositivos\r\nSuporte para filtrar dispositivos por nome da sala e tipo de dispositivo, bem como filtragem por família.\r\n### Modo de Controle\r\n- Automático: Quando um gateway central Xiaomi disponível na rede local está disponível, o Home Assistant enviará comandos de controle de dispositivo através do gateway central para realizar a função de controle local. Quando não há gateway central na rede local, ele tentará enviar comandos de controle através do protocolo OT da Xiaomi para realizar a função de controle local. Somente quando as condições de controle local acima não forem atendidas, os comandos de controle do dispositivo serão enviados através da nuvem.\r\n- Nuvem: Os comandos de controle são enviados apenas através da nuvem.\r\n### Modo de Depuração de Ações\r\nPara métodos definidos pelo MIoT-Spec-V2 do dispositivo, além de gerar uma entidade de notificação, também será gerada uma entidade de caixa de texto para você enviar comandos de controle ao dispositivo durante a depuração.\r\n### Ocultar Entidades Geradas Não Padrão\r\nOcultar entidades geradas por instâncias MIoT-Spec-V2 não padrão que começam com \"*\".\r\n### Modo de exibição do sensor binário\r\nExibe sensores binários no Xiaomi Home como entidade de sensor de texto ou entidade de sensor binário。\r\n### Exibir notificações de mudança de status do dispositivo\r\nExibir notificações detalhadas de mudança de status do dispositivo, mostrando apenas as notificações selecionadas.", "data": { "devices_filter": "Filtrar Dispositivos", "ctrl_mode": "Modo de Controle", "action_debug": "Modo de Depuração de Ações", "hide_non_standard_entities": "Ocultar Entidades Geradas Não Padrão", + "display_binary_mode": "Modo de exibição do sensor binário", "display_devices_changed_notify": "Exibir notificações de mudança de status do dispositivo" } }, @@ -119,6 +120,7 @@ "update_devices": "Atualizar lista de dispositivos", "action_debug": "Modo de depuração para ação", "hide_non_standard_entities": "Ocultar entidades não padrão criadas", + "display_binary_mode": "Modo de exibição do sensor binário", "display_devices_changed_notify": "Exibir notificações de mudança de status do dispositivo", "update_trans_rules": "Atualizar regras de conversão de entidades", "update_lan_ctrl_config": "Atualizar configuração de controle LAN", diff --git a/custom_components/xiaomi_home/translations/pt.json b/custom_components/xiaomi_home/translations/pt.json index ce58cd5..2287585 100644 --- a/custom_components/xiaomi_home/translations/pt.json +++ b/custom_components/xiaomi_home/translations/pt.json @@ -42,12 +42,13 @@ }, "advanced_options": { "title": "Opções Avançadas", - "description": "## Introdução\r\n### A menos que você entenda claramente o significado das opções abaixo, mantenha as configurações padrão.\r\n### Filtrar Dispositivos\r\nSuporte para filtrar dispositivos por nome da sala e tipo de dispositivo, bem como filtragem por família.\r\n### Modo de Controle\r\n- Automático: Quando um gateway central Xiaomi está disponível na rede local, o Home Assistant enviará comandos de controlo de dispositivos através do gateway central para realizar o controlo local. Quando não há gateway central na rede local, tentará enviar comandos de controlo através do protocolo Xiaomi OT para realizar o controlo local. Apenas quando as condições de controlo local acima não são atendidas, os comandos de controlo de dispositivos serão enviados através da nuvem.\r\n- Nuvem: Os comandos de controlo são enviados apenas através da nuvem.\r\n### Modo de Depuração de Ações\r\nPara métodos definidos pelo MIoT-Spec-V2, além de gerar uma entidade de notificação, também será gerada uma entidade de caixa de texto para depuração de controlo de dispositivos.\r\n### Ocultar Entidades Geradas Não Padrão\r\nOcultar entidades geradas por instâncias MIoT-Spec-V2 não padrão, cujos nomes começam com \"*\".\r\n### Exibir notificações de mudança de status do dispositivo\r\nExibir notificações detalhadas de mudança de status do dispositivo, mostrando apenas as notificações selecionadas.", + "description": "## Introdução\r\n### A menos que você entenda claramente o significado das opções abaixo, mantenha as configurações padrão.\r\n### Filtrar Dispositivos\r\nSuporte para filtrar dispositivos por nome da sala e tipo de dispositivo, bem como filtragem por família.\r\n### Modo de Controle\r\n- Automático: Quando um gateway central Xiaomi está disponível na rede local, o Home Assistant enviará comandos de controlo de dispositivos através do gateway central para realizar o controlo local. Quando não há gateway central na rede local, tentará enviar comandos de controlo através do protocolo Xiaomi OT para realizar o controlo local. Apenas quando as condições de controlo local acima não são atendidas, os comandos de controlo de dispositivos serão enviados através da nuvem.\r\n- Nuvem: Os comandos de controlo são enviados apenas através da nuvem.\r\n### Modo de Depuração de Ações\r\nPara métodos definidos pelo MIoT-Spec-V2, além de gerar uma entidade de notificação, também será gerada uma entidade de caixa de texto para depuração de controlo de dispositivos.\r\n### Ocultar Entidades Geradas Não Padrão\r\nOcultar entidades geradas por instâncias MIoT-Spec-V2 não padrão, cujos nomes começam com \"*\".\r\n### Modo de exibição do sensor binário\r\nExibe sensores binários no Xiaomi Home como entidade de sensor de texto ou entidade de sensor binário。\r\n### Exibir notificações de mudança de status do dispositivo\r\nExibir notificações detalhadas de mudança de status do dispositivo, mostrando apenas as notificações selecionadas.", "data": { "devices_filter": "Filtrar Dispositivos", "ctrl_mode": "Modo de Controlo", "action_debug": "Modo de Depuração de Ações", "hide_non_standard_entities": "Ocultar Entidades Geradas Não Padrão", + "display_binary_mode": "Modo de exibição do sensor binário", "display_devices_changed_notify": "Exibir notificações de mudança de status do dispositivo" } }, @@ -119,6 +120,7 @@ "update_devices": "Atualizar lista de dispositivos", "action_debug": "Modo de depuração de ação", "hide_non_standard_entities": "Ocultar entidades não padrão", + "display_binary_mode": "Modo de exibição do sensor binário", "display_devices_changed_notify": "Exibir notificações de mudança de status do dispositivo", "update_trans_rules": "Atualizar regras de conversão de entidades", "update_lan_ctrl_config": "Atualizar configuração de controlo LAN", diff --git a/custom_components/xiaomi_home/translations/ru.json b/custom_components/xiaomi_home/translations/ru.json index a492869..fba3edc 100644 --- a/custom_components/xiaomi_home/translations/ru.json +++ b/custom_components/xiaomi_home/translations/ru.json @@ -42,12 +42,13 @@ }, "advanced_options": { "title": "Расширенные настройки", - "description": "## Введение\r\n### Если вы не очень хорошо понимаете значение следующих параметров, оставьте их по умолчанию.\r\n### Фильтрация устройств\r\nПоддерживает фильтрацию устройств по названию комнаты и типу устройства, а также фильтрацию по уровню устройства.\r\n### Режим управления\r\n- Автоматически: при наличии доступного центрального шлюза Xiaomi в локальной сети Home Assistant Home Assistant будет отправлять команды управления устройствами через центральный шлюз для локального управления. Если центрального шлюза нет в локальной сети, Home Assistant попытается отправить команды управления устройствами через протокол OT Xiaomi для локального управления. Только если вышеуказанные условия локального управления не выполняются, команды управления устройствами будут отправляться через облако.\r\n- Облако: команды управления отправляются только через облако.\r\n### Режим отладки действий\r\nДля методов, определенных устройством MIoT-Spec-V2, помимо создания уведомления, будет создана сущность текстового поля, которую вы можете использовать для отправки команд управления устройством во время отладки.\r\n### Скрыть нестандартные сущности\r\nСкрыть сущности, созданные нестандартными экземплярами MIoT-Spec-V2, имена которых начинаются с «*».\r\n### Отображать уведомления о изменении состояния устройства\r\nОтображать подробные уведомления о изменении состояния устройства, показывая только выбранные уведомления.", + "description": "## Введение\r\n### Если вы не очень хорошо понимаете значение следующих параметров, оставьте их по умолчанию.\r\n### Фильтрация устройств\r\nПоддерживает фильтрацию устройств по названию комнаты и типу устройства, а также фильтрацию по уровню устройства.\r\n### Режим управления\r\n- Автоматически: при наличии доступного центрального шлюза Xiaomi в локальной сети Home Assistant Home Assistant будет отправлять команды управления устройствами через центральный шлюз для локального управления. Если центрального шлюза нет в локальной сети, Home Assistant попытается отправить команды управления устройствами через протокол OT Xiaomi для локального управления. Только если вышеуказанные условия локального управления не выполняются, команды управления устройствами будут отправляться через облако.\r\n- Облако: команды управления отправляются только через облако.\r\n### Режим отладки действий\r\nДля методов, определенных устройством MIoT-Spec-V2, помимо создания уведомления, будет создана сущность текстового поля, которую вы можете использовать для отправки команд управления устройством во время отладки.\r\n### Скрыть нестандартные сущности\r\nСкрыть сущности, созданные нестандартными экземплярами MIoT-Spec-V2, имена которых начинаются с «*».\r\n### Режим отображения бинарного датчика\r\nОтображает бинарные датчики в Xiaomi Home как сущность текстового датчика или сущность бинарного датчика。\r\n### Отображать уведомления о изменении состояния устройства\r\nОтображать подробные уведомления о изменении состояния устройства, показывая только выбранные уведомления.", "data": { "devices_filter": "Фильтрация устройств", "ctrl_mode": "Режим управления", "action_debug": "Режим отладки действий", "hide_non_standard_entities": "Скрыть нестандартные сущности", + "display_binary_mode": "Режим отображения бинарного датчика", "display_devices_changed_notify": "Отображать уведомления о изменении состояния устройства" } }, @@ -119,6 +120,7 @@ "update_devices": "Обновить список устройств", "action_debug": "Режим отладки Action", "hide_non_standard_entities": "Скрыть нестандартные сущности", + "display_binary_mode": "Режим отображения бинарного датчика", "display_devices_changed_notify": "Отображать уведомления о изменении состояния устройства", "update_trans_rules": "Обновить правила преобразования сущностей", "update_lan_ctrl_config": "Обновить конфигурацию управления LAN", diff --git a/custom_components/xiaomi_home/translations/zh-Hans.json b/custom_components/xiaomi_home/translations/zh-Hans.json index 39859da..67c134c 100644 --- a/custom_components/xiaomi_home/translations/zh-Hans.json +++ b/custom_components/xiaomi_home/translations/zh-Hans.json @@ -42,12 +42,13 @@ }, "advanced_options": { "title": "高级设置选项", - "description": "## 使用介绍\r\n### 除非您非常清楚下列选项的含义,否则请保持默认。\r\n### 筛选设备\r\n支持按照家庭房间名称、设备接入类型、设备型号筛选设备,同时也支持设备维度筛选。\r\n### 控制模式\r\n- 自动:本地局域网内存在可用的小米中枢网关时, Home Assistant 会优先通过中枢网关发送设备控制指令,以实现本地化控制功能。本地局域网不存在中枢时,会尝试通过小米OT协议发送控制指令,以实现本地化控制功能。只有当上述本地化控制条件不满足时,设备控制指令才会通过云端发送。\r\n- 云端:控制指令仅通过云端发送。\r\n### Action 调试模式\r\n对于设备 MIoT-Spec-V2 定义的方法,在生成通知实体之外,还会生成一个文本输入框实体,您可以在调试时用它向设备发送控制指令。\r\n### 隐藏非标准生成实体\r\n隐藏名称以“*”开头的非标准 MIoT-Spec-V2 实例生成的实体。\r\n### 显示设备状态变化通知\r\n细化显示设备状态变化通知,只显示勾选的通知消息。", + "description": "## 使用介绍\r\n### 除非您非常清楚下列选项的含义,否则请保持默认。\r\n### 筛选设备\r\n支持按照家庭房间名称、设备接入类型、设备型号筛选设备,同时也支持设备维度筛选。\r\n### 控制模式\r\n- 自动:本地局域网内存在可用的小米中枢网关时, Home Assistant 会优先通过中枢网关发送设备控制指令,以实现本地化控制功能。本地局域网不存在中枢时,会尝试通过小米OT协议发送控制指令,以实现本地化控制功能。只有当上述本地化控制条件不满足时,设备控制指令才会通过云端发送。\r\n- 云端:控制指令仅通过云端发送。\r\n### Action 调试模式\r\n对于设备 MIoT-Spec-V2 定义的方法,在生成通知实体之外,还会生成一个文本输入框实体,您可以在调试时用它向设备发送控制指令。\r\n### 隐藏非标准生成实体\r\n隐藏名称以“*”开头的非标准 MIoT-Spec-V2 实例生成的实体。\r\n### 二进制传感器显示模式\r\n将米家中的二进制传感器显示为文本传感器实体或者二进制传感器实体。\r\n### 显示设备状态变化通知\r\n细化显示设备状态变化通知,只显示勾选的通知消息。", "data": { "devices_filter": "筛选设备", "ctrl_mode": "控制模式", "action_debug": "Action 调试模式", "hide_non_standard_entities": "隐藏非标准生成实体", + "display_binary_mode": "二进制传感器显示模式", "display_devices_changed_notify": "显示设备状态变化通知" } }, @@ -119,6 +120,7 @@ "update_devices": "更新设备列表", "action_debug": "Action 调试模式", "hide_non_standard_entities": "隐藏非标准生成实体", + "display_binary_mode": "二进制传感器显示模式", "display_devices_changed_notify": "显示设备状态变化通知", "update_trans_rules": "更新实体转换规则", "update_lan_ctrl_config": "更新局域网控制配置", diff --git a/custom_components/xiaomi_home/translations/zh-Hant.json b/custom_components/xiaomi_home/translations/zh-Hant.json index 59580ae..68cc982 100644 --- a/custom_components/xiaomi_home/translations/zh-Hant.json +++ b/custom_components/xiaomi_home/translations/zh-Hant.json @@ -42,12 +42,13 @@ }, "advanced_options": { "title": "高級設置選項", - "description": "## 使用介紹\r\n### 除非您非常清楚下列選項的含義,否則請保持默認。\r\n### 篩選設備\r\n支持按照房間名稱和設備類型篩選設備,同時也支持設備維度篩選。\r\n### 控制模式\r\n- 自動:本地局域網內存在可用的小米中樞網關時, Home Assistant 會優先通過中樞網關發送設備控制指令,以實現本地化控制功能。本地局域網不存在中樞時,會嘗試通過小米OT協議發送控制指令,以實現本地化控制功能。只有當上述本地化控制條件不滿足時,設備控制指令才會通過雲端發送。\r\n- 雲端:控制指令僅通過雲端發送。\r\n### Action 調試模式\r\n對於設備 MIoT-Spec-V2 定義的方法,在生成通知實體之外,還會生成一個文本輸入框實體,您可以在調試時用它向設備發送控制指令。\r\n### 隱藏非標準生成實體\r\n隱藏名稱以“*”開頭的非標準 MIoT-Spec-V2 實例生成的實體。\r\n### 顯示設備狀態變化通知\r\n細化顯示設備狀態變化通知,只顯示勾選的通知消息。", + "description": "## 使用介紹\r\n### 除非您非常清楚下列選項的含義,否則請保持默認。\r\n### 篩選設備\r\n支持按照房間名稱和設備類型篩選設備,同時也支持設備維度篩選。\r\n### 控制模式\r\n- 自動:本地局域網內存在可用的小米中樞網關時, Home Assistant 會優先通過中樞網關發送設備控制指令,以實現本地化控制功能。本地局域網不存在中樞時,會嘗試通過小米OT協議發送控制指令,以實現本地化控制功能。只有當上述本地化控制條件不滿足時,設備控制指令才會通過雲端發送。\r\n- 雲端:控制指令僅通過雲端發送。\r\n### Action 調試模式\r\n對於設備 MIoT-Spec-V2 定義的方法,在生成通知實體之外,還會生成一個文本輸入框實體,您可以在調試時用它向設備發送控制指令。\r\n### 隱藏非標準生成實體\r\n隱藏名稱以“*”開頭的非標準 MIoT-Spec-V2 實例生成的實體。\r\n### 二進制傳感器顯示模式\r\n將米家中的二進制傳感器顯示為文本傳感器實體或者二進制傳感器實體。\r\n### 顯示設備狀態變化通知\r\n細化顯示設備狀態變化通知,只顯示勾選的通知消息。", "data": { "devices_filter": "篩選設備", "ctrl_mode": "控制模式", "action_debug": "Action 調試模式", "hide_non_standard_entities": "隱藏非標準生成實體", + "display_binary_mode": "二進制傳感器顯示模式", "display_devices_changed_notify": "顯示設備狀態變化通知" } }, @@ -119,6 +120,7 @@ "update_devices": "更新設備列表", "action_debug": "Action 調試模式", "hide_non_standard_entities": "隱藏非標準生成實體", + "display_binary_mode": "二進制傳感器顯示模式", "display_devices_changed_notify": "顯示設備狀態變化通知", "update_trans_rules": "更新實體轉換規則", "update_lan_ctrl_config": "更新局域網控制配置", diff --git a/custom_components/xiaomi_home/vacuum.py b/custom_components/xiaomi_home/vacuum.py index fda2d5a..232e676 100644 --- a/custom_components/xiaomi_home/vacuum.py +++ b/custom_components/xiaomi_home/vacuum.py @@ -120,28 +120,18 @@ class Vacuum(MIoTServiceEntity, StateVacuumEntity): # properties for prop in entity_data.props: if prop.name == 'status': - if ( - not isinstance(prop.value_list, list) - or not prop.value_list - ): + if not prop.value_list: _LOGGER.error( 'invalid status value_list, %s', self.entity_id) continue - self._status_map = { - item['value']: item['description'] - for item in prop.value_list} + self._status_map = prop.value_list.to_map() self._prop_status = prop elif prop.name == 'fan-level': - if ( - not isinstance(prop.value_list, list) - or not prop.value_list - ): + if not prop.value_list: _LOGGER.error( 'invalid fan-level value_list, %s', self.entity_id) continue - self._fan_level_map = { - item['value']: item['description'] - for item in prop.value_list} + self._fan_level_map = prop.value_list.to_map() self._attr_fan_speed_list = list(self._fan_level_map.values()) self._attr_supported_features |= VacuumEntityFeature.FAN_SPEED self._prop_fan_level = prop @@ -202,7 +192,7 @@ class Vacuum(MIoTServiceEntity, StateVacuumEntity): @property def state(self) -> Optional[str]: """Return the current state of the vacuum cleaner.""" - return self.get_map_description( + return self.get_map_value( map_=self._status_map, key=self.get_prop_value(prop=self._prop_status)) @@ -214,6 +204,6 @@ class Vacuum(MIoTServiceEntity, StateVacuumEntity): @property def fan_speed(self) -> Optional[str]: """Return the current fan speed of the vacuum cleaner.""" - return self.get_map_description( + return self.get_map_value( map_=self._fan_level_map, key=self.get_prop_value(prop=self._prop_fan_level)) diff --git a/custom_components/xiaomi_home/water_heater.py b/custom_components/xiaomi_home/water_heater.py index aa7fe67..aba6093 100644 --- a/custom_components/xiaomi_home/water_heater.py +++ b/custom_components/xiaomi_home/water_heater.py @@ -93,7 +93,7 @@ class WaterHeater(MIoTServiceEntity, WaterHeaterEntity): _prop_target_temp: Optional[MIoTSpecProperty] _prop_mode: Optional[MIoTSpecProperty] - _mode_list: Optional[dict[Any, Any]] + _mode_map: Optional[dict[Any, Any]] def __init__( self, miot_device: MIoTDevice, entity_data: MIoTEntityData @@ -106,7 +106,7 @@ class WaterHeater(MIoTServiceEntity, WaterHeaterEntity): self._prop_temp = None self._prop_target_temp = None self._prop_mode = None - self._mode_list = None + self._mode_map = None # properties for prop in entity_data.props: @@ -115,7 +115,7 @@ class WaterHeater(MIoTServiceEntity, WaterHeaterEntity): self._prop_on = prop # temperature if prop.name == 'temperature': - if isinstance(prop.value_range, dict): + if prop.value_range: if ( self._attr_temperature_unit is None and prop.external_unit @@ -128,9 +128,14 @@ class WaterHeater(MIoTServiceEntity, WaterHeaterEntity): self.entity_id) # target-temperature if prop.name == 'target-temperature': - self._attr_min_temp = prop.value_range['min'] - self._attr_max_temp = prop.value_range['max'] - self._attr_precision = prop.value_range['step'] + if not prop.value_range: + _LOGGER.error( + 'invalid target-temperature value_range format, %s', + self.entity_id) + continue + self._attr_min_temp = prop.value_range.min_ + self._attr_max_temp = prop.value_range.max_ + self._attr_precision = prop.value_range.step if self._attr_temperature_unit is None and prop.external_unit: self._attr_temperature_unit = prop.external_unit self._attr_supported_features |= ( @@ -138,17 +143,12 @@ class WaterHeater(MIoTServiceEntity, WaterHeaterEntity): self._prop_target_temp = prop # mode if prop.name == 'mode': - if ( - not isinstance(prop.value_list, list) - or not prop.value_list - ): + if not prop.value_list: _LOGGER.error( 'mode value_list is None, %s', self.entity_id) continue - self._mode_list = { - item['value']: item['description'] - for item in prop.value_list} - self._attr_operation_list = list(self._mode_list.values()) + self._mode_map = prop.value_list.to_map() + self._attr_operation_list = list(self._mode_map.values()) self._attr_supported_features |= ( WaterHeaterEntityFeature.OPERATION_MODE) self._prop_mode = prop @@ -184,7 +184,9 @@ class WaterHeater(MIoTServiceEntity, WaterHeaterEntity): prop=self._prop_on, value=True, update=False) await self.set_property_async( prop=self._prop_mode, - value=self.__get_mode_value(description=operation_mode)) + value=self.get_map_key( + map_=self._mode_map, + value=operation_mode)) async def async_turn_away_mode_on(self) -> None: """Set the water heater to away mode.""" @@ -207,20 +209,6 @@ class WaterHeater(MIoTServiceEntity, WaterHeaterEntity): return STATE_OFF if not self._prop_mode and self.get_prop_value(prop=self._prop_on): return STATE_ON - return self.__get_mode_description( + return self.get_map_value( + map_=self._mode_map, key=self.get_prop_value(prop=self._prop_mode)) - - def __get_mode_description(self, key: int) -> Optional[str]: - """Convert mode value to description.""" - if self._mode_list is None: - return None - return self._mode_list.get(key, None) - - def __get_mode_value(self, description: str) -> Optional[int]: - """Convert mode description to value.""" - if self._mode_list is None: - return None - for key, value in self._mode_list.items(): - if value == description: - return key - return None diff --git a/test/check_rule_format.py b/test/check_rule_format.py index 5075367..18ce034 100644 --- a/test/check_rule_format.py +++ b/test/check_rule_format.py @@ -16,13 +16,10 @@ MIOT_I18N_RELATIVE_PATH: str = path.join( ROOT_PATH, '../custom_components/xiaomi_home/miot/i18n') SPEC_BOOL_TRANS_FILE = path.join( ROOT_PATH, - '../custom_components/xiaomi_home/miot/specs/bool_trans.json') -SPEC_MULTI_LANG_FILE = path.join( - ROOT_PATH, - '../custom_components/xiaomi_home/miot/specs/multi_lang.json') + '../custom_components/xiaomi_home/miot/specs/bool_trans.yaml') SPEC_FILTER_FILE = path.join( ROOT_PATH, - '../custom_components/xiaomi_home/miot/specs/spec_filter.json') + '../custom_components/xiaomi_home/miot/specs/spec_filter.yaml') def load_json_file(file_path: str) -> Optional[dict]: @@ -54,6 +51,12 @@ def load_yaml_file(file_path: str) -> Optional[dict]: return None +def save_yaml_file(file_path: str, data: dict) -> None: + with open(file_path, 'w', encoding='utf-8') as file: + yaml.safe_dump( + data, file, default_flow_style=False, allow_unicode=True, indent=2) + + def dict_str_str(d: dict) -> bool: """restricted format: dict[str, str]""" if not isinstance(d, dict): @@ -161,25 +164,17 @@ def compare_dict_structure(dict1: dict, dict2: dict) -> bool: def sort_bool_trans(file_path: str): - trans_data: dict = load_json_file(file_path=file_path) + trans_data = load_yaml_file(file_path=file_path) + assert isinstance(trans_data, dict), f'{file_path} format error' trans_data['data'] = dict(sorted(trans_data['data'].items())) for key, trans in trans_data['translate'].items(): trans_data['translate'][key] = dict(sorted(trans.items())) return trans_data -def sort_multi_lang(file_path: str): - multi_lang: dict = load_json_file(file_path=file_path) - multi_lang = dict(sorted(multi_lang.items())) - for urn, trans in multi_lang.items(): - multi_lang[urn] = dict(sorted(trans.items())) - for lang, spec in multi_lang[urn].items(): - multi_lang[urn][lang] = dict(sorted(spec.items())) - return multi_lang - - def sort_spec_filter(file_path: str): - filter_data: dict = load_json_file(file_path=file_path) + filter_data = load_yaml_file(file_path=file_path) + assert isinstance(filter_data, dict), f'{file_path} format error' filter_data = dict(sorted(filter_data.items())) for urn, spec in filter_data.items(): filter_data[urn] = dict(sorted(spec.items())) @@ -188,30 +183,26 @@ def sort_spec_filter(file_path: str): @pytest.mark.github def test_bool_trans(): - data: dict = load_json_file(SPEC_BOOL_TRANS_FILE) + data = load_yaml_file(SPEC_BOOL_TRANS_FILE) + assert isinstance(data, dict) assert data, f'load {SPEC_BOOL_TRANS_FILE} failed' assert bool_trans(data), f'{SPEC_BOOL_TRANS_FILE} format error' @pytest.mark.github def test_spec_filter(): - data: dict = load_json_file(SPEC_FILTER_FILE) + data = load_yaml_file(SPEC_FILTER_FILE) + assert isinstance(data, dict) assert data, f'load {SPEC_FILTER_FILE} failed' assert spec_filter(data), f'{SPEC_FILTER_FILE} format error' -@pytest.mark.github -def test_multi_lang(): - data: dict = load_json_file(SPEC_MULTI_LANG_FILE) - assert data, f'load {SPEC_MULTI_LANG_FILE} failed' - assert nested_3_dict_str_str(data), f'{SPEC_MULTI_LANG_FILE} format error' - - @pytest.mark.github def test_miot_i18n(): for file_name in listdir(MIOT_I18N_RELATIVE_PATH): file_path: str = path.join(MIOT_I18N_RELATIVE_PATH, file_name) - data: dict = load_json_file(file_path) + data = load_json_file(file_path) + assert isinstance(data, dict) assert data, f'load {file_path} failed' assert nested_3_dict_str_str(data), f'{file_path} format error' @@ -220,7 +211,8 @@ def test_miot_i18n(): def test_translations(): for file_name in listdir(TRANS_RELATIVE_PATH): file_path: str = path.join(TRANS_RELATIVE_PATH, file_name) - data: dict = load_json_file(file_path) + data = load_json_file(file_path) + assert isinstance(data, dict) assert data, f'load {file_path} failed' assert dict_str_dict(data), f'{file_path} format error' @@ -237,15 +229,16 @@ def test_miot_lang_integrity(): i18n_names: set[str] = set(listdir(MIOT_I18N_RELATIVE_PATH)) assert len(i18n_names) == len(translations_names) assert i18n_names == translations_names - bool_trans_data: set[str] = load_json_file(SPEC_BOOL_TRANS_FILE) + bool_trans_data = load_yaml_file(SPEC_BOOL_TRANS_FILE) + assert isinstance(bool_trans_data, dict) bool_trans_names: set[str] = set( bool_trans_data['translate']['default'].keys()) assert len(bool_trans_names) == len(translations_names) # Check translation files structure - default_dict: dict = load_json_file( + default_dict = load_json_file( path.join(TRANS_RELATIVE_PATH, integration_lang_list[0])) for name in list(integration_lang_list)[1:]: - compare_dict: dict = load_json_file( + compare_dict = load_json_file( path.join(TRANS_RELATIVE_PATH, name)) if not compare_dict_structure(default_dict, compare_dict): _LOGGER.info( @@ -255,7 +248,7 @@ def test_miot_lang_integrity(): default_dict = load_json_file( path.join(MIOT_I18N_RELATIVE_PATH, integration_lang_list[0])) for name in list(integration_lang_list)[1:]: - compare_dict: dict = load_json_file( + compare_dict = load_json_file( path.join(MIOT_I18N_RELATIVE_PATH, name)) if not compare_dict_structure(default_dict, compare_dict): _LOGGER.info( @@ -272,19 +265,13 @@ def test_miot_data_sort(): 'INTEGRATION_LANGUAGES not sorted, correct order\r\n' f'{list(sort_langs.keys())}') assert json.dumps( - load_json_file(file_path=SPEC_BOOL_TRANS_FILE)) == json.dumps( + load_yaml_file(file_path=SPEC_BOOL_TRANS_FILE)) == json.dumps( sort_bool_trans(file_path=SPEC_BOOL_TRANS_FILE)), ( f'{SPEC_BOOL_TRANS_FILE} not sorted, goto project root path' ' and run the following command sorting, ', 'pytest -s -v -m update ./test/check_rule_format.py') assert json.dumps( - load_json_file(file_path=SPEC_MULTI_LANG_FILE)) == json.dumps( - sort_multi_lang(file_path=SPEC_MULTI_LANG_FILE)), ( - f'{SPEC_MULTI_LANG_FILE} not sorted, goto project root path' - ' and run the following command sorting, ', - 'pytest -s -v -m update ./test/check_rule_format.py') - assert json.dumps( - load_json_file(file_path=SPEC_FILTER_FILE)) == json.dumps( + load_yaml_file(file_path=SPEC_FILTER_FILE)) == json.dumps( sort_spec_filter(file_path=SPEC_FILTER_FILE)), ( f'{SPEC_FILTER_FILE} not sorted, goto project root path' ' and run the following command sorting, ', @@ -294,11 +281,8 @@ def test_miot_data_sort(): @pytest.mark.update def test_sort_spec_data(): sort_data: dict = sort_bool_trans(file_path=SPEC_BOOL_TRANS_FILE) - save_json_file(file_path=SPEC_BOOL_TRANS_FILE, data=sort_data) + save_yaml_file(file_path=SPEC_BOOL_TRANS_FILE, data=sort_data) _LOGGER.info('%s formatted.', SPEC_BOOL_TRANS_FILE) - sort_data = sort_multi_lang(file_path=SPEC_MULTI_LANG_FILE) - save_json_file(file_path=SPEC_MULTI_LANG_FILE, data=sort_data) - _LOGGER.info('%s formatted.', SPEC_MULTI_LANG_FILE) sort_data = sort_spec_filter(file_path=SPEC_FILTER_FILE) - save_json_file(file_path=SPEC_FILTER_FILE, data=sort_data) + save_yaml_file(file_path=SPEC_FILTER_FILE, data=sort_data) _LOGGER.info('%s formatted.', SPEC_FILTER_FILE) From 1022838eb85431d974145e39f611d9ebfc69723f Mon Sep 17 00:00:00 2001 From: Paul Shawn <32349595+topsworld@users.noreply.github.com> Date: Fri, 17 Jan 2025 19:09:20 +0800 Subject: [PATCH 14/14] docs: update changelog and version to v0.1.5b2 (#659) --- CHANGELOG.md | 18 ++++++++++++++++++ custom_components/xiaomi_home/manifest.json | 2 +- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c07dace..899e3b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # CHANGELOG +## v0.1.5b2 +### Added +- Support binary sensors to be displayed as text sensor entities and binary sensor entities. [#592](https://github.com/XiaoMi/ha_xiaomi_home/pull/592) +- Add miot cloud test case. [#620](https://github.com/XiaoMi/ha_xiaomi_home/pull/620) +- Add test case for user cert. [#638](https://github.com/XiaoMi/ha_xiaomi_home/pull/638) +- Add mips test case & Change mips reconnect logic. [#641](https://github.com/XiaoMi/ha_xiaomi_home/pull/641) +- Support remove device. [#622](https://github.com/XiaoMi/ha_xiaomi_home/pull/622) +- Support italian translation. [#183](https://github.com/XiaoMi/ha_xiaomi_home/pull/183) +### Changed +- Refactor miot spec. [#592](https://github.com/XiaoMi/ha_xiaomi_home/pull/592) +- Refactor miot mips & fix type errors. [#365](https://github.com/XiaoMi/ha_xiaomi_home/pull/365) +- Using logging for test case log print. [#636](https://github.com/XiaoMi/ha_xiaomi_home/pull/636) +- Add power properties trans. [#571](https://github.com/XiaoMi/ha_xiaomi_home/pull/571) +- Move web page to html. [#627](https://github.com/XiaoMi/ha_xiaomi_home/pull/627) +### Fixed +- Fix miot cloud and mdns error. [#637](https://github.com/XiaoMi/ha_xiaomi_home/pull/637) +- Fix type error + ## v0.1.5b1 This version will cause some Xiaomi routers that do not support access (#564) to become unavailable. You can update the device list in the configuration or delete it manually. ### Added diff --git a/custom_components/xiaomi_home/manifest.json b/custom_components/xiaomi_home/manifest.json index 624ae29..ca5d71e 100644 --- a/custom_components/xiaomi_home/manifest.json +++ b/custom_components/xiaomi_home/manifest.json @@ -25,7 +25,7 @@ "cryptography", "psutil" ], - "version": "v0.1.5b1", + "version": "v0.1.5b2", "zeroconf": [ "_miot-central._tcp.local." ]