Skip to content

Commit 6c18971

Browse files
committed
feat: 初步接入米家小爱音箱
1 parent d488c88 commit 6c18971

File tree

11 files changed

+755
-0
lines changed

11 files changed

+755
-0
lines changed

astrbot/core/config/default.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,17 @@
105105
"host": "localhost",
106106
"port": 11451,
107107
},
108+
"mispeaker(小爱音箱)": {
109+
"id": "mispeaker",
110+
"type": "mispeaker",
111+
"enable": False,
112+
"username": "",
113+
"password": "",
114+
"did": "",
115+
"activate_word": "测试",
116+
"deactivate_word": "停止",
117+
"interval": 1,
118+
},
108119
},
109120
"items": {
110121
"id": {

astrbot/core/platform/manager.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ def __init__(self, config: AstrBotConfig, event_queue: Queue):
2727
from .sources.vchat.vchat_platform_adapter import VChatPlatformAdapter # noqa: F401
2828
case "gewechat":
2929
from .sources.gewechat.gewechat_platform_adapter import GewechatPlatformAdapter # noqa: F401
30+
case "mispeaker":
31+
from .sources.mispeaker.mispeaker_adapter import MiSpeakerPlatformAdapter # noqa: F401
3032

3133

3234
async def initialize(self):
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import os
2+
import json
3+
import asyncio
4+
import aiohttp
5+
import time
6+
import traceback
7+
from .miservice import MiAccount, MiNAService, MiIOService, miio_command, miio_command_help
8+
from astrbot.core import logger
9+
from astrbot.api.platform import AstrBotMessage, MessageMember, MessageType
10+
from astrbot.api.message_components import Plain, Image, At
11+
12+
class SimpleMiSpeakerClient():
13+
'''
14+
@author: Soulter
15+
@references: https://github.com/yihong0618/xiaogpt/blob/main/xiaogpt/xiaogpt.py
16+
'''
17+
def __init__(self, config: dict):
18+
self.username = config['username']
19+
self.password = config['password']
20+
self.did = config['did']
21+
self.store = os.path.join("data", '.mi.token')
22+
self.interval = float(config.get('interval', 1))
23+
24+
self.conv_query_cookies = {
25+
'userId': '',
26+
'deviceId': '',
27+
'serviceToken': ''
28+
}
29+
30+
self.MI_CONVERSATION_URL = "https://userprofile.mina.mi.com/device_profile/v2/conversation?source=dialogu&hardware={hardware}&timestamp={timestamp}&limit=1"
31+
32+
self.session = aiohttp.ClientSession()
33+
34+
self.activate_word = config.get('activate_word', '测试')
35+
self.deactivate_word = config.get('deactivate_word', '停止')
36+
37+
self.entered = False
38+
39+
async def initialize(self):
40+
account = MiAccount(self.session, self.username, self.password, self.store)
41+
self.miio_service = MiIOService(account) # 小米设备服务
42+
self.mina_service = MiNAService(account) # 小爱音箱服务
43+
44+
device = await self.get_mina_device()
45+
46+
self.deviceID = device['deviceID']
47+
self.hardware = device['hardware']
48+
49+
with open(self.store, 'r') as f:
50+
data = json.load(f)
51+
self.userId = data['userId']
52+
self.serviceToken = data['micoapi'][1]
53+
self.conv_query_cookies['userId'] = self.userId
54+
self.conv_query_cookies['deviceId'] = self.deviceID
55+
self.conv_query_cookies['serviceToken'] = self.serviceToken
56+
57+
logger.info(f"MiSpeakerClient initialized. Conv cookies: {self.conv_query_cookies}. Hardware: {self.hardware}")
58+
59+
async def get_mina_device(self) -> dict:
60+
devices = await self.mina_service.device_list()
61+
for device in devices:
62+
if device['miotDID'] == self.did:
63+
logger.info(f"找到设备 {device['alias']}({device['name']}) 了!")
64+
return device
65+
66+
async def get_conv(self) -> str:
67+
# 时区请确保为北京时间
68+
async with aiohttp.ClientSession() as session:
69+
session.cookie_jar.update_cookies(self.conv_query_cookies)
70+
query_ts = int(time.time())*1000
71+
logger.debug(f"Querying conversation at {query_ts}")
72+
async with session.get(self.MI_CONVERSATION_URL.format(hardware=self.hardware, timestamp=str(query_ts))) as resp:
73+
json_blob = await resp.json()
74+
if json_blob['code'] == 0:
75+
data = json.loads(json_blob['data'])
76+
records = data.get('records', None)
77+
for record in records:
78+
if record['time'] >= query_ts - self.interval*1000:
79+
return record['query']
80+
else:
81+
logger.error(f"Failed to get conversation: {json_blob}")
82+
83+
return None
84+
85+
async def start_pooling(self):
86+
while True:
87+
await asyncio.sleep(self.interval)
88+
try:
89+
query = await self.get_conv()
90+
if not query:
91+
continue
92+
93+
# is wake
94+
if query == self.activate_word:
95+
self.entered = True
96+
await self.stop_playing()
97+
await self.send("我来啦!")
98+
continue
99+
elif query == self.deactivate_word:
100+
self.entered = False
101+
await self.stop_playing()
102+
await self.send("再见,欢迎给个 Star。")
103+
continue
104+
if not self.entered:
105+
continue
106+
107+
await self.send("")
108+
abm = await self._convert(query)
109+
110+
if abm:
111+
coro = getattr(self, "on_event_received")
112+
if coro:
113+
await coro(abm)
114+
115+
except BaseException as e:
116+
traceback.print_exc()
117+
logger.error(e)
118+
119+
async def _convert(self, query: str):
120+
abm = AstrBotMessage()
121+
abm.message = [Plain(query)]
122+
abm.message_id = str(int(time.time()))
123+
abm.message_str = query
124+
abm.raw_message = query
125+
abm.session_id = f"{self.hardware}_{self.did}_{self.username}"
126+
abm.sender = MessageMember(self.username, "主人")
127+
abm.self_id = f"{self.hardware}_{self.did}"
128+
abm.type = MessageType.FRIEND_MESSAGE
129+
return abm
130+
131+
async def send(self, message: str):
132+
text = f'5 {message}'
133+
await miio_command(self.miio_service, self.did, text, 'astrbot')
134+
135+
async def stop_playing(self):
136+
text = f'3-2'
137+
await miio_command(self.miio_service, self.did, text, 'astrbot')
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
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.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
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+
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import base64
2+
import hashlib
3+
import json
4+
import logging
5+
import os
6+
import random
7+
import string
8+
from urllib import parse
9+
from aiohttp import ClientSession
10+
from aiofiles import open as async_open
11+
12+
_LOGGER = logging.getLogger(__package__)
13+
14+
15+
def get_random(length):
16+
return ''.join(random.sample(string.ascii_letters + string.digits, length))
17+
18+
19+
class MiTokenStore:
20+
21+
def __init__(self, token_path):
22+
self.token_path = token_path
23+
24+
async def load_token(self):
25+
if os.path.isfile(self.token_path):
26+
try:
27+
async with async_open(self.token_path) as f:
28+
return json.loads(await f.read())
29+
except Exception as e:
30+
_LOGGER.exception("Exception on load token from %s: %s", self.token_path, e)
31+
return None
32+
33+
async def save_token(self, token=None):
34+
if token:
35+
try:
36+
async with async_open(self.token_path, 'w') as f:
37+
await f.write(json.dumps(token, indent=2))
38+
except Exception as e:
39+
_LOGGER.exception("Exception on save token to %s: %s", self.token_path, e)
40+
elif os.path.isfile(self.token_path):
41+
os.remove(self.token_path)
42+
43+
44+
class MiAccount:
45+
46+
def __init__(self, session: ClientSession, username, password, token_store='.mi.token'):
47+
self.session = session
48+
self.username = username
49+
self.password = password
50+
self.token_store = MiTokenStore(token_store) if isinstance(token_store, str) else token_store
51+
self.token = None
52+
53+
async def login(self, sid):
54+
if not self.token:
55+
self.token = {'deviceId': get_random(16).upper()}
56+
try:
57+
resp = await self._serviceLogin(f'serviceLogin?sid={sid}&_json=true')
58+
if resp['code'] != 0:
59+
data = {
60+
'_json': 'true',
61+
'qs': resp['qs'],
62+
'sid': resp['sid'],
63+
'_sign': resp['_sign'],
64+
'callback': resp['callback'],
65+
'user': self.username,
66+
'hash': hashlib.md5(self.password.encode()).hexdigest().upper()
67+
}
68+
resp = await self._serviceLogin('serviceLoginAuth2', data)
69+
if resp['code'] != 0:
70+
raise Exception(resp)
71+
72+
self.token['userId'] = resp['userId']
73+
self.token['passToken'] = resp['passToken']
74+
75+
serviceToken = await self._securityTokenService(resp['location'], resp['nonce'], resp['ssecurity'])
76+
self.token[sid] = (resp['ssecurity'], serviceToken)
77+
if self.token_store:
78+
await self.token_store.save_token(self.token)
79+
return True
80+
81+
except Exception as e:
82+
self.token = None
83+
if self.token_store:
84+
await self.token_store.save_token()
85+
_LOGGER.exception("Exception on login %s: %s", self.username, e)
86+
return False
87+
88+
async def _serviceLogin(self, uri, data=None):
89+
headers = {'User-Agent': 'APP/com.xiaomi.mihome APPV/6.0.103 iosPassportSDK/3.9.0 iOS/14.4 miHSTS'}
90+
cookies = {'sdkVersion': '3.9', 'deviceId': self.token['deviceId']}
91+
if 'passToken' in self.token:
92+
cookies['userId'] = self.token['userId']
93+
cookies['passToken'] = self.token['passToken']
94+
url = 'https://account.xiaomi.com/pass/' + uri
95+
async with self.session.request('GET' if data is None else 'POST', url, data=data, cookies=cookies, headers=headers) as r:
96+
raw = await r.read()
97+
resp = json.loads(raw[11:])
98+
_LOGGER.debug("%s: %s", uri, resp)
99+
return resp
100+
101+
async def _securityTokenService(self, location, nonce, ssecurity):
102+
nsec = 'nonce=' + str(nonce) + '&' + ssecurity
103+
clientSign = base64.b64encode(hashlib.sha1(nsec.encode()).digest()).decode()
104+
async with self.session.get(location + '&clientSign=' + parse.quote(clientSign)) as r:
105+
serviceToken = r.cookies['serviceToken'].value
106+
if not serviceToken:
107+
raise Exception(await r.text())
108+
return serviceToken
109+
110+
async def mi_request(self, sid, url, data, headers, relogin=True):
111+
if self.token is None and self.token_store is not None:
112+
self.token = await self.token_store.load_token()
113+
if (self.token and sid in self.token) or await self.login(sid): # Ensure login
114+
cookies = {'userId': self.token['userId'], 'serviceToken': self.token[sid][1]}
115+
content = data(self.token, cookies) if callable(data) else data
116+
method = 'GET' if data is None else 'POST'
117+
_LOGGER.debug("%s %s", url, content)
118+
async with self.session.request(method, url, data=content, cookies=cookies, headers=headers) as r:
119+
status = r.status
120+
if status == 200:
121+
resp = await r.json(content_type=None)
122+
code = resp['code']
123+
if code == 0:
124+
return resp
125+
if 'auth' in resp.get('message', '').lower():
126+
status = 401
127+
else:
128+
resp = await r.text()
129+
if status == 401 and relogin:
130+
_LOGGER.warn("Auth error on request %s %s, relogin...", url, resp)
131+
self.token = None # Auth error, reset login
132+
return await self.mi_request(sid, url, data, headers, False)
133+
else:
134+
resp = "Login failed"
135+
raise Exception(f"Error {url}: {resp}")

0 commit comments

Comments
 (0)