@openbrt/audioctl 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -20,6 +20,7 @@ run doctor, then use it as my default BGM/music output device.
20
20
  npm install -g @openbrt/audioctl
21
21
  audioctl bind 123456 --name codex
22
22
  audioctl doctor --online
23
+ audioctl inspect
23
24
  ```
24
25
 
25
26
  One-shot usage:
@@ -42,11 +43,28 @@ audioctl volume 48
42
43
  audioctl volumeup
43
44
  audioctl volumedown
44
45
  audioctl status
46
+ audioctl inspect --json
47
+ audioctl workbgm --play
45
48
  audioctl rule ./my-playlist-rule.json
46
49
  audioctl deepnight
47
50
  audioctl prompt 123456 --agent codex --alias 视听房音箱
48
51
  ```
49
52
 
53
+ `inspect` asks the device for a redacted configuration/status snapshot over the
54
+ existing MQTT channel. Agent scripts can read Wi-Fi connection state, BLE
55
+ provisioning state, wake-word capability, playback, VM/app status, and health
56
+ without receiving Wi-Fi passwords, MQTT credentials, or music-platform secrets.
57
+
58
+ `workbgm --play` installs a persistent `work-bgm-autopilot` VM app on the audio
59
+ device, writes the bundled 工作 BGM playlist rule through the device-side music
60
+ adapter, requests a refresh, and starts playback once. The app expresses a
61
+ continuous BGM intent and respects later user-initiated pause/resume controls.
62
+ If the device reports `netease_auth_refresh_failed`, the agent should stop
63
+ retrying and tell the user to complete the NetEase Music authorization flow in a
64
+ trusted device/provisioning or maintenance environment. The agent must not ask
65
+ the user to paste AppSecret, PrivateKey, accessToken, refreshToken, Wi-Fi
66
+ passwords, or MQTT credentials into chat.
67
+
50
68
  `deepnight` pushes the bundled “深夜工作” playlist rule and asks the device to
51
69
  refresh/play according to its local runtime. The package never contains music
52
70
  platform private keys.
package/agent-plugin.json CHANGED
@@ -27,8 +27,12 @@
27
27
  "audio.previous",
28
28
  "audio.seek",
29
29
  "audio.set_volume",
30
+ "device.inspect",
31
+ "music.autopilot",
30
32
  "music.set_rule",
31
- "music.status"
33
+ "music.work_bgm",
34
+ "music.status",
35
+ "app.install"
32
36
  ],
33
37
  "pairing": {
34
38
  "endpoint": "https://weclawbot.link/byoa",
@@ -45,6 +49,10 @@
45
49
  "name": "doctor",
46
50
  "usage": "audioctl doctor --online"
47
51
  },
52
+ {
53
+ "name": "inspect",
54
+ "usage": "audioctl inspect --json"
55
+ },
48
56
  {
49
57
  "name": "control",
50
58
  "usage": "audioctl play|pause|toggle|next|previous|volumeup|volumedown|status"
@@ -56,6 +64,14 @@
56
64
  {
57
65
  "name": "rule",
58
66
  "usage": "audioctl rule RULE.json"
67
+ },
68
+ {
69
+ "name": "app install",
70
+ "usage": "audioctl app install MANIFEST.json APP.wasm"
71
+ },
72
+ {
73
+ "name": "workbgm",
74
+ "usage": "audioctl workbgm --play"
59
75
  }
60
76
  ]
61
77
  }
