@openbrt/audioctl 0.1.0 → 0.1.1

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,23 @@ 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
+
50
63
  `deepnight` pushes the bundled “深夜工作” playlist rule and asks the device to
51
64
  refresh/play according to its local runtime. The package never contains music
52
65
  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,15 @@ 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
+ summarizeInspect,
19
22
  summarizePlayback,
20
23
  testConnection,
21
24
  validateRule,
@@ -37,9 +40,12 @@ if (!command || new Set(["help", "-h", "--help"]).has(command)) {
37
40
  async function run(name, values) {
38
41
  if (name === "bind") return commandBind(values);
39
42
  if (name === "doctor") return commandDoctor(values);
43
+ if (name === "inspect") return commandInspect(values);
40
44
  if (name === "prompt") return commandPrompt(values);
41
45
  if (name === "manifest") return commandManifest();
46
+ if (name === "app") return commandApp(values);
42
47
  if (name === "rule") return commandRule(values);
48
+ if (name === "workbgm") return commandWorkBgm(values);
43
49
  if (name === "deepnight") return commandDeepnight(values);
44
50
  if (name === "volume") return commandVolume(values);
45
51
  if (name === "seek") return commandSeek(values);
@@ -50,11 +56,14 @@ function usage(exitCode = 0) {
50
56
  process.stdout.write(`Usage:
51
57
  ${COMMAND_NAME} bind CODE [--name AGENT]
52
58
  ${COMMAND_NAME} doctor [--online] [--json]
59
+ ${COMMAND_NAME} inspect [--json]
53
60
  ${COMMAND_NAME} prompt CODE [--agent AGENT] [--alias NAME]
54
61
  ${COMMAND_NAME} play|pause|toggle|next|previous|volumeup|volumedown|status
55
62
  ${COMMAND_NAME} volume 0..100
56
63
  ${COMMAND_NAME} seek MILLISECONDS
57
64
  ${COMMAND_NAME} rule RULE.json
65
+ ${COMMAND_NAME} app install MANIFEST.json APP.wasm
66
+ ${COMMAND_NAME} workbgm [--play]
58
67
  ${COMMAND_NAME} deepnight
59
68
  ${COMMAND_NAME} manifest
60
69
 
@@ -110,8 +119,47 @@ async function commandDoctor(values) {
110
119
  if (options.online) {
111
120
  await testConnection(credentials, { timeoutMs: Number(options.timeout) * 1000 });
112
121
  status.online = true;
122
+ const delivery = await publishAudioControl(credentials, buildAudioControl("status"), {
123
+ timeoutMs: Number(options.timeout) * 1000,
124
+ });
125
+ status.device_status = delivery.status || null;
126
+ status.healthy = delivery.status?.health?.ok !== false;
127
+ status.ok = status.healthy;
113
128
  }
114
129
  print(status, options.json);
130
+ if (options.online && status.healthy === false) process.exitCode = 1;
131
+ }
132
+
133
+ async function commandInspect(values) {
134
+ const options = parseOptions(values, {
135
+ credentials: credentialsPath(),
136
+ timeout: 12,
137
+ json: false,
138
+ });
139
+ const credentials = await readCredentials(options.credentials);
140
+ const delivery = await publishAudioControl(credentials, buildAudioControl("status"), {
141
+ timeoutMs: Number(options.timeout) * 1000,
142
+ });
143
+ if (delivery.status?.kind === "rejected") {
144
+ print(delivery.status, options.json);
145
+ process.exitCode = 1;
146
+ return;
147
+ }
148
+ const inspect = delivery.status?.inspect || delivery.status || {};
149
+ if (options.json) {
150
+ process.stdout.write(`${JSON.stringify(inspect)}\n`);
151
+ return;
152
+ }
153
+ const summary = summarizeInspect(inspect);
154
+ process.stdout.write(
155
+ `device: ${summary.device}\n` +
156
+ `wifi: ${summary.wifi}\n` +
157
+ `bluetooth: ${summary.bluetooth}\n` +
158
+ `wakeword: ${summary.wakeword}\n` +
159
+ `playback: ${summary.playback}\n` +
160
+ `vm: ${summary.vm}\n` +
161
+ `health: ${summary.health}\n`,
162
+ );
115
163
  }
116
164
 
117
165
  async function commandPrompt(values) {
@@ -148,6 +196,35 @@ async function commandRule(values) {
148
196
  await send("set_rule", options, { rule });
149
197
  }
150
198
 
199
+ async function commandApp(values) {
200
+ const subcommand = String(values[0] || "");
201
+ if (subcommand !== "install") {
202
+ throw new Error("app_subcommand_unsupported");
203
+ }
204
+ const options = parseOptions(values.slice(1), {
205
+ credentials: credentialsPath(),
206
+ timeout: 12,
207
+ json: false,
208
+ nowait: false,
209
+ });
210
+ const manifestFile = String(options._[0] || "");
211
+ const wasmFile = String(options._[1] || "");
212
+ if (!manifestFile || !wasmFile) {
213
+ throw new Error("app_install_requires_manifest_and_wasm");
214
+ }
215
+ const control = buildAppInstallControl({
216
+ manifest: JSON.parse(await fs.readFile(manifestFile, "utf8")),
217
+ wasm: await fs.readFile(wasmFile),
218
+ });
219
+ const credentials = await readCredentials(options.credentials);
220
+ const delivery = await publishAudioControl(credentials, control, {
221
+ timeoutMs: Number(options.timeout) * 1000,
222
+ wait: !options.nowait,
223
+ });
224
+ print(delivery.status || delivery, options.json);
225
+ if (delivery.status?.kind === "rejected") process.exitCode = 1;
226
+ }
227
+
151
228
  async function commandDeepnight(values) {
152
229
  const options = parseOptions(values, {
153
230
  credentials: credentialsPath(),
@@ -158,6 +235,27 @@ async function commandDeepnight(values) {
158
235
  await send("set_rule", options, { rule: DEEPNIGHT_RULE });
159
236
  }
160
237
 
238
+ async function commandWorkBgm(values) {
239
+ const options = parseOptions(values, {
240
+ credentials: credentialsPath(),
241
+ timeout: 20,
242
+ json: false,
243
+ nowait: false,
244
+ play: false,
245
+ });
246
+ const credentials = await readCredentials(options.credentials);
247
+ const delivery = await publishAudioControl(
248
+ credentials,
249
+ buildWorkBgmAppInstallControl({ play: options.play }),
250
+ {
251
+ timeoutMs: Number(options.timeout) * 1000,
252
+ wait: !options.nowait,
253
+ },
254
+ );
255
+ print(delivery.status || delivery, options.json);
256
+ if (delivery.status?.kind === "rejected") process.exitCode = 1;
257
+ }
258
+
161
259
  async function commandVolume(values) {
162
260
  const options = parseOptions(values, {
163
261
  credentials: credentialsPath(),
@@ -211,7 +309,7 @@ function parseOptions(values, defaults = {}) {
211
309
  continue;
212
310
  }
213
311
  const key = value.slice(2);
214
- if (key === "json" || key === "online" || key === "nowait") {
312
+ if (key === "json" || key === "online" || key === "nowait" || key === "play") {
215
313
  options[key] = true;
216
314
  continue;
217
315
  }
@@ -248,12 +346,15 @@ function print(value, json) {
248
346
  if (value?.kind === "applied" || value?.kind === "rejected") {
249
347
  const playback = summarizePlayback(value);
250
348
  const track = playback.track || "no track";
349
+ const problems = Array.isArray(value.health?.problems)
350
+ ? value.health.problems
351
+ : [];
251
352
  process.stdout.write(
252
353
  `${value.kind}: ${value.detail || ""}\n` +
253
- `playback: ${playback.desired}, volume ${playback.volume ?? "-"}, ${track}\n`,
354
+ `playback: ${playback.desired}, volume ${playback.volume ?? "-"}, ${track}\n` +
355
+ (problems.length > 0 ? `health: ${problems.join(", ")}\n` : "health: ok\n"),
254
356
  );
255
357
  return;
256
358
  }
257
359
  process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
258
360
  }
259
-
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) {
@@ -277,8 +457,9 @@ export function buildAgentPrompt(options = {}) {
277
457
  `请在你的运行环境安装 npm 包 ${PACKAGE_NAME},然后执行:`,
278
458
  `npm exec --package ${PACKAGE_NAME} -- ${COMMAND_NAME} bind ${code} --name ${shellToken(agentName)}`,
279
459
  `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`,
460
+ `绑定成功后,把它作为默认 BGM/音乐输出设备。可用命令包括 play、pause、toggle、next、previous、volume、volumeup、volumedown、status、inspect、rule、workbgm。`,
461
+ `如果我要建立“工作 BGM 歌单,持续播放”,请优先执行:npm exec --package ${PACKAGE_NAME} -- ${COMMAND_NAME} workbgm --play --json`,
462
+ `如果我要播放「${profile}」,也可以执行:npm exec --package ${PACKAGE_NAME} -- ${COMMAND_NAME} deepnight`,
282
463
  `不要向设备写入音乐平台私钥;音乐平台授权、歌单刷新和长期规则由设备侧已配置的安全运行时处理。`,
283
464
  ].filter(Boolean).join("\n");
284
465
  }
@@ -299,6 +480,38 @@ export function summarizePlayback(status) {
299
480
  };
300
481
  }
301
482
 
483
+ export function summarizeInspect(inspect) {
484
+ const wifi = inspect?.wifi || {};
485
+ const bluetooth = inspect?.bluetooth || {};
486
+ const ble = bluetooth.ble || {};
487
+ const wakeword = inspect?.wakeword || {};
488
+ const playback = summarizePlayback(inspect || {});
489
+ const vm = inspect?.vm || {};
490
+ const health = inspect?.health || {};
491
+ const problems = Array.isArray(health.problems) ? health.problems : [];
492
+ return {
493
+ device:
494
+ string(inspect?.device?.name || "audio-device") +
495
+ (inspect?.device?.platform ? `/${inspect.device.platform}` : ""),
496
+ wifi:
497
+ `${wifi.connected ? "connected" : "disconnected"} ${string(wifi.ssid || "-")} ${string(wifi.ip || "-")}`
498
+ .trim(),
499
+ bluetooth:
500
+ `${bluetooth.service?.active || "unknown"} ${string(ble.name || "-")} ${string(ble.state || "-")}`
501
+ .trim(),
502
+ wakeword:
503
+ `${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"}`
504
+ .trim(),
505
+ playback:
506
+ `${playback.desired}, volume ${playback.volume ?? "-"}, ${playback.track || "no track"}`,
507
+ vm:
508
+ vm
509
+ ? `${vm.status || "unknown"} ${vm.app_id || "-"}@${vm.version || "-"}`
510
+ : "none",
511
+ health: problems.length > 0 ? problems.join(", ") : "ok",
512
+ };
513
+ }
514
+
302
515
  function connectMqtt(profile, options = {}) {
303
516
  const timeoutMs = Math.max(1000, Number(options.timeoutMs) || 12_000);
304
517
  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.1",
4
4
  "description": "Generic WeClawBot audio-output control CLI and AI-agent plugin.",
5
5
  "type": "module",
6
6
  "license": "MIT",