Skip to content

Commit 4ab21c0

Browse files
author
linyuan.yang
committed
小爱支持多台音箱
1 parent 75ff2cc commit 4ab21c0

5 files changed

Lines changed: 173 additions & 103 deletions

File tree

packages/channel.xiaoai/src/XiaoaiAPI.ts

Lines changed: 94 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@ const MICO_USER_AGENT =
1818
const DEFAULT_CHUNK_LIMIT = 200;
1919
const CHUNK_DELAY_MS = 200;
2020

21+
/**
22+
* 401 后重新登录的最小间隔。小米 passport 对同一 (userId, deviceId, sid) 只保留最新一份
23+
* serviceToken,同一账号被多处登录时会互相挤掉;不限流会形成登录风暴。
24+
*/
25+
const REAUTH_MIN_INTERVAL_MS = 30_000;
26+
2127
export enum XiaoaiAuthMode {
2228
Password = 'password',
2329
PassToken = 'passToken',
@@ -107,75 +113,109 @@ function chunkText(text: string, limit: number = DEFAULT_CHUNK_LIMIT): string[]
107113

108114
export class XiaoaiAPI {
109115
private authed?: AuthedAccount;
110-
private speakerDeviceId = '';
116+
private authing?: Promise<AuthedAccount>;
117+
private lastAuthAt = 0;
111118

112119
constructor(private options: XiaoaiAPIOptions) {}
113120

114-
setSpeakerDeviceId(deviceId: string): void {
115-
this.speakerDeviceId = deviceId;
116-
}
117-
118121
async getDeviceList(): Promise<MiNADevice[]> {
119-
const resp = await axios.get(`${MINA_BASE}/admin/v2/device_list`, {
120-
params: { master: 1 },
121-
headers: this.minaHeaders(await this.auth()),
122+
return this.withAuthRetry(async (account) => {
123+
const resp = await axios.get(`${MINA_BASE}/admin/v2/device_list`, {
124+
params: { master: 1 },
125+
headers: this.minaHeaders(account),
126+
});
127+
return resp.data?.data ?? [];
122128
});
123-
return resp.data?.data ?? [];
124129
}
125130

126131
async getConversations(
132+
speakerDeviceId: string,
127133
hardware: string,
128134
limit = 2,
129135
): Promise<MiConversation[]> {
130-
if (!this.speakerDeviceId) return [];
131-
132-
const account = await this.auth();
133-
const cookie = `userId=${account.userId}; serviceToken=${account.serviceToken}; deviceId=${this.speakerDeviceId}`;
134-
const resp = await axios.get(`${USER_PROFILE_BASE}/device_profile/v2/conversation`, {
135-
params: {
136-
source: 'dialogu',
137-
hardware,
138-
limit,
139-
requestId: crypto.randomUUID(),
140-
},
141-
headers: {
142-
'User-Agent': MICO_USER_AGENT,
143-
Referer: 'https://userprofile.mina.mi.com/dialogue-note/index.html',
144-
Cookie: cookie,
145-
},
146-
});
136+
if (!speakerDeviceId) return [];
137+
138+
return this.withAuthRetry(async (account) => {
139+
const cookie = `userId=${account.userId}; serviceToken=${account.serviceToken}; deviceId=${speakerDeviceId}`;
140+
const resp = await axios.get(`${USER_PROFILE_BASE}/device_profile/v2/conversation`, {
141+
params: {
142+
source: 'dialogu',
143+
hardware,
144+
limit,
145+
requestId: crypto.randomUUID(),
146+
},
147+
headers: {
148+
'User-Agent': MICO_USER_AGENT,
149+
Referer: 'https://userprofile.mina.mi.com/dialogue-note/index.html',
150+
Cookie: cookie,
151+
},
152+
});
147153

148-
let payload: any = resp.data?.data;
149-
if (typeof payload === 'string') {
150-
try {
151-
payload = JSON.parse(payload);
152-
} catch {
153-
return [];
154+
let payload: any = resp.data?.data;
155+
if (typeof payload === 'string') {
156+
try {
157+
payload = JSON.parse(payload);
158+
} catch {
159+
return [];
160+
}
154161
}
155-
}
156-
return payload?.records ?? [];
162+
return payload?.records ?? [];
163+
});
157164
}
158165

159166
async speak(
167+
speakerDeviceId: string,
160168
text: string,
161169
options?: XiaoaiSpeakOptions,
162170
): Promise<void> {
163-
if (!this.speakerDeviceId) return;
171+
if (!speakerDeviceId) return;
164172

165173
if (options?.volume) {
166-
await this.setVolume(this.speakerDeviceId, options.volume);
174+
await this.setVolume(speakerDeviceId, options.volume);
167175
}
168176

169177
const chunks = chunkText(text, options?.chunkLimit ?? DEFAULT_CHUNK_LIMIT);
170178
for (let i = 0; i < chunks.length; i++) {
171179
if (i > 0) await sleep(CHUNK_DELAY_MS);
172-
await this.textToSpeech(this.speakerDeviceId, chunks[i]);
180+
await this.textToSpeech(speakerDeviceId, chunks[i]);
173181
}
174182
}
175183

184+
/**
185+
* 请求遇到 401 时作废缓存的 serviceToken、重新登录并重试一次。
186+
* 不重试其他错误:轮询侧已有指数退避。
187+
*/
188+
private async withAuthRetry<T>(fn: (account: AuthedAccount) => Promise<T>): Promise<T> {
189+
const account = await this.auth();
190+
try {
191+
return await fn(account);
192+
} catch (e: any) {
193+
if (e?.response?.status !== 401) throw e;
194+
// token 已被其他并发调用换掉 → 直接拿新的重试;仍是同一份才作废重登
195+
if (this.authed === account && !this.invalidate()) throw e;
196+
return fn(await this.auth());
197+
}
198+
}
199+
200+
/** 作废当前 token;距上次登录不足 REAUTH_MIN_INTERVAL_MS 则拒绝,返回是否已作废。 */
201+
private invalidate(): boolean {
202+
if (Date.now() - this.lastAuthAt < REAUTH_MIN_INTERVAL_MS) return false;
203+
this.authed = undefined;
204+
return true;
205+
}
206+
176207
private async auth(): Promise<AuthedAccount> {
177208
if (this.authed) return this.authed;
209+
// 多台音箱共用同一实例,首次轮询会并发触发登录——去重,只登录一次
210+
if (this.authing) return this.authing;
178211

212+
this.authing = this.login().finally(() => {
213+
this.authing = undefined;
214+
});
215+
return this.authing;
216+
}
217+
218+
private async login(): Promise<AuthedAccount> {
179219
const { userId, authMode, credential } = this.options;
180220
const deviceId = this.options.deviceId || randomDeviceId();
181221
const password = authMode === XiaoaiAuthMode.Password ? credential : '';
@@ -226,6 +266,7 @@ export class XiaoaiAPI {
226266
const tokenUrl = `${pass.location}&clientSign=${encodeURIComponent(clientSign)}`;
227267
const serviceToken = await this.resolveServiceToken(tokenUrl);
228268
this.authed = { userId, serviceToken, deviceId };
269+
this.lastAuthAt = Date.now();
229270
return this.authed;
230271
}
231272

@@ -249,39 +290,34 @@ export class XiaoaiAPI {
249290
}
250291

251292
private async textToSpeech(speakerDeviceId: string, text: string): Promise<void> {
252-
await axios.post(
253-
`${MINA_BASE}/remote/ubus`,
254-
new URLSearchParams({
255-
deviceId: speakerDeviceId,
256-
path: 'mibrain',
257-
method: 'text_to_speech',
258-
message: JSON.stringify({ text, save: 0 }),
259-
}).toString(),
260-
{
261-
headers: {
262-
...this.minaHeaders(await this.auth()),
263-
'Content-Type': 'application/x-www-form-urlencoded',
264-
},
265-
},
266-
);
293+
await this.ubus(speakerDeviceId, 'mibrain', 'text_to_speech', { text, save: 0 });
267294
}
268295

269296
private async setVolume(speakerDeviceId: string, volume: number): Promise<void> {
270-
await axios.post(
297+
await this.ubus(speakerDeviceId, 'mediaplayer', 'player_set_volume', { volume, media: 'app_ios' });
298+
}
299+
300+
private async ubus(
301+
speakerDeviceId: string,
302+
path: string,
303+
method: string,
304+
message: Record<string, any>,
305+
): Promise<void> {
306+
await this.withAuthRetry((account) => axios.post(
271307
`${MINA_BASE}/remote/ubus`,
272308
new URLSearchParams({
273309
deviceId: speakerDeviceId,
274-
path: 'mediaplayer',
275-
method: 'player_set_volume',
276-
message: JSON.stringify({ volume, media: 'app_ios' }),
310+
path,
311+
method,
312+
message: JSON.stringify(message),
277313
}).toString(),
278314
{
279315
headers: {
280-
...this.minaHeaders(await this.auth()),
316+
...this.minaHeaders(account),
281317
'Content-Type': 'application/x-www-form-urlencoded',
282318
},
283319
},
284-
);
320+
));
285321
}
286322

287323
private passportCookies(deviceId: string, passToken?: string): string {

packages/channel.xiaoai/src/XiaoaiService.ts

Lines changed: 51 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,11 @@ import {
55
import { XiaoaiAPI, XiaoaiAuthMode } from './XiaoaiAPI';
66
import { MessagePoller, type PollingMessage } from './polling';
77
import { XiaoaiSessionHandler } from './XiaoaiSessionHandler';
8+
import type { MiNADevice } from './types';
89

910
export interface XiaoaiMessageArgs extends ChannelMessageArgs {
1011
accountUserId: string;
11-
deviceId: string;
12+
/** 音箱在米家里的别名,用于组装 sessionName;deviceID 见继承来的 sessionId */
1213
deviceName: string;
1314
}
1415

@@ -17,7 +18,8 @@ export interface XiaoaiServiceOptions {
1718
authMode: XiaoaiAuthMode;
1819
credential: string;
1920
loginDeviceId?: string;
20-
deviceName: string;
21+
/** 要接入的音箱名称/别名/deviceID 列表,一个频道可同时绑定多台 */
22+
deviceNames: string[];
2123
heartbeat: number;
2224
textChunkLimit: number;
2325
volume?: number;
@@ -47,48 +49,72 @@ export class XiaoaiService implements IChannelService {
4749
return new XiaoaiSessionHandler(session, this);
4850
}
4951

50-
async sendTextToSession(_sessionId: string, text: string): Promise<void> {
51-
await this.api.speak(text, {
52+
/** sessionId 就是音箱 deviceID;校验它属于本频道已启动的音箱,避免把库里的历史值当设备用 */
53+
async sendTextToSession(sessionId: string, text: string): Promise<void> {
54+
if (!this.poller?.hasDevice(sessionId)) {
55+
this.logger?.warn(`XiaoAi sendTextToSession: unknown session ${sessionId}`);
56+
return;
57+
}
58+
await this.api.speak(sessionId, text, {
5259
chunkLimit: this.options.textChunkLimit,
5360
volume: this.options.volume,
5461
});
5562
}
5663

5764
async start(): Promise<void> {
58-
const { userId, deviceName } = this.options;
65+
const { userId, deviceNames } = this.options;
5966

6067
const allDevices = await this.api.getDeviceList();
68+
const available = allDevices
69+
.map((d) => (d.alias && d.alias !== d.name ? `${d.name} (${d.alias})` : d.name))
70+
.join(', ');
6171

62-
// 配置值可以是米家里的名称/别名,也可以是 deviceID / miotDID
63-
const matches = allDevices.filter(
64-
(d) => d.name === deviceName || d.alias === deviceName
65-
|| d.deviceID === deviceName || d.miotDID === deviceName,
66-
);
67-
if (matches.length === 0) {
68-
const available = allDevices
69-
.map((d) => (d.alias && d.alias !== d.name ? `${d.name} (${d.alias})` : d.name))
70-
.join(', ');
71-
throw new Error(`Device "${deviceName}" not found. Available: ${available}`);
72+
// deviceID 去重:多个配置项(名称、别名、deviceID)可能指向同一台音箱
73+
const matched = new Map<string, MiNADevice>();
74+
const missing: string[] = [];
75+
for (const deviceName of deviceNames) {
76+
// 配置值可以是米家里的名称/别名,也可以是 deviceID / miotDID
77+
const matches = allDevices.filter(
78+
(d) => d.name === deviceName || d.alias === deviceName
79+
|| d.deviceID === deviceName || d.miotDID === deviceName,
80+
);
81+
if (matches.length === 0) {
82+
missing.push(deviceName);
83+
continue;
84+
}
85+
if (matches.length > 1) {
86+
this.logger?.warn(
87+
`XiaoAi: "${deviceName}" matched ${matches.length} devices, using deviceId=${matches[0].deviceID}. `
88+
+ '改填 deviceID 可精确指定。',
89+
);
90+
}
91+
matched.set(matches[0].deviceID, matches[0]);
7292
}
73-
const matched = matches[0];
74-
if (matches.length > 1) {
93+
94+
if (matched.size === 0) {
95+
throw new Error(`Device "${deviceNames.join('", "')}" not found. Available: ${available}`);
96+
}
97+
// 部分匹配失败只告警:一台音箱下线/改名不应拖垮同频道其余音箱
98+
if (missing.length > 0) {
7599
this.logger?.warn(
76-
`XiaoAi: "${deviceName}" matched ${matches.length} devices, using deviceId=${matched.deviceID}. `
77-
+ '改填 deviceID 可精确指定。',
100+
`XiaoAi: device(s) not found, skipped: "${missing.join('", "')}". Available: ${available}`,
78101
);
79102
}
80-
const displayName = matched.alias || matched.name || deviceName;
81103

82104
this.poller = new MessagePoller(
83105
this.api,
84106
this.options.heartbeat,
85107
(msg) => this.handleMessage(msg),
86108
this.logger,
87109
);
88-
this.api.setSpeakerDeviceId(matched.deviceID);
89-
this.poller.startDevice(matched.deviceID, displayName, matched.hardware);
110+
const started: string[] = [];
111+
for (const device of matched.values()) {
112+
const displayName = device.alias || device.name || device.deviceID;
113+
this.poller.startDevice(device.deviceID, displayName, device.hardware);
114+
started.push(`${displayName}(deviceId=${device.deviceID}, hardware=${device.hardware})`);
115+
}
90116
this.logger?.info(
91-
`XiaoAi started: userId=${userId}, deviceName=${displayName}, deviceId=${matched.deviceID}, hardware=${matched.hardware}`,
117+
`XiaoAi started: userId=${userId}, ${matched.size} device(s): ${started.join(', ')}`,
92118
);
93119
}
94120

@@ -97,13 +123,11 @@ export class XiaoaiService implements IChannelService {
97123
const eventId = `xiaoai_${userId}_${msg.deviceId}_${msg.timestamp}`;
98124
if (!(await this.options.filterEvent(eventId))) return;
99125

100-
// 用 deviceId 而非名称:音箱在米家里改名后会话不断裂
101-
const sessionId = `xiaoai:${userId}:${msg.deviceId}`;
102126
await this.options.onReceiveMessage(
103127
{
104-
sessionId,
128+
// 用 deviceId 而非名称做 sessionId:音箱在米家里改名后会话不断裂
129+
sessionId: msg.deviceId,
105130
accountUserId: userId,
106-
deviceId: msg.deviceId,
107131
deviceName: msg.deviceName,
108132
},
109133
msg.text,

0 commit comments

Comments
 (0)