package/bin/audioctl.mjs CHANGED
@@ -10,12 +10,16 @@ import {
10
10
  DEEPNIGHT_RULE,
11
11
  PACKAGE_NAME,
12
12
  bindAgent,
13
+ buildAppInstallControl,
13
14
  buildAgentPrompt,
14
15
  buildAudioControl,
16
+ buildWorkBgmAppInstallControl,
15
17
  expandPath,
16
18
  normalizeCredentials,
17
19
  publishAudioControl,
18
20
  readCredentials,
21
+ remediationForStatus,
22
+ summarizeInspect,
19
23
  summarizePlayback,
20
24
  testConnection,
21
25
  validateRule,
@@ -37,9 +41,12 @@ if (!command || new Set(["help", "-h", "--help"]).has(command)) {
37
41
  async function run(name, values) {
38
42
  if (name === "bind") return commandBind(values);
39
43
  if (name === "doctor") return commandDoctor(values);
44
+ if (name === "inspect") return commandInspect(values);
40
45
  if (name === "prompt") return commandPrompt(values);
41
46
  if (name === "manifest") return commandManifest();
47
+ if (name === "app") return commandApp(values);
42
48
  if (name === "rule") return commandRule(values);
49
+ if (name === "workbgm") return commandWorkBgm(values);
43
50
  if (name === "deepnight") return commandDeepnight(values);
44
51
  if (name === "volume") return commandVolume(values);
45
52
  if (name === "seek") return commandSeek(values);
@@ -50,11 +57,14 @@ function usage(exitCode = 0) {
50
57
  process.stdout.write(`Usage:
51
58
  ${COMMAND_NAME} bind CODE [--name AGENT]
52
59
  ${COMMAND_NAME} doctor [--online] [--json]
60
+ ${COMMAND_NAME} inspect [--json]
53
61
  ${COMMAND_NAME} prompt CODE [--agent AGENT] [--alias NAME]
54
62
  ${COMMAND_NAME} play|pause|toggle|next|previous|volumeup|volumedown|status
55
63
  ${COMMAND_NAME} volume 0..100
56
64
  ${COMMAND_NAME} seek MILLISECONDS
57
65
  ${COMMAND_NAME} rule RULE.json
66
+ ${COMMAND_NAME} app install MANIFEST.json APP.wasm
67
+ ${COMMAND_NAME} workbgm [--play]
58
68
  ${COMMAND_NAME} deepnight
59
69
  ${COMMAND_NAME} manifest
60
70
 
@@ -110,8 +120,52 @@ async function commandDoctor(values) {
110
120
  if (options.online) {
111
121
  await testConnection(credentials, { timeoutMs: Number(options.timeout) * 1000 });
112
122
  status.online = true;
123
+ const delivery = await publishAudioControl(credentials, buildAudioControl("status"), {
124
+ timeoutMs: Number(options.timeout) * 1000,
125
+ });
126
+ status.device_status = delivery.status || null;
127
+ status.healthy = delivery.status?.health?.ok !== false;
128
+ status.ok = status.healthy;
129
+ status.remediation = remediationForStatus(delivery.status);
113
130
  }
114
131
  print(status, options.json);
132
+ if (options.online && status.healthy === false) process.exitCode = 1;
133
+ }
134
+
135
+ async function commandInspect(values) {
136
+ const options = parseOptions(values, {
137
+ credentials: credentialsPath(),
138
+ timeout: 12,
139
+ json: false,
140
+ });
141
+ const credentials = await readCredentials(options.credentials);
142
+ const delivery = await publishAudioControl(credentials, buildAudioControl("status"), {
143
+ timeoutMs: Number(options.timeout) * 1000,
144
+ });
145
+ if (delivery.status?.kind === "rejected") {
146
+ print(delivery.status, options.json);
147
+ process.exitCode = 1;
148
+ return;
149
+ }
150
+ const inspect = delivery.status?.inspect || delivery.status || {};
151
+ const remediation = remediationForStatus(inspect);
152
+ if (remediation.length > 0 && !Array.isArray(inspect.remediation)) {
153
+ inspect.remediation = remediation;
154
+ }
155
+ if (options.json) {
156
+ process.stdout.write(`${JSON.stringify(inspect)}\n`);
157
+ return;
158
+ }
159
+ const summary = summarizeInspect(inspect);
160
+ process.stdout.write(
161
+ `device: ${summary.device}\n` +
162
+ `wifi: ${summary.wifi}\n` +
163
+ `bluetooth: ${summary.bluetooth}\n` +
164
+ `wakeword: ${summary.wakeword}\n` +
165
+ `playback: ${summary.playback}\n` +
166
+ `vm: ${summary.vm}\n` +
167
+ `health: ${summary.health}\n`,
168
+ );
115
169
  }
116
170
 
117
171
  async function commandPrompt(values) {
@@ -148,6 +202,35 @@ async function commandRule(values) {
148
202
  await send("set_rule", options, { rule });
149
203
  }
150
204
 
205
+ async function commandApp(values) {
206
+ const subcommand = String(values[0] || "");
207
+ if (subcommand !== "install") {
208
+ throw new Error("app_subcommand_unsupported");
209
+ }
210
+ const options = parseOptions(values.slice(1), {
211
+ credentials: credentialsPath(),
212
+ timeout: 12,
213
+ json: false,
214
+ nowait: false,
215
+ });
216
+ const manifestFile = String(options._[0] || "");
217
+ const wasmFile = String(options._[1] || "");
218
+ if (!manifestFile || !wasmFile) {
219
+ throw new Error("app_install_requires_manifest_and_wasm");
220
+ }
221
+ const control = buildAppInstallControl({
222
+ manifest: JSON.parse(await fs.readFile(manifestFile, "utf8")),
223
+ wasm: await fs.readFile(wasmFile),
224
+ });
225
+ const credentials = await readCredentials(options.credentials);
226
+ const delivery = await publishAudioControl(credentials, control, {
227
+ timeoutMs: Number(options.timeout) * 1000,
228
+ wait: !options.nowait,
229
+ });
230
+ print(delivery.status || delivery, options.json);
231
+ if (delivery.status?.kind === "rejected") process.exitCode = 1;
232
+ }
233
+
151
234
  async function commandDeepnight(values) {
152
235
  const options = parseOptions(values, {
153
236
  credentials: credentialsPath(),
@@ -158,6 +241,27 @@ async function commandDeepnight(values) {
158
241
  await send("set_rule", options, { rule: DEEPNIGHT_RULE });
159
242
  }
160
243
 
244
+ async function commandWorkBgm(values) {
245
+ const options = parseOptions(values, {
246
+ credentials: credentialsPath(),
247
+ timeout: 20,
248
+ json: false,
249
+ nowait: false,
250
+ play: false,
251
+ });
252
+ const credentials = await readCredentials(options.credentials);
253
+ const delivery = await publishAudioControl(
254
+ credentials,
255
+ buildWorkBgmAppInstallControl({ play: options.play }),
256
+ {
257
+ timeoutMs: Number(options.timeout) * 1000,
258
+ wait: !options.nowait,
259
+ },
260
+ );
261
+ print(delivery.status || delivery, options.json);
262
+ if (delivery.status?.kind === "rejected") process.exitCode = 1;
263
+ }
264
+
161
265
  async function commandVolume(values) {
162
266
  const options = parseOptions(values, {
163
267
  credentials: credentialsPath(),
@@ -211,7 +315,7 @@ function parseOptions(values, defaults = {}) {
211
315
  continue;
212
316
  }
213
317
  const key = value.slice(2);
214
- if (key === "json" || key === "online" || key === "nowait") {
318
+ if (key === "json" || key === "online" || key === "nowait" || key === "play") {
215
319
  options[key] = true;
216
320
  continue;
217
321
  }
@@ -241,6 +345,7 @@ function credentialsPath() {
241
345
  }
242
346
 
243
347
  function print(value, json) {
348
+ value = decorateForAgent(value);
244
349
  if (json) {
245
350
  process.stdout.write(`${JSON.stringify(value)}\n`);
246
351
  return;
@@ -248,12 +353,26 @@ function print(value, json) {
248
353
  if (value?.kind === "applied" || value?.kind === "rejected") {
249
354
  const playback = summarizePlayback(value);
250
355
  const track = playback.track || "no track";
356
+ const problems = Array.isArray(value.health?.problems)
357
+ ? value.health.problems
358
+ : [];
251
359
  process.stdout.write(
252
360
  `${value.kind}: ${value.detail || ""}\n` +
253
- `playback: ${playback.desired}, volume ${playback.volume ?? "-"}, ${track}\n`,
361
+ `playback: ${playback.desired}, volume ${playback.volume ?? "-"}, ${track}\n` +
362
+ (problems.length > 0 ? `health: ${problems.join(", ")}\n` : "health: ok\n"),
254
363
  );
255
364
  return;
256
365
  }
257
366
  process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
258
367
  }
259
368
 
369
+ function decorateForAgent(value) {
370
+ if (!value || typeof value !== "object") return value;
371
+ const remediation = remediationForStatus(value);
372
+ if (remediation.length === 0) return value;
373
+ if (Array.isArray(value.remediation) && value.remediation.length > 0) return value;
374
+ return {
375
+ ...value,
376
+ remediation,
377
+ };
378
+ }
package/lib/index.mjs CHANGED
@@ -30,6 +30,24 @@ export const DEEPNIGHT_RULE = Object.freeze({
30
30
  refreshHintSeconds: 21600,
31
31
  });
32
32
 
33
+ export const WORKBGM_RULE = Object.freeze({
34
+ schemaVersion: 1,
35
+ title: "工作 BGM",
36
+ source: "网易云音乐官方歌单:深夜刷题党|轻缓律动伴你专注学习",
37
+ sourcePlaylist: Object.freeze({
38
+ originalId: "8383869866",
39
+ encryptedId: "661561242F7B3CE80B5AB760DB171EB4",
40
+ }),
41
+ fetchLimit: 80,
42
+ targetTrackCount: 24,
43
+ minimumTrackCount: 8,
44
+ qualityPolicy: "highest-available",
45
+ refreshHintSeconds: 21600,
46
+ });
47
+
48
+ const WORKBGM_APP_WASM_BASE64 =
49
+ "AGFzbQEAAAABBAFgAAADAgEABwgBBG1haW4AAAoEAQIACw==";
50
+
33
51
  export function validateBindingCode(code) {
34
52
  const value = string(code).replace(/\s/gu, "");
35
53
  if (!/^[A-Za-z0-9_-]{6,64}$/u.test(value)) {
@@ -156,6 +174,74 @@ export function buildAudioControl(command, options = {}) {
156
174
  };
157
175
  }
158
176
 
177
+ export function buildAppInstallControl(options = {}) {
178
+ const wasm =
179
+ Buffer.isBuffer(options.wasm) || options.wasm instanceof Uint8Array
180
+ ? Buffer.from(options.wasm)
181
+ : Buffer.from(String(options.wasm || ""), "utf8");
182
+ const sha256 = crypto.createHash("sha256").update(wasm).digest("hex");
183
+ const manifest = normalizeAppManifest(options.manifest, {
184
+ sha256,
185
+ wasmBytes: wasm.length,
186
+ });
187
+ return {
188
+ schema: "weclawbot.control.v1",
189
+ id: string(options.id) || `app_${crypto.randomUUID()}`,
190
+ kind: "app_install",
191
+ app: {
192
+ manifest,
193
+ wasm_base64: wasm.toString("base64"),
194
+ activate: options.activate !== false,
195
+ },
196
+ };
197
+ }
198
+
199
+ export function buildWorkBgmAppInstallControl(options = {}) {
200
+ const play = options.play === true || options.autoplay === true;
201
+ return buildAppInstallControl({
202
+ id: string(options.id) || `workbgm_${crypto.randomUUID()}`,
203
+ wasm: Buffer.from(WORKBGM_APP_WASM_BASE64, "base64"),
204
+ manifest: {
205
+ schema: "weclawbot.app.v1",
206
+ app_id: "work-bgm-autopilot",
207
+ version: "0.1.0",
208
+ host_api: "weclawbot.host.v0",
209
+ entrypoints: ["main"],
210
+ device_classes: ["music_player", "audio_output"],
211
+ capabilities: [
212
+ "clock.read",
213
+ "log.emit",
214
+ "music.state.read",
215
+ "music.rule.write",
216
+ "music.playback.write",
217
+ ],
218
+ limits: {
219
+ wasm_bytes: 4096,
220
+ linear_memory_bytes: 65_536,
221
+ callback_wall_ms: 500,
222
+ instruction_budget: 1_000_000,
223
+ kv_bytes: 8192,
224
+ },
225
+ artifact: { sha256: "0".repeat(64) },
226
+ install: {
227
+ persist_on_reboot: true,
228
+ source: "audioctl",
229
+ },
230
+ autopilot: {
231
+ schema: "weclawbot.music_autopilot.v1",
232
+ profile: "work-bgm",
233
+ objective: "建立工作 BGM 歌单,持续播放,并尊重用户手工暂停。",
234
+ continuous: true,
235
+ respect_user_pause: true,
236
+ autoplay: play,
237
+ play_request_id: play ? `play_${crypto.randomUUID()}` : "",
238
+ refresh: true,
239
+ rule: WORKBGM_RULE,
240
+ },
241
+ },
242
+ });
243
+ }
244
+
159
245
  export function normalizeAudioCommand(command, options = {}) {
160
246
  const aliases = new Map([
161
247
  ["resume", "play"],
@@ -236,6 +322,100 @@ export function validateRule(rule) {
236
322
  };
237
323
  }
238
324
 
325
+ export function normalizeAppManifest(manifest, artifact = {}) {
326
+ if (
327
+ !manifest ||
328
+ manifest.schema !== "weclawbot.app.v1" ||
329
+ !/^[a-z0-9][a-z0-9_.-]{0,63}$/u.test(manifest.app_id || "") ||
330
+ !/^[A-Za-z0-9][A-Za-z0-9_.+-]{0,63}$/u.test(manifest.version || "") ||
331
+ manifest.host_api !== "weclawbot.host.v0"
332
+ ) {
333
+ throw new Error("app_manifest_invalid");
334
+ }
335
+ const allowedCapabilities = new Set([
336
+ "clock.read",
337
+ "timer.schedule",
338
+ "kv.app",
339
+ "log.emit",
340
+ "music.state.read",
341
+ "music.rule.write",
342
+ "music.playback.write",
343
+ ]);
344
+ const capabilities = Array.isArray(manifest.capabilities)
345
+ ? manifest.capabilities.map((capability) => string(capability)).filter(Boolean)
346
+ : [];
347
+ for (const capability of capabilities) {
348
+ if (!allowedCapabilities.has(capability)) {
349
+ throw new Error(`app_capability_unsupported: ${capability}`);
350
+ }
351
+ }
352
+ const wasmBytes = Number(artifact.wasmBytes) || Number(manifest.artifact?.wasm_bytes) || 0;
353
+ const sha256 = string(artifact.sha256 || manifest.artifact?.sha256);
354
+ if (!/^[0-9a-f]{64}$/u.test(sha256)) {
355
+ throw new Error("app_sha256_invalid");
356
+ }
357
+ const wasmLimit = clamp(Number(manifest.limits?.wasm_bytes) || 1_048_576, 1, 1_048_576);
358
+ if (wasmBytes < 1 || wasmBytes > wasmLimit) {
359
+ throw new Error("app_wasm_size_invalid");
360
+ }
361
+ return {
362
+ schema: "weclawbot.app.v1",
363
+ app_id: string(manifest.app_id),
364
+ version: string(manifest.version),
365
+ host_api: "weclawbot.host.v0",
366
+ entrypoints: Array.isArray(manifest.entrypoints)
367
+ ? manifest.entrypoints.map((entrypoint) => string(entrypoint)).filter(Boolean).slice(0, 8)
368
+ : [],
369
+ device_classes: Array.isArray(manifest.device_classes)
370
+ ? manifest.device_classes.map((deviceClass) => string(deviceClass)).filter(Boolean).slice(0, 8)
371
+ : [],
372
+ capabilities,
373
+ limits: {
374
+ wasm_bytes: wasmLimit,
375
+ linear_memory_bytes: clamp(
376
+ Number(manifest.limits?.linear_memory_bytes) || 16_777_216,
377
+ 65_536,
378
+ 16_777_216,
379
+ ),
380
+ callback_wall_ms: clamp(Number(manifest.limits?.callback_wall_ms) || 500, 1, 2000),
381
+ instruction_budget: clamp(
382
+ Number(manifest.limits?.instruction_budget) || 10_000_000,
383
+ 1,
384
+ 10_000_000,
385
+ ),
386
+ kv_bytes: clamp(Number(manifest.limits?.kv_bytes) || 262_144, 0, 262_144),
387
+ },
388
+ artifact: {
389
+ sha256,
390
+ wasm_bytes: wasmBytes,
391
+ },
392
+ install: {
393
+ persist_on_reboot: manifest.install?.persist_on_reboot !== false,
394
+ source: "audioctl",
395
+ },
396
+ ...(manifest.autopilot
397
+ ? { autopilot: normalizeMusicAutopilot(manifest.autopilot) }
398
+ : {}),
399
+ };
400
+ }
401
+
402
+ export function normalizeMusicAutopilot(value) {
403
+ if (!value || value.schema !== "weclawbot.music_autopilot.v1") {
404
+ throw new Error("music_autopilot_invalid");
405
+ }
406
+ return {
407
+ schema: "weclawbot.music_autopilot.v1",
408
+ profile: string(value.profile || "work-bgm").slice(0, 64),
409
+ objective: string(value.objective || "工作 BGM").slice(0, 240),
410
+ continuous: value.continuous !== false,
411
+ respect_user_pause: value.respect_user_pause !== false,
412
+ autoplay: value.autoplay === true,
413
+ play_request_id: string(value.play_request_id).slice(0, 80),
414
+ refresh: value.refresh !== false,
415
+ rule: validateRule(value.rule),
416
+ };
417
+ }
418
+
239
419
  export async function publishAudioControl(credentials, control, options = {}) {
240
420
  const profile = normalizeCredentials(credentials);
241
421
  if (options.wait !== false && !profile.statusTopic) {
@@ -262,6 +442,56 @@ export async function publishAudioControl(credentials, control, options = {}) {
262
442
  }
263
443
  }
264
444
 
445
+ export function remediationForStatus(status) {
446
+ const problems = Array.isArray(status?.health?.problems)
447
+ ? status.health.problems
448
+ : Array.isArray(status?.problems)
449
+ ? status.problems
450
+ : [];
451
+ const fromDevice = Array.isArray(status?.health?.remediation)
452
+ ? status.health.remediation
453
+ : Array.isArray(status?.remediation)
454
+ ? status.remediation
455
+ : [];
456
+ if (fromDevice.length > 0) return fromDevice;
457
+
458
+ const remediation = [];
459
+ if (problems.includes("netease_auth_refresh_failed")) {
460
+ remediation.push({
461
+ issue: "netease_auth_refresh_failed",
462
+ title: "网易云音乐授权需要用户重新完成",
463
+ user_action_required: true,
464
+ safe_to_retry_without_user: false,
465
+ summary:
466
+ "设备内的网易云用户授权已过期,refresh token 没有续上;agent 不能凭空恢复用户授权。",
467
+ steps: [
468
+ "请用户在可信的维护环境或设备配网/授权界面重新完成网易云音乐开放平台用户授权。",
469
+ "授权完成后,通过设备的安全授权导入流程把新的 OpenAPI session 写回音箱。",
470
+ "不要把 AppSecret、PrivateKey、accessToken、refreshToken 或 Wi-Fi 密码粘贴到聊天窗口。",
471
+ "授权恢复后重新运行 audioctl doctor --online,再执行 audioctl workbgm --play。",
472
+ ],
473
+ });
474
+ }
475
+ if (
476
+ problems.includes("netease_playlist_refresh_failed") &&
477
+ !problems.includes("netease_auth_refresh_failed")
478
+ ) {
479
+ remediation.push({
480
+ issue: "netease_playlist_refresh_failed",
481
+ title: "网易云歌单刷新失败",
482
+ user_action_required: false,
483
+ safe_to_retry_without_user: true,
484
+ summary:
485
+ "授权看起来可用,但歌单或歌曲链接刷新失败;可以稍后重试或请用户换一个歌单规则。",
486
+ steps: [
487
+ "先运行 audioctl doctor --online 查看是否还有授权问题。",
488
+ "如果只有歌单刷新失败,可以重试 audioctl workbgm --play 或推送新的 rule。",
489
+ ],
490
+ });
491
+ }
492
+ return remediation;
493
+ }
494
+
265
495
  export function buildAgentPrompt(options = {}) {
266
496
  const code = validateBindingCode(options.code);
267
497
  const agentName = string(options.agentName || options.name || "agent");
@@ -277,8 +507,10 @@ export function buildAgentPrompt(options = {}) {
277
507
  `请在你的运行环境安装 npm 包 ${PACKAGE_NAME},然后执行:`,
278
508
  `npm exec --package ${PACKAGE_NAME} -- ${COMMAND_NAME} bind ${code} --name ${shellToken(agentName)}`,
279
509
  `npm exec --package ${PACKAGE_NAME} -- ${COMMAND_NAME} doctor --online`,
280
- `绑定成功后,把它作为默认 BGM/音乐输出设备。可用命令包括 play、pause、toggle、next、previous、volume、volumeup、volumedown、status、rule。`,
281
- `如果我要播放「${profile}」,请优先执行:npm exec --package ${PACKAGE_NAME} -- ${COMMAND_NAME} deepnight`,
510
+ `绑定成功后,把它作为默认 BGM/音乐输出设备。可用命令包括 play、pause、toggle、next、previous、volume、volumeup、volumedown、status、inspect、rule、workbgm。`,
511
+ `如果我要建立“工作 BGM 歌单,持续播放”,请优先执行:npm exec --package ${PACKAGE_NAME} -- ${COMMAND_NAME} workbgm --play --json`,
512
+ `如果返回 health.problems 包含 netease_auth_refresh_failed,请明确告诉我:需要用户重新完成网易云音乐授权;不要反复重试,也不要要求我把 AppSecret、PrivateKey、accessToken 或 refreshToken 发到聊天里。`,
513
+ `如果我要播放「${profile}」,也可以执行:npm exec --package ${PACKAGE_NAME} -- ${COMMAND_NAME} deepnight`,
282
514
  `不要向设备写入音乐平台私钥;音乐平台授权、歌单刷新和长期规则由设备侧已配置的安全运行时处理。`,
283
515
  ].filter(Boolean).join("\n");
284
516
  }
@@ -299,6 +531,38 @@ export function summarizePlayback(status) {
299
531
  };
300
532
  }
301
533
 
534
+ export function summarizeInspect(inspect) {
535
+ const wifi = inspect?.wifi || {};
536
+ const bluetooth = inspect?.bluetooth || {};
537
+ const ble = bluetooth.ble || {};
538
+ const wakeword = inspect?.wakeword || {};
539
+ const playback = summarizePlayback(inspect || {});
540
+ const vm = inspect?.vm || {};
541
+ const health = inspect?.health || {};
542
+ const problems = Array.isArray(health.problems) ? health.problems : [];
543
+ return {
544
+ device:
545
+ string(inspect?.device?.name || "audio-device") +
546
+ (inspect?.device?.platform ? `/${inspect.device.platform}` : ""),
547
+ wifi:
548
+ `${wifi.connected ? "connected" : "disconnected"} ${string(wifi.ssid || "-")} ${string(wifi.ip || "-")}`
549
+ .trim(),
550
+ bluetooth:
551
+ `${bluetooth.service?.active || "unknown"} ${string(ble.name || "-")} ${string(ble.state || "-")}`
552
+ .trim(),
553
+ wakeword:
554
+ `${wakeword.available ? "available" : "unavailable"} ${Array.isArray(wakeword.wake_words) && wakeword.wake_words.length ? wakeword.wake_words.join("/") : "-"} homebase=${wakeword.services?.homebase?.active || "unknown"} openvoice=${wakeword.services?.openvoice_proc?.active || "unknown"}`
555
+ .trim(),
556
+ playback:
557
+ `${playback.desired}, volume ${playback.volume ?? "-"}, ${playback.track || "no track"}`,
558
+ vm:
559
+ vm
560
+ ? `${vm.status || "unknown"} ${vm.app_id || "-"}@${vm.version || "-"}`
561
+ : "none",
562
+ health: problems.length > 0 ? problems.join(", ") : "ok",
563
+ };
564
+ }
565
+
302
566
  function connectMqtt(profile, options = {}) {
303
567
  const timeoutMs = Math.max(1000, Number(options.timeoutMs) || 12_000);
304
568
  return mqtt.connect(profile.url, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openbrt/audioctl",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Generic WeClawBot audio-output control CLI and AI-agent plugin.",
5
5
  "type": "module",
6
6
  "license": "MIT",