初始化config文件

This commit is contained in:
2026-08-24 21:55:33 +08:00
commit 80a05d172e
2983 changed files with 1221334 additions and 0 deletions
@@ -0,0 +1,11 @@
from .device import Device, DeviceInfo
from .hass_entry import HassEntry
from .hass_entity import BasicEntity, XEntity
__all__ = [
'Device',
'DeviceInfo',
'HassEntry',
'BasicEntity',
'XEntity',
]
@@ -0,0 +1,114 @@
from enum import Enum
from typing import Union
from homeassistant.const import __version__ as HAVERSION # noqa
from awesomeversion import AwesomeVersion
from .device_customizes import DEVICE_CUSTOMIZES, GLOBAL_CONVERTERS # noqa
from .miot_local_devices import MIOT_LOCAL_MODELS # noqa
from .translation_languages import TRANSLATION_LANGUAGES # noqa
DOMAIN = 'xiaomi_miot'
DEFAULT_NAME = 'Xiaomi Miot'
HA_VERSION = AwesomeVersion(HAVERSION)
CONF_MODEL = 'model'
CONF_XIAOMI_CLOUD = 'xiaomi_cloud'
CONF_SERVER_COUNTRY = 'server_country'
CONF_CONN_MODE = 'conn_mode'
CONF_CONFIG_VERSION = 'config_version'
DEFAULT_CONN_MODE = 'cloud'
SUPPORTED_DOMAINS = [
'sensor',
'binary_sensor',
'switch',
'number',
'select',
'button',
'text',
'light',
'fan',
'climate',
'cover',
'humidifier',
'media_player',
'camera',
'vacuum',
'water_heater',
'device_tracker',
'remote',
'alarm_control_panel',
]
CLOUD_SERVERS = {
'cn': '中国大陆',
'tw': '中國台灣',
'de': 'Europe',
'i2': 'India',
'ru': 'Russia',
'sg': 'Singapore',
'us': 'United States',
}
try:
# python 3.11
from enum import StrEnum
except (ModuleNotFoundError, ImportError):
class StrEnum(str, Enum):
pass
try:
# hass 2023.3
from homeassistant.util.json import JsonObjectType
except (ModuleNotFoundError, ImportError):
JsonObjectType = dict
try:
# hass 2023.7
from homeassistant.core import ServiceResponse, SupportsResponse
except (ModuleNotFoundError, ImportError):
SupportsResponse = None
ServiceResponse = Union[dict, None]
try:
# hass 2024.10
from homeassistant.components.camera import CameraState
except (ModuleNotFoundError, ImportError):
class CameraState(StrEnum):
RECORDING = 'recording'
STREAMING = 'streaming'
IDLE = 'idle'
try:
# hass 2024.11
from homeassistant.core_config import DATA_CUSTOMIZE
except (ModuleNotFoundError, ImportError):
from homeassistant.helpers.entity import DATA_CUSTOMIZE
try:
# hass 2024.11
from homeassistant.components.alarm_control_panel import AlarmControlPanelState
except (ModuleNotFoundError, ImportError):
class AlarmControlPanelState(StrEnum):
"""Alarm control panel entity states."""
DISARMED = "disarmed"
ARMED_HOME = "armed_home"
ARMED_AWAY = "armed_away"
ARMED_NIGHT = "armed_night"
ARMED_VACATION = "armed_vacation"
ARMED_CUSTOM_BYPASS = "armed_custom_bypass"
PENDING = "pending"
ARMING = "arming"
DISARMING = "disarming"
TRIGGERED = "triggered"
try:
# hass 2025.1
from homeassistant.components.vacuum import VacuumActivity
except (ModuleNotFoundError, ImportError):
class VacuumActivity(StrEnum):
CLEANING = "cleaning"
DOCKED = "docked"
RETURNING = "returning"
ERROR = "error"
@@ -0,0 +1,349 @@
from typing import TYPE_CHECKING, Any
from dataclasses import dataclass
from homeassistant.util import color, percentage
if TYPE_CHECKING:
from .device import Device
from .miot_spec import MiotService, MiotProperty, MiotAction
@dataclass
class BaseConv:
attr: str
domain: str = None
mi: str | int = None
attrs: list = None
option: dict = None
def __post_init__(self):
if self.attrs is None:
self.attrs = []
if self.option is None:
self.option = {}
def with_option(self, **kwargs):
self.option.update(kwargs)
return self
@property
def full_name(self):
if not self.domain:
return self.attr
return f'{self.domain}.{self.attr}'
def value_from_dict(self, data):
return data.get(self.full_name, data.get(self.attr))
# to hass
def decode(self, device: 'Device', payload: dict, value):
payload[self.full_name] = value
# from hass
def encode(self, device: 'Device', payload: dict, value):
params = None
if self.mi and 'prop.' in self.mi:
_, s, p = self.mi.split('.')
payload['method'] = 'set_properties'
params = {'siid': int(s), 'piid': int(p)}
if params:
params.update({'did': device.did, 'value': value})
payload.setdefault('params', []).append(params)
@dataclass
class InfoConv(BaseConv):
attr: str = 'info'
domain: str = 'button'
def decode(self, device: 'Device', payload: dict, value):
updater = device.data.get('updater')
infos = {
self.full_name: device.name,
'model': device.model,
'did': device.info.did,
'mac': device.info.mac,
'lan_ip': device.info.host,
'app_link': device.app_link,
'miot_type': device.info.urn,
'available': device.available,
'home_room': device.info.home_room,
'icon': self.option.get('icon') if device.available else 'mdi:information-off',
'updater': updater or 'none',
}
customizes = {**device.customizes}
customizes.pop('append_converters', None)
customizes.pop('extend_miot_specs', None)
payload.update({
**infos,
**device.props,
'converters': [c.full_name for c in device.converters],
'customizes': customizes,
**infos,
})
if device.available:
payload.pop('miot_error', None)
if device.miot_results:
if err := device.miot_results.errors:
payload['miot_error'] = str(err)
def encode(self, device: 'Device', payload: dict, value):
payload.update({
'method': 'update_status',
})
@dataclass
class AttrConv(BaseConv):
pass
@dataclass
class MiotPropConv(BaseConv):
prop: 'MiotProperty' = None
desc: bool = None
def __post_init__(self):
super().__post_init__()
if self.prop:
if not self.mi:
from .miot_spec import MiotSpec
self.mi = MiotSpec.unique_prop(self.prop.siid, piid=self.prop.iid)
if self.desc == None:
self.desc = self.prop.use_desc(self.domain)
def decode(self, device: 'Device', payload: dict, value):
if self.desc and self.prop:
value = self.prop.list_description(value)
if self.domain == 'sensor' and isinstance(value, str):
value = value.lower()
super().decode(device, payload, value)
def encode(self, device: 'Device', payload: dict, value):
if self.prop:
if self.desc:
if isinstance(value, list):
value = self.prop.list_first(value)
else:
value = self.prop.list_value(value)
elif self.prop.is_integer:
value = int(value) # bool to int
super().encode(device, payload, value)
@dataclass
class MiotPropValueConv(MiotPropConv):
value: Any = None
description: str = None
def decode(self, device: 'Device', payload: dict, value):
pass
@dataclass
class MiotActionConv(BaseConv):
action: 'MiotAction' = None
prop: 'MiotProperty' = None
def __post_init__(self):
super().__post_init__()
if not self.mi:
from .miot_spec import MiotSpec
self.mi = MiotSpec.unique_prop(self.action.siid, aiid=self.action.iid)
if not self.prop:
self.prop = self.action.in_properties()[0] if self.action.ins else None
def decode(self, device: 'Device', payload: dict, value):
super().decode(device, payload, value)
def encode(self, device: 'Device', payload: dict, value):
if self.prop and isinstance(value, str):
if self.prop.value_list or self.prop.value_range:
value = self.prop.list_value(value)
elif self.prop.is_integer:
value = int(value)
ins = value if isinstance(value, list) else [] if value is None else [value]
_, s, p = self.mi.split('.')
payload['method'] = 'action'
payload['param'] = {
'did': device.did,
'siid': int(s),
'aiid': int(p),
'in': ins,
}
@dataclass
class MiotServiceConv(MiotPropConv):
attr: str = None
service: 'MiotService' = None
prop: 'MiotProperty' = None
main_props: list = None
def __post_init__(self):
if not self.prop and self.service and self.main_props:
self.prop = self.service.get_property(*self.main_props)
super().__post_init__()
if self.attr:
pass
elif self.prop:
self.attr = self.prop.full_name
elif self.service:
self.attr = self.service.desc_name
@dataclass
class MiotSensorConv(MiotServiceConv):
domain: str = 'sensor'
@dataclass
class MiotSwitchConv(MiotServiceConv):
domain: str = 'switch'
def __post_init__(self):
if not self.main_props:
self.main_props = ['on', 'switch']
super().__post_init__()
@dataclass
class MiotLightConv(MiotSwitchConv):
domain: str = 'light'
@dataclass
class MiotBrightnessConv(MiotPropConv):
def decode(self, device: 'Device', payload: dict, value: int):
max = self.prop.range_max()
if max != None:
super().decode(device, payload, value / max * 255.0)
def encode(self, device: 'Device', payload: dict, value: float):
max = self.prop.range_max()
if max != None:
value = round(value / 255.0 * max)
super().encode(device, payload, int(value))
@dataclass
class MiotColorTempConv(MiotPropConv):
def decode(self, device: 'Device', payload: dict, value: int):
if self.prop.unit == 'percentage':
if not value:
return
value = self.percentage_to_kelvin(value)
elif self.prop.unit != 'kelvin':
if not value:
return
value = round(1000000.0 / value)
super().decode(device, payload, value)
def encode(self, device: 'Device', payload: dict, value: int):
if self.prop.unit == 'percentage':
if not value:
return
value = self.kelvin_to_percentage(value)
elif self.prop.unit != 'kelvin':
if not value:
return
value = round(1000000.0 / value)
if value < self.prop.range_min():
value = self.prop.range_min()
if value > self.prop.range_max():
value = self.prop.range_max()
super().encode(device, payload, value)
@staticmethod
def percentage_to_kelvin(p: int) -> int:
return 6500 - p * 40
@staticmethod
def kelvin_to_percentage(k: int) -> int:
return round((6500 - k) / 40)
@dataclass
class MiotRgbColorConv(MiotPropConv):
def decode(self, device: 'Device', payload: dict, value: int):
super().decode(device, payload, MiotRgbColorConv.int_to_rgb(value))
def encode(self, device: 'Device', payload: dict, rgb: tuple[int, int, int]):
super().encode(device, payload, MiotRgbColorConv.rgb_to_int(rgb))
@staticmethod
def rgb_to_int(rgb: tuple[int, int, int]):
num = int(rgb[0]) << 16 | int(rgb[1]) << 8 | int(rgb[2])
return int(num)
@staticmethod
def int_to_rgb(value: int):
x = int(value)
r = (x >> 16) & 0xFF
g = (x >> 8) & 0xFF
b = x & 0xFF
return r, g, b
@dataclass
class MiotHsColorConv(MiotPropConv):
def decode(self, device: 'Device', payload: dict, value: int):
rgb = MiotRgbColorConv.int_to_rgb(value)
super().decode(device, payload, color.color_RGB_to_hs(*rgb))
def encode(self, device: 'Device', payload: dict, value: tuple):
rgb = color.color_hs_to_RGB(*value)
super().encode(device, payload, MiotRgbColorConv.rgb_to_int(rgb))
@dataclass
class MiotFanConv(MiotServiceConv):
domain: str = 'fan'
def __post_init__(self):
if not self.main_props:
self.main_props = ['on', 'fan_level']
super().__post_init__()
@dataclass
class MiotClimateConv(MiotServiceConv):
domain: str = 'climate'
def __post_init__(self):
if not self.main_props:
self.main_props = ['mode', 'target_temperature']
super().__post_init__()
@dataclass
class MiotCoverConv(MiotServiceConv):
domain: str = 'cover'
def __post_init__(self):
if not self.main_props:
self.main_props = ['motor_control']
super().__post_init__()
@dataclass
class MiotCameraConv(MiotServiceConv):
domain: str = 'camera'
@dataclass
class MiotHumidifierConv(MiotServiceConv):
domain: str = 'humidifier'
def __post_init__(self):
if not self.main_props:
self.main_props = ['on', 'target_humidity']
super().__post_init__()
@dataclass
class PercentagePropConv(MiotPropConv):
ranged = None
def __post_init__(self):
super().__post_init__()
if self.prop and self.prop.value_range:
self.ranged = (self.prop.range_min(), self.prop.range_max())
def decode(self, device: 'Device', payload: dict, value: int):
if self.ranged:
value = int(percentage.scale_ranged_value_to_int_range(self.ranged, (0, 100), value))
super().decode(device, payload, value)
def encode(self, device: 'Device', payload: dict, value: int):
if self.ranged:
value = int(percentage.scale_to_ranged_value((0, 100), self.ranged, value))
if value < self.ranged[0]:
value = self.ranged[0]
if value > self.ranged[1]:
value = self.ranged[1]
super().encode(device, payload, value)
class MiotTargetPositionConv(PercentagePropConv):
pass
@@ -0,0 +1,123 @@
"""
https://github.com/wandergis/coordTransform_py/blob/master/coordTransform_utils.py
"""
import math
from math import pi
a = 6378245.0 # 长半轴
ee = 0.00669342162296594323 # 偏心率平方
x_pi = pi * 3000.0 / 180.0
def wgs84_to_gcj02(lng, lat):
"""
WGS84转GCJ02(火星坐标系)
:param lng:WGS84坐标系的经度
:param lat:WGS84坐标系的纬度
:return:火星坐标
"""
if out_of_china(lng, lat): # 判断是否在国内
return [lng, lat]
dlat = _transformlat(lng - 105.0, lat - 35.0)
dlng = _transformlng(lng - 105.0, lat - 35.0)
radlat = lat / 180.0 * pi
magic = math.sin(radlat)
magic = 1 - ee * magic * magic
sqrtmagic = math.sqrt(magic)
dlat = (dlat * 180.0) / ((a * (1 - ee)) / (magic * sqrtmagic) * pi)
dlng = (dlng * 180.0) / (a / sqrtmagic * math.cos(radlat) * pi)
mglat = lat + dlat
mglng = lng + dlng
return [mglng, mglat]
def wgs84_to_bd09(lon, lat):
lon, lat = wgs84_to_gcj02(lon, lat)
return gcj02_to_bd09(lon, lat)
def gcj02_to_wgs84(lng, lat):
"""
GCJ02(火星坐标系)转GPS84
:param lng:火星坐标系的经度
:param lat:火星坐标系纬度
:return:原始坐标
"""
if out_of_china(lng, lat):
return [lng, lat]
dlat = _transformlat(lng - 105.0, lat - 35.0)
dlng = _transformlng(lng - 105.0, lat - 35.0)
radlat = lat / 180.0 * pi
magic = math.sin(radlat)
magic = 1 - ee * magic * magic
sqrtmagic = math.sqrt(magic)
dlat = (dlat * 180.0) / ((a * (1 - ee)) / (magic * sqrtmagic) * pi)
dlng = (dlng * 180.0) / (a / sqrtmagic * math.cos(radlat) * pi)
mglat = lat + dlat
mglng = lng + dlng
return [lng * 2 - mglng, lat * 2 - mglat]
def gcj02_to_bd09(lng, lat):
"""
火星坐标系(GCJ-02)转百度坐标系(BD-09)
谷歌、高德——>百度
:param lng:火星坐标经度
:param lat:火星坐标纬度
:return:百度坐标
"""
z = math.sqrt(lng * lng + lat * lat) + 0.00002 * math.sin(lat * x_pi)
theta = math.atan2(lat, lng) + 0.000003 * math.cos(lng * x_pi)
bd_lng = z * math.cos(theta) + 0.0065
bd_lat = z * math.sin(theta) + 0.006
return [bd_lng, bd_lat]
def bd09_to_gcj02(bd_lon, bd_lat):
"""
百度坐标系(BD-09)转火星坐标系(GCJ-02)
百度——>谷歌、高德
:param bd_lat:百度坐标纬度
:param bd_lon:百度坐标经度
:return:转换后的坐标列表形式
"""
x = bd_lon - 0.0065
y = bd_lat - 0.006
z = math.sqrt(x * x + y * y) - 0.00002 * math.sin(y * x_pi)
theta = math.atan2(y, x) - 0.000003 * math.cos(x * x_pi)
gg_lng = z * math.cos(theta)
gg_lat = z * math.sin(theta)
return [gg_lng, gg_lat]
def bd09_to_wgs84(bd_lon, bd_lat):
lon, lat = bd09_to_gcj02(bd_lon, bd_lat)
return gcj02_to_wgs84(lon, lat)
def _transformlat(lng, lat):
ret = -100.0 + 2.0 * lng + 3.0 * lat + 0.2 * lat * lat + \
0.1 * lng * lat + 0.2 * math.sqrt(math.fabs(lng))
ret += (20.0 * math.sin(6.0 * lng * pi) + 20.0 *
math.sin(2.0 * lng * pi)) * 2.0 / 3.0
ret += (20.0 * math.sin(lat * pi) + 40.0 *
math.sin(lat / 3.0 * pi)) * 2.0 / 3.0
ret += (160.0 * math.sin(lat / 12.0 * pi) + 320 *
math.sin(lat * pi / 30.0)) * 2.0 / 3.0
return ret
def _transformlng(lng, lat):
ret = 300.0 + lng + 2.0 * lat + 0.1 * lng * lng + \
0.1 * lng * lat + 0.1 * math.sqrt(math.fabs(lng))
ret += (20.0 * math.sin(6.0 * lng * pi) + 20.0 *
math.sin(2.0 * lng * pi)) * 2.0 / 3.0
ret += (20.0 * math.sin(lng * pi) + 40.0 *
math.sin(lng / 3.0 * pi)) * 2.0 / 3.0
ret += (150.0 * math.sin(lng / 12.0 * pi) + 300.0 *
math.sin(lng / 30.0 * pi)) * 2.0 / 3.0
return ret
def out_of_china(lng, lat):
return not (73.66 < lng < 135.05 and 3.86 < lat < 53.55)
@@ -0,0 +1,52 @@
import logging
from typing import TYPE_CHECKING
from homeassistant.core import HassJob, HassJobType
from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
if TYPE_CHECKING:
from .device import Device
_LOGGER = logging.getLogger(__name__)
class DataCoordinator(DataUpdateCoordinator):
def __init__(self, device: 'Device', update_method, **kwargs):
kwargs.setdefault('always_update', True)
if callable(update_method):
name = update_method.__name__
elif isinstance(update_method, str):
name = update_method
update_method = getattr(device, name, None)
else:
raise ValueError('Invalid update method')
name = kwargs.pop('name', name)
super().__init__(
device.hass,
logger=device.log,
name=f'{device.unique_id}-{name}',
update_method=update_method,
**kwargs,
)
self.device = device
if not hasattr(self, 'setup_method'):
# hass v2024.7-
self.async_add_listener(self.coordinator_updated)
async def async_setup(self, index=0):
await self._async_setup()
job = HassJob(self._async_refresh_later, job_type=HassJobType.Coroutinefunction)
async_call_later(self.hass, index, job)
async def _async_setup(self):
"""Set up coordinator."""
self.async_add_listener(self.coordinator_updated)
def coordinator_updated(self):
_LOGGER.debug('%s: Coordinator updated: %s', self.device.name_model, [self.name, self.data])
async def _async_refresh_later(self, _=None):
await self.async_request_refresh()
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,273 @@
import logging
from typing import TYPE_CHECKING, Optional, Callable
from functools import cached_property
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import Entity, EntityCategory
from homeassistant.helpers.restore_state import ExtraStoredData, RestoredExtraData
from .const import DOMAIN
from .utils import get_customize_via_entity, wildcard_models, CustomConfigHelper
from .miot_spec import MiotService, MiotProperty, MiotAction
from .converters import BaseConv, InfoConv, MiotServiceConv, MiotPropConv, MiotActionConv
if TYPE_CHECKING:
from .device import Device
_LOGGER = logging.getLogger(__name__)
class BasicEntity(Entity, CustomConfigHelper):
hass: HomeAssistant = None
device: 'Device' = None
conv: 'BaseConv' = None
def custom_config(self, key=None, default=None):
return get_customize_via_entity(self, key, default)
def global_config(self, key=None, default=None):
if not self.hass:
return default
cfg = self.hass.data[DOMAIN]['config'] or {}
return cfg if key is None else cfg.get(key, default)
def filter_state_attributes(self, dat: dict):
if exl := self.global_config('exclude_state_attributes'):
if not isinstance(exl, (list, tuple)):
exl = [exl]
dat = {
k: v
for k, v in dat.items()
if k not in exl
}
return dat
@cached_property
def extra_state_attributes(self):
if hasattr(self, '_attr_extra_state_attributes'):
return self.filter_state_attributes(self._attr_extra_state_attributes)
return None
async def async_get_properties(self, mapping, update_entity=False, **kwargs):
return await self.device.async_get_properties(mapping, update_entity, **kwargs)
async def async_set_property(self, field, value):
return await self.device.async_set_property(field, value)
async def async_set_miot_property(self, siid, piid, value, **kwargs):
return await self.device.async_set_miot_property(siid, piid, value, **kwargs)
async def async_call_action(self, siid, aiid=None, params=None, **kwargs):
if isinstance(siid, MiotAction):
if params is None:
params = aiid
action = siid
siid = action.service.iid
aiid = action.iid
return await self.device.async_call_action(siid, aiid, params, **kwargs)
async def async_miio_command(self, method, params=None, **kwargs):
if not self.device.local:
return {'error': 'Unsupported'}
if params is None:
params = []
_LOGGER.debug('%s: Send miio command: %s(%s)', self.device.name_model, method, params)
return await self.device.local.async_send(method, params)
class XEntity(BasicEntity):
CLS: dict[str, Callable] = {}
log = _LOGGER
added = False
_attr_available = False
_attr_should_poll = False
_attr_has_entity_name = True
_miot_service: Optional[MiotService] = None
_miot_property: Optional[MiotProperty] = None
_miot_action: Optional[MiotAction] = None
def __init__(self, device: 'Device', conv: 'BaseConv'):
self.device = device
self.hass = device.hass
self.conv = conv
self.attr = conv.full_name
self.log = device.log
if isinstance(conv, MiotServiceConv):
self.entity_id = conv.service.generate_entity_id(self, conv.domain)
self._attr_name = str(conv.service.friendly_desc)
self._attr_translation_key = conv.service.name
self._miot_service = conv.service
self._miot_property = conv.prop
elif isinstance(conv, MiotPropConv):
self.entity_id = conv.prop.generate_entity_id(self, conv.domain)
self._attr_name = str(conv.prop.friendly_desc)
self._attr_translation_key = conv.prop.friendly_name
self._miot_service = conv.prop.service
self._miot_property = conv.prop
if not conv.prop.readable:
self._attr_available = True
elif isinstance(conv, MiotActionConv):
self.entity_id = device.spec.generate_entity_id(self, conv.action.name, conv.domain)
self._attr_name = str(conv.action.friendly_desc)
self._attr_translation_key = conv.action.friendly_name
self._miot_service = conv.action.service
self._miot_action = conv.action
self._miot_property = conv.prop
self._attr_available = True
else:
self.entity_id = device.spec.generate_entity_id(self, conv.attr, conv.domain)
# self._attr_name = conv.attr.replace('_', '').title()
self._attr_translation_key = conv.attr
if isinstance(conv, InfoConv):
self._attr_available = True
self.listen_attrs = {self.attr} | set(conv.attrs)
if getattr(self, '_attr_name', None):
self._attr_name = self._attr_name.replace(device.name, '').strip()
self._attr_unique_id = f'{device.unique_id}-{convert_unique_id(conv)}'
self._attr_device_info = self.device.hass_device_info
self._attr_extra_state_attributes = {}
self._attr_icon = conv.option.get('icon')
self._attr_device_class = self.custom_config('device_class') or conv.option.get('device_class')
if self._attr_translation_key:
self._attr_translation_key = ( # hassfest
self._attr_translation_key
.replace(':', '-')
.replace('.', '-')
)
cate = self.custom_config('entity_category') or conv.option.get('entity_category')
if isinstance(cate, str):
try:
cate = EntityCategory(cate)
except Exception:
cate = None
if cate:
self._attr_entity_category = cate
else:
cats = {
'configuration_entities': EntityCategory.CONFIG,
'diagnostic_entities': EntityCategory.DIAGNOSTIC,
}
for k, v in cats.items():
names = self.custom_config_list(k) or []
if self._miot_property and self._miot_property.in_list(names):
self._attr_entity_category = v
break
if self._miot_action and self._miot_action.in_list(names):
self._attr_entity_category = v
break
self.on_init()
self.device.add_listener(self.on_device_update)
@cached_property
def model(self):
return self.device.model
@cached_property
def unique_mac(self):
return self.device.info.unique_id
def on_init(self):
"""Run on class init."""
async def async_update(self):
await self.device.update_status()
def on_device_update(self, data: dict, only_info=False):
state_change = False
available = self.device.available
if not only_info:
pass
elif isinstance(self.conv, InfoConv):
self._attr_available = available = True
self._attr_icon = data.get('icon', self._attr_icon)
old_data = self._attr_extra_state_attributes.copy()
old_data.pop('updated_at', None)
if data == old_data:
return
data['updated_at'] = str(self.device.data.get('updated', ''))
self._attr_extra_state_attributes = data
else:
return
if keys := self.listen_attrs & data.keys():
self._attr_available = available
self.set_state(data)
state_change = True
for key in keys:
if conv := self.device.find_converter(key):
self._attr_extra_state_attributes[conv.attr] = self.device.props.get(conv.attr)
if state_change and self.added:
self._async_write_ha_state()
state = data.get(self.attr, data)
_LOGGER.debug('%s: Entity state updated: %s', self.entity_id, state)
def get_state(self) -> dict:
"""Run before entity remove if entity is subclass from RestoreEntity."""
return {}
def set_state(self, data: dict):
"""Run on data from device."""
self._attr_state = data.get(self.attr)
@property
def extra_restore_state_data(self) -> ExtraStoredData | None:
# filter None values
if state := {k: v for k, v in self.get_state().items() if v is not None}:
return RestoredExtraData(state)
return None
async def async_added_to_hass(self) -> None:
self.added = True
self.hass.data[DOMAIN]['entities'][self.entity_id] = self
if call := getattr(self, 'async_get_last_extra_data', None):
data: RestoredExtraData = await call()
if data and self.listen_attrs & data.as_dict().keys():
self.set_state(data.as_dict())
async def async_will_remove_from_hass(self) -> None:
self.device.remove_listener(self.on_device_update)
@cached_property
def customize_keys(self):
keys = []
prop = getattr(self.conv, 'prop', None)
action = getattr(self.conv, 'action', None)
for mod in wildcard_models(self.device.model):
if isinstance(action, MiotAction):
keys.append(f'{mod}:{action.full_name}')
keys.append(f'{mod}:{action.name}')
if isinstance(prop, MiotProperty):
keys.append(f'{mod}:{prop.full_name}')
keys.append(f'{mod}:{prop.name}')
if self.attr and not (prop or action):
keys.append(f'{mod}:{self.conv.attr}')
return keys
def convert_unique_id(conv: 'BaseConv'):
service = getattr(conv, 'service', None)
if isinstance(conv, MiotServiceConv) and isinstance(service, MiotService):
return service.iid
action = getattr(conv, 'action', None)
if isinstance(action, MiotAction):
return action.unique_name
prop = getattr(conv, 'prop', None)
if isinstance(prop, MiotProperty):
return prop.unique_name
return conv.attr
@@ -0,0 +1,139 @@
import logging
import asyncio
from typing import TYPE_CHECKING
from homeassistant.core import HomeAssistant
from homeassistant.const import CONF_USERNAME
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import SUPPORTED_DOMAINS
from .xiaomi_cloud import MiotCloud
if TYPE_CHECKING:
from .device import Device
_LOGGER = logging.getLogger(__name__)
class HassEntry:
ALL: dict[str, 'HassEntry'] = {}
cloud: MiotCloud = None
cloud_devices = None
def __init__(self, hass: HomeAssistant, entry: ConfigEntry):
self.id = entry.entry_id
self.hass = hass
self.entry = entry
self.adders: dict[str, AddEntitiesCallback] = {}
self.devices: dict[str, 'Device'] = {}
self.mac_to_did = {}
self.did_to_unique = {}
@staticmethod
def init(hass: HomeAssistant, entry: ConfigEntry):
this = HassEntry.ALL.get(entry.entry_id)
if not this:
this = HassEntry(hass, entry)
HassEntry.ALL[entry.entry_id] = this
return this
async def async_unload(self):
ret = all(
await asyncio.gather(
*[
self.hass.config_entries.async_forward_entry_unload(self.entry, domain)
for domain in SUPPORTED_DOMAINS
]
)
)
if ret:
for device in self.devices.values():
await device.async_unload()
HassEntry.ALL.pop(self.entry.entry_id, None)
return ret
def __getattr__(self, item):
return getattr(self.entry, item)
@property
def setup_in_progress(self):
return self.entry.state == ConfigEntryState.SETUP_IN_PROGRESS
def get_config(self, key=None, default=None):
dat = {
**self.entry.data,
**self.entry.options,
}
if self.filter_models:
dat.pop('filter_did', None)
dat.pop('did_list', None)
else:
dat.pop('filter_model', None)
dat.pop('model_list', None)
if key:
return dat.get(key, default)
return dat
@property
def filter_models(self):
data = {
**self.entry.data,
**self.entry.options,
}
if data.get('did_list'):
return False
if data.get('model_list'):
return True
if 'did_list' in data:
return False
if 'model_list' in data:
return True
return data.get('filter_models', False)
async def new_device(self, device_info: dict):
from .device import Device, DeviceInfo
info = DeviceInfo(device_info)
if device := self.devices.get(info.unique_id):
return device
device = Device(info, self)
self.devices[info.unique_id] = device
self.did_to_unique[info.did] = info.unique_id
await device.async_init()
return device
def new_adder(self, domain, adder: AddEntitiesCallback):
self.adders[domain] = adder
_LOGGER.info('New adder: %s', [domain, adder])
for device in self.devices.values():
device.add_entities(domain)
return self
async def get_cloud(self, check=False, login=False):
if not self.cloud:
if not self.get_config(CONF_USERNAME):
return None
self.cloud = await MiotCloud.from_token(self.hass, self.get_config(), login=login)
if check:
await self.cloud.async_check_auth(notify=True)
return self.cloud
async def get_cloud_devices(self):
if isinstance(self.cloud_devices, dict):
return self.cloud_devices
cloud = await self.get_cloud()
if not cloud:
return {}
config = self.get_config()
self.cloud_devices = await cloud.async_get_devices_by_key('did', filters=config) or {}
for did, info in self.cloud_devices.items():
mac = info.get('mac') or did
self.mac_to_did[mac] = did
return self.cloud_devices
async def get_cloud_device(self, did=None, mac=None):
devices = await self.get_cloud_devices()
if mac and not did:
did = self.mac_to_did.get(mac)
if did:
return devices.get(did)
return None
@@ -0,0 +1,297 @@
import time
import logging
import voluptuous as vol
from typing import Tuple
from .utils import (
DeviceException,
is_offline_exception,
rgb_to_int,
int_to_rgb,
)
from .templates import template
from .miot_spec import (MiotSpec, MiotProperty, MiotAction)
from .miio2miot_specs import MIIO_TO_MIOT_SPECS
import homeassistant.helpers.config_validation as cv
_LOGGER = logging.getLogger(__name__)
class Miio2MiotHelper:
def __init__(self, hass, config: dict, miot_spec: MiotSpec, from_model=None):
self.hass = hass
if ext := config.get('extend_model'):
if m2m := Miio2MiotHelper.from_model(hass, ext, miot_spec):
sps = {**m2m.config.get('miio_specs', {})}
sps.update(config.get('miio_specs', {}))
config = {**m2m.config, **config, 'miio_specs': sps}
self.config = config
self.miot_spec = miot_spec
self.specs = config.get('miio_specs', {})
self.model = from_model or config.get('model', None)
self.miio_props = []
for k, v in self.specs.items():
if p := v.get('prop'):
self.miio_props.append(p)
self.extend_miio_props(config.get('miio_props', []))
self.miio_props_values = {}
@staticmethod
def from_model(hass, model, miot_spec, from_model=None):
if not from_model:
from_model = model
cfg = MIIO_TO_MIOT_SPECS.get(model) or {}
if isinstance(cfg, str):
return Miio2MiotHelper.from_model(hass, cfg, miot_spec, from_model)
if cfg:
cfg.setdefault('model', model)
return Miio2MiotHelper(hass, cfg, miot_spec, from_model)
return None
def extend_miio_props(self, props: list):
self.miio_props.extend(props)
self.miio_props = list(dict(zip(self.miio_props, self.miio_props)).keys())
return self.miio_props
async def async_get_miio_props(self, device):
dic = {}
if not self.config.get('without_props'):
try:
num = int(self.config.get('chunk_properties'))
except (TypeError, ValueError):
num = None
try:
vls = await device.async_get_prop(self.miio_props, max_properties=num)
dic.update(dict(zip(self.miio_props, vls)))
except (DeviceException, OSError) as exc:
if is_offline_exception(exc):
raise exc
_LOGGER.error('%s: Got MiioException: %s while get_prop(%s)', self.model, exc, self.miio_props)
except TypeError:
_LOGGER.error('%s: Got TypeError while get_prop(%s)', self.model, self.miio_props, exc_info=True)
if cls := self.config.get('miio_commands'):
for c in cls:
if dly := c.get('delay', 0):
time.sleep(dly)
pms = c.get('params', [])
try:
vls = await device.async_send(c['method'], pms)
except (DeviceException, OSError, TypeError, UnboundLocalError) as exc:
if is_offline_exception(exc):
raise exc
if not c.get('ignore_error'):
_LOGGER.error('%s: Got MiioException: %s while %s(%s)', self.model, exc, c['method'], pms)
continue
kls = c.get('values', [])
if kls is True:
kls = c.get('params', [])
if tpl := c.get('template'):
tpl = template(tpl, self.hass)
pdt = tpl.async_render({'results': vls})
if isinstance(pdt, dict):
dic.update(pdt)
elif kls:
if isinstance(vls, dict):
vls = list(vls.values())
i = 0
for k in kls:
if i >= len(vls):
break
dic[k] = vls[i]
i += 1
if tpl := self.config.get('miio_template'):
tpl = template(tpl, self.hass)
pdt = tpl.async_render({'props': dic})
if isinstance(pdt, dict):
dic.update(pdt)
self.miio_props_values = dic
_LOGGER.debug('%s: Got miio props for miot: %s', self.model, dic)
return dic
async def async_get_miot_props(self, device, mapping: dict = None):
if mapping is None:
mapping = device.mapping or {}
rls = []
if dic := await self.async_get_miio_props(device):
for k, v in mapping.items():
u = MiotSpec.unique_prop(v, valid=True)
c = self.specs.get(u, {})
p = c.get('prop')
if not p:
continue
val = dic.get(p)
prop = self.miot_spec.specs.get(u)
if prop and isinstance(prop, MiotProperty):
mph = MiioPropertyHelper(prop)
fmt = c.get('format')
try:
if tpl := c.get('template', {}):
tpl = template(tpl, self.hass)
val = tpl.async_render({
'value': val,
'props': dic,
'dict': c.get('dict', {}),
'min': prop.range_min(),
'max': prop.range_max(),
'step': prop.range_step(),
'description': prop.list_description(val) if prop.value_list else None,
})
elif fmt and hasattr(mph, fmt):
val = getattr(mph, fmt)(val)
elif d := c.get('dict', {}):
val = d.get(val, c.get('default', val))
elif prop.format in ['bool']:
val = cv.boolean(val)
elif r := c.get('value_ratio'):
val = round(float(val) * float(r), 3)
elif prop.format in ['float']:
val = round(float(val), 4)
elif prop.is_integer:
val = int(val)
except (TypeError, ValueError, vol.Invalid):
val = dic.get(p)
rls.append({
**v,
'code': 0,
'value': val,
})
return rls
def has_setter(self, siid, piid=None, aiid=None):
key = MiotSpec.unique_prop(siid=siid, piid=piid, aiid=aiid)
ret = self.specs.get(key, {}).get('setter')
return ret
async def async_set_property(self, device, siid, piid, value):
key = MiotSpec.unique_prop(siid=siid, piid=piid)
cfg = self.specs.get(key, {})
setter = cfg.get('setter')
if setter is True:
setter = None
if prop := cfg.get('prop'):
setter = f'set_{prop}'
pms = [value]
prop = self.miot_spec.specs.get(key)
if prop and isinstance(prop, MiotProperty):
mph = MiioPropertyHelper(prop, reverse=True)
fmt = cfg.get('format')
if tpl := cfg.get('set_template'):
tpl = template(tpl, self.hass)
pms = tpl.async_render({
'value': value,
'props': self.miio_props_values,
'dict': cfg.get('dict', {}),
'min': prop.range_min(),
'max': prop.range_max(),
'step': prop.range_step(),
'description': prop.list_description(value) if prop.value_list else None,
}) or []
if isinstance(pms, dict) and 'method' in pms:
setter = pms.get('method', setter)
pms = pms.get('params', [])
elif fmt and hasattr(mph, fmt):
pms = [getattr(mph, fmt)(value)]
elif d := cfg.get('dict', {}):
for dk, dv in d.items():
if dv == value:
pms = [dk]
break
pms = cv.ensure_list(pms)
if not setter:
_LOGGER.warning('%s: Set miio prop via miot failed: %s', self.model, [key, setter, cfg])
return None
_LOGGER.info('%s: Set miio prop via miot: %s', self.model, [key, setter, pms])
ret = await device.async_send(setter, pms) or ['']
iok = ret == ['ok']
if self.config.get('ignore_result'):
iok = ret or isinstance(ret, list)
cbk = cfg.get('set_callback')
if iok and cbk:
cbk(prop=cfg.get('prop'), config=cfg, setter=setter, params=pms, props=self.miio_props_values)
return {
'code': 0 if iok else 1,
'siid': siid,
'piid': piid,
'result': ret,
}
async def async_call_action(self, device, siid, aiid, params):
key = MiotSpec.unique_prop(siid=siid, aiid=aiid)
cfg = self.specs.get(key, {})
setter = cfg.get('setter')
pms = cv.ensure_list(params)
act = self.miot_spec.specs.get(key)
if act and isinstance(act, MiotAction):
if tpl := cfg.get('set_template'):
tpl = template(tpl, self.hass)
pms = tpl.async_render({
'params': pms,
'props': self.miio_props_values,
}) or []
if isinstance(pms, dict) and 'method' in pms:
setter = pms.get('method', setter)
pms = pms.get('params', [])
pms = cv.ensure_list(pms)
if not setter:
_LOGGER.warning('%s: Call miio method via miot action failed: %s', self.model, [key, setter, cfg])
return None
_LOGGER.info('%s: Call miio method via miot action: %s', self.model, [key, setter, pms])
ret = await device.async_send(setter, pms) or ['']
iok = ret == ['ok']
if self.config.get('ignore_result'):
iok = ret or isinstance(ret, list)
return {
'code': 0 if iok else 1,
'siid': siid,
'aiid': aiid,
'result': ret,
}
def entity_attrs(self):
adt = {}
eas = self.config.get('entity_attrs', [])
if isinstance(eas, list):
eas = {
k: k
for k in eas
}
for k, p in eas.items():
v = self.miio_props_values.get(p)
if v is not None:
adt[k] = v
return adt
def only_miio_props(self, props: list):
rls = []
for p in props:
rls.append(self.miio_props_values.get(p))
return rls
class MiioPropertyHelper:
def __init__(self, prop: MiotProperty, reverse=False):
self.property = prop
self.reverse = reverse
def onoff(self, value):
if self.reverse:
return 'on' if value else 'off'
return value is True or 'on' == f'{value}'.lower()
def rgb(self, value):
is_tuple = isinstance(value, Tuple)
if self.reverse:
if not is_tuple:
value = int_to_rgb(value)
value = list(value)
elif is_tuple:
return rgb_to_int(value)
return value
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,353 @@
# https://github.com/AlexxIT/XiaomiGateway3/blob/02aaddc3c16f63df116a95578f44d5b0da1c4f2c/custom_components/xiaomi_gateway3/core/mini_miio.py
import asyncio
import hashlib
import json
import logging
import random
import socket
import time
from asyncio import DatagramProtocol, Future
from asyncio.protocols import BaseProtocol
from asyncio.transports import DatagramTransport
from typing import Union
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import padding
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
_LOGGER = logging.getLogger(__package__ + ".miio")
HELLO = bytes.fromhex(
"21310020ffffffffffffffffffffffffffffffffffffffffffffffffffffffff"
)
class BasemiIO:
"""A simple class that implements the miIO protocol."""
device_id = None
delta_ts = None
debug = False
def __init__(self, host: str, token: str, timeout: float = 3):
self.addr = (host, 54321)
self.token = bytes.fromhex(token)
self.timeout = timeout
key = hashlib.md5(self.token).digest()
iv = hashlib.md5(key + self.token).digest()
self.cipher = Cipher(
algorithms.AES(key), modes.CBC(iv), backend=default_backend()
)
def _encrypt(self, plaintext: bytes):
padder = padding.PKCS7(128).padder()
padded_plaintext = padder.update(plaintext) + padder.finalize()
encryptor = self.cipher.encryptor()
return encryptor.update(padded_plaintext) + encryptor.finalize()
def _decrypt(self, ciphertext: bytes):
decryptor = self.cipher.decryptor()
padded_plaintext = decryptor.update(ciphertext) + decryptor.finalize()
unpadder = padding.PKCS7(128).unpadder()
return unpadder.update(padded_plaintext) + unpadder.finalize()
def _pack_raw(self, msg_id: int, method: str, params: Union[dict, list] = None):
# latest zero unnecessary
payload = (
json.dumps(
{"id": msg_id, "method": method, "params": params or []},
separators=(",", ":"),
).encode()
+ b"\x00"
)
data = self._encrypt(payload)
raw = b"\x21\x31"
raw += (32 + len(data)).to_bytes(2, "big") # total length
raw += b"\x00\x00\x00\x00" # unknow
raw += self.device_id.to_bytes(4, "big")
raw += int(time.time() - self.delta_ts).to_bytes(4, "big")
raw += hashlib.md5(raw + self.token + data).digest()
raw += data
assert len(raw) < 1024, "Exceeded message size"
return raw
def _unpack_raw(self, raw: bytes):
assert raw[:2] == b"\x21\x31"
# length = int.from_bytes(raw[2:4], 'big')
# unknown = raw[4:8]
# device_id = int.from_bytes(raw[8:12], 'big')
# ts = int.from_bytes(raw[12:16], 'big')
# checksum = raw[16:32]
return self._decrypt(raw[32:])
class SyncMiIO(BasemiIO):
"""Synchronous miIO protocol."""
def ping(self, sock: socket) -> bool:
"""Returns `true` if the connection to the miio device is working. The
token is not verified at this stage.
"""
try:
sock.sendto(HELLO, self.addr)
raw = sock.recv(1024)
if raw[:2] == b"\x21\x31":
self.device_id = int.from_bytes(raw[8:12], "big")
self.delta_ts = time.time() - int.from_bytes(raw[12:16], "big")
return True
except Exception:
pass
return False
def send(self, method: str, params: Union[dict, list] = None):
"""Send command to miIO device and get result from it. Params can be
dict or list depend on command.
"""
pings = 0
for times in range(1, 4):
try:
# create socket every time for reset connection, because we can
# reseive answer on previous request or request from another
# thread
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.settimeout(self.timeout)
# need device_id for send command, can get it from ping cmd
if self.delta_ts is None and not self.ping(sock):
pings += 1
continue
# pack each time for new message id
msg_id = random.randint(100000000, 999999999)
raw_send = self._pack_raw(msg_id, method, params)
t = time.monotonic()
sock.sendto(raw_send, self.addr)
# can receive more than 1024 bytes (1056 approximate maximum)
raw_recv = sock.recv(10240)
t = time.monotonic() - t
data = self._unpack_raw(raw_recv).rstrip(b"\x00")
if data == b"":
# mgl03 fw 1.4.6_0012 without Internet respond on miIO.info
# command with empty answer
data = {"result": ""}
break
data = json.loads(data)
# check if we received response for our cmd
if data["id"] == msg_id:
break
_LOGGER.debug(f"{self.addr[0]} | wrong ID")
except socket.timeout:
_LOGGER.debug(f"{self.addr[0]} | timeout {times}")
except Exception as e:
_LOGGER.debug(f"{self.addr[0]}", exc_info=e)
# init ping again
self.delta_ts = None
else:
_LOGGER.debug(
f"{self.addr[0]} | Device offline"
if pings >= 2
else f"{self.addr[0]} | Can't send {method} {params}"
)
return None
if self.debug:
_LOGGER.debug(
f"{self.addr[0]} | Send {method} {len(raw_send)}B, "
f"recv {len(raw_recv)}B in {t:.1f} sec and {times} try"
)
if "result" in data:
return data["result"]
else:
_LOGGER.debug(f"{self.addr[0]} | {data}")
return None
def send_bulk(self, method: str, params: list):
"""Sends a command with a large number of parameters. Splits into
multiple requests when the size of one request is exceeded.
"""
try:
result = []
# Chunk of 15 is seems like the best size. Because request should
# be lower than 1024 and response should be not more than 1056.
# {'did':'1234567890','siid': 2,'piid': 1,'value': False,'code': 0}
for i in range(0, len(params), 15):
result += self.send(method, params[i : i + 15])
return result
except Exception:
return None
def info(self) -> Union[dict, str, None]:
"""Get info about miIO device.
Response dict - device ok, token ok
Response empty string - device ok, token ok (mgl03 on fw 1.4.6_0012
without cloud connection)
Response None, device_id not None - device ok, token wrong
Response None, device_id None - device offline
"""
return self.send("miIO.info")
# noinspection PyUnusedLocal
class AsyncSocket(DatagramProtocol):
timeout = 0
transport: DatagramTransport = None
response: Future = None
def __init__(self):
self.response = asyncio.get_event_loop().create_future()
def connection_made(self, transport: DatagramTransport):
# _LOGGER.debug(f"connection_made {transport}")
self.transport = transport
def datagram_received(self, data: bytes, addr):
# _LOGGER.debug(f"datagram_received {data}")
self.response.set_result(data)
def settimeout(self, value: int):
self.timeout = value
def sendto(self, data: bytes):
self.transport.sendto(data)
def close(self):
if not self.transport:
return
try:
self.transport.close()
except Exception as e:
_LOGGER.error("Error when closing async socket", exc_info=e)
async def connect(self, addr: tuple[str, int]):
coro = asyncio.get_event_loop().create_datagram_endpoint(
lambda: self, remote_addr=addr
)
if self.timeout:
await asyncio.wait_for(coro, self.timeout)
else:
await coro
async def recv(self, *args):
self.response = asyncio.get_event_loop().create_future()
if self.timeout:
return await asyncio.wait_for(self.response, self.timeout)
return await self.response
# noinspection PyMethodMayBeStatic,PyTypeChecker
class AsyncMiIO(BasemiIO, BaseProtocol):
async def ping(self, sock: AsyncSocket) -> bool:
"""Returns `true` if the connection to the miio device is working. The
token is not verified at this stage.
"""
try:
sock.sendto(HELLO)
raw = await sock.recv(1024)
if raw[:2] == b"\x21\x31":
self.device_id = int.from_bytes(raw[8:12], "big")
self.delta_ts = time.time() - int.from_bytes(raw[12:16], "big")
return True
except Exception:
pass
return False
async def send(self, method: str, params: Union[dict, list] = None, tries=3):
"""Send command to miIO device and get result from it. Params can be
dict or list depend on command.
Possible results:
- None - device offline
- {} (empty dict) - device online but can't response on cmd
- {'id':123,'result':...} - device answered on cmd with good result
- {'id':123,'error':...}
"""
offline = False
for _ in range(0, tries):
sock = AsyncSocket()
sock.settimeout(self.timeout)
try:
# create socket every time for reset connection, because we can
# reseive answer on previous request or request from another
# thread
await sock.connect(self.addr)
# need device_id for send command, can get it from ping cmd
if self.delta_ts is None and not await self.ping(sock):
# device doesn't answered on ping
offline = True
continue
# pack each time for new message id
msg_id = random.randint(100000000, 999999999)
raw_send = self._pack_raw(msg_id, method, params)
sock.sendto(raw_send)
# can receive more than 1024 bytes (1056 approximate maximum)
raw_recv = await sock.recv(10240)
data = self._unpack_raw(raw_recv).rstrip(b"\x00")
if data == b"":
# mgl03 fw 1.4.6_0012 without Internet respond on miIO.info
# command with empty answer
continue
data = json.loads(data)
# check if we received response for our cmd
if data["id"] != msg_id:
_LOGGER.debug(f"{self.addr[0]} | wrong answer ID")
continue
return data
except (asyncio.TimeoutError, OSError):
# OSError: [Errno 101] Network unreachable
pass
except Exception as e:
_LOGGER.debug(f"{self.addr[0]} | {method}", exc_info=e)
finally:
sock.close()
# init ping again
self.delta_ts = None
if offline:
_LOGGER.debug(f"{self.addr[0]} | Device offline")
return None
_LOGGER.debug(f"{self.addr[0]} | No answer on {method} {params}")
return {}
async def send_bulk(self, method: str, params: list, chunk: int = 0) -> list:
"""Sends a command with a large number of parameters. Splits into
multiple requests when the size of one request is exceeded.
"""
if not chunk:
chunk = 15
try:
result = []
for i in range(0, len(params), chunk):
resp = await self.send(method, params[i : i + chunk])
result += resp["result"]
return result
except Exception:
return None
async def info(self, tries: int = 3) -> dict | None:
"""Get info about miIO device."""
resp = await self.send("miIO.info", tries=tries)
return resp.get("result") if resp else resp
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,197 @@
from homeassistant.helpers.template import Template
CUSTOM_TEMPLATES = {
# https://iot.mi.com/new/doc/accesses/direct-access/embedded-development/ble/object-definition#%E7%89%99%E5%88%B7%E4%BA%8B%E4%BB%B6
'ble_toothbrush_events': "{%- set dat = props.get('event.16') | default('{}',true) | from_json %}"
"{%- set tim = dat.timestamp | default(0,true) | timestamp_local %}"
"{%- set val = dat.get('value',[]).0 | default('0000') %}"
"{%- set typ = val[0:2] | int(0,16) %}"
"{%- set num = val[2:4] | int(0,16) %}"
"{{ {"
"'event': 'start',"
"'counter': num,"
"'timestamp': tim,"
"} if typ == 0 else {"
"'event': 'finish',"
"'score': num,"
"'timestamp': tim,"
"} }}",
# https://iot.mi.com/new/doc/accesses/direct-access/embedded-development/ble/object-definition#%E9%94%81%E4%BA%8B%E4%BB%B6
'ble_lock_events': "{%- set mark_data = props.get('event.6') | default('{}',true) | from_json %}"
"{%- set mark = mark_data.get('value',[]).0 | default('') %}"
"{%- set door_data = props.get('event.7') | default('{}',true) | from_json %}"
"{%- set door = (door_data.get('value',[]).0 | default(''))[:2] | int(-1,16) %}"
"{%- set fang_data = props.get('event.8') | default('{}',true) | from_json %}"
"{%- set fang = (fang_data.get('value',[]).0 | default(''))[:2] | int(-1,16) %}"
"{%- set lock_data = props.get('event.11') | default('{}',true) | from_json %}"
"{%- set lock = lock_data.get('value',[]).0 | default('') %}"
"{%- set lock_action = lock[:2] | int(-1,16) % 16 %}"
"{%- set lock_method = lock[:2] | int(-1,16) // 16 %}"
"{%- set lock_key = lock[2:10] %}"
"{%- set lock_fault = lock_key if lock_key[:4] == 'c0de' else None %}"
"{%- set key_id = (0).from_bytes((0).to_bytes(0,'little').fromhex(lock_key),'little') %}"
"{%- set key_types = {'00000000':'admin','ffffffff':'unknown','deadbeef':'invalid'} %}"
"{%- set key_results = ['success','fail','timeout','blurry','less','dry','wet'] %}"
"{%- set door_states = ['open','close','close_timeout','knock','breaking','stuck'] %}"
"{%- set lock_actions = ['outside_unlock','lock','anti_lock_on','anti_lock_off',"
"'inside_unlock','lock_inside','child_lock_on','child_lock_off','lock_outside'] %}"
"{%- set lock_methods = ['bluetooth','password','biological','key','turntable','nfc',"
"'one_time_password','two_step_verification','coercion','homekit','manual','automatic'] %}"
"{{ {"
"'fingerprint_id': key_types[mark[:8]] | default(mark[:8]),"
"'fingerprint_result': key_results[mark[8:10] | int(-1,16)] | default('unknown'),"
"'door_state': door_states[door] | default('unknown'),"
"'armed_state': true if fang > 0 else false,"
"'lock_action': lock_actions[lock_action] | default('unknown'),"
"'lock_method': lock_methods[lock_method] | default('unknown'),"
"'lock_key': key_types[lock_key] | default(lock_key),"
"'lock_key_id': key_id,"
"'lock_fault': lock_fault | default('none',true),"
"'lock_data': lock,"
"'timestamp': lock_data.timestamp | default(0,true) | timestamp_local,"
"} }}",
# https://iot.mi.com/new/doc/accesses/direct-access/embedded-development/ble/object-definition#%E7%83%9F%E9%9B%BE%E5%B1%9E%E6%80%A7
'ble_sensor_smoke': "{%- set val = props.get('prop.4117','00') | int(0,16) %}"
"{{ {"
"'smoke_status': val == 1,"
"} }}",
'chuangmi_plug_v3_power_cost': "{%- set val = (result.0 | default({})).get('value',{}) %}"
"{%- set day = now().day %}"
"{{ {"
"'today': val.pc | default(0),"
"'today_duration': val.pc_time | default(0),"
"'month': result[:day] | sum(attribute='value.pc'),"
"'month_duration': result[:day] | sum(attribute='value.pc_time'),"
"} }}",
'lock_event_7_template': "{%- set val = (result.0 | default({})).get('value','[-1]') %}"
"{%- set val = (val | from_json).0 | string %}"
"{%- set evt = val[:2] | int(-1,16) %}"
"{%- set els = ['open','close','close_timeout',"
"'knock','breaking','stuck','unknown'] %}"
"{{ {"
"'door_event': evt,"
"'door_state': els[evt] | default('unknown'),"
"} }}",
'lock_event_11_template': "{%- set val = (result.0 | default({})).get('value','[-1]') %}"
"{%- set val = (val | from_json).0 | string %}"
"{%- set evt = val[:2] | int(-1,16) % 16 %}"
"{%- set how = val[:2] | int(-1,16) // 16 %}"
"{%- set key = (0).from_bytes((0).to_bytes(0,'little')"
".fromhex(val[2:10]), 'little') %}"
"{%- set els = ['outside_unlock','lock','anti_lock_on','anti_lock_off',"
"'inside_unlock','lock_inside','child_lock_on','child_lock_off','unknown'] %}"
"{%- set mls = ['bluetooth','password','biological','key','turntable',"
"'nfc','one_time_password','two_step_verification','coercion','homekit',"
"'manual','automatic','unknown'] %}"
"{{ {"
"'lock_event': evt,"
"'lock_state': els[evt] | default('unknown'),"
"'method_id': how,"
"'method': mls[how] | default('unknown'),"
"'key_id': key,"
"} }}",
'lumi_acpartner_electric_power': "{%- set val = props.get('prop.load_power') or props.get('prop.ac_power',0) %}"
"{{ {"
"'electric_power': val | default(0,true) | round(2),"
"} }}",
'lumi_acpartner_miio_status': "{%- set model = results[0] | default('') %}"
"{%- set state = results[1] | default('') %}"
"{{ {"
"'power': state[2:3] | int(0) == 1,"
"'mode': [3,1,0,2,4][state[3:4] | int(2)],"
"'fan_level': [1,2,3,0][state[4:5] | int(3)],"
"'vertical_swing': state[5:6] in ['0','C'],"
"'target_temperature': state[6:8] | int(0,16),"
"'load_power': results[2] | default(0) | float | round(2),"
"} }}",
'micloud_statistics_power_cost': "{%- set dat = namespace(today=0,month=0) %}"
"{%- set tim = now() %}"
"{%- set stm = tim - timedelta(minutes=tim.minute,seconds=tim.second) %}"
"{%- set tod = stm - timedelta(hours=tim.hour) %}"
"{%- set stm = tod - timedelta(days=tim.day-1) %}"
"{%- set tod = tod | as_timestamp | int(0) %}"
"{%- set stm = stm | as_timestamp | int(0) %}"
"{%- for d in (result or []) %}"
"{%- set t = d.time | default(0) | int(0) %}"
"{%- if t >= stm %}"
"{%- set v = (d.value | default('[]') | string | from_json) or [] %}"
"{%- set n = v[0] | default(0) %}"
"{%- if t >= tod %}"
"{%- set dat.today = n %}"
"{%- endif %}"
"{%- set dat.month = dat.month + n %}"
"{%- endif %}"
"{%- endfor %}"
"{{ {"
"'power_cost_today': dat.today | round(3),"
"'power_cost_month': dat.month | round(3),"
"} }}",
'midr_rv_mirror_cloud_props': "{%- set sta = props.get('prop.Status',0) | int %}"
"{%- set pos = props.get('prop.Position','{}') | from_json %}"
"{%- set tim = pos.get('up_time_stamp',0) | int %}"
"{{ {"
"'prop.status': sta,"
"'prop.position': pos,"
"'prop.update_at': (tim / 1000) | timestamp_local,"
"} }}",
'mxiang_cateye_cloud_props': "{{ {"
"'battery_level': props.get('prop.battery_level','0') | from_json | int,"
"'is_can_open_video': props.get('prop.is_can_open_video','0') | from_json | int,"
"} }}",
'mxiang_cateye_human_visit_details': "{%- set val = (result.0 | default({})).get('value','{}') %}"
"{%- set val = val | from_json %}"
"{{ {"
"'motion_video_time': val.get('visitTime',0) | timestamp_local,"
"'motion_video_type': val.get('action'),"
"'motion_video_latest': val,"
"'_entity_attrs': True,"
"} }}",
'yeelink_bhf_light_v2_fan_levels': "{%- set val = ('00000' ~ value)[-5:] %}"
"{%- set mds = {"
"'drying_cloth': val[0],"
"'coolwind': val[1],"
"'drying': val[2],"
"'venting': val[3],"
"'warmwind': val[4],"
"} %}"
"{{ [1,2,3][mds[props.bh_mode] | default(0) | int(0)] | default(3) }}",
'yeelink_bhf_light_v5_fan_levels': "{%- set val = ('000' ~ value)[-3:] %}"
"{%- set mds = {"
"'warmwind': val[0],"
"'coolwind': val[1],"
"'venting': val[2],"
"} %}"
"{{ [1,1,3,3][mds[props.bh_mode] | default(0) | int(0)] | default(1) }}",
'yeelink_bhf_light_v5_miio_props': "{%- set val = ('000' ~ props.fan_speed_idx)[-3:] %}"
"{{ {"
"'warmwind_gear': val[0],"
"'coolwind_gear': val[1],"
"'venting_gear': val[2],"
"} }}",
'zimi_powerstrip_v2_power_cost': "{%- set val = (result.0 | default({})).get('value','[0]') %}"
"{%- set day = now().day %}"
"{%- set vls = (val | from_json)[0-day:] %}"
"{%- set dat = namespace(today=0,month=0) %}"
"{%- for v in vls %}"
"{%- set v = (v | string).split(',') %}"
"{%- if v[0] | default(0) | int(0) > 86400 %}"
"{%- set dat.today = v[1] | default(0) | round(3) %}"
"{%- set dat.month = dat.month + dat.today %}"
"{%- endif %}"
"{%- endfor %}"
"{{ {"
"'today': dat.today,"
"'month': dat.month | round(3),"
"} }}",
}
def template(value, hass):
if value is None:
raise ValueError('template value is None')
if isinstance(value, (list, dict, Template)):
raise TypeError('template value should be a string')
value = CUSTOM_TEMPLATES.get(value, value)
template_value = Template(str(value), hass)
template_value.ensure_valid()
return template_value
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,369 @@
import os
import re
import json
import locale
import aiohttp
import asyncio
import tzlocal
import logging
import fnmatch
import voluptuous as vol
from typing import Type, Tuple, Optional, Callable, Set
from functools import wraps
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import Entity
from homeassistant.util import language as language_util
from homeassistant.util.dt import DEFAULT_TIME_ZONE, get_time_zone
from homeassistant.helpers.aiohttp_client import async_get_clientsession
import homeassistant.helpers.config_validation as cv
from .const import DOMAIN, DEVICE_CUSTOMIZES, DATA_CUSTOMIZE
from .translation_languages import TRANSLATION_LANGUAGES
def get_value(obj, key, def_value=None, sep='.'):
keys = f'{key}'.split(sep)
result = obj
for k in keys:
if result is None:
return None
if isinstance(result, dict):
result = result.get(k, def_value)
elif isinstance(result, (list, tuple)):
try:
result = result[int(k)]
except Exception:
result = def_value
return result
def get_customize_via_model(model, key=None, default=None):
cfg = {}
for m in wildcard_models(model):
cus = DEVICE_CUSTOMIZES.get(m) or {}
if key is not None and key not in cus:
continue
if cus:
cfg = {**cus, **cfg}
return cfg if key is None else cfg.get(key, default)
def get_customize_via_entity(entity, key=None, default=None):
if key is None:
default = {}
if not isinstance(entity, Entity):
return default
cfg = {}
if entity.hass and entity.entity_id:
cfg = {
**(entity.hass.data[DATA_CUSTOMIZE].get(entity.entity_id) or {}),
**(entity.hass.data[DOMAIN].get(DATA_CUSTOMIZE, {}).get(entity.entity_id) or {}),
}
if key is not None and key in cfg:
return cfg.get(key)
mls = []
if model := getattr(entity, 'model', None):
if hasattr(entity, 'customize_keys'):
mls.extend(entity.customize_keys)
mls.append(model)
for mod in mls:
cus = get_customize_via_model(mod)
cfg = {**cus, **cfg}
return cfg if key is None else cfg.get(key, default)
class CustomConfigHelper:
def custom_config(self, key=None, default=None):
raise NotImplementedError
def custom_config_bool(self, key=None, default=None):
val = self.custom_config(key, default)
try:
val = cv.boolean(val)
except vol.Invalid:
val = default
return val
def custom_config_number(self, key=None, default=None):
num = default
val = self.custom_config(key)
if val is not None:
try:
num = float(f'{val}')
except (TypeError, ValueError):
num = default
return num
def custom_config_integer(self, key=None, default=None):
num = self.custom_config_number(key, default)
if num is not None:
num = int(num)
return num
def custom_config_list(self, key=None, default=None):
lst = self.custom_config(key)
if lst is None:
return default
if isinstance(lst, (int, float, bool)):
lst = [lst]
elif not isinstance(lst, list):
lst = f'{lst}'.split(',')
lst = list(map(lambda x: x.strip(), lst))
return lst
def custom_config_json(self, key=None, default=None):
dic = self.custom_config(key)
if dic:
if not isinstance(dic, (dict, list)):
try:
dic = json.loads(dic or '{}')
except (TypeError, ValueError):
dic = None
if isinstance(dic, (dict, list)):
return dic
return default
def get_manifest(field=None, default=None):
manifest = {}
with open(f'{os.path.dirname(__file__)}/../manifest.json') as fil:
manifest = json.load(fil) or {}
return manifest.get(field, default) if field else manifest
async def async_get_manifest(hass: HomeAssistant, field=None, default=None):
return await hass.async_add_executor_job(get_manifest, field, default)
def local_zone(hass=None):
try:
if isinstance(hass, HomeAssistant):
return get_time_zone(hass.config.time_zone)
return tzlocal.get_localzone()
except KeyError:
pass
return DEFAULT_TIME_ZONE
def in_china(hass=None):
if isinstance(hass, HomeAssistant):
if hass.config.time_zone in ['Asia/Shanghai', 'Asia/Hong_Kong']:
return True
try:
return f'{locale.getdefaultlocale()[0]}'[:3] == 'zh_'
except (KeyError, Exception):
pass
return False
def wildcard_models(model):
if not model:
return []
if ':' in model:
return [model]
wil = re.sub(r'\.[^.]+$', '.*', model)
return [
model,
wil,
re.sub(r'^[^.]+\.', '*.', wil),
'*',
]
def convert_globs_to_pattern(globs: list[str] | None):
"""Convert a list of globs to a re pattern list."""
if not globs:
return None
translated_patterns = [
pattern for glob in set(globs) if (pattern := fnmatch.translate(str(glob)))
]
if not translated_patterns:
return None
inner = '|'.join(translated_patterns)
combined = f'(?:{inner})'
return re.compile(combined)
def get_translation(key, keys=None):
dic = get_translations(*(keys or []))
val = dic.get(key, key)
if isinstance(val, str):
return val
return key
def get_translations(*keys):
dic = {
**TRANSLATION_LANGUAGES,
**(TRANSLATION_LANGUAGES.get('_globals', {})),
}
for k in keys:
tls = TRANSLATION_LANGUAGES.get(k)
if not isinstance(tls, dict):
continue
dic.update(tls)
return dic
def get_translation_langs(hass: HomeAssistant, langs=None):
lang = hass.config.language
if not langs:
return [lang]
if 'en' not in langs:
langs.append('en')
return language_util.matches(lang, langs)
class DeviceException(Exception):
"""Exception wrapping any communication errors with the device."""
def is_offline_exception(exc):
err = f'{exc}'
ret = 'Unable to discover the device' in err
if not ret:
ret = 'No response from the device' in err
if not ret:
ret = 'OSError: [Errno 64] Host is down' in err
if not ret:
ret = 'OSError: [Errno 65] No route to host' in err
return ret
def update_attrs_with_suffix(attrs, new_dict):
updated_attrs = {}
for key, value in new_dict.items():
if key in attrs:
suffix = 2
while f"{key}_{suffix}" in attrs:
suffix += 1
updated_key = f"{key}_{suffix}"
else:
updated_key = key
updated_attrs[updated_key] = value
attrs.update(updated_attrs)
def logger_filter(record):
def sub(msg):
return re.sub(r'[\w/+-]{30,}', '***', msg)
if isinstance(record, str):
return sub(record)
if isinstance(record, logging.LogRecord):
record.msg = sub(record.getMessage())
return True
return record
def int_to_rgb(x: int) -> Tuple[int, int, int]:
"""Return a RGB tuple from integer."""
red = (x >> 16) & 0xFF
green = (x >> 8) & 0xFF
blue = x & 0xFF
return red, green, blue
def rgb_to_int(x: Tuple[int, int, int]) -> int:
"""Return an integer from RGB tuple."""
return int(x[0] << 16 | x[1] << 8 | x[2])
def aiohttp_retry(
max_retries: int = 3,
backoff_factor: float = 2.0,
retry_on_status: Optional[Set[int]] = None,
exceptions: Tuple[Type[BaseException], ...] = (aiohttp.ClientError, asyncio.TimeoutError),
logger=None,
):
if retry_on_status is None:
retry_on_status = {500, 502, 503, 504}
def decorator(func: Callable):
@wraps(func)
async def wrapper(*args, **kwargs):
last_exception = None
for attempt in range(max_retries + 1):
try:
return await func(*args, **kwargs)
except exceptions as exc:
if logger:
logger.warning(
"Error while executing request from %s, attempt: %s",
func.__name__, attempt, exc_info=True,
)
last_exception = exc
if attempt == max_retries:
break
is_retryable_status = isinstance(exc, aiohttp.ClientConnectorDNSError)
if isinstance(exc, aiohttp.ClientResponseError) and exc.status in retry_on_status:
is_retryable_status = True
if not is_retryable_status:
break
delay = backoff_factor * (2 ** attempt)
await asyncio.sleep(delay)
if last_exception:
raise last_exception
return wrapper
return decorator
async def async_analytics_track_event(hass: HomeAssistant, event, action, label, value=0, **kwargs):
pms = {
'model': label,
'event': event,
'action': action,
'label': label,
'value': value,
'tz': hass.config.time_zone,
'ver': await async_get_manifest(hass, 'version', ''),
**kwargs,
}
url = 'https://hacc.miot-spec.com/api/track'
try:
session = async_get_clientsession(hass)
return await session.post(url, data=pms, timeout=3)
except (Exception, ValueError):
return False
class RC4:
_idx = 0
_jdx = 0
_ksa: list
def __init__(self, pwd):
self.init_key(pwd)
def init_key(self, pwd):
cnt = len(pwd)
ksa = list(range(256))
j = 0
for i in range(256):
j = (j + ksa[i] + pwd[i % cnt]) & 255
ksa[i], ksa[j] = ksa[j], ksa[i]
self._ksa = ksa
self._idx = 0
self._jdx = 0
return self
def crypt(self, data):
if isinstance(data, str):
data = data.encode()
ksa = self._ksa
i = self._idx
j = self._jdx
out = []
for byt in data:
i = (i + 1) & 255
j = (j + ksa[i]) & 255
ksa[i], ksa[j] = ksa[j], ksa[i]
out.append(byt ^ ksa[(ksa[i] + ksa[j]) & 255])
self._idx = i
self._jdx = j
self._ksa = ksa
return bytearray(out)
def init1024(self):
self.crypt(bytes(1024))
return self
@@ -0,0 +1,954 @@
import logging
import aiohttp
import asyncio
import json
import time
import string
import random
import base64
import locale
import hashlib
import micloud
import requests
from datetime import datetime
from functools import partial
from typing import Optional
from urllib import parse
from homeassistant.const import (
CONF_PASSWORD,
CONF_USERNAME,
)
from homeassistant.helpers.storage import Store
from homeassistant.helpers.aiohttp_client import async_create_clientsession
from homeassistant.components import persistent_notification
from .const import DOMAIN, CONF_XIAOMI_CLOUD
from .utils import RC4, aiohttp_retry, local_zone, logger_filter
from micloud import miutils
from micloud.micloudexception import MiCloudException
try:
from micloud.micloudexception import MiCloudAccessDenied
except (ModuleNotFoundError, ImportError):
class MiCloudAccessDenied(MiCloudException):
""" micloud==0.4 """
_LOGGER = logging.getLogger(__name__)
_LOGGER.addFilter(logger_filter)
ACCOUNT_BASE = 'https://account.xiaomi.com'
UA = "Android-7.1.1-1.0.0-ONEPLUS A3010-136-%s APP/xiaomi.smarthome APPV/62830"
class MiotCloud(micloud.MiCloud):
user_id = None
cuser_id = None
ssecurity = None
pass_token = None
service_token = None
failed_logins = 0
session = None
async_session: Optional[aiohttp.ClientSession] = None
def __init__(self, hass, username, password, country=None, sid=None):
try:
super().__init__(username, password)
timezone = datetime.now(local_zone(hass)).strftime('%z')
self.timezone = 'GMT{0}:{1}'.format(timezone[:-2], timezone[-2:])
self.locale = locale.getlocale()[0]
except Exception:
self.timezone = 'GMT+08:00'
self.locale = 'zh_CN'
self.hass = hass
self.username = username
self.password = password
self.default_server = country or 'cn'
self.sid = sid or 'xiaomiio'
self.agent_id = self.get_random_string(16)
self.client_id = self.agent_id
self.useragent = UA % self.client_id
self.http_timeout = int(hass.data[DOMAIN].get('config', {}).get('http_timeout') or 10)
self.login_times = 0
self.cookies = {}
self.attrs = {}
@property
def unique_id(self):
uid = self.user_id or self.username
return f'{uid}-{self.default_server}-{self.sid}'
async def async_get_properties_for_mapping(self, did, mapping: dict):
pms = []
rmp = {}
for k, v in mapping.items():
if not isinstance(v, dict):
continue
s = v.get('siid')
p = v.get('piid')
pms.append({'did': str(did), 'siid': s, 'piid': p})
rmp[f'prop.{s}.{p}'] = k
rls = await self.async_get_props(pms)
if not rls:
return None
dls = []
for v in rls:
s = v.get('siid')
p = v.get('piid')
k = rmp.get(f'prop.{s}.{p}')
if not k:
continue
v['prop'] = k
dls.append(v)
return dls
async def async_get_props(self, params=None):
return await self.async_request_miot_spec('prop/get', params)
@aiohttp_retry(3, logger=_LOGGER)
async def async_set_props(self, params=None):
return await self.async_request_miot_spec('prop/set', params, timeout=5, raise_timeout=True)
async def async_do_action(self, params=None):
return await self.async_request_miot_spec('action', params)
async def async_request_miot_spec(self, api, params=None, **kwargs):
resp = await self.async_request_api('miotspec/' + api, {
'params': params or [],
}, **kwargs) or {}
result = resp.get('result')
if not result and self.is_token_expired(resp):
now = int(time.time())
tim = self.attrs.get('last_relogin_time', 0)
if now - tim > 600:
self.attrs['last_relogin_time'] = now
if await self.async_check_auth(notify=True):
return await self.async_request_miot_spec(api, params, **kwargs)
raise MiCloudException(json.dumps(resp, ensure_ascii=False))
if not result and resp.get('code'):
raise MiCloudException(json.dumps(resp, ensure_ascii=False))
return result
async def async_get_user_device_data(self, did, key, typ='prop', raw=False, **kwargs):
now = int(time.time())
timeout = kwargs.pop('timeout', self.http_timeout)
params = {
'did': did,
'key': key,
'type': typ,
'time_start': now - 86400 * 7,
'time_end': now + 60,
'limit': 5,
**kwargs,
}
rdt = await self.async_request_api('user/get_user_device_data', params, timeout=timeout) or {}
return rdt if raw else rdt.get('result')
async def async_get_last_device_data(self, did, key, typ='prop', **kwargs):
kwargs['raw'] = False
kwargs['limit'] = 1
rls = await self.async_get_user_device_data(did, key, typ, **kwargs) or [None]
rdt = rls.pop(0) or {}
if kwargs.get('not_value'):
return rdt
val = rdt.get('value')
if val is None:
return None
try:
vls = json.loads(val)
except (TypeError, ValueError):
vls = [val]
return vls.pop(0)
async def async_check_auth(self, notify=False):
if self.service_token:
api = 'v2/message/v2/check_new_msg'
dat = {
'begin_at': int(time.time()) - 60,
}
try:
rdt = await self.async_request_api(api, dat, method='POST') or {}
if not self.is_token_expired(rdt):
return True
except requests.exceptions.ConnectionError:
return None
except requests.exceptions.Timeout:
return None
# auth err
_LOGGER.info('Xiaomi auth failed, try relogin. %s', rdt)
nid = f'xiaomi-miot-auth-warning-{self.user_id}'
need_verify = None
try:
if await self.async_relogin():
persistent_notification.dismiss(self.hass, nid)
return True
except MiCloudNeedVerify as exc:
need_verify = exc
if notify:
lnk = f'/config/integrations/integration/{DOMAIN}'
persistent_notification.create(
self.hass,
f'Xiaomi account: {self.user_id} auth failed, '
f'Your Xiaomi account login status has expired, Please login again through [integrated configuration]({lnk}).\n'
f'你的小米账号登陆状态已失效,但本次需要手动验证,请通过[集成配置]({lnk})重新登陆。',
'Xiaomi Miot Warning',
nid,
)
_LOGGER.error(
'Xiaomi account: %s auth failed, Please update option for this integration to refresh token.\n%s',
self.user_id,
rdt,
)
elif need_verify:
raise need_verify
else:
_LOGGER.warning('Retry login xiaomi account failed: %s', self.username)
return False
def is_token_expired(self, data: dict):
eno = data.get('code', 0)
msg = data.get('message', '')
if eno in [2, 3]:
return True
elif 'auth err' in msg:
return True
elif msg in ['invalid signature', 'SERVICETOKEN_EXPIRED']:
return True
return False
async def async_request_api(self, api, data, method='POST', crypt=True, debug=True, **kwargs):
if not self.service_token:
await self.async_login()
params = {}
if data is not None:
params['data'] = self.json_encode(data)
raise_timeout = kwargs.pop('raise_timeout', None)
raw = kwargs.pop('raw', self.sid != 'xiaomiio')
rsp = None
try:
if raw:
rsp = await self.hass.async_add_executor_job(
partial(self.request_raw, api, data, method, **kwargs)
)
elif crypt:
rsp = await self.async_request_rc4_api(api, params, method, **kwargs)
else:
rsp = await self.hass.async_add_executor_job(
partial(self.request, self.get_api_url(api), params, **kwargs)
)
rdt = json.loads(rsp)
if debug:
_LOGGER.debug(
'Request miot api: %s %s result: %s',
api, data, rsp,
)
self.attrs['timeouts'] = 0
except asyncio.TimeoutError as exc:
if raise_timeout:
raise exc
rdt = None
self.attrs.setdefault('timeouts', 0)
self.attrs['timeouts'] += 1
if 5 < self.attrs['timeouts'] <= 10:
_LOGGER.error('Request xiaomi api: %s %s timeout, exception: %s', api, data or {}, exc)
elif self.attrs['timeouts'] <= 5:
_LOGGER.warning('Request xiaomi api: %s timeout (%s times)', api, self.attrs['timeouts'])
except asyncio.exceptions.CancelledError as exc:
rdt = None
_LOGGER.warning('Request xiaomi api: %s was cancelled, likely due to timeout: %s', api, exc)
except (TypeError, ValueError):
rdt = None
code = rdt.get('code') if rdt else None
if code == 3:
self._logout()
_LOGGER.warning('Unauthorized while request to %s, response: %s, logged out.', api, rsp)
elif code or not rdt:
fun = _LOGGER.info if rdt else _LOGGER.warning
fun('Request xiaomi api: %s %s failed, response: %s', api, data, rsp)
return rdt
async def async_get_device(self, mac=None, host=None):
dvs = await self.async_get_devices() or []
for d in dvs:
if not isinstance(d, dict):
continue
if mac and mac == d.get('mac'):
return d
if host and host == d.get('localip'):
return d
return None
async def get_device_list(self):
rdt = await self.async_request_api('home/device_list', {
'getVirtualModel': True,
'getHuamiDevices': 1,
'get_split_device': False,
'support_smart_home': True,
}, debug=False, timeout=60) or {}
result = rdt.get('result')
if result:
return result['list']
_LOGGER.warning('Got xiaomi devices for %s failed: %s', self.username, rdt)
return None
async def get_all_devices(self, homes=None):
devices = {
d['did']: d
for d in await self.get_device_list() or []
}
if not isinstance(homes, list):
return await self.get_device_list() or []
for home in homes:
hid = int(home.get('id', 0))
uid = int(home.get('uid', 0))
start_did = ''
has_more = True
while has_more:
rdt = await self.async_request_api('v2/home/home_device_list', {
'home_owner': uid,
'home_id': hid,
'limit': 300,
'start_did': start_did,
'get_split_device': False,
'support_smart_home': True,
'get_cariot_device': True,
'get_third_device': True,
}, debug=False, timeout=20) or {}
result = rdt.get('result') or {}
if not result:
_LOGGER.warning('Got xiaomi devices for %s failed: %s', self.username, rdt)
for d in result.get('device_info') or []:
did = d.get('did')
devices.setdefault(did, {}).update(d)
start_did = result.get('max_did') or ''
has_more = result.get('has_more') and start_did
return list(devices.values())
async def get_home_devices(self):
rdt = await self.async_request_api('v2/homeroom/gethome_merged', {
'fg': True,
'fetch_share': True,
'fetch_share_dev': True,
'fetch_cariot': True,
'limit': 300,
'app_ver': 7,
'plat_form': 0,
}, debug=False, timeout=60) or {}
result = rdt.get('result') or {}
if not result:
_LOGGER.warning('Got xiaomi home devices for %s failed: %s', self.username, rdt)
devices = result.setdefault('devices', {})
for h in result.get('homelist', []):
for r in h.get('roomlist', []):
for did in r.get('dids', []):
devices[did] = {
'home_id': h.get('id'),
'room_id': r.get('id'),
'home_name': h.get('name'),
'room_name': r.get('name'),
}
return result
async def async_get_devices(self, renew=False, return_all=False):
if not self.user_id:
return None
fnm = f'xiaomi_miot/devices-{self.user_id}-{self.default_server}.json'
store = Store(self.hass, 1, fnm)
now = time.time()
cds = []
dvs = []
try:
dat = await store.async_load() or {}
except ValueError:
await store.async_remove()
dat = {}
if isinstance(dat, dict):
cds = dat.get('devices') or []
if not renew and dat.get('update_time', 0) > (now - 86400):
dvs = cds
if not dvs:
try:
hls = await self.get_home_devices()
dvs = await self.get_all_devices(hls.get('homelist', []))
if dvs:
if hls:
hds = hls.get('devices') or {}
dvs = [
{**d, **(hds.get(d.get('did')) or {})}
for d in dvs
]
dat = {
'update_time': now,
'devices': dvs,
'homes': hls.get('homelist', []),
}
await store.async_save(dat)
_LOGGER.info('Got %s devices from xiaomi cloud', len(dvs))
except requests.exceptions.ConnectionError as exc:
if not cds:
raise exc
dvs = cds
_LOGGER.warning('Get xiaomi devices filed: %s, use cached %s devices.', exc, len(cds))
if return_all:
return dat
return dvs
async def async_renew_devices(self):
return await self.async_get_devices(renew=True)
async def async_get_devices_by_key(self, key, renew=False, filters=None):
dat = {}
if filters is None:
filters = {}
fls = ['ssid', 'bssid', 'home_id', 'model', 'did']
dvs = await self.async_get_devices(renew=renew) or []
for d in dvs:
if not isinstance(d, dict):
continue
if self.is_hide(d):
continue
if not d.get('mac'):
d['mac'] = d.get('did')
k = d.get(key)
for f in fls:
ft = filters.get(f'filter_{f}')
if not ft:
continue
ex = ft != 'include'
fl = filters.get(f'{f}_list') or {}
fv = d.get(f)
if ex:
ok = fv not in fl
else:
ok = fv in fl
if not ok:
k = None
if k:
dat[k] = d
return dat
async def async_get_homerooms(self, renew=False):
dat = await self.async_get_devices(renew=renew, return_all=True) or {}
return dat.get('homes') or []
async def async_get_beaconkey(self, did):
dat = {'did': did, 'pdid': 1}
rdt = await self.async_request_api('v2/device/blt_get_beaconkey', dat) or {}
return rdt.get('result', rdt)
@staticmethod
def is_hide(d):
did = d.get('did', '')
pid = d.get('pid', '')
if pid == '21':
prt = d.get('parent_id')
if prt and prt in did:
# issues/263
return True
return False
async def async_login(self, login_data=None):
if self.login_times > 5:
await self.async_stored_auth(remove=True)
if self.login_times > 10:
raise MiCloudException(
'Too many failures when login to Xiaomi, '
'please reload/config xiaomi_miot component.'
)
self.login_times += 1
ret = await self.hass.async_add_executor_job(self._login_request, login_data)
if ret:
self.hass.data[DOMAIN]['sessions'][self.unique_id] = self
await self.async_stored_auth(save=True)
self.login_times = 0
return ret
async def async_relogin(self):
self._logout()
return await self.async_login()
def _logout(self):
self.service_token = None
self.async_session = None
def _login_request(self, login_data=None):
self._init_session(True)
location = ''
auth = self.attrs.pop('login_data', {})
if not login_data:
pass
elif ticket := login_data.get('verify_ticket'):
resp = self.verify_ticket(ticket)
location = resp.get('location', '')
if location:
self.account_get(location, allow_redirects=True)
auth = self._login_step1()
location = auth.get('location', '')
elif auth:
auth.update(login_data)
else:
auth = self._login_step1()
if not location:
location = self._login_step2(**auth)
response = self._login_step3(location)
http_code = response.status_code
if http_code == 200:
return True
elif http_code == 403:
raise MiCloudAccessDenied(f'Login to xiaomi error: {response.text} ({http_code})')
else:
_LOGGER.error(
'Xiaomi login request returned status %s, reason: %s, content: %s',
http_code, response.reason, response.text,
)
raise MiCloudException(f'Login to xiaomi error: {response.text} ({http_code})')
def _login_step1(self):
self.cookies.update({'sdkVersion': '3.8.6', 'deviceId': self.client_id})
try:
auth = self.account_get(
'/pass/serviceLogin',
params={'sid': self.sid, '_json': 'true'},
headers={'User-Agent': self.useragent},
)
except Exception as exc:
raise MiCloudException(f'Error getting xiaomi login sign. Cannot parse response. {exc}')
if auth.get('code') == 0:
self.user_id = auth.get('userId', self.user_id)
self.cuser_id = auth.get('cUserId', self.cuser_id)
self.ssecurity = auth.get('ssecurity', self.ssecurity)
self.pass_token = auth.get('passToken', self.pass_token)
self.async_session = None
return auth
def _login_step2(self, captcha=None, **kwargs):
url = '/pass/serviceLoginAuth2'
post = {
'user': self.username,
'hash': hashlib.md5(self.password.encode()).hexdigest().upper(),
'callback': kwargs.get('callback') or '',
'sid': kwargs.get('sid') or self.sid,
'qs': kwargs.get('qs') or '',
'_sign': kwargs.get('_sign') or '',
}
params = {'_json': 'true'}
cookies = {}
if captcha:
post['captCode'] = captcha
params['_dc'] = int(time.time() * 1000)
cookies['ick'] = self.attrs.pop('captchaIck', '')
response = self.account_post(url, data=post, params=params, cookies=cookies, response=True)
auth = self.json_decode(response.text) or {}
code = auth.get('code')
# 20003 InvalidUserNameException
# 22009 PackageNameDeniedException
# 70002 InvalidCredentialException
# 70016 InvalidCredentialException with captchaUrl / Password error
# 81003 NeedVerificationException
# 87001 InvalidResponseException captCode error
# other NeedCaptchaException
location = auth.get('location')
if not location:
if ntf := auth.get('notificationUrl'):
if ntf[:4] != 'http':
ntf = f'{ACCOUNT_BASE}{ntf}'
self.attrs['verify_url'] = ntf
raise MiCloudNeedVerify('need_verify').with_url(ntf)
if cap := auth.get('captchaUrl'):
if cap[:4] != 'http':
cap = f'{ACCOUNT_BASE}{cap}'
if self._get_captcha(cap):
self.attrs['login_data'] = kwargs
_LOGGER.error(
'Xiaomi serviceLoginAuth2: %s' %
[url, self.login_times, {**post, 'hash': '*'}, cookies, response.text],
)
raise MiCloudAccessDenied(f'Login to xiaomi error: {response.text}')
self.user_id = str(auth.get('userId', ''))
self.cuser_id = auth.get('cUserId')
self.ssecurity = auth.get('ssecurity')
self.pass_token = auth.get('passToken')
if self.sid != 'xiaomiio':
sign = f'nonce={auth.get("nonce")}&{auth.get("ssecurity")}'
sign = hashlib.sha1(sign.encode()).digest()
sign = base64.b64encode(sign).decode()
location += '&clientSign=' + parse.quote(sign)
_LOGGER.info('Xiaomi serviceLoginAuth2: %s', [auth, self.cookies])
return location
def _login_step3(self, location):
self.session.headers.update({'content-type': 'application/x-www-form-urlencoded'})
response = self.account_get(location, response=True)
cookies = response.cookies
service_token = cookies.get('serviceToken')
if service_token:
self.service_token = service_token
self.user_id = cookies.get('userId', self.user_id)
self.cuser_id = cookies.get('cUserId', self.cuser_id)
self.async_session = None
else:
err = {
'location': location,
'status_code': response.status_code,
'cookies': cookies.get_dict(),
'response': response.text,
}
raise MiCloudAccessDenied(f'Login to xiaomi error: {err}')
return response
def _get_captcha(self, url):
response = self.session.get(url)
if ick := response.cookies.get('ick'):
self.attrs['captchaIck'] = ick
self.attrs['captchaImg'] = base64.b64encode(response.content).decode()
return ick
def check_identity_list(self, url, path='fe/service/identity/authStart'):
if path not in url:
return None
resp = self.account_get(url.replace(path, 'identity/list'), response=True)
identity_session = resp.cookies.get('identity_session')
if not identity_session:
return False
data = self.json_decode(resp.text) or {}
flag = data.get('flag', 4)
options = data.get('options', [flag])
return options
def verify_ticket(self, ticket):
url = self.attrs.get('verify_url')
if not url:
return {}
options = self.check_identity_list(url) or []
for flag in options:
api = {
4: '/identity/auth/verifyPhone',
8: '/identity/auth/verifyEmail',
}.get(flag)
if not api:
continue
data = self.account_post(
api,
params={
'_dc': int(time.time() * 1000),
},
data={
'_flag': flag,
'ticket': ticket,
'trust': 'true',
'_json': 'true',
},
cookies={
'identity_session': self.attrs.get('identity_session'),
},
)
if data.get('code') == 0:
self.attrs.pop('identity_session', None)
return data
return {}
def account_get(self, url, method='GET', **kwargs):
return self.account_post(url, method, **kwargs)
def account_post(self, url, method='POST', **kwargs):
if url[:4] != 'http':
url = f'{ACCOUNT_BASE}{url}'
kwargs['cookies'] = {
**self.cookies,
**kwargs.get('cookies', {}),
}
kwargs.setdefault('headers', {'User-Agent': self.useragent})
response = kwargs.pop('response', None)
resp = self.session.request(method, url, **kwargs)
try:
data = self.json_decode(resp.text) or {}
except Exception:
data = {
'code': resp.status_code,
'response': resp.text,
}
cookies = resp.cookies.get_dict()
self.cookies.update(cookies)
log = _LOGGER.warning if data.get('code') else _LOGGER.info
log('Account request: %s' % [url, kwargs, resp.text, cookies])
if response:
return resp
return data
def json_decode(self, text):
return json.loads(text.replace('&&&START&&&', ''))
def to_config(self):
return {
CONF_USERNAME: self.username,
CONF_PASSWORD: self.password,
'server_country': self.default_server,
'user_id': self.user_id,
'service_token': self.service_token,
'ssecurity': self.ssecurity,
'sid': self.sid,
'device_id': self.client_id,
}
@staticmethod
async def from_token(hass, config: dict, login=None):
mic = await hass.async_add_executor_job(
MiotCloud,
hass,
config.get(CONF_USERNAME),
config.get(CONF_PASSWORD),
config.get('server_country'),
config.get('sid'),
)
mic.user_id = str(config.get('user_id') or '')
if a := hass.data[DOMAIN].get('sessions', {}).get(mic.unique_id):
mic = a
mic.merger_config(config)
if not mic.service_token:
sdt = await mic.async_stored_auth(save=False)
config.update(sdt)
mic.service_token = config.get('service_token')
mic.ssecurity = config.get('ssecurity')
did = config.get('device_id') or ''
if did and len(did) <= 32:
mic.client_id = did
mic.useragent = UA % did
if login is None:
if not mic.service_token:
login = True
if login:
await mic.async_login()
else:
hass.data[DOMAIN]['sessions'][mic.unique_id] = mic
return mic
def merger_config(self, config: dict, changed=None):
if self.username != config.get(CONF_USERNAME):
self.username = config.get(CONF_USERNAME)
changed = True
if self.password != config.get(CONF_PASSWORD):
self.password = config.get(CONF_PASSWORD)
changed = True
if changed:
self.async_session = None
self.service_token = None
self.ssecurity = None
self.cookies = {}
self.attrs = {}
return self
async def async_change_sid(self, sid: str, login=None):
config = {
**self.to_config(),
'sid': sid,
'service_token': None,
'ssecurity': None,
}
mic = await self.from_token(self.hass, config, login)
return mic
async def async_stored_auth(self, uid=None, save=False, remove=False):
if not uid:
uid = self.user_id or self.username
fnm = f'xiaomi_miot/auth-{uid}-{self.default_server}.json'
if self.sid != 'xiaomiio':
fnm = fnm.replace('.json', f'-{self.sid}.json')
store = Store(self.hass, 1, fnm)
if remove:
await store.async_remove()
try:
old = await store.async_load() or {}
except ValueError:
if not remove:
await store.async_remove()
old = {}
if save:
cfg = self.to_config()
cfg.pop(CONF_PASSWORD, None)
if cfg.get('service_token') == old.get('service_token'):
cfg['update_at'] = old.get('update_at')
else:
cfg['update_at'] = f'{datetime.fromtimestamp(int(time.time()))}'
await store.async_save(cfg)
return cfg
return old
def api_session(self, **kwargs):
if not self.service_token or not self.user_id:
raise MiCloudException('Cannot execute request. service token or userId missing. Make sure to login.')
if kwargs.get('async'):
session = self.async_session
if not session or session.closed:
session = async_create_clientsession(
self.hass,
headers=self.api_headers(),
cookies=self.api_cookies(),
)
self.async_session = session
else:
session = requests.Session()
session.headers.update(self.api_headers())
session.cookies.update(self.api_cookies())
return session
def api_headers(self):
return {
'X-XIAOMI-PROTOCAL-FLAG-CLI': 'PROTOCAL-HTTP2',
'Content-Type': 'application/x-www-form-urlencoded',
'User-Agent': self.useragent,
}
def api_cookies(self):
return {
'userId': str(self.user_id),
'yetAnotherServiceToken': self.service_token,
'serviceToken': self.service_token,
'locale': str(self.locale),
'timezone': str(self.timezone),
'is_daylight': str(time.daylight),
'dst_offset': str(time.localtime().tm_isdst * 60 * 60 * 1000),
'channel': 'MI_APP_STORE',
}
def request(self, url, params, **kwargs):
self.session = self.api_session()
timeout = kwargs.get('timeout', self.http_timeout)
try:
nonce = miutils.gen_nonce()
signed_nonce = miutils.signed_nonce(self.ssecurity, nonce)
signature = miutils.gen_signature(url.replace('/app/', '/'), signed_nonce, nonce, params)
post_data = {
'signature': signature,
'_nonce': nonce,
'data': params['data'],
}
response = self.session.post(url, data=post_data, timeout=timeout)
return response.text
except requests.exceptions.HTTPError as exc:
_LOGGER.error('Error while executing request to %s: %s', url, exc)
except MiCloudException as exc:
_LOGGER.error('Error while decrypting response of request to %s: %s', url, exc)
async def async_request_rc4_api(self, api, params: dict, method='POST', **kwargs):
url = self.get_api_url(api)
session = self.api_session(**{'async': True})
timeout = aiohttp.ClientTimeout(total=kwargs.get('timeout', self.http_timeout))
headers = {
'MIOT-ENCRYPT-ALGORITHM': 'ENCRYPT-RC4',
'Accept-Encoding': 'identity',
}
params = self.rc4_params(method, url, params)
if method == 'GET':
response = await session.get(url, params=params, timeout=timeout, headers=headers)
else:
response = await session.post(url, data=params, timeout=timeout, headers=headers)
rsp = await response.text()
if not rsp or 'error' in rsp or 'invalid' in rsp:
_LOGGER.warning('Error while executing request to %s: %s', url, rsp or response.status)
elif 'message' not in rsp:
try:
signed_nonce = self.signed_nonce(params['_nonce'])
rsp = MiotCloud.decrypt_data(signed_nonce, rsp)
except ValueError:
_LOGGER.warning('Error while decrypting response of request to %s :%s', url, rsp)
return rsp
def request_raw(self, url, data=None, method='GET', **kwargs):
self.session = self.api_session()
url = self.get_api_url(url)
kwargs.setdefault('params' if method == 'GET' else 'data', data)
kwargs.setdefault('timeout', self.http_timeout)
try:
response = self.session.request(method, url, **kwargs)
if response.status_code == 401:
self._logout()
_LOGGER.warning('Unauthorized while executing request to %s, logged out.', url)
rsp = response.text
if not rsp or 'error' in rsp or 'invalid' in rsp:
log = _LOGGER.info if 'remote/ubus' in url else _LOGGER.warning
log('Error while executing request to %s: %s', url, rsp or response.status_code)
return rsp
except requests.exceptions.HTTPError as exc:
_LOGGER.warning('Error while executing request to %s: %s', url, exc)
return None
def get_api_by_host(self, host, api=''):
srv = self.default_server.lower()
if srv and srv != 'cn':
host = f'{srv}.{host}'
api = str(api).lstrip('/')
return f'https://{host}/{api}'
def get_api_url(self, api):
if api[:6] == 'https:' or api[:5] == 'http:':
url = api
else:
api = str(api).lstrip('/')
url = self._get_api_url(self.default_server) + '/' + api
return url
def rc4_params(self, method, url, params: dict):
nonce = miutils.gen_nonce()
signed_nonce = self.signed_nonce(nonce)
params['rc4_hash__'] = MiotCloud.sha1_sign(method, url, params, signed_nonce)
for k, v in params.items():
params[k] = MiotCloud.encrypt_data(signed_nonce, v)
params.update({
'signature': MiotCloud.sha1_sign(method, url, params, signed_nonce),
'ssecurity': self.ssecurity,
'_nonce': nonce,
})
return params
def signed_nonce(self, nonce):
return miutils.signed_nonce(self.ssecurity, nonce)
@staticmethod
def json_encode(data):
return json.dumps(data, separators=(',', ':'))
@staticmethod
def sha1_sign(method, url, dat: dict, nonce):
path = parse.urlparse(url).path
if path[:5] == '/app/':
path = path[4:]
arr = [str(method).upper(), path]
for k, v in dat.items():
arr.append(f'{k}={v}')
arr.append(nonce)
raw = hashlib.sha1('&'.join(arr).encode('utf-8')).digest()
return base64.b64encode(raw).decode()
@staticmethod
def encrypt_data(pwd, data):
return base64.b64encode(RC4(base64.b64decode(pwd)).init1024().crypt(data)).decode()
@staticmethod
def decrypt_data(pwd, data):
return RC4(base64.b64decode(pwd)).init1024().crypt(base64.b64decode(data))
@staticmethod
def all_clouds(hass):
cls = {}
for k, v in hass.data[DOMAIN].items():
if isinstance(v, dict):
v = v.get(CONF_XIAOMI_CLOUD)
if isinstance(v, MiotCloud):
cls[v.unique_id] = v
return list(cls.values())
@staticmethod
def get_random_string(length):
seq = string.ascii_uppercase + string.digits
return ''.join((random.choice(seq) for _ in range(length)))
class MiCloudNeedVerify(MiCloudException):
url = None
def with_url(self, url):
self.url = url
return self