Merge branch 'XiaoMi:main' into main

This commit is contained in:
Zeyu Li 2025-01-14 17:57:17 +08:00 committed by GitHub
commit cc7cf52b3f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
52 changed files with 2499 additions and 1417 deletions

View File

@ -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)

View File

@ -351,7 +351,7 @@ The instance code is the code of the MIoT-Spec-V2 instance, which is in the form
```
service:<siid> # service
service:<siid>:property:<piid> # property
service:<siid>:property:<piid>:valuelist:<value> # the value in value-list of a property
service:<siid>:property:<piid>:valuelist:<index> # The index of a value in the value-list of a property
service:<siid>:event:<eiid> # event
service:<siid>:action:<aiid> # action
```

View File

@ -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

View File

@ -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 (
@ -90,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
@ -247,6 +249,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,15 +424,17 @@ 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))
self.hass.data[DOMAIN][self._virtual_did]['oauth_state'] = state
cloud_server=self._cloud_server,
uuid=self._uuid,
loop=self._main_loop)
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)
self.hass.data[DOMAIN][self._virtual_did]['i18n'] = (
self._miot_i18n)
_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(
@ -498,11 +509,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,15 +1151,14 @@ 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))
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)
self.hass.data[DOMAIN][self._virtual_did]['i18n'] = (
self._miot_i18n)
_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(
@ -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')

View File

@ -55,7 +55,9 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.components.fan import FanEntity, FanEntityFeature
from homeassistant.util.percentage import (
percentage_to_ranged_value,
ranged_value_to_percentage
ranged_value_to_percentage,
ordered_list_item_to_percentage,
percentage_to_ordered_list_item
)
from .miot.miot_spec import MIoTSpecProperty
@ -89,10 +91,15 @@ class Fan(MIoTServiceEntity, FanEntity):
_prop_fan_level: Optional[MIoTSpecProperty]
_prop_mode: Optional[MIoTSpecProperty]
_prop_horizontal_swing: Optional[MIoTSpecProperty]
_prop_wind_reverse: Optional[MIoTSpecProperty]
_prop_wind_reverse_forward: Any
_prop_wind_reverse_reverse: Any
_speed_min: Optional[int]
_speed_max: Optional[int]
_speed_step: Optional[int]
_speed_min: int
_speed_max: int
_speed_step: int
_speed_names: Optional[list]
_speed_name_map: Optional[dict[int, str]]
_mode_list: Optional[dict[Any, Any]]
def __init__(
@ -101,15 +108,22 @@ class Fan(MIoTServiceEntity, FanEntity):
"""Initialize the Fan."""
super().__init__(miot_device=miot_device, entity_data=entity_data)
self._attr_preset_modes = []
self._attr_current_direction = None
self._attr_supported_features = FanEntityFeature(0)
self._prop_on = None
self._prop_fan_level = None
self._prop_mode = None
self._prop_horizontal_swing = None
self._prop_wind_reverse = None
self._prop_wind_reverse_forward = None
self._prop_wind_reverse_reverse = None
self._speed_min = 65535
self._speed_max = 0
self._speed_step = 1
self._speed_names = []
self._speed_name_map = {}
self._mode_list = None
# properties
@ -124,7 +138,8 @@ class Fan(MIoTServiceEntity, FanEntity):
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 = self._speed_max - self._speed_min+1
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 (
@ -133,10 +148,13 @@ class Fan(MIoTServiceEntity, FanEntity):
and prop.value_list
):
# Fan level with value-list
for item in prop.value_list:
self._speed_min = min(self._speed_min, item['value'])
self._speed_max = max(self._speed_max, item['value'])
self._attr_speed_count = self._speed_max - self._speed_min+1
# 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_names = list(self._speed_name_map.values())
self._attr_speed_count = len(prop.value_list)
self._attr_supported_features |= FanEntityFeature.SET_SPEED
self._prop_fan_level = prop
elif prop.name == 'mode':
@ -156,6 +174,30 @@ class Fan(MIoTServiceEntity, FanEntity):
elif prop.name == 'horizontal-swing':
self._attr_supported_features |= FanEntityFeature.OSCILLATE
self._prop_horizontal_swing = prop
elif prop.name == 'wind-reverse':
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']
if (
self._prop_wind_reverse_forward is None
or self._prop_wind_reverse_reverse is None
):
# NOTICE: Value may be 0 or False
_LOGGER.info(
'invalid wind-reverse, %s', self.entity_id)
continue
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:
@ -182,9 +224,19 @@ class Fan(MIoTServiceEntity, FanEntity):
await self.set_property_async(prop=self._prop_on, value=True)
# percentage
if percentage:
await self.set_property_async(
prop=self._prop_fan_level,
value=int(percentage*self._attr_speed_count/100))
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)
else:
await self.set_property_async(
prop=self._prop_fan_level,
value=int(percentage_to_ranged_value(
low_high_range=(self._speed_min, self._speed_max),
percentage=percentage)))
# preset_mode
if preset_mode:
await self.set_property_async(
@ -202,11 +254,19 @@ class Fan(MIoTServiceEntity, FanEntity):
async def async_set_percentage(self, percentage: int) -> None:
"""Set the percentage of the fan speed."""
if percentage > 0:
await self.set_property_async(
prop=self._prop_fan_level,
value=int(percentage_to_ranged_value(
low_high_range=(self._speed_min, self._speed_max),
percentage=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)
else:
await self.set_property_async(
prop=self._prop_fan_level,
value=int(percentage_to_ranged_value(
low_high_range=(self._speed_min, self._speed_max),
percentage=percentage)))
if not self.is_on:
# If the fan is off, turn it on.
await self.set_property_async(prop=self._prop_on, value=True)
@ -221,6 +281,14 @@ class Fan(MIoTServiceEntity, FanEntity):
async def async_set_direction(self, direction: str) -> None:
"""Set the direction of the fan."""
if not self._prop_wind_reverse:
return
await self.set_property_async(
prop=self._prop_wind_reverse,
value=(
self._prop_wind_reverse_reverse
if self.current_direction == 'reverse'
else self._prop_wind_reverse_forward))
async def async_oscillate(self, oscillating: bool) -> None:
"""Oscillate the fan."""
@ -242,13 +310,28 @@ class Fan(MIoTServiceEntity, FanEntity):
key=self.get_prop_value(prop=self._prop_mode))
if self._prop_mode else None)
@property
def current_direction(self) -> Optional[str]:
"""Return the current direction of the fan."""
if not self._prop_wind_reverse:
return None
return 'reverse' if self.get_prop_value(
prop=self._prop_wind_reverse
) == self._prop_wind_reverse_reverse else 'forward'
@property
def percentage(self) -> Optional[int]:
"""Return the current percentage of the fan speed."""
fan_level = self.get_prop_value(prop=self._prop_fan_level)
return ranged_value_to_percentage(
low_high_range=(self._speed_min, self._speed_max),
value=fan_level) if fan_level else None
if fan_level is None:
return None
if self._speed_names:
return ordered_list_item_to_percentage(
self._speed_names, self._speed_name_map[fan_level])
else:
return ranged_value_to_percentage(
low_high_range=(self._speed_min, self._speed_max),
value=fan_level)
@property
def oscillating(self) -> Optional[bool]:
@ -257,8 +340,3 @@ class Fan(MIoTServiceEntity, FanEntity):
self.get_prop_value(
prop=self._prop_horizontal_swing)
if self._prop_horizontal_swing else None)
@property
def percentage_step(self) -> float:
"""Return the step of the fan speed."""
return self._speed_step

View File

@ -25,7 +25,7 @@
"cryptography",
"psutil"
],
"version": "v0.1.5b0",
"version": "v0.1.5b1",
"zeroconf": [
"_miot-central._tcp.local."
]

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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統合ページに入り、[オプション]をクリックして再認証してください",

