初始化config文件
This commit is contained in:
@@ -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',
|
||||
]
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
Reference in New Issue
Block a user