@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 +18 -0
- package/agent-plugin.json +17 -1
- package/bin/audioctl.mjs +121 -2
- package/lib/index.mjs +266 -2
- package/package.json +1 -1
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.
|
|
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
|
-
|
|
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, {
|