View File

@ -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.",

View File

@ -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.",

View File

@ -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.",

View File

@ -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, нажмите 'Опции' для повторной аутентификации",

View File

@ -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 集成页面,点击“选项”重新认证",

View File

@ -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 集成頁面,點擊“選項”重新認證",

View File

@ -18,6 +18,10 @@
ts: 1603967572
1245.airpurifier.dl01:
ts: 1607502661
17216.magic_touch.d150:
ts: 1575097876
17216.magic_touch.d152:
ts: 1575097876
17216.massage.ec1266a:
ts: 1615881124
397.light.hallight:
@ -56,6 +60,10 @@ bj352.airmonitor.m30:
ts: 1686644541
bj352.waterpuri.s100cm:
ts: 1615795630
bymiot.gateway.v1:
ts: 1575097876
bymiot.gateway.v2:
ts: 1575097876
cgllc.airmonitor.b1:
ts: 1676339912
cgllc.airmonitor.s1:
@ -64,6 +72,8 @@ cgllc.clock.cgc1:
ts: 1686644422
cgllc.clock.dove:
ts: 1619607474
cgllc.gateway.s1:
ts: 1575097876
cgllc.magnet.hodor:
ts: 1724329476
cgllc.motion.cgpr1:
@ -120,8 +130,14 @@ chuangmi.cateye.ipc018:
ts: 1632735241
chuangmi.cateye.ipc508:
ts: 1633677521
chuangmi.door.hmi508:
ts: 1611733437
chuangmi.door.hmi515:
ts: 1640334316
chuangmi.gateway.ipc011:
ts: 1575097876
chuangmi.ir.v2:
ts: 1575097876
chuangmi.lock.hmi501:
ts: 1614742147
chuangmi.lock.hmi501b01:
@ -142,10 +158,18 @@ chuangmi.plug.v1:
ts: 1621925183
chuangmi.plug.v3:
ts: 1644480255
chuangmi.plug.vtl_v1:
ts: 1575097876
chuangmi.radio.v1:
ts: 1531108800
chuangmi.radio.v2:
ts: 1531108800
chuangmi.remote.h102a03:
ts: 1575097876
chuangmi.remote.h102c01:
ts: 1575097876
chuangmi.remote.v2:
ts: 1575097876
chunmi.cooker.eh1:
ts: 1607339278
chunmi.cooker.eh402:
@ -204,6 +228,8 @@ dmaker.airfresh.t2017:
ts: 1686731233
dmaker.fan.p5:
ts: 1655793784
doco.fcb.docov001:
ts: 1575097876
dsm.lock.h3:
ts: 1615283790
dsm.lock.q3:
@ -218,6 +244,30 @@ fawad.airrtc.fwd20011:
ts: 1610607149
fbs.airmonitor.pth02:
ts: 1686644918
fengmi.projector.fm05:
ts: 1575097876
fengmi.projector.fm15:
ts: 1575097876
fengmi.projector.fm154k:
ts: 1575097876
fengmi.projector.l166:
ts: 1650352923
fengmi.projector.l176:
ts: 1649936204
fengmi.projector.l246:
ts: 1575097876
fengmi.projector.m055:
ts: 1652839826
fengmi.projector.m055d:
ts: 1654067980
fengyu.intercom.beebird:
ts: 1575097876
fengyu.intercom.sharkv1:
ts: 1575097876
fotile.hood.emd1tmi:
ts: 1607483642
guoshi.other.sem01:
ts: 1602662080
hannto.printer.anise:
ts: 1618989537
hannto.printer.honey:
@ -226,14 +276,26 @@ hannto.printer.honey1s:
ts: 1614332725
hfjh.fishbowl.v1:
ts: 1615278556
hhcc.bleflowerpot.v2:
ts: 1575097876
hhcc.plantmonitor.v1:
ts: 1664163526
hith.foot_bath.q2:
ts: 1531108800
hmpace.bracelet.v4:
ts: 1575097876
hmpace.scales.mibfs:
ts: 1575097876
hmpace.scales.miscale2:
ts: 1575097876
huohe.lock.m1:
ts: 1635410938
huoman.litter_box.co1:
ts: 1687165034
hutlon.lock.v0001:
ts: 1634799698
idelan.aircondition.g1:
ts: 1575097876
idelan.aircondition.v1:
ts: 1614666973
idelan.aircondition.v2:
@ -248,14 +310,22 @@ ikea.light.led1537r6:
ts: 1605162872
ikea.light.led1545g12:
ts: 1605162937
ikea.light.led1546g12:
ts: 1575097876
ikea.light.led1623g12:
ts: 1605163009
ikea.light.led1649c5:
ts: 1605163064
ikea.light.led1650r5:
ts: 1575097876
imibar.cooker.mbihr3:
ts: 1624620659
imou99.camera.tp2:
ts: 1531108800
inovel.projector.me2:
ts: 1575097876
iracc.aircondition.d19:
ts: 1609914362
isa.camera.df3:
ts: 1531108800
isa.camera.hl5:
@ -266,18 +336,34 @@ isa.camera.isc5:
ts: 1531108800
isa.camera.isc5c1:
ts: 1621238175
isa.camera.qf3:
ts: 1575097876
isa.cateye.hldb6:
ts: 1575097876
isa.magnet.dw2hl:
ts: 1638274655
jieman.magic_touch.js78:
ts: 1575097876
jiqid.mistory.ipen1:
ts: 1575097876
jiqid.mistory.pro:
ts: 1531108800
jiqid.mistory.v1:
ts: 1531108800
jiqid.mistudy.v2:
ts: 1610612349
jiqid.robot.cube:
ts: 1575097876
jiwu.lock.jwp01:
ts: 1614752632
jyaiot.cm.ccj01:
ts: 1611824545
k0918.toothbrush.kid01:
ts: 1575097876
kejia.airer.th001:
ts: 1575097876
ksmb.treadmill.k12:
ts: 1575097876
ksmb.treadmill.v1:
ts: 1611211447
ksmb.treadmill.v2:
@ -390,6 +476,8 @@ loock.lock.xfvl10:
ts: 1632814256
loock.safe.v1:
ts: 1619607755
lumi.acpartner.mcn02:
ts: 1655791626
lumi.acpartner.v1:
ts: 1531108800
lumi.acpartner.v2:
@ -462,6 +550,8 @@ lumi.lock.acn02:
ts: 1623928631
lumi.lock.acn03:
ts: 1614752574
lumi.lock.aq1:
ts: 1612518044
lumi.lock.bacn01:
ts: 1614741699
lumi.lock.bmcn02:
@ -482,6 +572,8 @@ lumi.lock.mcn007:
ts: 1650446757
lumi.lock.mcn01:
ts: 1679881881
lumi.lock.v1:
ts: 1575097876
lumi.lock.wbmcn1:
ts: 1619422072
lumi.motion.bmgl01:
@ -510,14 +602,20 @@ lumi.sensor_86sw1.v1:
ts: 1609311038
lumi.sensor_86sw2.v1:
ts: 1608795035
lumi.sensor_cube.aqgl01:
ts: 1575097876
lumi.sensor_ht.v1:
ts: 1621239877
lumi.sensor_magnet.aq2:
ts: 1641112867
lumi.sensor_magnet.v1:
ts: 1606120416
lumi.sensor_magnet.v2:
ts: 1641113779
lumi.sensor_motion.aq2:
ts: 1676433994
lumi.sensor_motion.v1:
ts: 1605093075
lumi.sensor_motion.v2:
ts: 1672818550
lumi.sensor_natgas.v1:
@ -530,6 +628,8 @@ lumi.sensor_switch.aq2:
ts: 1615256430
lumi.sensor_switch.aq3:
ts: 1607399487
lumi.sensor_switch.v1:
ts: 1606874434
lumi.sensor_switch.v2:
ts: 1609310683
lumi.sensor_wleak.aq1:
@ -574,6 +674,20 @@ miaomiaoce.sensor_ht.t1:
ts: 1616057242
miaomiaoce.sensor_ht.t2:
ts: 1636603553
miaomiaoce.thermo.t01:
ts: 1575097876
midea.aircondition.v1:
ts: 1575097876
midea.aircondition.xa1:
ts: 1575097876
midea.aircondition.xa2:
ts: 1575097876
midr.rv_mirror.m2:
ts: 1575097876
midr.rv_mirror.m5:
ts: 1575097876
midr.rv_mirror.v1:
ts: 1575097876
miir.aircondition.ir01:
ts: 1531108800
miir.aircondition.ir02:
@ -612,6 +726,8 @@ minij.washer.v5:
ts: 1622792196
minij.washer.v8:
ts: 1615777868
minuo.tracker.lm001:
ts: 1575097876
miot.light.plato2:
ts: 1685518142
miot.light.plato3:
@ -624,18 +740,32 @@ mmgg.feeder.snack:
ts: 1607503182
moyu.washer.s1hm:
ts: 1624620888
mrbond.airer.m0:
ts: 1575097876
mrbond.airer.m1pro:
ts: 1646393746
mrbond.airer.m1s:
ts: 1646393874
mrbond.airer.m1super:
ts: 1575097876
msj.f_washer.m1:
ts: 1614914340
mxiang.cateye.mdb10:
ts: 1616140362
mxiang.cateye.xmcatt1:
ts: 1616140207
nhy.airrtc.v1:
ts: 1575097876
ninebot.scooter.v1:
ts: 1602662395
ninebot.scooter.v6:
ts: 1575097876
nuwa.robot.minikiwi:
ts: 1575097876
nwt.derh.wdh318efw1:
ts: 1611822375
onemore.wifispeaker.sm4:
ts: 1575097876
opple.light.bydceiling:
ts: 1608187619
opple.light.fanlight:
@ -646,6 +776,8 @@ opple.remote.5pb112:
ts: 1627453840
opple.remote.5pb113:
ts: 1636599905
orion.wifispeaker.cm1:
ts: 1575097876
ows.towel_w.mj1x0:
ts: 1610604939
philips.light.bceiling1:
@ -696,6 +828,8 @@ pwzn.relay.apple:
ts: 1611217196
pwzn.relay.banana:
ts: 1646647255
qicyc.bike.tdp02z:
ts: 1575097876
qike.bhf_light.qk201801:
ts: 1608174909
qmi.powerstrip.v1:
@ -726,8 +860,32 @@ roborock.vacuum.t6:
ts: 1619423841
rockrobo.vacuum.v1:
ts: 1531108800
roidmi.carairpuri.pro:
ts: 1575097876
roidmi.carairpuri.v1:
ts: 1575097876
roidmi.cleaner.f8pro:
ts: 1575097876
roidmi.cleaner.v1:
ts: 1575097876
roidmi.cleaner.v2:
ts: 1638514177
roidmi.cleaner.v382:
ts: 1575097876
roidmi.vacuum.v1:
ts: 1575097876
rokid.robot.me:
ts: 1575097876
rokid.robot.mini:
ts: 1575097876
rokid.robot.pebble:
ts: 1575097876
rokid.robot.pebble2:
ts: 1575097876
roome.bhf_light.yf6002:
ts: 1531108800
rotai.magic_touch.sx300:
ts: 1602662578
rotai.massage.rt5728:
ts: 1610607000
rotai.massage.rt5850:
@ -738,22 +896,42 @@ rotai.massage.rt5863:
ts: 1611827937
rotai.massage.rt5870:
ts: 1632376570
runmi.suitcase.v1:
ts: 1575097876
scishare.coffee.s1102:
ts: 1611824402
shjszn.gateway.c1:
ts: 1575097876
shjszn.lock.c1:
ts: 1575097876
shjszn.lock.kx:
ts: 1575097876
shuii.humidifier.jsq001:
ts: 1575097876
shuii.humidifier.jsq002:
ts: 1606376290
skyrc.feeder.dfeed:
ts: 1626082349
skyrc.pet_waterer.fre1:
ts: 1608186812
smith.w_soften.cxs05ta1:
ts: 1575097876
smith.waterheater.cxea1:
ts: 1611826349
smith.waterheater.cxeb1:
ts: 1611826388
smith.waterpuri.jnt600:
ts: 1531108800
soocare.toothbrush.m1:
ts: 1575097876
soocare.toothbrush.m1s:
ts: 1610611310
soocare.toothbrush.mc1:
ts: 1575097876
soocare.toothbrush.t501:
ts: 1672192586
soocare.toothbrush.x3:
ts: 1575097876
sxds.pillow.pillow02:
ts: 1611222235
syniot.curtain.syc1:
@ -778,6 +956,10 @@ tokit.oven.tk32pro1:
ts: 1617002408
tokit.pre_cooker.tkih1:
ts: 1607410832
trios1.bleshoes.v02:
ts: 1602662599
txdd.wifispeaker.x1:
ts: 1575097876
viomi.aircondition.v10:
ts: 1606375041
viomi.aircondition.v21:
@ -830,12 +1012,16 @@ viomi.fridge.u13:
ts: 1614667152
viomi.fridge.u15:
ts: 1607505693
viomi.fridge.u17:
ts: 1575097876
viomi.fridge.u18:
ts: 1614655755
viomi.fridge.u2:
ts: 1531108800
viomi.fridge.u24:
ts: 1614667214
viomi.fridge.u25:
ts: 1575097876
viomi.fridge.u4:
ts: 1614667295
viomi.fridge.u6:
@ -992,6 +1178,82 @@ xiaomi.aircondition.ma6:
ts: 1721629272
xiaomi.aircondition.ma9:
ts: 1721629362
xiaomi.plc.v1:
ts: 1575097876
xiaomi.repeater.v1:
ts: 1575097876
xiaomi.repeater.v2:
ts: 1575097876
xiaomi.repeater.v3:
ts: 1575097876
xiaomi.router.d01:
ts: 1575097876
xiaomi.router.lv1:
ts: 1575097876
xiaomi.router.lv3:
ts: 1575097876
xiaomi.router.mv1:
ts: 1575097876
xiaomi.router.r2100:
ts: 1575097876
xiaomi.router.r3600:
ts: 1575097876
xiaomi.router.r3a:
ts: 1575097876
xiaomi.router.r3d:
ts: 1575097876
xiaomi.router.r3g:
ts: 1575097876
xiaomi.router.r3gv2:
ts: 1575097876
xiaomi.router.r3gv2n:
ts: 1575097876
xiaomi.router.r3p:
ts: 1575097876
xiaomi.router.r4:
ts: 1575097876
xiaomi.router.r4a:
ts: 1575097876
xiaomi.router.r4ac:
ts: 1575097876
xiaomi.router.r4c:
ts: 1575097876
xiaomi.router.r4cm:
ts: 1575097876
xiaomi.router.rm1800:
ts: 1575097876
xiaomi.router.v1:
ts: 1575097876
xiaomi.router.v2:
ts: 1575097876
xiaomi.router.v3:
ts: 1575097876
xiaomi.split_tv.b1:
ts: 1575097876
xiaomi.split_tv.v1:
ts: 1575097876
xiaomi.tv.b1:
ts: 1661248580
xiaomi.tv.h1:
ts: 1575097876
xiaomi.tv.i1:
ts: 1661248572
xiaomi.tv.v1:
ts: 1670811870
xiaomi.tvbox.b1:
ts: 1694503508
xiaomi.tvbox.i1:
ts: 1694503515
xiaomi.tvbox.v1:
ts: 1694503501
xiaomi.watch.band1:
ts: 1575097876
xiaomi.watch.band1A:
ts: 1575097876
xiaomi.watch.band1S:
ts: 1575097876
xiaomi.watch.band2:
ts: 1575097876
xiaomi.wifispeaker.l04m:
ts: 1658817956
xiaomi.wifispeaker.l06a:
@ -1012,6 +1274,10 @@ xiaomi.wifispeaker.lx5a:
ts: 1672299577
xiaomi.wifispeaker.s12:
ts: 1672299594
xiaomi.wifispeaker.v1:
ts: 1575097876
xiaomi.wifispeaker.v3:
ts: 1575097876
xiaomi.wifispeaker.x08a:
ts: 1672818945
xiaomi.wifispeaker.x08c:
@ -1028,6 +1294,44 @@ xiaovv.camera.xvd5:
ts: 1531108800
xiaovv.camera.xvsnowman:
ts: 1531108800
xiaoxun.robot.v1:
ts: 1575097876
xiaoxun.tracker.v1:
ts: 1575097876
xiaoxun.watch.sw306:
ts: 1575097876
xiaoxun.watch.sw560:
ts: 1575097876
xiaoxun.watch.sw705:
ts: 1575097876
xiaoxun.watch.sw710a2:
ts: 1575097876
xiaoxun.watch.sw760:
ts: 1575097876
xiaoxun.watch.sw900:
ts: 1575097876
xiaoxun.watch.sw960:
ts: 1575097876
xiaoxun.watch.v1:
ts: 1575097876
xiaoxun.watch.v10:
ts: 1575097876
xiaoxun.watch.v11:
ts: 1575097876
xiaoxun.watch.v2:
ts: 1575097876
xiaoxun.watch.v3:
ts: 1575097876
xiaoxun.watch.v4:
ts: 1575097876
xiaoxun.watch.v5:
ts: 1575097876
xiaoxun.watch.v7:
ts: 1575097876
xiaoxun.watch.v8:
ts: 1575097876
xiaoxun.watch.v9:
ts: 1575097876
xjx.toilet.pro:
ts: 1615965466
xjx.toilet.pure:
@ -1054,6 +1358,8 @@ yeelink.bhf_light.v3:
ts: 1608790102
yeelink.bhf_light.v5:
ts: 1601292562
yeelink.gateway.v1:
ts: 1575097876
yeelink.light.bslamp1:
ts: 1703120679
yeelink.light.bslamp2:
@ -1192,6 +1498,10 @@ yunmi.kettle.r2:
ts: 1606372087
yunmi.kettle.r3:
ts: 1637309534
yunmi.kettle.v1:
ts: 1575097876
yunmi.kettle.v9:
ts: 1602662686
yunmi.plmachine.mg2:
ts: 1611833658
yunmi.waterpuri.c5:
@ -1230,18 +1540,26 @@ yunmi.waterpurifier.v2:
ts: 1632377061
yunmi.waterpurifier.v3:
ts: 1611221428
yunyi.camera.v1:
ts: 1575097876
yyunyi.wopener.yypy24:
ts: 1616741966
yyzhn.gateway.yn181126:
ts: 1610689325
zdeer.ajh.a8:
ts: 1531108800
zdeer.ajh.a9:
ts: 1531108800
zdeer.ajh.ajb:
ts: 1608276454
zdeer.ajh.zda10:
ts: 1531108800
zdeer.ajh.zda9:
ts: 1531108800
zdeer.ajh.zjy:
ts: 1531108800
zhij.toothbrush.bv1:
ts: 1575097876
zhimi.aircondition.ma1:
ts: 1615185265
zhimi.aircondition.ma3:
@ -1250,6 +1568,8 @@ zhimi.aircondition.ma4:
ts: 1626334057
zhimi.aircondition.v1:
ts: 1610610931
zhimi.aircondition.v2:
ts: 1575097876
zhimi.aircondition.va1:
ts: 1609924720
zhimi.aircondition.za1:
@ -1276,8 +1596,12 @@ zhimi.airpurifier.sa2:
ts: 1635820002
zhimi.airpurifier.v1:
ts: 1635855633
zhimi.airpurifier.v2:
ts: 1575097876
zhimi.airpurifier.v3:
ts: 1676339933
zhimi.airpurifier.v5:
ts: 1575097876
zhimi.airpurifier.v6:
ts: 1636978652
zhimi.airpurifier.v7:
@ -1318,3 +1642,5 @@ zimi.mosq.v1:
ts: 1620728957
zimi.powerstrip.v2:
ts: 1620812714
zimi.projector.v1:
ts: 1575097876

