├── README.md ├── custom_components └── xiaomi_cloud │ ├── __init__.py │ ├── config_flow.py │ ├── const.py │ ├── device_tracker.py │ ├── manifest.json │ ├── services.yaml │ └── translations │ ├── en.json │ └── zh-Hans.json └── hacs.json /README.md: -------------------------------------------------------------------------------- 1 | 9 | 10 | # 小米云服务 11 | 12 | [![hacs_badge](https://img.shields.io/badge/HACS-Default-orange.svg)](https://github.com/custom-components/hacs) 13 | 14 | 小米云服务, 支持一个账号下的多设备 15 | 提供`device_tracker`用来定位设备 16 | 可以提供锁机, 响铃, 云剪贴板这些服务 17 | 18 | ## 更新 19 | 20 | + ### v1.0 21 | + 支持多设备 22 | + 设备查找 23 | + ### v1.1 24 | + 增加坐标类型选择 25 | + 增加位置刷新频率设置,单位为分钟(过高的频率会导致手机耗电严重) 26 | 27 | + ### v1.2 28 | + 增加设备发声服务 29 | + 增加设备丢失模式 30 | + 增加云剪贴板 31 | 32 | + ### v1.2.1 33 | + 修复数据不更新的问题 34 | 35 | + ### v1.2.2 36 | + 小米不在提供`original`坐标格式, 默认改为`baidu`坐标 37 | 38 | + ### v1.2.3 39 | + `original`坐标格式回归 40 | 41 | + ### v1.2.4 42 | + 修复无法登录的问题 43 | + 设置默认扫描间隔为60分钟 #5 44 | 某些情况下特别是双卡用户小米会启用短信验证,导致意外的费用.所以加大默认扫描间隔, 过于频繁的请求位置也会增加电耗. 45 | + ### v1.2.5 46 | + 修复配置页面无法登录的问题 47 | + manifest.json增加版本号 48 | + ### v1.2.6 49 | + 不知道更新了啥 50 | + ### v1.2.7 51 | + 修复配置页面无法登录的问题 52 | 53 | ## 安装配置 54 | 55 | 建议使用HACS安装,在配置-集成里面添加和设置 56 | 57 | ## 服务 58 | 59 | ### `xiaomi_cloud.clipboard` 60 | 云剪贴板 61 | 62 | | 服务属性 | 选项 | 描述| 63 | |---------|------|----| 64 | |`text` | 必须 | 发送到剪贴板的文本内容| 65 | 66 | ### `xiaomi_cloud.find` 67 | 查找设备 68 | 69 | | 服务属性 | 选项 | 描述| 70 | |---------|------|----| 71 | |`imei` | 必须 | 设备的imei号,请在集成的`device_tracker`属性里查找| 72 | 73 | ### `xiaomi_cloud.noise` 74 | 设备发声 75 | 76 | | 服务属性 | 选项 | 描述| 77 | |---------|------|----| 78 | |`imei` | 必须 | 设备的imei号,请在集成的`device_tracker`属性里查找| 79 | 80 | ### `xiaomi_cloud.lost` 81 | 开启丢失模式 82 | 83 | | 服务属性 | 选项 | 描述| 84 | |---------|------|----| 85 | |`imei` | 必须 | 设备的imei号,请在集成的`device_tracker`属性里查找| 86 | |`content` | 必须 | 锁定设备屏幕上显示的留言| 87 | |`phone` | 必须 | 锁定设备屏幕上显示的手机号码| 88 | |`onlinenotify` | 必须 | 定位到丢失设备时,是否短信通知上面号码| 89 | 90 | -------------------------------------------------------------------------------- /custom_components/xiaomi_cloud/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Component to integrate with xiaomi cloud. 3 | 4 | For more details about this component, please refer to 5 | https://github.com/fineemb/xiaomi-cloud 6 | """ 7 | import asyncio 8 | import json 9 | import datetime 10 | import time 11 | import logging 12 | import re 13 | import base64 14 | import hashlib 15 | import math 16 | from urllib import parse 17 | import async_timeout 18 | from aiohttp.client_exceptions import ClientConnectorError 19 | from homeassistant.core import Config, HomeAssistant 20 | from homeassistant.exceptions import ConfigEntryNotReady 21 | from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed 22 | from homeassistant.helpers.aiohttp_client import async_get_clientsession 23 | from homeassistant.components.device_tracker import ( 24 | ATTR_BATTERY, 25 | DOMAIN as DEVICE_TRACKER, 26 | ) 27 | from homeassistant.const import ( 28 | CONF_PASSWORD, 29 | CONF_USERNAME, 30 | CONF_SCAN_INTERVAL 31 | ) 32 | 33 | from .const import ( 34 | DOMAIN, 35 | UNDO_UPDATE_LISTENER, 36 | COORDINATOR, 37 | CONF_COORDINATE_TYPE, 38 | CONF_COORDINATE_TYPE_BAIDU, 39 | CONF_COORDINATE_TYPE_ORIGINAL, 40 | ) 41 | 42 | _LOGGER = logging.getLogger(__name__) 43 | 44 | async def async_setup(hass: HomeAssistant, config: Config) -> bool: 45 | """Set up configured xiaomi cloud.""" 46 | hass.data[DOMAIN] = {"devices": set(), "unsub_device_tracker": {}} 47 | return True 48 | 49 | async def async_setup_entry(hass, config_entry) -> bool: 50 | """Set up xiaomi cloud as config entry.""" 51 | username = config_entry.data[CONF_USERNAME] 52 | password = config_entry.data[CONF_PASSWORD] 53 | scan_interval = config_entry.options.get(CONF_SCAN_INTERVAL, 60) 54 | coordinate_type = config_entry.options.get(CONF_COORDINATE_TYPE, CONF_COORDINATE_TYPE_ORIGINAL) 55 | 56 | _LOGGER.debug("Username: %s", username) 57 | 58 | 59 | coordinator = XiaomiCloudDataUpdateCoordinator( 60 | hass, username, password, scan_interval, coordinate_type 61 | ) 62 | await coordinator.async_refresh() 63 | 64 | if not coordinator.last_update_success: 65 | raise ConfigEntryNotReady 66 | 67 | undo_listener = config_entry.add_update_listener(update_listener) 68 | 69 | hass.data[DOMAIN][config_entry.entry_id] = { 70 | COORDINATOR: coordinator, 71 | UNDO_UPDATE_LISTENER: undo_listener, 72 | } 73 | hass.async_create_task( 74 | hass.config_entries.async_forward_entry_setup(config_entry, DEVICE_TRACKER) 75 | ) 76 | 77 | async def services(call): 78 | """Handle the service call.""" 79 | imei = call.data.get("imei") 80 | service = call.service 81 | if service == "noise": 82 | await coordinator._send_command({'service':'noise','data':{'imei':imei}}) 83 | elif service == "find": 84 | await coordinator._send_command({'service':'find','data':{'imei':imei}}) 85 | elif service == "lost": 86 | await coordinator._send_command({ 87 | 'service':'lost', 88 | 'data':{ 89 | 'imei':imei, 90 | 'content':call.data.get("content"), 91 | 'phone':call.data.get("phone"), 92 | 'onlinenotify':call.data.get("onlinenotify") 93 | }}) 94 | elif service == "clipboard": 95 | await coordinator._send_command({'service':'clipboard','data':{'text':call.data.get("text")}}) 96 | 97 | hass.services.async_register(DOMAIN, "noise", services) 98 | hass.services.async_register(DOMAIN, "find", services) 99 | hass.services.async_register(DOMAIN, "lost", services) 100 | hass.services.async_register(DOMAIN, "clipboard", services) 101 | 102 | return True 103 | 104 | async def async_unload_entry(hass, config_entry): 105 | """Unload a config entry.""" 106 | unload_ok = await hass.config_entries.async_forward_entry_unload(config_entry, DEVICE_TRACKER) 107 | 108 | hass.data[DOMAIN][config_entry.entry_id][UNDO_UPDATE_LISTENER]() 109 | 110 | if unload_ok: 111 | hass.data[DOMAIN].pop(config_entry.entry_id) 112 | 113 | return unload_ok 114 | 115 | async def update_listener(hass, config_entry): 116 | """Update listener.""" 117 | await hass.config_entries.async_reload(config_entry.entry_id) 118 | 119 | class XiaomiCloudDataUpdateCoordinator(DataUpdateCoordinator): 120 | """Class to manage fetching XiaomiCloud data API.""" 121 | def __init__(self, hass, user, password, scan_interval, coordinate_type): 122 | """Initialize.""" 123 | self._username = user 124 | self._password = password 125 | self._headers = {} 126 | self._cookies = {} 127 | self._device_info = {} 128 | self._serviceLoginAuth2_json = {} 129 | self._sign = None 130 | self._scan_interval = scan_interval 131 | self._coordinate_type = coordinate_type 132 | self.service_data = None 133 | self.userId = None 134 | self.login_result = False 135 | self.service = None 136 | 137 | update_interval = ( 138 | datetime.timedelta(minutes=self._scan_interval) 139 | ) 140 | _LOGGER.debug("Data will be update every %s", update_interval) 141 | 142 | super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=update_interval) 143 | 144 | async def _get_sign(self, session): 145 | url = 'https://account.xiaomi.com/pass/serviceLogin?sid%3Di.mi.com&sid=i.mi.com&_locale=zh_CN&_snsNone=true' 146 | pattern = re.compile(r'_sign=(.*?)&') 147 | 148 | try: 149 | with async_timeout.timeout(15, loop=self.hass.loop): 150 | r = await session.get(url, headers=self._headers) 151 | self._cookies['pass_trace'] = r.history[0].headers.getall('Set-Cookie')[2].split(";")[0].split("=")[1] 152 | _LOGGER.debug("--2---%s",parse.unquote(pattern.findall(r.history[0].headers.getall('Location')[0])[0])) 153 | self._sign = parse.unquote(pattern.findall(r.history[0].headers.getall('Location')[0])[0]) 154 | return True 155 | except BaseException as e: 156 | _LOGGER.warning(e.args[0]) 157 | return False 158 | 159 | async def _serviceLoginAuth2(self, session, captCode=None): 160 | url = 'https://account.xiaomi.com/pass/serviceLoginAuth2' 161 | self._headers['Content-Type'] = 'application/x-www-form-urlencoded' 162 | self._headers['Accept'] = '*/*' 163 | self._headers['Origin'] = 'https://account.xiaomi.com' 164 | self._headers['Referer'] = 'https://account.xiaomi.com/pass/serviceLogin?sid%3Di.mi.com&sid=i.mi.com&_locale=zh_CN&_snsNone=true' 165 | self._headers['Cookie'] = 'pass_trace={};'.format(self._cookies['pass_trace']) 166 | 167 | auth_post_data = {'_json': 'true', 168 | '_sign': self._sign, 169 | 'callback': 'https://i.mi.com/sts', 170 | 'hash': hashlib.md5(self._password.encode('utf-8')).hexdigest().upper(), 171 | 'qs': '%3Fsid%253Di.mi.com%26sid%3Di.mi.com%26_locale%3Dzh_CN%26_snsNone%3Dtrue', 172 | 'serviceParam': '{"checkSafePhone":false}', 173 | 'sid': 'i.mi.com', 174 | 'user': self._username} 175 | try: 176 | if captCode != None: 177 | url = 'https://account.xiaomi.com/pass/serviceLoginAuth2?_dc={}'.format( 178 | int(round(time.time() * 1000))) 179 | auth_post_data['captCode'] = captCode 180 | self._headers['Cookie'] = self._headers['Cookie'] + \ 181 | '; ick={}'.format(self._cookies['ick']) 182 | with async_timeout.timeout(15, loop=self.hass.loop): 183 | r = await session.post(url, headers=self._headers, data=auth_post_data, cookies=self._cookies) 184 | self._cookies['pwdToken'] = r.cookies.get('passToken').value 185 | self._serviceLoginAuth2_json = json.loads((await r.text())[11:]) 186 | return True 187 | except BaseException as e: 188 | _LOGGER.warning(e.args[0]) 189 | return False 190 | 191 | async def _login_miai(self, session): 192 | serviceToken = "nonce={}&{}".format( 193 | self._serviceLoginAuth2_json['nonce'], self._serviceLoginAuth2_json['ssecurity']) 194 | serviceToken_sha1 = hashlib.sha1(serviceToken.encode('utf-8')).digest() 195 | base64_serviceToken = base64.b64encode(serviceToken_sha1) 196 | loginmiai_header = {'User-Agent': 'MISoundBox/1.4.0,iosPassportSDK/iOS-3.2.7 iOS/11.2.5', 197 | 'Accept-Language': 'zh-cn', 'Connection': 'keep-alive'} 198 | url = self._serviceLoginAuth2_json['location'] + \ 199 | "&clientSign=" + parse.quote(base64_serviceToken.decode()) 200 | try: 201 | with async_timeout.timeout(15, loop=self.hass.loop): 202 | r = await session.get(url, headers=loginmiai_header) 203 | if r.status == 200: 204 | self._Service_Token = r.cookies.get('serviceToken').value 205 | self.userId = r.cookies.get('userId').value 206 | return True 207 | else: 208 | return False 209 | except BaseException as e: 210 | _LOGGER.warning(e.args[0]) 211 | return False 212 | 213 | async def _get_device_info(self, session): 214 | url = 'https://i.mi.com/find/device/full/status?ts={}'.format( 215 | int(round(time.time() * 1000))) 216 | get_device_list_header = {'Cookie': 'userId={};serviceToken={}'.format( 217 | self.userId, self._Service_Token)} 218 | try: 219 | with async_timeout.timeout(15, loop=self.hass.loop): 220 | r = await session.get(url, headers=get_device_list_header) 221 | if r.status == 200: 222 | data = json.loads(await 223 | r.text())['data']['devices'] 224 | 225 | self._device_info = data 226 | return True 227 | else: 228 | return False 229 | except BaseException as e: 230 | _LOGGER.warning(e.args[0]) 231 | return False 232 | 233 | async def _send_find_device_command(self, session): 234 | flag = True 235 | for vin in self._device_info: 236 | imei = vin["imei"] 237 | url = 'https://i.mi.com/find/device/{}/location'.format( 238 | imei) 239 | _send_find_device_command_header = { 240 | 'Cookie': 'userId={};serviceToken={}'.format(self.userId, self._Service_Token)} 241 | data = {'userId': self.userId, 'imei': imei, 242 | 'auto': 'false', 'channel': 'web', 'serviceToken': self._Service_Token} 243 | try: 244 | with async_timeout.timeout(15, loop=self.hass.loop): 245 | r = await session.post(url, headers=_send_find_device_command_header, data=data) 246 | _LOGGER.debug("find_device res: %s", await r.json()) 247 | if r.status == 200: 248 | flag = True 249 | else: 250 | flag = False 251 | self.login_result = False 252 | except BaseException as e: 253 | _LOGGER.warning(e.args[0]) 254 | self.login_result = False 255 | flag = False 256 | return flag 257 | 258 | async def _send_noise_command(self, session): 259 | flag = True 260 | imei = self.service_data['imei'] 261 | url = 'https://i.mi.com/find/device/{}/noise'.format( 262 | imei) 263 | _send_noise_command_header = { 264 | 'Cookie': 'userId={};serviceToken={}'.format(self.userId, self._Service_Token)} 265 | data = {'userId': self.userId, 'imei': imei, 266 | 'auto': 'false', 'channel': 'web', 'serviceToken': self._Service_Token} 267 | try: 268 | with async_timeout.timeout(15, loop=self.hass.loop): 269 | r = await session.post(url, headers=_send_noise_command_header, data=data) 270 | _LOGGER.debug("noise res: %s", await r.json()) 271 | if r.status == 200: 272 | flag = True 273 | self.service = None 274 | self.service_data = None 275 | else: 276 | flag = False 277 | self.login_result = False 278 | except BaseException as e: 279 | _LOGGER.warning(e.args[0]) 280 | self.login_result = False 281 | flag = False 282 | return flag 283 | 284 | async def _send_lost_command(self, session): 285 | flag = True 286 | imei = self.service_data['imei'] 287 | content = self.service_data['content'] 288 | phone = self.service_data['phone'] 289 | message = {"content":content, "phone": phone} 290 | onlinenotify = self.service_data['onlinenotify'] 291 | url = 'https://i.mi.com/find/device/{}/lost'.format( 292 | imei) 293 | _send_lost_command_header = { 294 | 'Cookie': 'userId={};serviceToken={}'.format(self.userId, self._Service_Token)} 295 | data = {'userId': self.userId, 'imei': imei, 296 | 'deleteCard': 'false', 'channel': 'web', 'serviceToken': self._Service_Token, 'onlineNotify': onlinenotify, 'message':json.dumps(message)} 297 | try: 298 | with async_timeout.timeout(15, loop=self.hass.loop): 299 | r = await session.post(url, headers=_send_lost_command_header, data=data) 300 | _LOGGER.debug("lost res: %s", await r.json()) 301 | if r.status == 200: 302 | flag = True 303 | self.service = None 304 | self.service_data = None 305 | else: 306 | flag = False 307 | self.login_result = False 308 | except BaseException as e: 309 | _LOGGER.warning(e.args[0]) 310 | self.login_result = False 311 | flag = False 312 | return flag 313 | 314 | async def _send_clipboard_command(self, session): 315 | flag = True 316 | text = self.service_data['text'] 317 | url = 'https://i.mi.com/clipboard/lite/text' 318 | _send_clipboard_command_header = { 319 | 'Cookie': 'userId={};serviceToken={}'.format(self.userId, self._Service_Token)} 320 | data = {'text': text, 'serviceToken': self._Service_Token} 321 | try: 322 | with async_timeout.timeout(15, loop=self.hass.loop): 323 | r = await session.post(url, headers=_send_clipboard_command_header, data=data) 324 | _LOGGER.debug("clipboard res: %s", await r.json()) 325 | if r.status == 200: 326 | flag = True 327 | self.service = None 328 | self.service_data = None 329 | else: 330 | flag = False 331 | self.login_result = False 332 | except BaseException as e: 333 | _LOGGER.warning(e.args[0]) 334 | self.login_result = False 335 | flag = False 336 | return flag 337 | 338 | async def _send_command(self, data): 339 | self.service_data = data['data'] 340 | self.service = data['service'] 341 | await self.async_refresh() 342 | 343 | async def _get_device_location(self, session): 344 | devices_info = [] 345 | for vin in self._device_info: 346 | imei = vin["imei"] 347 | model = vin["model"] 348 | version = vin["version"] 349 | url = 'https://i.mi.com/find/device/status?ts={}&fid={}'.format( 350 | int(round(time.time() * 1000)), imei) 351 | _send_find_device_command_header = { 352 | 'Cookie': 'userId={};serviceToken={}'.format(self.userId, self._Service_Token)} 353 | try: 354 | with async_timeout.timeout(15, loop=self.hass.loop): 355 | r = await session.get(url, headers=_send_find_device_command_header) 356 | if r.status == 200: 357 | _LOGGER.debug("get_device_location_data: %s", json.loads(await r.text())) 358 | if "receipt" in json.loads(await r.text())['data']['location']: 359 | device_info = {} 360 | location_info_json = {} 361 | if self._coordinate_type == "baidu": 362 | location_info_json = json.loads(await r.text())['data']['location']['receipt']['gpsInfo'] 363 | elif self._coordinate_type == "google": 364 | location_info_json = json.loads(await r.text())['data']['location']['receipt']['gpsInfoExtra'][0] 365 | elif self._coordinate_type == "original": 366 | gpsInfoExtra = json.loads(await r.text())['data']['location']['receipt']['gpsInfoExtra'] 367 | if(len(gpsInfoExtra)>1): 368 | location_info_json = json.loads(await r.text())['data']['location']['receipt']['gpsInfoExtra'][1] 369 | else: 370 | wgs84 = self.GCJ2WGS(gpsInfoExtra[0]['longitude'],gpsInfoExtra[0]['latitude']) 371 | _LOGGER.debug("get_device_location_data_wgs84: %s", wgs84) 372 | location_info_json = { 373 | "accuracy":int(gpsInfoExtra[0]['accuracy']), 374 | "coordinateType":'wgs84', 375 | "latitude":wgs84[1], 376 | "longitude":wgs84[0], 377 | } 378 | else: 379 | location_info_json = json.loads(await r.text())['data']['location']['receipt']['gpsInfo'] 380 | 381 | device_info["device_lat"] = location_info_json['latitude'] 382 | device_info["device_accuracy"] = int(location_info_json['accuracy']) 383 | device_info["device_lon"] = location_info_json['longitude'] 384 | device_info["coordinate_type"] = location_info_json['coordinateType'] 385 | 386 | device_info["device_power"] = json.loads( 387 | await r.text())['data']['location']['receipt'].get('powerLevel',0) 388 | device_info["device_phone"] = json.loads( 389 | await r.text())['data']['location']['receipt'].get('phone',0) 390 | timeArray = time.localtime(int(json.loads( 391 | await r.text())['data']['location']['receipt']['infoTime']) / 1000) 392 | device_info["device_location_update_time"] = time.strftime("%Y-%m-%d %H:%M:%S", timeArray) 393 | device_info["imei"] = imei 394 | device_info["model"] = model 395 | device_info["version"] = version 396 | devices_info.append(device_info) 397 | else: 398 | self.login_result = False 399 | else: 400 | self.login_result = False 401 | except BaseException as e: 402 | self.login_result = False 403 | _LOGGER.warning(e) 404 | return devices_info 405 | def GCJ2WGS(self,lon,lat): 406 | a = 6378245.0 # 克拉索夫斯基椭球参数长半轴a 407 | ee = 0.00669342162296594323 #克拉索夫斯基椭球参数第一偏心率平方 408 | PI = 3.14159265358979324 # 圆周率 409 | 410 | x = lon - 105.0 411 | y = lat - 35.0 412 | 413 | dLon = 300.0 + x + 2.0 * y + 0.1 * x * x + 0.1 * x * y + 0.1 * math.sqrt(abs(x)); 414 | dLon += (20.0 * math.sin(6.0 * x * PI) + 20.0 * math.sin(2.0 * x * PI)) * 2.0 / 3.0; 415 | dLon += (20.0 * math.sin(x * PI) + 40.0 * math.sin(x / 3.0 * PI)) * 2.0 / 3.0; 416 | dLon += (150.0 * math.sin(x / 12.0 * PI) + 300.0 * math.sin(x / 30.0 * PI)) * 2.0 / 3.0; 417 | 418 | dLat = -100.0 + 2.0 * x + 3.0 * y + 0.2 * y * y + 0.1 * x * y + 0.2 * math.sqrt(abs(x)); 419 | dLat += (20.0 * math.sin(6.0 * x * PI) + 20.0 * math.sin(2.0 * x * PI)) * 2.0 / 3.0; 420 | dLat += (20.0 * math.sin(y * PI) + 40.0 * math.sin(y / 3.0 * PI)) * 2.0 / 3.0; 421 | dLat += (160.0 * math.sin(y / 12.0 * PI) + 320 * math.sin(y * PI / 30.0)) * 2.0 / 3.0; 422 | radLat = lat / 180.0 * PI 423 | magic = math.sin(radLat) 424 | magic = 1 - ee * magic * magic 425 | sqrtMagic = math.sqrt(magic) 426 | dLat = (dLat * 180.0) / ((a * (1 - ee)) / (magic * sqrtMagic) * PI); 427 | dLon = (dLon * 180.0) / (a / sqrtMagic * math.cos(radLat) * PI); 428 | wgsLon = lon - dLon 429 | wgsLat = lat - dLat 430 | return [wgsLon,wgsLat] 431 | 432 | async def _async_update_data(self): 433 | """Update data via library.""" 434 | _LOGGER.debug("service: %s", self.service) 435 | try: 436 | session = async_get_clientsession(self.hass) 437 | if self.login_result is True: 438 | if self.service == "noise": 439 | tmp = await self._send_noise_command(session) 440 | elif self.service == 'lost': 441 | tmp = await self._send_lost_command(session) 442 | elif self.service == 'clipboard': 443 | tmp = await self._send_clipboard_command(session) 444 | elif self._scan_interval>0: 445 | tmp = await self._send_find_device_command(session) 446 | if tmp is True: 447 | await asyncio.sleep(15) 448 | tmp = await self._get_device_location(session) 449 | if not tmp: 450 | _LOGGER.info("_get_device_location0 Failed") 451 | else: 452 | _LOGGER.info("_get_device_location0 succeed") 453 | else: 454 | _LOGGER.info("send_command Failed") 455 | else: 456 | if self._scan_interval>0: 457 | session.cookie_jar.clear() 458 | tmp = await self._get_sign(session) 459 | if not tmp: 460 | _LOGGER.warning("get_sign Failed") 461 | else: 462 | tmp = await self._serviceLoginAuth2(session) 463 | if not tmp: 464 | _LOGGER.warning('Request Login_url Failed') 465 | else: 466 | if self._serviceLoginAuth2_json['code'] == 0: 467 | # logon success,run self._login_miai() 468 | tmp = await self._login_miai(session) 469 | if not tmp: 470 | _LOGGER.warning('login Mi Cloud Failed') 471 | else: 472 | tmp = await self._get_device_info(session) 473 | if not tmp: 474 | _LOGGER.warning('get_device info Failed') 475 | else: 476 | _LOGGER.info("get_device info succeed") 477 | self.login_result = True 478 | if self.service == "noise": 479 | tmp = await self._send_noise_command(session) 480 | elif self.service == 'lost': 481 | tmp = await self._send_lost_command(session) 482 | elif self.service == 'clipboard': 483 | tmp = await self._send_clipboard_command(session) 484 | else: 485 | tmp = await self._send_find_device_command(session) 486 | if tmp is True: 487 | await asyncio.sleep(15) 488 | tmp = await self._get_device_location(session) 489 | if not tmp: 490 | _LOGGER.info("_get_device_location1 Failed") 491 | else: 492 | _LOGGER.info("_get_device_location1 succeed") 493 | else: 494 | _LOGGER.info("send_command Failed") 495 | 496 | except ( 497 | ClientConnectorError 498 | ) as error: 499 | raise UpdateFailed(error) 500 | return tmp 501 | -------------------------------------------------------------------------------- /custom_components/xiaomi_cloud/config_flow.py: -------------------------------------------------------------------------------- 1 | 2 | """Adds config flow for Colorfulclouds.""" 3 | import logging 4 | import json 5 | import time 6 | import re 7 | import base64 8 | import hashlib 9 | import traceback 10 | 11 | import voluptuous as vol 12 | from urllib import parse 13 | import async_timeout 14 | from collections import OrderedDict 15 | from homeassistant.helpers import aiohttp_client, config_validation as cv 16 | from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME 17 | 18 | from homeassistant import config_entries, core, exceptions 19 | from homeassistant.helpers.aiohttp_client import async_get_clientsession 20 | from homeassistant.core import callback 21 | from homeassistant.const import ( 22 | CONF_ACCESS_TOKEN, 23 | CONF_PASSWORD, 24 | CONF_SCAN_INTERVAL, 25 | CONF_TOKEN, 26 | CONF_USERNAME, 27 | ) 28 | 29 | from .const import ( 30 | CONF_WAKE_ON_START, 31 | DEFAULT_SCAN_INTERVAL, 32 | DEFAULT_WAKE_ON_START, 33 | DOMAIN, 34 | MIN_SCAN_INTERVAL, 35 | CONF_COORDINATE_TYPE, 36 | CONF_COORDINATE_TYPE_BAIDU, 37 | CONF_COORDINATE_TYPE_ORIGINAL, 38 | CONF_COORDINATE_TYPE_GOOGLE 39 | ) 40 | 41 | 42 | 43 | _LOGGER = logging.getLogger(__name__) 44 | 45 | @config_entries.HANDLERS.register(DOMAIN) 46 | class XiaomiCloudlowHandler(config_entries.ConfigFlow, domain=DOMAIN): 47 | @staticmethod 48 | @callback 49 | def async_get_options_flow(config_entry): 50 | """Get the options flow for this handler.""" 51 | return XiaomiCloudOptionsFlow(config_entry) 52 | 53 | def __init__(self): 54 | """Initialize.""" 55 | self._errors = {} 56 | self.login_result = False 57 | self._user = None 58 | self._password = None 59 | self._headers = {} 60 | self._cookies = {} 61 | self._serviceLoginAuth2_json = {} 62 | self._sign = None 63 | 64 | async def async_step_user(self, user_input={}): 65 | self._errors = {} 66 | if user_input is not None: 67 | # Check if entered host is already in HomeAssistant 68 | existing = await self._check_existing(user_input[CONF_USERNAME]) 69 | if existing: 70 | return self.async_abort(reason="already_configured") 71 | 72 | # If it is not, continue with communication test 73 | self._user = user_input[CONF_USERNAME] 74 | self._password = user_input[CONF_PASSWORD] 75 | 76 | session = async_get_clientsession(self.hass) 77 | url = 'https://account.xiaomi.com/pass/serviceLogin?sid%3Di.mi.com&sid=i.mi.com&_locale=zh_CN&_snsNone=true' 78 | pattern = re.compile(r'_sign":"(.*?)",') 79 | 80 | try: 81 | 82 | session = async_get_clientsession(self.hass) 83 | session.cookie_jar.clear() 84 | tmp = await self._get_sign(session) 85 | if not tmp: 86 | _LOGGER.warning("get_sign Failed") 87 | self._errors["base"] = "get_sign_failed" 88 | return await self._show_config_form(user_input) 89 | else: 90 | tmp = await self._serviceLoginAuth2(session) 91 | _LOGGER.debug("_serviceLoginAuth2: %s", tmp) 92 | if not tmp: 93 | _LOGGER.warning('Request Login_url Failed') 94 | self._errors["base"] = "login_url_failed" 95 | return await self._show_config_form(user_input) 96 | else: 97 | if self._serviceLoginAuth2_json['code'] == 0: 98 | # logon success,run self._login_miai() 99 | tmp = await self._login_miai(session) 100 | if not tmp: 101 | _LOGGER.warning('login Mi Cloud Failed') 102 | self._errors["base"] = "login_mi_cloud_failed" 103 | return await self._show_config_form(user_input) 104 | else: 105 | tmp = await self._get_device_info(session) 106 | if not tmp: 107 | _LOGGER.warning('get_device info Failed') 108 | self._errors["base"] = "get_device_failed" 109 | return await self._show_config_form(user_input) 110 | else: 111 | return self.async_create_entry( 112 | title=user_input[CONF_USERNAME], data=user_input 113 | ) 114 | return True 115 | except BaseException as e: 116 | _LOGGER.warning(e.args[0]) 117 | return False 118 | return await self._show_config_form(user_input) 119 | return await self._show_config_form(user_input) 120 | 121 | async def _get_sign(self, session): 122 | url = 'https://account.xiaomi.com/pass/serviceLogin?sid%3Di.mi.com&sid=i.mi.com&_locale=zh_CN&_snsNone=true' 123 | pattern = re.compile(r'_sign=(.*?)&') 124 | 125 | try: 126 | # with async_timeout.timeout(15, loop=self.hass.loop): 127 | # r = await session.get(url, headers=self._headers) 128 | r = await session.get(url, headers=self._headers) 129 | self._cookies['pass_trace'] = r.history[0].headers.getall('Set-Cookie')[2].split(";")[0].split("=")[1] 130 | _LOGGER.debug("--2---%s",parse.unquote(pattern.findall(r.history[0].headers.getall('Location')[0])[0])) 131 | self._sign = parse.unquote(pattern.findall(r.history[0].headers.getall('Location')[0])[0]) 132 | return True 133 | except BaseException as e: 134 | _LOGGER.warning(e.args[0]) 135 | return False 136 | 137 | async def _serviceLoginAuth2(self, session, captCode=None): 138 | url = 'https://account.xiaomi.com/pass/serviceLoginAuth2' 139 | self._headers['Content-Type'] = 'application/x-www-form-urlencoded' 140 | self._headers['Accept'] = '*/*' 141 | self._headers['Origin'] = 'https://account.xiaomi.com' 142 | self._headers['Referer'] = 'https://account.xiaomi.com/pass/serviceLogin?sid%3Di.mi.com&sid=i.mi.com&_locale=zh_CN&_snsNone=true' 143 | self._headers['Cookie'] = 'pass_trace={};'.format(self._cookies['pass_trace']) 144 | 145 | auth_post_data = {'_json': 'true', 146 | '_sign': self._sign, 147 | 'callback': 'https://i.mi.com/sts', 148 | 'hash': hashlib.md5(self._password.encode('utf-8')).hexdigest().upper(), 149 | 'qs': '%3Fsid%253Di.mi.com%26sid%3Di.mi.com%26_locale%3Dzh_CN%26_snsNone%3Dtrue', 150 | 'serviceParam': '{"checkSafePhone":false}', 151 | 'sid': 'i.mi.com', 152 | 'user': self._user} 153 | try: 154 | if captCode != None: 155 | url = 'https://account.xiaomi.com/pass/serviceLoginAuth2?_dc={}'.format( 156 | int(round(time.time() * 1000))) 157 | auth_post_data['captCode'] = captCode 158 | self._headers['Cookie'] = self._headers['Cookie'] + \ 159 | '; ick={}'.format(self._cookies['ick']) 160 | # with async_timeout.timeout(15, loop=self.hass.loop): 161 | # r = await session.post(url, headers=self._headers, data=auth_post_data, cookies=self._cookies) 162 | r = await session.post(url, headers=self._headers, data=auth_post_data, cookies=self._cookies) 163 | self._cookies['pwdToken'] = r.cookies.get('passToken').value 164 | self._serviceLoginAuth2_json = json.loads((await r.text())[11:]) 165 | _LOGGER.debug("_serviceLoginAuth2_json: %s", self._serviceLoginAuth2_json['ssecurity']) 166 | return True 167 | except BaseException as e: 168 | _LOGGER.warning(e.args[0]) 169 | return False 170 | 171 | async def _login_miai(self, session): 172 | serviceToken = "nonce={}&{}".format( 173 | self._serviceLoginAuth2_json['nonce'], self._serviceLoginAuth2_json['ssecurity']) 174 | serviceToken_sha1 = hashlib.sha1(serviceToken.encode('utf-8')).digest() 175 | base64_serviceToken = base64.b64encode(serviceToken_sha1) 176 | loginmiai_header = {'User-Agent': 'MISoundBox/1.4.0,iosPassportSDK/iOS-3.2.7 iOS/11.2.5', 177 | 'Accept-Language': 'zh-cn', 'Connection': 'keep-alive'} 178 | url = self._serviceLoginAuth2_json['location'] + \ 179 | "&clientSign=" + parse.quote(base64_serviceToken.decode()) 180 | try: 181 | # with async_timeout.timeout(15, loop=self.hass.loop): 182 | # r = await session.get(url, headers=loginmiai_header) 183 | r = await session.get(url, headers=loginmiai_header) 184 | if r.status == 200: 185 | self._Service_Token = r.cookies.get('serviceToken').value 186 | self.userId = r.cookies.get('userId').value 187 | return True 188 | else: 189 | return False 190 | except BaseException as e: 191 | _LOGGER.warning(e.args[0]) 192 | return False 193 | 194 | async def _get_device_info(self, session): 195 | url = 'https://i.mi.com/find/device/full/status?ts={}'.format( 196 | int(round(time.time() * 1000))) 197 | get_device_list_header = {'Cookie': 'userId={};serviceToken={}'.format( 198 | self.userId, self._Service_Token)} 199 | try: 200 | r = await session.get(url, headers=get_device_list_header) 201 | # with async_timeout.timeout(15, loop=self.hass.loop): 202 | # r = await session.get(url, headers=get_device_list_header) 203 | if r.status == 200: 204 | data = json.loads(await r.text())['data']['devices'] 205 | return data 206 | else: 207 | return False 208 | except: 209 | _LOGGER.warning(traceback.format_exc()) 210 | return False 211 | 212 | async def _show_config_form(self, user_input): 213 | 214 | data_schema = OrderedDict() 215 | data_schema[vol.Required(CONF_USERNAME)] = str 216 | data_schema[vol.Required(CONF_PASSWORD)] = str 217 | return self.async_show_form( 218 | step_id="user", data_schema=vol.Schema(data_schema), errors=self._errors 219 | ) 220 | 221 | async def async_step_import(self, user_input): 222 | """Import a config entry. 223 | 224 | Special type of import, we're not actually going to store any data. 225 | Instead, we're going to rely on the values that are in config file. 226 | """ 227 | if self._async_current_entries(): 228 | return self.async_abort(reason="single_instance_allowed") 229 | 230 | return self.async_create_entry(title="configuration.yaml", data={}) 231 | 232 | async def _check_existing(self, host): 233 | for entry in self._async_current_entries(): 234 | if host == entry.data.get(CONF_NAME): 235 | return True 236 | 237 | 238 | class XiaomiCloudOptionsFlow(config_entries.OptionsFlow): 239 | """Config flow options for Colorfulclouds.""" 240 | 241 | def __init__(self, config_entry): 242 | """Initialize Colorfulclouds options flow.""" 243 | self.config_entry = config_entry 244 | 245 | async def async_step_init(self, user_input=None): 246 | """Manage the options.""" 247 | return await self.async_step_user() 248 | 249 | async def async_step_user(self, user_input=None): 250 | """Handle a flow initialized by the user.""" 251 | if user_input is not None: 252 | return self.async_create_entry(title="", data=user_input) 253 | 254 | return self.async_show_form( 255 | step_id="user", 256 | data_schema=vol.Schema( 257 | { 258 | vol.Optional( 259 | CONF_SCAN_INTERVAL, 260 | default=self.config_entry.options.get(CONF_SCAN_INTERVAL, 60), 261 | ): int, 262 | vol.Optional( 263 | CONF_COORDINATE_TYPE, 264 | default=self.config_entry.options.get(CONF_COORDINATE_TYPE, CONF_COORDINATE_TYPE_ORIGINAL), 265 | ): vol.In([CONF_COORDINATE_TYPE_BAIDU, CONF_COORDINATE_TYPE_GOOGLE, CONF_COORDINATE_TYPE_ORIGINAL]) 266 | } 267 | ), 268 | ) 269 | 270 | -------------------------------------------------------------------------------- /custom_components/xiaomi_cloud/const.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Author : fineemb 3 | Github : https://github.com/fineemb 4 | Description : 5 | Date : 2020-09-12 20:46:33 6 | LastEditors : fineemb 7 | LastEditTime : 2020-09-22 22:49:23 8 | ''' 9 | 10 | 11 | """Const file for Xiaomi Cloud.""" 12 | CONF_WAKE_ON_START = "enable_wake_on_start" 13 | DOMAIN = "xiaomi_cloud" 14 | COORDINATOR = "coordinator" 15 | DATA_LISTENER = "listener" 16 | UNDO_UPDATE_LISTENER = "undo_update_listener" 17 | DEFAULT_SCAN_INTERVAL = 660 18 | DEFAULT_WAKE_ON_START = False 19 | MIN_SCAN_INTERVAL = 60 20 | SIGNAL_STATE_UPDATED = f"{DOMAIN}.updated" 21 | CONF_COORDINATE_TYPE = "coordinate_type" 22 | CONF_COORDINATE_TYPE_BAIDU = "baidu" 23 | CONF_COORDINATE_TYPE_ORIGINAL = "original" 24 | CONF_COORDINATE_TYPE_GOOGLE = "google" 25 | 26 | -------------------------------------------------------------------------------- /custom_components/xiaomi_cloud/device_tracker.py: -------------------------------------------------------------------------------- 1 | 2 | """Support for the Xiaomi device tracking.""" 3 | import logging 4 | 5 | 6 | from homeassistant.components.device_tracker import SOURCE_TYPE_GPS 7 | from homeassistant.components.device_tracker.config_entry import TrackerEntity 8 | from homeassistant.const import ( 9 | ATTR_BATTERY_LEVEL, 10 | ATTR_GPS_ACCURACY, 11 | ATTR_LATITUDE, 12 | ATTR_LONGITUDE, 13 | ) 14 | from homeassistant.core import callback 15 | from homeassistant.helpers import device_registry 16 | from homeassistant.helpers.dispatcher import async_dispatcher_connect 17 | from homeassistant.helpers.restore_state import RestoreEntity 18 | from homeassistant.helpers.typing import HomeAssistantType 19 | from homeassistant.helpers.entity import Entity 20 | 21 | from .const import ( 22 | DOMAIN, 23 | COORDINATOR, 24 | SIGNAL_STATE_UPDATED 25 | ) 26 | 27 | 28 | _LOGGER = logging.getLogger(__name__) 29 | 30 | async def async_setup_entry(hass: HomeAssistantType, config_entry, async_add_entities): 31 | """Configure a dispatcher connection based on a config entry.""" 32 | 33 | coordinator = hass.data[DOMAIN][config_entry.entry_id][COORDINATOR] 34 | devices = [] 35 | for i in range(len(coordinator.data)): 36 | devices.append(XiaomiDeviceEntity(hass, coordinator, i)) 37 | # _LOGGER.debug("device is : %s", i) 38 | async_add_entities(devices, True) 39 | 40 | class XiaomiDeviceEntity(TrackerEntity, RestoreEntity, Entity): 41 | """Represent a tracked device.""" 42 | 43 | def __init__(self, hass, coordinator, vin) -> None: 44 | """Set up Geofency entity.""" 45 | self._hass = hass 46 | self._vin = vin 47 | self.coordinator = coordinator 48 | self._unique_id = coordinator.data[vin]["imei"] 49 | self._name = coordinator.data[vin]["model"] 50 | self._icon = "mdi:cellphone-android" 51 | self.sw_version = coordinator.data[vin]["version"] 52 | 53 | async def async_update(self): 54 | """Update Colorfulclouds entity.""" 55 | _LOGGER.debug("async_update") 56 | await self.coordinator.async_request_refresh() 57 | async def async_added_to_hass(self): 58 | """Subscribe for update from the hub""" 59 | 60 | _LOGGER.debug("device_tracker_unique_id: %s", self._unique_id) 61 | 62 | async def async_update_state(): 63 | """Update sensor state.""" 64 | await self.async_update_ha_state(True) 65 | 66 | self.async_on_remove( 67 | self.coordinator.async_add_listener(self.async_write_ha_state) 68 | ) 69 | 70 | @property 71 | def battery_level(self): 72 | """Return battery value of the device.""" 73 | return self.coordinator.data[self._vin]["device_power"] 74 | 75 | @property 76 | def device_state_attributes(self): 77 | """Return device specific attributes.""" 78 | attrs = { 79 | "last_update": self.coordinator.data[self._vin]["device_location_update_time"], 80 | "coordinate_type": self.coordinator.data[self._vin]["coordinate_type"], 81 | "device_phone": self.coordinator.data[self._vin]["device_phone"], 82 | "imei": self.coordinator.data[self._vin]["imei"], 83 | } 84 | 85 | return attrs 86 | 87 | @property 88 | def latitude(self): 89 | """Return latitude value of the device.""" 90 | return self.coordinator.data[self._vin]["device_lat"] 91 | 92 | @property 93 | def longitude(self): 94 | """Return longitude value of the device.""" 95 | return self.coordinator.data[self._vin]["device_lon"] 96 | 97 | @property 98 | def location_accuracy(self): 99 | """Return the gps accuracy of the device.""" 100 | return self.coordinator.data[self._vin]["device_accuracy"] 101 | 102 | @property 103 | def icon(self): 104 | """Icon to use in the frontend, if any.""" 105 | return self._icon 106 | 107 | @property 108 | def name(self): 109 | """Return the name of the device.""" 110 | return self._name 111 | 112 | @property 113 | def unique_id(self): 114 | """Return the unique ID.""" 115 | return self._unique_id 116 | @property 117 | def device_info(self): 118 | """Return the device info.""" 119 | return { 120 | "identifiers": {(DOMAIN, self._unique_id)}, 121 | "name": self._name, 122 | "manufacturer": "Xiaomi", 123 | "entry_type": "device", 124 | "sw_version": self.sw_version, 125 | "model": self._name 126 | } 127 | 128 | @property 129 | def should_poll(self): 130 | """Return the polling requirement of the entity.""" 131 | return False 132 | 133 | @property 134 | def source_type(self): 135 | """Return the source type, eg gps or router, of the device.""" 136 | return SOURCE_TYPE_GPS 137 | 138 | 139 | 140 | -------------------------------------------------------------------------------- /custom_components/xiaomi_cloud/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "xiaomi_cloud", 3 | "name": "Xiaomi Cloud", 4 | "documentation": "https://github.com/fineemb/xiaomi-cloud", 5 | "issue_tracker": "https://github.com/fineemb/xiaomi-cloud/issues", 6 | "dependencies": [], 7 | "iot_class": "cloud_polling", 8 | "version": "1.2.7", 9 | "config_flow": true, 10 | "codeowners": ["@fineemb"], 11 | "requirements": [] 12 | } 13 | -------------------------------------------------------------------------------- /custom_components/xiaomi_cloud/services.yaml: -------------------------------------------------------------------------------- 1 | # ############################################################################# 2 | # @Author : fineemb 3 | # @Github : https://github.com/fineemb 4 | # @Description : 5 | # @Date : 2019-09-07 02:58:01 6 | # @LastEditors : fineemb 7 | # @LastEditTime : 2020-09-26 20:35:23 8 | # ############################################################################# 9 | 10 | 11 | noise: 12 | description: 设备发声。 13 | fields: 14 | imei: 15 | description: 指定设备的imei. 16 | example: '22275750525251265a4426275c7c6b25585840212f59436b507120317c4' 17 | find: 18 | description: 查找设备,更新设备定位 19 | fields: 20 | imei: 21 | description: 指定设备的imei. 22 | example: '22275750525251265a4426275c7c6b25585840212f59436b507120317c4' 23 | lost: 24 | description: 丢失模式 25 | fields: 26 | imei: 27 | description: 指定设备的imei. 28 | example: '22275750525251265a4426275c7c6b25585840212f59436b507120317c4' 29 | content: 30 | description: 锁定设备屏幕上显示的留言. 31 | example: '捡到手机的好心人,手机里有我十分重要的信息,我希望能找回。麻烦您与我联系,非常感谢。' 32 | phone: 33 | description: 手机号码 34 | example: '1396865665' 35 | onlinenotify: 36 | description: 定位到丢失设备时,短信通知此号码 37 | example: true 38 | 39 | clipboard: 40 | description: 云剪贴板 41 | fields: 42 | text: 43 | description: 输入需要发送到设备剪贴板的文字 44 | example: '云剪贴板' -------------------------------------------------------------------------------- /custom_components/xiaomi_cloud/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "title": "Xiaomi Cloud", 4 | "step": { 5 | "user": { 6 | "title": "Xiaomi Cloud", 7 | "description": "If you need help with the configuration have a look here: https://github.com/fineemb/xiaomi-cloud", 8 | "data": { 9 | "password": "Password", 10 | "username": "Username" 11 | } 12 | } 13 | }, 14 | "error": { 15 | "get_sign_failed": "Get sign failed", 16 | "login_url_failed": "Login failed", 17 | "login_mi_cloud_failed": "Login xiaomi cloud failed", 18 | "get_device_failed": "Get device failed" 19 | }, 20 | "abort": { 21 | "single_instance_allowed": "Only a single configuration of Colorfulclouds Weather is allowed.", 22 | "already_configured": "This is already configured." 23 | } 24 | }, 25 | "options": { 26 | "step": { 27 | "user": { 28 | "data": { 29 | "scan_interval": "Scan interval", 30 | "coordinate_type": "Coordinate type" 31 | } 32 | } 33 | } 34 | } 35 | } -------------------------------------------------------------------------------- /custom_components/xiaomi_cloud/translations/zh-Hans.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "title": "小米云服务", 4 | "step": { 5 | "user": { 6 | "title": "小米云服务", 7 | "description": "如果您需要配置方面的帮助,请查看此处: https://github.com/fineemb/xiaomi-cloud", 8 | "data": { 9 | "password": "密码", 10 | "username": "账号" 11 | } 12 | } 13 | }, 14 | "error": { 15 | "get_sign_failed": "获得Sign失败", 16 | "login_url_failed": "登录失败", 17 | "login_mi_cloud_failed": "登录小米云失败", 18 | "get_device_failed": "没有找到设备" 19 | }, 20 | "abort": { 21 | "single_instance_allowed": "仅允许单个配置.", 22 | "already_configured": "请勿重复配置." 23 | } 24 | }, 25 | "options": { 26 | "step": { 27 | "user": { 28 | "data": { 29 | "scan_interval": "位置更新时间间隔", 30 | "coordinate_type": "坐标体系" 31 | } 32 | } 33 | } 34 | } 35 | } -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "小米云服务", 3 | "domains": ["device_tracker","sensor"], 4 | "render_readme": true, 5 | "homeassistant": "0.99.9", 6 | "country": ["CN"] 7 | } 8 | --------------------------------------------------------------------------------