├── LICENSE ├── README.md ├── micli.py ├── miservice ├── __init__.py ├── miaccount.py ├── miiocommand.py ├── miioservice.py └── minaservice.py └── setup.py /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021-2022 Yonsm 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MiService 2 | XiaoMi Cloud Service for mi.com 3 | 4 | ## Install 5 | ``` 6 | pip3 install aiohttp aiofiles miservice 7 | ``` 8 | 9 | ## Library 10 | ``` 11 | MiService:XiaoMi Cloud Service 12 | | 13 | |-- MiAccount:Account Srvice 14 | |-- MiBaseService:(TODO if needed) 15 | | | 16 | | |-- MiIOService:MiIO Service (sid=xiaomiio) 17 | | | | 18 | | | |-- MIoT_xxx:MIoT Service, Based on MiIO 19 | | | 20 | | |-- MiNAService:MiAI Service (sid=micoapi) 21 | | | 22 | | |-- MiAPIService:(TODO) 23 | |-- MiIOCommand:MiIO Command Style Interface 24 | ``` 25 | 26 | ## Command Line 27 | ``` 28 | MiService 2.1.2 - XiaoMi Cloud Service 29 | 30 | Usage: The following variables must be set: 31 | export MI_USER= 32 | export MI_PASS= 33 | export MI_DID= 34 | 35 | Get Props: ./micli.py [,...] 36 | ./micli.py 1,1-2,1-3,1-4,2-1,2-2,3 37 | Set Props: ./micli.py [,...] 38 | ./micli.py 2=60,2-1=#60,2-2=false,2-3="null",3=test 39 | Do Action: ./micli.py [...] 40 | ./micli.py 2 [] 41 | ./micli.py 5 Hello 42 | ./micli.py 5-4 Hello 1 43 | 44 | Call MIoT: ./micli.py 45 | ./micli.py action '{"did":"267090026","siid":5,"aiid":1,"in":["Hello"]}' 46 | 47 | Call MiIO: ./micli.py / 48 | ./micli.py /home/device_list '{"getVirtualModel":false,"getHuamiDevices":1}' 49 | 50 | Devs List: ./micli.py list [name=full|name_keyword] [getVirtualModel=false|true] [getHuamiDevices=0|1] 51 | ./micli.py list Light true 0 52 | 53 | MIoT Spec: ./micli.py spec [model_keyword|type_urn] [format=text|python|json] 54 | ./micli.py spec 55 | ./micli.py spec speaker 56 | ./micli.py spec xiaomi.wifispeaker.lx04 57 | ./micli.py spec urn:miot-spec-v2:device:speaker:0000A015:xiaomi-lx04:1 58 | 59 | MIoT Decode: ./micli.py decode [gzip] 60 | ``` 61 | 62 | ## 套路,例子: 63 | 64 | `请在 Mac OS 或 Linux 下执行,Windows 下要支持也应该容易但可能需要修改?` 65 | 66 | ### 1. 先设置账号 67 | 68 | ``` 69 | export MI_USER= 70 | export MI_PASS= 71 | ``` 72 | 73 | ### 2. 查询自己的设备 74 | 75 | ``` 76 | micli.py list 77 | ``` 78 | 可以显示自己账号下的设备列表,包含名称、类型、DID、Token 等信息。 79 | 80 | ### 3. 设置 DID 81 | 82 | 为了后续操作,请设置 Device ID(来自上面这条命令的结果)。 83 | 84 | ``` 85 | export MI_DID= 86 | ``` 87 | 88 | ### 4. 查询设备的接口文档 89 | 90 | 查询设备的 MIoT 接口能力描述: 91 | ``` 92 | micli.py spec xiaomi.wifispeaker.lx04 93 | ``` 94 | 其中分为属性获取、属性设置、动作调用三种描述。 95 | 96 | ### 5. 查询音量属性 97 | 98 | ``` 99 | micli.py 2-1 100 | ``` 101 | 其中 `2` 为 `siid`,`1` 为 `piid`(如果是 `1` 则可以省略),可从 spec 接口描述中查得。 102 | 103 | ### 6. 设置音量属性 104 | 105 | ``` 106 | micli.py 2=#60 107 | ``` 108 | 109 | 参数类型要根据接口描述文档来确定: 110 | - `#`是强制文本类型,还可以用单引号`'`和双引号`"`来强制文本类型`'`(可单个引号,也可以两个); 111 | - 如果不强制文本类型,默认将检测类型;可能的检测结果是 JSON 的 `null`、`false`、`true`、`整数`、`浮点数`或者`文本`。 112 | 113 | ### 7. 动作调用:TTS 播报和执行文本 114 | 115 | 以下命令执行后小爱音箱会播报“您好”: 116 | ``` 117 | micli.py 5 您好 118 | ``` 119 | 其中,5 为 `siid`,此处省略了 `aiid`(默认为`1`)。 120 | 121 | 以下命令执行后相当于直接对对音箱说“小爱同学,查询天气”是一个效果: 122 | ``` 123 | micli.py 5-4 查询天气 1 124 | ``` 125 | 126 | 其中 `1` 表示设备语音回应,如果要执行默默关灯(不要音箱回应),可以如下: 127 | ``` 128 | micli.py 5-4 关灯 0 129 | ``` 130 | 131 | 如果没有参数,请传入`[]`保留占位。 132 | 133 | ### 8. 其它应用 134 | 135 | 在扩展插件中使用,比如,参考 [ZhiMsg 小爱同学 TTS 播报/执行插件](https://github.com/Yonsm/ZhiMsg) 136 | -------------------------------------------------------------------------------- /micli.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | from aiohttp import ClientSession 3 | import asyncio 4 | import logging 5 | import json 6 | import os 7 | import sys 8 | from pathlib import Path 9 | 10 | from miservice import MiAccount, MiNAService, MiIOService, miio_command, miio_command_help 11 | 12 | MISERVICE_VERSION = '2.3.0' 13 | 14 | def usage(): 15 | print("MiService %s - XiaoMi Cloud Service\n" % MISERVICE_VERSION) 16 | print("Usage: The following variables must be set:") 17 | print(" export MI_USER=") 18 | print(" export MI_PASS=") 19 | print(" export MI_DID=\n") 20 | print(miio_command_help(prefix=sys.argv[0] + ' ')) 21 | 22 | 23 | async def main(args): 24 | try: 25 | env_get = os.environ.get 26 | store = os.path.join(str(Path.home()), '.mi.token') 27 | async with ClientSession() as session: 28 | account = MiAccount(session, env_get('MI_USER'), env_get('MI_PASS'), store) 29 | if args.startswith('mina'): 30 | service = MiNAService(account) 31 | result = await service.device_list() 32 | if len(args) > 4: 33 | await service.send_message(result, -1, args[4:]) 34 | else: 35 | service = MiIOService(account) 36 | result = await miio_command(service, env_get('MI_DID'), args, sys.argv[0] + ' ') 37 | if not isinstance(result, str): 38 | result = json.dumps(result, indent=2, ensure_ascii=False) 39 | except Exception as e: 40 | result = e 41 | print(result) 42 | 43 | if __name__ == '__main__': 44 | argv = sys.argv 45 | argc = len(argv) 46 | if argc > 1 and argv[1].startswith('-v'): 47 | argi = 2 48 | index = int(argv[1][2]) if len(argv[1]) > 2 else 4 49 | level = [logging.NOTSET, logging.FATAL, logging.ERROR, logging.WARN, logging.INFO, logging.DEBUG][index] 50 | else: 51 | argi = 1 52 | level = logging.WARNING 53 | if argc > argi: 54 | if level != logging.NOTSET: 55 | _LOGGER = logging.getLogger('miservice') 56 | _LOGGER.setLevel(level) 57 | _LOGGER.addHandler(logging.StreamHandler()) 58 | asyncio.run(main(' '.join(argv[argi:]))) 59 | else: 60 | usage() 61 | -------------------------------------------------------------------------------- /miservice/__init__.py: -------------------------------------------------------------------------------- 1 | from .miaccount import MiAccount, MiTokenStore 2 | from .minaservice import MiNAService 3 | from .miioservice import MiIOService 4 | from .miiocommand import miio_command, miio_command_help 5 | 6 | -------------------------------------------------------------------------------- /miservice/miaccount.py: -------------------------------------------------------------------------------- 1 | from base64 import b64encode 2 | from hashlib import md5, sha1 3 | from json import dumps, loads 4 | from os import remove, path 5 | from random import sample 6 | from string import ascii_letters, digits 7 | from urllib import parse 8 | from aiofiles import open as aio_open 9 | 10 | from logging import getLogger 11 | _LOGGER = getLogger(__package__) 12 | 13 | 14 | def get_random(length): 15 | return ''.join(sample(ascii_letters + digits, length)) 16 | 17 | 18 | class MiTokenStore: 19 | 20 | def __init__(self, token_path): 21 | self.token_path = token_path 22 | 23 | async def load_token(self): 24 | if path.isfile(self.token_path): 25 | try: 26 | async with aio_open(self.token_path) as f: 27 | return loads(await f.read()) 28 | except Exception as e: 29 | _LOGGER.exception("Exception on load token from %s: %s", self.token_path, e) 30 | return None 31 | 32 | async def save_token(self, token=None): 33 | if token: 34 | try: 35 | async with aio_open(self.token_path, 'w') as f: 36 | await f.write(dumps(token, indent=2)) 37 | except Exception as e: 38 | _LOGGER.exception("Exception on save token to %s: %s", self.token_path, e) 39 | elif path.isfile(self.token_path): 40 | remove(self.token_path) 41 | 42 | 43 | class MiAccount: 44 | 45 | def __init__(self, session, username, password, token_store='.mi.token'): 46 | self._session = session 47 | self.username = username 48 | self.password = password 49 | self.token_store = MiTokenStore(token_store) if isinstance(token_store, str) else token_store 50 | self.token = None 51 | 52 | def request(self, url, method='GET', **kwargs): 53 | if self._session: 54 | return self._session.request(method, url, **kwargs) 55 | 56 | class RequestContextManager: 57 | async def __aenter__(self): 58 | from aiohttp import ClientSession 59 | self.sess = ClientSession() 60 | self.resp = await self.sess.request(method, url, **kwargs) 61 | return self.resp 62 | 63 | async def __aexit__(self, exc_type, exc, tb): 64 | await self.resp.release() 65 | await self.sess.close() 66 | return RequestContextManager() 67 | 68 | async def login(self, sid): 69 | if not self.token: 70 | self.token = {'deviceId': get_random(16).upper()} 71 | try: 72 | resp = await self._serviceLogin(f'serviceLogin?sid={sid}&_json=true') 73 | if resp['code'] != 0: 74 | data = { 75 | '_json': 'true', 76 | 'qs': resp['qs'], 77 | 'sid': resp['sid'], 78 | '_sign': resp['_sign'], 79 | 'callback': resp['callback'], 80 | 'user': self.username, 81 | 'hash': md5(self.password.encode()).hexdigest().upper() 82 | } 83 | resp = await self._serviceLogin('serviceLoginAuth2', data) 84 | if resp['code'] != 0: 85 | raise Exception(resp) 86 | 87 | self.token['userId'] = resp['userId'] 88 | self.token['passToken'] = resp['passToken'] 89 | 90 | serviceToken = await self._securityTokenService(resp['location'], resp['nonce'], resp['ssecurity']) 91 | self.token[sid] = (resp['ssecurity'], serviceToken) 92 | if self.token_store: 93 | await self.token_store.save_token(self.token) 94 | return True 95 | 96 | except Exception as e: 97 | self.token = None 98 | if self.token_store: 99 | await self.token_store.save_token() 100 | _LOGGER.exception("Exception on login %s: %s", self.username, e) 101 | return False 102 | 103 | async def _serviceLogin(self, uri, data=None): 104 | headers = {'User-Agent': 'APP/com.xiaomi.mihome APPV/6.0.103 iosPassportSDK/3.9.0 iOS/14.4 miHSTS'} 105 | cookies = {'sdkVersion': '3.9', 'deviceId': self.token['deviceId']} 106 | if 'passToken' in self.token: 107 | cookies['userId'] = self.token['userId'] 108 | cookies['passToken'] = self.token['passToken'] 109 | url = 'https://account.xiaomi.com/pass/' + uri 110 | async with self.request(url, 'GET' if data is None else 'POST', data=data, cookies=cookies, headers=headers) as r: 111 | raw = await r.read() 112 | resp = loads(raw[11:]) 113 | # _LOGGER.debug("%s: %s", uri, resp) 114 | return resp 115 | 116 | async def _securityTokenService(self, location, nonce, ssecurity): 117 | nsec = 'nonce=' + str(nonce) + '&' + ssecurity 118 | clientSign = b64encode(sha1(nsec.encode()).digest()).decode() 119 | async with self.request(location + '&clientSign=' + parse.quote(clientSign)) as r: 120 | serviceToken = r.cookies['serviceToken'].value 121 | if not serviceToken: 122 | raise Exception(await r.text()) 123 | return serviceToken 124 | 125 | async def mi_request(self, sid, url, data, headers, relogin=True): 126 | if self.token is None and self.token_store is not None: 127 | self.token = await self.token_store.load_token() 128 | if (self.token and sid in self.token) or await self.login(sid): # Ensure login 129 | cookies = {'userId': self.token['userId'], 'serviceToken': self.token[sid][1]} 130 | content = data(self.token, cookies) if callable(data) else data 131 | method = 'GET' if data is None else 'POST' 132 | # _LOGGER.debug("%s %s", url, content) 133 | async with self.request(url, method, data=content, cookies=cookies, headers=headers) as r: 134 | status = r.status 135 | if status == 200: 136 | resp = await r.json(content_type=None) 137 | code = resp['code'] 138 | if code == 0: 139 | return resp 140 | if 'auth' in resp.get('message', '').lower(): 141 | status = 401 142 | else: 143 | resp = await r.text() 144 | if status == 401 and relogin: 145 | _LOGGER.warning("Auth error on request %s %s, relogin...", url, resp) 146 | self.token = None # Auth error, reset login 147 | if self.token_store: 148 | await self.token_store.save_token() 149 | return await self.mi_request(sid, url, data, headers, False) 150 | else: 151 | resp = "Login failed" 152 | raise Exception(f"Error {url}: {resp}") 153 | -------------------------------------------------------------------------------- /miservice/miiocommand.py: -------------------------------------------------------------------------------- 1 | 2 | from json import loads 3 | from .miioservice import MiIOService 4 | 5 | 6 | def str2tup(string, sep, default=None): 7 | pos = string.find(sep) 8 | return (string, default) if pos == -1 else (string[0:pos], string[pos + 1:]) 9 | 10 | 11 | def str2val(string): 12 | if string[0] in '"\'#': 13 | return string[1:-1] if string[-1] in '"\'#' else string[1:] 14 | elif string == 'null': 15 | return None 16 | elif string == 'false': 17 | return False 18 | elif string == 'true': 19 | return True 20 | elif string.isdigit(): 21 | return int(string) 22 | try: 23 | return float(string) 24 | except: 25 | return string 26 | 27 | 28 | def miio_command_help(did=None, prefix='?'): 29 | quote = '' if prefix == '?' else "'" 30 | return f'\ 31 | Get Props: {prefix}[,...]\n\ 32 | {prefix}1,1-2,1-3,1-4,2-1,2-2,3\n\ 33 | Set Props: {prefix}[,...]\n\ 34 | {prefix}2=60,2-1=#60,2-2=false,2-3="null",3=test\n\ 35 | Do Action: {prefix} [...] \n\ 36 | {prefix}2 []\n\ 37 | {prefix}5 Hello\n\ 38 | {prefix}5-4 Hello 1\n\n\ 39 | Call MIoT: {prefix} \n\ 40 | {prefix}action {quote}{{"did":"{did or "267090026"}","siid":5,"aiid":1,"in":["Hello"]}}{quote}\n\n\ 41 | Call MiIO: {prefix}/ \n\ 42 | {prefix}/home/device_list {quote}{{"getVirtualModel":false,"getHuamiDevices":1}}{quote}\n\n\ 43 | Devs List: {prefix}list [name=full|name_keyword] [getVirtualModel=false|true] [getHuamiDevices=0|1]\n\ 44 | {prefix}list Light true 0\n\n\ 45 | MIoT Spec: {prefix}spec [model_keyword|type_urn] [format=text|python|json]\n\ 46 | {prefix}spec\n\ 47 | {prefix}spec speaker\n\ 48 | {prefix}spec xiaomi.wifispeaker.lx04\n\ 49 | {prefix}spec urn:miot-spec-v2:device:speaker:0000A015:xiaomi-lx04:1\n\n\ 50 | MIoT Decode: {prefix}decode [gzip]\n\ 51 | ' 52 | 53 | 54 | async def miio_command(service: MiIOService, did, text, prefix='?'): 55 | cmd, arg = str2tup(text, ' ') 56 | 57 | if cmd.startswith('/'): 58 | return await service.miio_request(cmd, arg) 59 | 60 | if cmd.startswith('prop') or cmd == 'action': 61 | return await service.miot_request(cmd, loads(arg) if arg else None) 62 | 63 | argv = arg.split(' ') if arg else [] 64 | argc = len(argv) 65 | if cmd == 'list': 66 | return await service.device_list(argc > 0 and argv[0], argc > 1 and str2val(argv[1]), argc > 2 and argv[2]) 67 | 68 | if cmd == 'spec': 69 | return await service.miot_spec(argc > 0 and argv[0], argc > 1 and argv[1]) 70 | 71 | if cmd == 'decode': 72 | return MiIOService.miot_decode(argv[0], argv[1], argv[2], argc > 3 and argv[3] == 'gzip') 73 | 74 | if not did or not cmd or cmd == '?' or cmd == '?' or cmd == 'help' or cmd == '-h' or cmd == '--help': 75 | return miio_command_help(did, prefix) 76 | 77 | if not did.isdigit(): 78 | devices = await service.device_list(did) 79 | if not devices: 80 | return "Device not found: " + did 81 | did = devices[0]['did'] 82 | 83 | props = [] 84 | setp = True 85 | for item in cmd.split(','): 86 | iid, val = str2tup(item, '=') 87 | if val is not None: 88 | iid = (iid, str2val(val)) 89 | if not setp: 90 | return "Invalid command: " + cmd 91 | elif setp: 92 | setp = False 93 | props.append(iid) 94 | miot = (props[0][0][0] if setp else props[0][0]).isdigit() 95 | 96 | if not setp and miot and argc > 0: 97 | args = [] if arg == '[]' else [str2val(a) for a in argv] 98 | return await service.miot_action(did, props[0], args) 99 | 100 | miio_props = service.miio_set_props if setp else service.miio_get_props 101 | return await miio_props(did, props) 102 | -------------------------------------------------------------------------------- /miservice/miioservice.py: -------------------------------------------------------------------------------- 1 | from os import urandom 2 | from os.path import join 3 | from time import time 4 | from base64 import b64encode, b64decode 5 | from hashlib import sha256 6 | from hmac import new as hmac_new 7 | from json import loads, dumps, load, dump 8 | 9 | # REGIONS = ['cn', 'de', 'i2', 'ru', 'sg', 'us'] 10 | 11 | 12 | def str2iid(iid): 13 | pos = iid.find('-') 14 | return (int(iid), 1) if pos == -1 else (int(iid[0:pos]), int(iid[pos + 1:])) 15 | 16 | 17 | class MiIOService: 18 | 19 | def __init__(self, account=None, region=None): 20 | self.account = account 21 | self.server = 'https://' + ('' if region is None or region == 'cn' else region + '.') + 'api.io.mi.com/app' 22 | 23 | async def miio_request(self, uri, data): 24 | def prepare_data(token, cookies): 25 | cookies['PassportDeviceId'] = token['deviceId'] 26 | return MiIOService.sign_data(uri, data, token['xiaomiio'][0]) 27 | headers = {'User-Agent': 'iOS-14.4-6.0.103-iPhone12,3--D7744744F7AF32F0544445285880DD63E47D9BE9-8816080-84A3F44E137B71AE-iPhone', 'x-xiaomi-protocal-flag-cli': 'PROTOCAL-HTTP2'} 28 | resp = await self.account.mi_request('xiaomiio', self.server + uri, prepare_data, headers) 29 | if 'result' not in resp: 30 | raise Exception(f"Error {uri}: {resp}") 31 | return resp['result'] 32 | 33 | async def miio_get_props(self, did, iids): 34 | # iid: (2,1)|[2,1]|"2-1"|"power" 35 | if isinstance(iids[0], str): 36 | if not iids[0][0].isdigit(): 37 | return await self.home_get_props(did, iids) 38 | iids = [str2iid(i) for i in iids] 39 | return await self.miot_get_props(did, iids) 40 | 41 | async def miio_set_props(self, did, props): 42 | if isinstance(props[0][0], str): 43 | if not props[0][0][0].isdigit(): 44 | return await self.home_set_props(did, props) 45 | props = [(*str2iid(prop[0]), prop[1]) for prop in props] 46 | return await self.miot_set_props(did, props) 47 | 48 | async def miio_get_prop(self, did, iid): 49 | if isinstance(iid, str): 50 | if not iid[0].isdigit(): 51 | return await self.home_get_prop(did, iid) 52 | iid = str2iid(iid) 53 | return await self.miot_get_prop(did, iid) 54 | 55 | async def miio_set_prop(self, did, iid, value): 56 | if isinstance(iid, str): 57 | if not iid[0].isdigit(): 58 | return await self.home_set_prop(did, iid, value) 59 | iid = str2iid(iid) 60 | return await self.miot_set_prop(did, iid, value) 61 | 62 | async def home_request(self, did, method, params): 63 | return await self.miio_request('/home/rpc/' + did, {'id': 1, 'method': method, "accessKey": "IOS00026747c5acafc2", 'params': params}) 64 | 65 | async def home_get_props(self, did, props): 66 | return await self.home_request(did, 'get_prop', props) 67 | 68 | async def home_set_props(self, did, props): 69 | return [await self.home_set_prop(did, i[0], i[1]) for i in props] 70 | 71 | async def home_get_prop(self, did, prop): 72 | return (await self.home_get_props(did, [prop]))[0] 73 | 74 | async def home_set_prop(self, did, prop, value): 75 | result = (await self.home_request(did, 'set_' + prop, value if isinstance(value, list) else [value]))[0] 76 | return 0 if result == 'ok' else result 77 | 78 | async def miot_request(self, cmd, params): 79 | return await self.miio_request('/miotspec/' + cmd, {'params': params}) 80 | 81 | async def miot_get_props(self, did, iids): 82 | params = [{'did': did, 'siid': i[0], 'piid': i[1]} for i in iids] 83 | result = await self.miot_request('prop/get', params) 84 | return [it.get('value') if it.get('code') == 0 else None for it in result] 85 | 86 | async def miot_set_props(self, did, props): 87 | params = [{'did': did, 'siid': i[0], 'piid': i[1], 'value': i[2]} for i in props] 88 | result = await self.miot_request('prop/set', params) 89 | return [it.get('code', -1) for it in result] 90 | 91 | async def miot_get_prop(self, did, iid): 92 | return (await self.miot_get_props(did, [iid]))[0] 93 | 94 | async def miot_set_prop(self, did, iid, value): 95 | return (await self.miot_set_props(did, [(iid[0], iid[1], value)]))[0] 96 | 97 | async def miot_action(self, did, iid, args=[]): 98 | result = await self.miot_request('action', {'did': did, 'siid': iid[0], 'aiid': iid[1], 'in': args}) 99 | return result.get('code', -1) 100 | 101 | async def device_list(self, name=None, getVirtualModel=False, getHuamiDevices=0): 102 | result = await self.miio_request('/home/device_list', {'getVirtualModel': bool(getVirtualModel), 'getHuamiDevices': int(getHuamiDevices)}) 103 | result = result['list'] 104 | return result if name == 'full' else [{'name': i['name'], 'model': i['model'], 'did': i['did'], 'token': i['token']} for i in result if not name or name in i['name']] 105 | 106 | async def miot_spec(self, type=None, format=None): 107 | if not type or not type.startswith('urn'): 108 | def get_spec(all): 109 | if not type: 110 | return all 111 | ret = {} 112 | for m, t in all.items(): 113 | if type == m: 114 | return {m: t} 115 | elif type in m: 116 | ret[m] = t 117 | return ret 118 | import tempfile 119 | file = join(tempfile.gettempdir(), 'miservice_miot_specs.json') 120 | try: 121 | with open(file) as f: 122 | result = get_spec(load(f)) 123 | except: 124 | result = None 125 | if not result: 126 | async with self.account.request('http://miot-spec.org/miot-spec-v2/instances?status=all') as r: 127 | all = {i['model']: i['type'] for i in (await r.json())['instances']} 128 | with open(file, 'w') as f: 129 | dump(all, f) 130 | result = get_spec(all) 131 | if len(result) != 1: 132 | return result 133 | type = list(result.values())[0] 134 | 135 | url = 'http://miot-spec.org/miot-spec-v2/instance?type=' + type 136 | async with self.account.request(url) as r: 137 | result = await r.json() 138 | 139 | def parse_desc(node): 140 | desc = node['description'] 141 | # pos = desc.find(' ') 142 | # if pos != -1: 143 | # return (desc[:pos], ' # ' + desc[pos + 2:]) 144 | name = '' 145 | for i in range(len(desc)): 146 | d = desc[i] 147 | if d in '-—{「[【((<《': 148 | return (name, ' # ' + desc[i:]) 149 | name += '_' if d == ' ' else d 150 | return (name, '') 151 | 152 | def make_line(siid, iid, desc, comment, readable=False): 153 | value = f"({siid}, {iid})" if format == 'python' else iid 154 | return f" {'' if readable else '_'}{desc} = {value}{comment}\n" 155 | 156 | if format != 'json': 157 | STR_HEAD, STR_SRV, STR_VALUE = ('from enum import Enum\n\n', '\nclass {}(tuple, Enum):\n', '\nclass {}(int, Enum):\n') if format == 'python' else ('', '{} = {}\n', '{}\n') 158 | text = '# Generated by https://github.com/Yonsm/MiService\n# ' + url + '\n\n' + STR_HEAD 159 | svcs = [] 160 | vals = [] 161 | 162 | for s in result['services']: 163 | siid = s['iid'] 164 | svc = s['description'].replace(' ', '_') 165 | svcs.append(svc) 166 | text += STR_SRV.format(svc, siid) 167 | for p in s.get('properties', []): 168 | name, comment = parse_desc(p) 169 | access = p['access'] 170 | 171 | comment += ''.join([' # ' + k for k, v in [(p['format'], 'string'), (''.join([a[0] for a in access]), 'r')] if k and k != v]) 172 | text += make_line(siid, p['iid'], name, comment, 'read' in access) 173 | if 'value-range' in p: 174 | valuer = p['value-range'] 175 | length = min(3, len(valuer)) 176 | values = {['MIN', 'MAX', 'STEP'][i]: valuer[i] for i in range(length) if i != 2 or valuer[i] != 1} 177 | elif 'value-list' in p: 178 | values = {i['description'].replace(' ', '_') if i['description'] else str(i['value']): i['value'] for i in p['value-list']} 179 | else: 180 | continue 181 | vals.append((svc + '_' + name, values)) 182 | if 'actions' in s: 183 | text += '\n' 184 | for a in s['actions']: 185 | name, comment = parse_desc(a) 186 | comment += ''.join([f" # {io}={a[io]}" for io in ['in', 'out'] if a[io]]) 187 | text += make_line(siid, a['iid'], name, comment) 188 | text += '\n' 189 | for name, values in vals: 190 | text += STR_VALUE.format(name) 191 | for k, v in values.items(): 192 | text += f" {'_' + k if k.isdigit() else k} = {v}\n" 193 | text += '\n' 194 | if format == 'python': 195 | text += '\nALL_SVCS = (' + ', '.join(svcs) + ')\n' 196 | result = text 197 | return result 198 | 199 | @staticmethod 200 | def miot_decode(ssecurity, nonce, data, gzip=False): 201 | from Crypto.Cipher import ARC4 202 | r = ARC4.new(b64decode(MiIOService.sign_nonce(ssecurity, nonce))) 203 | r.encrypt(bytes(1024)) 204 | decrypted = r.encrypt(b64decode(data)) 205 | if gzip: 206 | try: 207 | from io import BytesIO 208 | from gzip import GzipFile 209 | compressed = BytesIO() 210 | compressed.write(decrypted) 211 | compressed.seek(0) 212 | decrypted = GzipFile(fileobj=compressed, mode='rb').read() 213 | except: 214 | pass 215 | return loads(decrypted.decode()) 216 | 217 | @staticmethod 218 | def sign_nonce(ssecurity, nonce): 219 | m = sha256() 220 | m.update(b64decode(ssecurity)) 221 | m.update(b64decode(nonce)) 222 | return b64encode(m.digest()).decode() 223 | 224 | @staticmethod 225 | def sign_data(uri, data, ssecurity): 226 | if not isinstance(data, str): 227 | data = dumps(data) 228 | nonce = b64encode(urandom(8) + int(time() / 60).to_bytes(4, 'big')).decode() 229 | snonce = MiIOService.sign_nonce(ssecurity, nonce) 230 | msg = '&'.join([uri, snonce, nonce, 'data=' + data]) 231 | sign = hmac_new(key=b64decode(snonce), msg=msg.encode(), digestmod=sha256).digest() 232 | return {'_nonce': nonce, 'data': data, 'signature': b64encode(sign).decode()} 233 | -------------------------------------------------------------------------------- /miservice/minaservice.py: -------------------------------------------------------------------------------- 1 | from json import dumps 2 | from .miaccount import MiAccount, get_random 3 | 4 | from logging import getLogger 5 | _LOGGER = getLogger(__package__) 6 | 7 | 8 | class MiNAService: 9 | 10 | def __init__(self, account: MiAccount): 11 | self.account = account 12 | 13 | async def mina_request(self, uri, data=None): 14 | requestId = 'app_ios_' + get_random(30) 15 | if data is not None: 16 | data['requestId'] = requestId 17 | else: 18 | uri += '&requestId=' + requestId 19 | headers = {'User-Agent': 'MiHome/6.0.103 (com.xiaomi.mihome; build:6.0.103.1; iOS 14.4.0) Alamofire/6.0.103 MICO/iOSApp/appStore/6.0.103'} 20 | return await self.account.mi_request('micoapi', 'https://api2.mina.mi.com' + uri, data, headers) 21 | 22 | async def device_list(self, master=0): 23 | result = await self.mina_request('/admin/v2/device_list?master=' + str(master)) 24 | return result.get('data') if result else None 25 | 26 | async def ubus_request(self, deviceId, method, path, message): 27 | message = dumps(message) 28 | result = await self.mina_request('/remote/ubus', {'deviceId': deviceId, 'message': message, 'method': method, 'path': path}) 29 | return result and result.get('code') == 0 30 | 31 | async def text_to_speech(self, deviceId, text): 32 | return await self.ubus_request(deviceId, 'text_to_speech', 'mibrain', {'text': text}) 33 | 34 | async def player_set_volume(self, deviceId, volume): 35 | return await self.ubus_request(deviceId, 'player_set_volume', 'mediaplayer', {'volume': volume, 'media': 'app_ios'}) 36 | 37 | async def send_message(self, devices, devno, message, volume=None): # -1/0/1... 38 | result = False 39 | for i in range(0, len(devices)): 40 | if devno == -1 or devno != i + 1 or devices[i]['capabilities'].get('yunduantts'): 41 | _LOGGER.debug("Send to devno=%d index=%d: %s", devno, i, message or volume) 42 | deviceId = devices[i]['deviceID'] 43 | result = True if volume is None else await self.player_set_volume(deviceId, volume) 44 | if result and message: 45 | result = await self.text_to_speech(deviceId, message) 46 | if not result: 47 | _LOGGER.error("Send failed: %s", message or volume) 48 | if devno != -1 or not result: 49 | break 50 | return result 51 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import os 4 | import sys 5 | 6 | if len(sys.argv) == 1: 7 | os.system('%s sdist' % sys.argv[0]) 8 | os.system('twine upload dist/*') 9 | os.system('rm -rf dist *.egg-info') 10 | exit(0) 11 | 12 | 13 | from pathlib import Path 14 | from setuptools import setup 15 | 16 | from micli import MISERVICE_VERSION 17 | 18 | setup( 19 | name='miservice', 20 | description='XiaoMi Cloud Service', 21 | version=MISERVICE_VERSION, 22 | license='MIT', 23 | author='Yonsm', 24 | author_email='Yonsm@qq.com', 25 | url='https://github.com/Yonsm/MiService', 26 | long_description=Path('README.md').read_text(), 27 | long_description_content_type='text/markdown', 28 | packages=['miservice'], 29 | scripts=['micli.py'], 30 | python_requires='>=3.7', 31 | install_requires=['aiohttp', 'aiofiles'], 32 | classifiers=[ 33 | "Programming Language :: Python :: 3", 34 | "License :: OSI Approved :: MIT License", 35 | "Operating System :: OS Independent" 36 | ] 37 | ) 38 | --------------------------------------------------------------------------------