View File

@ -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(
@ -356,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()
@ -369,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()):
@ -847,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:

View File

@ -47,6 +47,7 @@ MIoT http client.
"""
import asyncio
import base64
import hashlib
import json
import logging
import re
@ -75,10 +76,12 @@ class MIoTOauthClient:
_oauth_host: str
_client_id: int
_redirect_url: str
_device_id: str
_state: 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 +90,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,8 +99,15 @@ 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._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()
@ -132,6 +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,
'state': self._state
}
if state:
params['state'] = state
@ -191,6 +205,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:
@ -531,9 +546,18 @@ class MIoTHttpClient:
name = device.get('name', None)
urn = device.get('spec_type', None)
model = device.get('model', None)
if did is None or name is None or urn is None or model is None:
_LOGGER.error(
'get_device_list, cloud, invalid device, %s', device)
if did is None or name is None:
_LOGGER.info(
'invalid device, cloud, %s', device)
continue
if urn is None or model is None:
_LOGGER.info(
'missing the urn|model field, cloud, %s', device)
continue
if did.startswith('miwifi.'):
# The miwifi.* routers defined SPEC functions, but none of them
# were implemented.
_LOGGER.info('ignore miwifi.* device, cloud, %s', did)
continue
device_infos[did] = {
'did': did,
@ -634,7 +658,7 @@ class MIoTHttpClient:
for did in dids:
if did not in results:
devices.pop(did, None)
_LOGGER.error('get device info failed, %s', did)
_LOGGER.info('get device info failed, %s', did)
continue
devices[did].update(results[did])
# Whether sub devices

View File

@ -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

View File

@ -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)

View File

@ -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:

View File

@ -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

View File

@ -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)

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,136 @@
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" href="https://cdn.web-global.fds.api.mi-img.com/mcfe--mi-account/static/favicon_new.ico">
<link as="style"
href="https://font.sec.miui.com/font/css?family=MiSans:300,400,500,600,700:Chinese_Simplify,Chinese_Traditional,Latin&amp;display=swap"
rel="preload">
<title>TITLE_PLACEHOLDER</title>
<style>
body {
background: white;
color: black;
display: flex;
justify-content: center;
align-items: center;
font-family: MiSans, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica Neue, Arial, Noto Sans, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji;
}
@media (prefers-color-scheme: dark) {
body {
background: black;
color: white;
}
}
.frame {
background: rgb(255 255 255 / 5%);
width: 360px;
padding: 40 45;
border-radius: 4px;
box-shadow: 0 20px 50px 0 hsla(0, 0%, 64%, .1);
text-align: center;
}
.logo-frame {
text-align: center;
}
.title-frame {
margin: 20px 0 20px 0;
font-size: 26px;
font-weight: 500;
line-height: 40px;
opacity: 0.8;
}
.content-frame {
font-size: 17px;
font-weight: 500;
line-height: 20px;
opacity: 0.8;
}
button {
margin-top: 20px;
background-color: #ff5c00;
border: none;
border-radius: 4px;
padding: 0 20px;
text-align: center;
width: 100%;
display: inline-block;
font-size: 18px;
font-weight: 400;
height: 60px;
line-height: 60px;
overflow: hidden;
position: relative;
text-overflow: ellipsis;
transition: all .3s cubic-bezier(.645, .045, .355, 1);
vertical-align: top;
white-space: nowrap;
cursor: pointer;
color: #fff;
}
</style>
</head>
<body>
<div class="frame">
<!-- XIAOMI LOGO-->
<div class="logo-frame">
<svg width="50" height="50" viewBox="0 0 193 193" xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink">
<title>编组</title>
<desc>Created with Sketch.</desc>
<defs>
<polygon id="path-1"
points="1.78097075e-14 0.000125324675 192.540685 0.000125324675 192.540685 192.540058 1.78097075e-14 192.540058">
</polygon>
</defs>
<g id="\u9875\u9762-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="\u7F16\u7EC4">
<mask id="mask-2" fill="white">
<use xlink:href="#path-1"></use>
</mask>
<g id="Clip-2"></g>
<path
d="M172.473071,20.1164903 C154.306633,2.02148701 128.188344,-1.78097075e-14 96.2706558,-1.78097075e-14 C64.312237,-1.78097075e-14 38.155724,2.0452987 19.9974318,20.1872987 C1.84352597,38.3261656 1.78097075e-14,64.4406948 1.78097075e-14,96.3640227 C1.78097075e-14,128.286724 1.84352597,154.415039 20.0049513,172.556412 C38.1638701,190.704052 64.3141169,192.540058 96.2706558,192.540058 C128.225942,192.540058 154.376815,190.704052 172.53636,172.556412 C190.694653,154.409399 192.540685,128.286724 192.540685,96.3640227 C192.540685,64.3999643 190.672721,38.2553571 172.473071,20.1164903"
id="Fill-1" fill="#FF6900" mask="url(#mask-2)"></path>
<path
d="M89.1841721,131.948836 C89.1841721,132.594885 88.640263,133.130648 87.9779221,133.130648 L71.5585097,133.130648 C70.8848896,133.130648 70.338474,132.594885 70.338474,131.948836 L70.338474,89.0100961 C70.338474,88.3584078 70.8848896,87.8251513 71.5585097,87.8251513 L87.9779221,87.8251513 C88.640263,87.8251513 89.1841721,88.3584078 89.1841721,89.0100961 L89.1841721,131.948836 Z"
id="Fill-3" fill="#FFFFFF" mask="url(#mask-2)"></path>
<path
d="M121.332896,131.948836 C121.332896,132.594885 120.786481,133.130648 120.121633,133.130648 L104.492393,133.130648 C103.821906,133.130648 103.275491,132.594885 103.275491,131.948836 L103.275491,131.788421 L103.275491,94.9022357 C103.259198,88.4342292 102.889491,81.7863818 99.5502146,78.445226 C96.6790263,75.5652649 91.3251562,74.9054305 85.7557276,74.7669468 L57.4242049,74.7669468 C56.7555977,74.7669468 56.2154484,75.3045896 56.2154484,75.9512649 L56.2154484,128.074424 L56.2154484,131.948836 C56.2154484,132.594885 55.6640198,133.130648 54.9954127,133.130648 L39.3555198,133.130648 C38.6875393,133.130648 38.1498964,132.594885 38.1498964,131.948836 L38.1498964,60.5996188 C38.1498964,59.9447974 38.6875393,59.4121675 39.3555198,59.4121675 L84.4786692,59.4121675 C96.2717211,59.4121675 108.599909,59.9498104 114.680036,66.0380831 C120.786481,72.1533006 121.332896,84.4595571 121.332896,96.2657682 L121.332896,131.948836 Z"
id="Fill-5" fill="#FFFFFF" mask="url(#mask-2)"></path>
<path
d="M153.53056,131.948836 C153.53056,132.594885 152.978505,133.130648 152.316164,133.130648 L136.678778,133.130648 C136.010797,133.130648 135.467515,132.594885 135.467515,131.948836 L135.467515,60.5996188 C135.467515,59.9447974 136.010797,59.4121675 136.678778,59.4121675 L152.316164,59.4121675 C152.978505,59.4121675 153.53056,59.9447974 153.53056,60.5996188 L153.53056,131.948836 Z"
id="Fill-7" fill="#FFFFFF" mask="url(#mask-2)"></path>
</g>
</g>
</svg>
</div>
<!-- TITLE -->
<div class="title-frame">
<a id="titleArea">TITLE_PLACEHOLDER</a>
</div>
<!-- CONTENT -->
<div class="content-frame">
<a id="contentArea">CONTENT_PLACEHOLDER</a>
</div>
<!-- BUTTON -->
<button onClick="window.close();" id="buttonArea">BUTTON_PLACEHOLDER</button>
</div>
<script>
if (STATUS_PLACEHOLDER) {
window.opener = null;
window.open('', '_self');
window.close();
}
</script>
</body>
</html>

View File

@ -59,5 +59,10 @@
"1",
"5"
]
},
"urn:miot-spec-v2:device:router:0000A036:xiaomi-rd03": {
"services": [
"*"
]
}
}

View File

@ -289,7 +289,7 @@ SPEC_SERVICE_TRANS_MAP: dict[str, dict | str] = {
}
},
'optional': {
'properties': {'mode', 'horizontal-swing'}
'properties': {'mode', 'horizontal-swing', 'wind-reverse'}
},
'entity': 'fan'
},
@ -439,6 +439,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',

View File

@ -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 '''
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" href="https://cdn.web-global.fds.api.mi-img.com/mcfe--mi-account/static/favicon_new.ico">
<link as="style"
href="https://font.sec.miui.com/font/css?family=MiSans:300,400,500,600,700:Chinese_Simplify,Chinese_Traditional,Latin&amp;display=swap"
rel="preload">
<title></title>
<style>
body {
background: white;
color: black;
display: flex;
justify-content: center;
align-items: center;
font-family: MiSans, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica Neue, Arial, Noto Sans, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji;
}
@media (prefers-color-scheme: dark) {
body {
background: black;
color: white;
}
}
.frame {
background: rgb(255 255 255 / 5%);
width: 360px;
padding: 40 45;
border-radius: 4px;
box-shadow: 0 20px 50px 0 hsla(0, 0%, 64%, .1);
text-align: center;
}
.logo-frame {
text-align: center;
}
.title-frame {
margin: 20px 0 20px 0;
font-size: 26px;
font-weight: 500;
line-height: 40px;
opacity: 0.8;
}
.content-frame {
font-size: 17px;
font-weight: 500;
line-height: 20px;
opacity: 0.8;
}
button {
margin-top: 20px;
background-color: #ff5c00;
border: none;
border-radius: 4px;
padding: 0 20px;
text-align: center;
width: 100%;
display: inline-block;
font-size: 18px;
font-weight: 400;
height: 60px;
line-height: 60px;
overflow: hidden;
position: relative;
text-overflow: ellipsis;
transition: all .3s cubic-bezier(.645, .045, .355, 1);
vertical-align: top;
white-space: nowrap;
cursor: pointer;
color: #fff;
}
</style>
</head>
<body>
<div class="frame">
<!-- XIAOMI LOGO-->
<div class="logo-frame">
<svg width="50" height="50" viewBox="0 0 193 193" xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"><title>编组</title>
<desc>Created with Sketch.</desc>
<defs>
<polygon id="path-1"
points="1.78097075e-14 0.000125324675 192.540685 0.000125324675 192.540685 192.540058 1.78097075e-14 192.540058"></polygon>
</defs>
<g id="\u9875\u9762-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="\u7F16\u7EC4">
<mask id="mask-2" fill="white">
<use xlink:href="#path-1"></use>
</mask>
<g id="Clip-2"></g>
<path d="M172.473071,20.1164903 C154.306633,2.02148701 128.188344,-1.78097075e-14 96.2706558,-1.78097075e-14 C64.312237,-1.78097075e-14 38.155724,2.0452987 19.9974318,20.1872987 C1.84352597,38.3261656 1.78097075e-14,64.4406948 1.78097075e-14,96.3640227 C1.78097075e-14,128.286724 1.84352597,154.415039 20.0049513,172.556412 C38.1638701,190.704052 64.3141169,192.540058 96.2706558,192.540058 C128.225942,192.540058 154.376815,190.704052 172.53636,172.556412 C190.694653,154.409399 192.540685,128.286724 192.540685,96.3640227 C192.540685,64.3999643 190.672721,38.2553571 172.473071,20.1164903"
id="Fill-1" fill="#FF6900" mask="url(#mask-2)"></path>
<path d="M89.1841721,131.948836 C89.1841721,132.594885 88.640263,133.130648 87.9779221,133.130648 L71.5585097,133.130648 C70.8848896,133.130648 70.338474,132.594885 70.338474,131.948836 L70.338474,89.0100961 C70.338474,88.3584078 70.8848896,87.8251513 71.5585097,87.8251513 L87.9779221,87.8251513 C88.640263,87.8251513 89.1841721,88.3584078 89.1841721,89.0100961 L89.1841721,131.948836 Z"
id="Fill-3" fill="#FFFFFF" mask="url(#mask-2)"></path>
<path d="M121.332896,131.948836 C121.332896,132.594885 120.786481,133.130648 120.121633,133.130648 L104.492393,133.130648 C103.821906,133.130648 103.275491,132.594885 103.275491,131.948836 L103.275491,131.788421 L103.275491,94.9022357 C103.259198,88.4342292 102.889491,81.7863818 99.5502146,78.445226 C96.6790263,75.5652649 91.3251562,74.9054305 85.7557276,74.7669468 L57.4242049,74.7669468 C56.7555977,74.7669468 56.2154484,75.3045896 56.2154484,75.9512649 L56.2154484,128.074424 L56.2154484,131.948836 C56.2154484,132.594885 55.6640198,133.130648 54.9954127,133.130648 L39.3555198,133.130648 C38.6875393,133.130648 38.1498964,132.594885 38.1498964,131.948836 L38.1498964,60.5996188 C38.1498964,59.9447974 38.6875393,59.4121675 39.3555198,59.4121675 L84.4786692,59.4121675 C96.2717211,59.4121675 108.599909,59.9498104 114.680036,66.0380831 C120.786481,72.1533006 121.332896,84.4595571 121.332896,96.2657682 L121.332896,131.948836 Z"
id="Fill-5" fill="#FFFFFF" mask="url(#mask-2)"></path>
<path d="M153.53056,131.948836 C153.53056,132.594885 152.978505,133.130648 152.316164,133.130648 L136.678778,133.130648 C136.010797,133.130648 135.467515,132.594885 135.467515,131.948836 L135.467515,60.5996188 C135.467515,59.9447974 136.010797,59.4121675 136.678778,59.4121675 L152.316164,59.4121675 C152.978505,59.4121675 153.53056,59.9447974 153.53056,60.5996188 L153.53056,131.948836 Z"
id="Fill-7" fill="#FFFFFF" mask="url(#mask-2)"></path>
</g>
</g>
</svg>
</div>
<!-- TITLE -->
<div class="title-frame">
<a id="titleArea"></a>
</div>
<!-- CONTENT -->
<div class="content-frame">
<a id="contentArea"></a>
</div>
<!-- BUTTON -->
<button onClick="window.close();" id="buttonArea"></button>
</div>
<script>
// get language (user language -> system language)
const locale = (localStorage.getItem('selectedLanguage')?? "''' + lang + '''").replaceAll('"','');
const language = locale.includes("-") ? locale.substring(0, locale.indexOf("-")).trim() : locale;
const status = "''' + status + '''";
console.log(locale);
// translation
let translation = {
zh: {
success: {
title: "认证完成",
content: "请关闭此页面,返回账号认证页面点击“下一步”",
button: "关闭页面"
},
fail: {
title: "认证失败",
content: "请关闭此页面,返回账号认证页面重新点击认链接进行认证。",
button: "关闭页面"
}
},
'zh-Hant': {
success: {
title: "認證完成",
content: "請關閉此頁面,返回帳號認證頁面點擊「下一步」。",
button: "關閉頁面"
},
fail: {
title: "認證失敗",
content: "請關閉此頁面,返回帳號認證頁面重新點擊認鏈接進行認證。",
button: "關閉頁面"
}
},
en: {
success: {
title: "Authentication Completed",
content: "Please close this page and return to the account authentication page to click NEXT",
button: "Close Page"
},
fail: {
title: "Authentication Failed",
content: "Please close this page and return to the account authentication page to click the authentication link again.",
button: "Close Page"
}
},
fr: {
success: {
title: "Authentification Terminée",
content: "Veuillez fermer cette page et revenir à la page d'authentification du compte pour cliquer sur « SUIVANT »",
button: "Fermer la page"
},
fail: {
title: "Échec de l'Authentification",
content: "Veuillez fermer cette page et revenir à la page d'authentification du compte pour cliquer de nouveau sur le lien d'authentification.",
button: "Fermer la page"
}
},
ru: {
success: {
title: "Подтверждение завершено",
content: "Пожалуйста, закройте эту страницу, вернитесь на страницу аутентификации учетной записи и нажмите кнопку «Далее».",
button: "Закрыть страницу"
},
fail: {
title: "Ошибка аутентификации",
content: "Пожалуйста, закройте эту страницу, вернитесь на страницу аутентификации учетной записи и повторите процесс аутентификации, щелкнув ссылку.",
button: "Закрыть страницу"
}
},
de: {
success: {
title: "Authentifizierung abgeschlossen",
content: "Bitte schließen Sie diese Seite, kehren Sie zur Kontobestätigungsseite zurück und klicken Sie auf „WEITER“.",
button: "Seite schließen"
},
fail: {
title: "Authentifizierung fehlgeschlagen",
content: "Bitte schließen Sie diese Seite, kehren Sie zur Kontobestätigungsseite zurück und wiederholen Sie den Authentifizierungsprozess, indem Sie auf den Link klicken.",
button: "Seite schließen"
}
},
es: {
success: {
title: "Autenticación completada",
content: "Por favor, cierre esta página, regrese a la página de autenticación de la cuenta y haga clic en 'SIGUIENTE'.",
button: "Cerrar página"
},
fail: {
title: "Error de autenticación",
content: "Por favor, cierre esta página, regrese a la página de autenticación de la cuenta y vuelva a hacer clic en el enlace de autenticación.",
button: "Cerrar página"
}
},
ja: {
success: {
title: "認証完了",
content: "このページを閉じて、アカウント認証ページに戻り、「次」をクリックしてください。",
button: "ページを閉じる"
},
fail: {
title: "認証失敗",
content: "このページを閉じて、アカウント認証ページに戻り、認証リンクを再度クリックしてください。",
button: "ページを閉じる"
}
}
}
// insert translate into page / match order: locale > language > english
document.title = translation[locale]?.[status]?.title ?? translation[language]?.[status]?.title ?? translation["en"]?.[status]?.title;
document.getElementById("titleArea").innerText = translation[locale]?.[status]?.title ?? translation[language]?.[status]?.title ?? translation["en"]?.[status]?.title;
document.getElementById("contentArea").innerText = translation[locale]?.[status]?.content ?? translation[language]?.[status]?.content ?? translation["en"]?.[status]?.content;
document.getElementById("buttonArea").innerText = translation[locale]?.[status]?.button ?? translation[language]?.[status]?.button ?? translation["en"]?.[status]?.button;
window.opener=null;
window.open('','_self');
window.close();
</script>
</body>
</html>
'''
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

View File

@ -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.",

View File

@ -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.",

View File

@ -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.",

View File

@ -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.",

View File

@ -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": "認証情報が期限切れになりました。統合ページにアクセスして、「設定」ボタンをクリックして再度認証してください。",

View File

@ -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.",

View File

@ -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.",

View File

@ -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.",

View File

@ -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": "Информация об авторизации истекла. Перейдите на страницу интеграции и нажмите кнопку «Настроить», чтобы переавторизоваться.",

View File

@ -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": "认证信息已过期。请进入集成页面,点击“配置”按钮重新认证。",

View File

@ -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": "認證信息已過期。請進入集成頁面,點擊“配置”按鈕重新認證。",

View File

@ -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)

View File

@ -1,16 +1,37 @@
# -*- coding: utf-8 -*-
"""Pytest fixtures."""
import logging
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_CLOUD_CACHE: str = 'cloud_cache'
_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():
@ -20,10 +41,10 @@ def load_py_file():
'const.py',
'miot_cloud.py',
'miot_error.py',
'miot_ev.py',
'miot_i18n.py',
'miot_lan.py',
'miot_mdns.py',
'miot_mips.py',
'miot_network.py',
'miot_spec.py',
'miot_storage.py']
@ -35,31 +56,35 @@ 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
# 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)
@ -80,6 +105,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
@ -90,6 +120,53 @@ 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_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')
def test_name_uuid() -> str:
return f'{TEST_CLOUD_SERVER}_uuid'
@pytest.fixture(scope='session')
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'

587
test/test_cloud.py Executable file
View File

@ -0,0 +1,587 @@
# -*- 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
@pytest.mark.dependency()
async def test_miot_oauth_async(
test_cache_path: str,
test_cloud_server: str,
test_oauth2_redirect_url: 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
from miot.miot_cloud import MIoTOauthClient
from miot.miot_storage import MIoTStorage
miot_storage = MIoTStorage(test_cache_path)
local_uuid = await miot_storage.load_async(
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(
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_cloud_cache, name=test_name_oauth2_info, 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())
):
_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)
_LOGGER.info('auth url: %s', 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
_LOGGER.info('get_access_token result: %s', res_obj)
rc = await miot_storage.save_async(
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_cloud_cache, test_name_uuid, uuid)
assert rc
_LOGGER.info('save uuid')
access_token = oauth_info.get('access_token', None)
assert isinstance(access_token, str)
_LOGGER.info('access_token: %s', access_token)
refresh_token = oauth_info.get('refresh_token', None)
assert isinstance(refresh_token, str)
_LOGGER.info('refresh_token: %s', refresh_token)
await miot_oauth.deinit_async()
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_cloud_cache: str,
test_name_oauth2_info: str,
test_name_uuid: str
):
from miot.const import OAUTH2_CLIENT_ID
from miot.miot_cloud import MIoTOauthClient
from miot.miot_storage import MIoTStorage
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)
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())
_LOGGER.info('token remaining valid time: %ss', remaining_time)
# 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
_LOGGER.info('refresh token, remaining valid time: %ss', remaining_time)
# Save oauth2 info
rc = await miot_storage.save_async(
test_domain_cloud_cache, test_name_oauth2_info, update_info)
assert rc
_LOGGER.info('refresh token success, %s', update_info)
await miot_oauth.deinit_async()
@pytest.mark.asyncio
@pytest.mark.dependency()
async def test_miot_cloud_get_nickname_async(
test_cache_path: str,
test_cloud_server: str,
test_domain_cloud_cache: str,
test_name_oauth2_info: str
):
from miot.const import OAUTH2_CLIENT_ID
from miot.miot_cloud import MIoTHttpClient
from miot.miot_storage import MIoTStorage
miot_storage = MIoTStorage(test_cache_path)
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
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']
_LOGGER.info('your nickname: %s', nickname)
await miot_http.deinit_async()
@pytest.mark.asyncio
@pytest.mark.dependency()
async def test_miot_cloud_get_uid_async(
test_cache_path: str,
test_cloud_server: 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
from miot.miot_storage import MIoTStorage
miot_storage = MIoTStorage(test_cache_path)
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
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)
_LOGGER.info('your uid: %s', uid)
# Save uid
rc = await miot_storage.save_async(
domain=test_domain_cloud_cache, name=test_name_uid, data=uid)
assert rc
await miot_http.deinit_async()
@pytest.mark.asyncio
@pytest.mark.dependency()
async def test_miot_cloud_get_homeinfos_async(
test_cache_path: str,
test_cloud_server: 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
from miot.miot_storage import MIoTStorage
miot_storage = MIoTStorage(test_cache_path)
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
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_cloud_cache, name=test_name_uid, type_=str)
assert uid == uid2
_LOGGER.info('your uid: %s', uid)
# Get homes
home_list = homeinfos.get('home_list', {})
_LOGGER.info('your home_list: ,%s', home_list)
# Get share homes
share_home_list = homeinfos.get('share_home_list', {})
_LOGGER.info('your share_home_list: %s', share_home_list)
await miot_http.deinit_async()
@pytest.mark.asyncio
@pytest.mark.dependency()
async def test_miot_cloud_get_devices_async(
test_cache_path: str,
test_cloud_server: 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
from miot.miot_storage import MIoTStorage
miot_storage = MIoTStorage(test_cache_path)
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
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_cloud_cache, name=test_name_uid, type_=str)
assert uid == uid2
_LOGGER.info('your uid: %s', uid)
# Get homes
homes = devices['homes']
_LOGGER.info('your homes: %s', homes)
# Get devices
devices = devices['devices']
_LOGGER.info('your devices count: %s', len(devices))
# Storage homes and devices
rc = await miot_storage.save_async(
domain=test_domain_cloud_cache, name=test_name_homes, data=homes)
assert rc
rc = await miot_storage.save_async(
domain=test_domain_cloud_cache, name=test_name_devices, data=devices)
assert rc
await miot_http.deinit_async()
@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_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
from miot.miot_storage import MIoTStorage
miot_storage = MIoTStorage(test_cache_path)
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
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_cloud_cache, name=test_name_devices, 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)
_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
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_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
from miot.miot_storage import MIoTStorage
miot_storage = MIoTStorage(test_cache_path)
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
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_cloud_cache, name=test_name_devices, 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']
_LOGGER.info('%s(%s), prop.2.1: %s', device_name, did, prop_value)
await miot_http.deinit_async()
@pytest.mark.asyncio
@pytest.mark.dependency()
async def test_miot_cloud_get_props_async(
test_cache_path: str,
test_cloud_server: 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
from miot.miot_storage import MIoTStorage
miot_storage = MIoTStorage(test_cache_path)
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
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_cloud_cache, name=test_name_devices, 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])
_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')
@pytest.mark.asyncio
@pytest.mark.dependency()
async def test_miot_cloud_set_prop_async(
test_cache_path: str,
test_cloud_server: 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
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
miot_storage = MIoTStorage(test_cache_path)
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
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_cloud_cache, name=test_name_devices, 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}])
_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}])
_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')
@pytest.mark.asyncio
@pytest.mark.dependency()
async def test_miot_cloud_action_async(
test_cache_path: str,
test_cloud_server: 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
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
miot_storage = MIoTStorage(test_cache_path)
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
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_cloud_cache, name=test_name_devices, 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.'}])
_LOGGER.info('test did, %s, action.4.1 -> %s', test_did, result)
await miot_http.deinit_async()

View File

@ -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

View File

@ -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()

View File

@ -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={

View File

@ -1,28 +1,35 @@
# -*- coding: utf-8 -*-
"""Unit test for miot_mdns.py."""
import asyncio
import logging
import pytest
from zeroconf import IPVersion
from zeroconf.asyncio import AsyncZeroconf
_LOGGER = logging.getLogger(__name__)
# pylint: disable=import-outside-toplevel, unused-argument
@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):
print(
group_id: str, state: MipsServiceState, data: dict):
_LOGGER.info(
'on_service_state_change, %s, %s, %s', group_id, state, data)
async with AsyncZeroconf(ip_version=IPVersion.V4Only) as aiozc:
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()
print('get all service, ', services_detail.keys())
_LOGGER.info('get all service, %s', list(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()

264
test/test_mips.py Normal file
View File

@ -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', [
('<Group id>', '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()

View File

@ -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()

View File

@ -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)

View File

@ -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')