@openbrt/audioctl 0.1.5 → 0.1.6
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 +53 -5
- package/agent-plugin.json +24 -1
- package/bin/audioctl.mjs +69 -0
- package/lib/index.mjs +218 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -23,11 +23,12 @@ Agent contract:
|
|
|
23
23
|
it to weclawbot.link. It must not decide whether the speaker is used for
|
|
24
24
|
NetEase, Spotify, voice remote control, smart-home control, or anything else.
|
|
25
25
|
- Preferred BYOA flow: use a legal music platform API in the agent environment
|
|
26
|
-
to build a playable queue, then push it with
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
Spotify Web
|
|
26
|
+
or another user-approved source to build a playable queue, then push it with
|
|
27
|
+
`audioctl queue QUEUE.json --play --json`. The plugin suggests sources; the
|
|
28
|
+
agent and user choose. NetEase Cloud Music Open Platform can be used this way
|
|
29
|
+
to request the highest available quality URLs. Spotify should be driven through
|
|
30
|
+
official Spotify Connect/Web Playback SDK or existing-user playback control;
|
|
31
|
+
do not assume the Spotify Web API gives full-track direct audio URLs.
|
|
31
32
|
- If music-platform authorization is missing, use the current agent's official
|
|
32
33
|
connector, OAuth flow, or secret store for that platform. Bluetooth/Wi-Fi
|
|
33
34
|
provisioning is not a music-platform authorization entry and is not a
|
|
@@ -65,6 +66,11 @@ audioctl volumeup
|
|
|
65
66
|
audioctl volumedown
|
|
66
67
|
audioctl status
|
|
67
68
|
audioctl inspect --json
|
|
69
|
+
audioctl feedback signal ready
|
|
70
|
+
audioctl feedback flash cyan --repeat 2
|
|
71
|
+
audioctl feedback volume 66
|
|
72
|
+
audioctl feedback beep
|
|
73
|
+
audioctl feedback clear
|
|
68
74
|
audioctl queue ./work-bgm.queue.json --play
|
|
69
75
|
audioctl workbgm --play
|
|
70
76
|
audioctl rule ./my-playlist-rule.json
|
|
@@ -77,6 +83,29 @@ existing MQTT channel. Agent scripts can read Wi-Fi connection state, BLE
|
|
|
77
83
|
provisioning state, wake-word capability, playback, VM/app status, and health
|
|
78
84
|
without receiving Wi-Fi passwords, MQTT credentials, or music-platform secrets.
|
|
79
85
|
|
|
86
|
+
`feedback`/`led` controls expose generic device feedback. Agents should prefer
|
|
87
|
+
semantic signals (`ready`, `busy`, `success`, `error`, `playing`, `paused`,
|
|
88
|
+
`volume_step_up`, `volume_step_down`, `button_press`) so each hardware adapter
|
|
89
|
+
can map them to its native LED ring, small screen, haptic motor, or speaker
|
|
90
|
+
prompt. Explicit LED controls are also available:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
audioctl feedback signal playing
|
|
94
|
+
audioctl feedback flash '#00ffff' --repeat 2
|
|
95
|
+
audioctl feedback volume 42
|
|
96
|
+
audioctl feedback beep
|
|
97
|
+
audioctl feedback clear
|
|
98
|
+
audioctl led flash amber --repeat 1
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Physical keys/touch surfaces are reported as VM capabilities such as
|
|
102
|
+
`input.touch.read`, `input.gesture.read`, and `input.button.bind`. A VM app can
|
|
103
|
+
declare those capabilities and bind local gestures to safe device actions; for a
|
|
104
|
+
music endpoint the recommended default is center click = play/pause toggle,
|
|
105
|
+
edge click = next, and clockwise/counter-clockwise = volume step up/down with a
|
|
106
|
+
short beep and a volume LED indication. These bindings are device-local for low
|
|
107
|
+
latency and do not require the agent to be online for every tap.
|
|
108
|
+
|
|
80
109
|
`queue --play` is the generic agent-built music path. The agent uses the user's
|
|
81
110
|
authorized music provider outside the speaker, writes a
|
|
82
111
|
`weclawbot.playable_queue.v1`/`schemaVersion: 1` queue containing only safe media
|
|
@@ -118,3 +147,22 @@ The MQTT control message is:
|
|
|
118
147
|
|
|
119
148
|
Supported commands include `play`, `pause`, `toggle`, `next`, `previous`,
|
|
120
149
|
`seek`, `set_volume`, `volume_up`, `volume_down`, `status`, and `set_rule`.
|
|
150
|
+
|
|
151
|
+
Feedback messages use the same MQTT envelope:
|
|
152
|
+
|
|
153
|
+
```json
|
|
154
|
+
{
|
|
155
|
+
"schema": "weclawbot.control.v1",
|
|
156
|
+
"id": "feedback_<uuid>",
|
|
157
|
+
"kind": "feedback_command",
|
|
158
|
+
"feedback": {
|
|
159
|
+
"command": "flash",
|
|
160
|
+
"color": { "red": 0, "green": 255, "blue": 255, "alpha": 255 },
|
|
161
|
+
"repeat": 2
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Supported feedback commands are `signal`, `flash`, `volume`, `beep`, and
|
|
167
|
+
`clear`. Devices that do not report a matching `feedback.*` capability should
|
|
168
|
+
reject the command instead of pretending to support it.
|
package/agent-plugin.json
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
"device_classes": [
|
|
16
16
|
"audio_io",
|
|
17
17
|
"audio_output",
|
|
18
|
+
"ambient_io",
|
|
18
19
|
"voice_endpoint",
|
|
19
20
|
"smart_home_endpoint",
|
|
20
21
|
"music_player"
|
|
@@ -40,6 +41,14 @@
|
|
|
40
41
|
"audio.tts",
|
|
41
42
|
"voice.wakeword",
|
|
42
43
|
"voice.capture",
|
|
44
|
+
"input.touch.read",
|
|
45
|
+
"input.gesture.read",
|
|
46
|
+
"input.button.bind",
|
|
47
|
+
"feedback.signal",
|
|
48
|
+
"feedback.led",
|
|
49
|
+
"feedback.led_ring",
|
|
50
|
+
"feedback.volume_level",
|
|
51
|
+
"feedback.beep",
|
|
43
52
|
"iot.control",
|
|
44
53
|
"device.inspect",
|
|
45
54
|
"music.autopilot",
|
|
@@ -59,13 +68,19 @@
|
|
|
59
68
|
{
|
|
60
69
|
"intent": "帮我在音箱建立一个工作 BGM 歌单,持续播放",
|
|
61
70
|
"primary_command": "audioctl queue QUEUE.json --play --json",
|
|
62
|
-
"agent_strategy": "先运行 audioctl inspect --json
|
|
71
|
+
"agent_strategy": "先运行 audioctl inspect --json。音乐来源由 agent 和用户协商,插件只做建议:可选用户已授权的音乐平台 API、用户媒体库、合法公开音源或生成式环境声。选定来源后,在 agent 侧生成 QUEUE.json,再运行 audioctl queue QUEUE.json --play --json。网易云开放平台可请求最高可用音质播放 URL;Spotify 必须走官方 Spotify Connect/Web Playback SDK 或控制用户已有 Spotify 设备,不能假设 Web API 能提供完整歌曲直链。如果 agent 当前没有音乐平台身份,只要求用户把音乐平台连接到该 agent 的连接器/OAuth/secret store;蓝牙配网和设备入网不是音乐平台授权入口。",
|
|
63
72
|
"fallback_command": "audioctl workbgm --play --json",
|
|
64
73
|
"if_unbound": "只向用户索要绑定码,然后运行 audioctl bind CODE --name AGENT;不要要求用户理解 MQTT、topic 或凭据文件。",
|
|
65
74
|
"if_music_platform_auth_missing": "告诉用户:我需要你把音乐平台身份连接到当前 agent。请使用当前 agent 提供的网易云开放平台、Spotify 等官方连接器/OAuth/secret store 完成授权;完成后只需回复“已授权”。音乐授权不属于蓝牙/Wi-Fi 配网范围,也不要把 AppSecret、PrivateKey、accessToken、refreshToken、Wi-Fi 密码或 MQTT 凭据粘贴到聊天里。",
|
|
66
75
|
"if_device_side_netease_auth_failed": "这是设备内置网易运行时的旧授权失效。优先不要让用户折腾设备侧音乐账号;改由 agent 侧使用用户授权的音乐平台 API 生成队列并推送。只有当用户明确要求继续使用设备侧网易运行时时,才提示需要一个独立的设备侧音乐账号设置入口;不要把它混入蓝牙/Wi-Fi 配网流程。",
|
|
67
76
|
"after_user_confirms_authorized": "agent 使用已授权的平台 API 生成 QUEUE.json,运行 audioctl queue QUEUE.json --play --json,然后报告是否 active/degraded。"
|
|
68
77
|
},
|
|
78
|
+
{
|
|
79
|
+
"intent": "帮我把设备灯效、音量灯和按键反馈音接上",
|
|
80
|
+
"primary_command": "audioctl feedback signal ready",
|
|
81
|
+
"agent_strategy": "先运行 audioctl inspect --json 确认设备报告 feedback.* 和 input.* capabilities。语义反馈优先使用 audioctl feedback signal ready|busy|success|error|playing|paused|volume_step_up|volume_step_down;显式灯色可用 audioctl feedback flash cyan --repeat 2;音量环可用 audioctl feedback volume 66;短反馈音可用 audioctl feedback beep。物理按键/触控属于 VM app 的 input.button.bind 能力,默认建议:中心单击 toggle/pause-play,边缘点按 next,旋转/滑动 volume_up 和 volume_down,同时给 feedback.beep 与 feedback.volume_level。",
|
|
82
|
+
"if_missing_capability": "不要伪造灯效或按键能力;如果 inspect 未报告对应 capability,只说明当前设备适配未提供该能力,并建议安装或升级设备 VM。"
|
|
83
|
+
},
|
|
69
84
|
{
|
|
70
85
|
"intent": "让音箱作为语音遥控、家庭智能或语音互动端点",
|
|
71
86
|
"primary_command": "audioctl app install MANIFEST.json APP.wasm",
|
|
@@ -89,6 +104,14 @@
|
|
|
89
104
|
"name": "control",
|
|
90
105
|
"usage": "audioctl play|pause|toggle|next|previous|volumeup|volumedown|status"
|
|
91
106
|
},
|
|
107
|
+
{
|
|
108
|
+
"name": "feedback",
|
|
109
|
+
"usage": "audioctl feedback signal NAME | flash COLOR | volume 0..100 | clear | beep"
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
"name": "led",
|
|
113
|
+
"usage": "audioctl led flash COLOR [--repeat N]"
|
|
114
|
+
},
|
|
92
115
|
{
|
|
93
116
|
"name": "volume",
|
|
94
117
|
"usage": "audioctl volume 0..100"
|
package/bin/audioctl.mjs
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
buildAppInstallControl,
|
|
14
14
|
buildAgentPrompt,
|
|
15
15
|
buildAudioControl,
|
|
16
|
+
buildFeedbackControl,
|
|
16
17
|
buildQueueControl,
|
|
17
18
|
buildWorkBgmAppInstallControl,
|
|
18
19
|
expandPath,
|
|
@@ -52,6 +53,7 @@ async function run(name, values) {
|
|
|
52
53
|
if (name === "deepnight") return commandDeepnight(values);
|
|
53
54
|
if (name === "volume") return commandVolume(values);
|
|
54
55
|
if (name === "seek") return commandSeek(values);
|
|
56
|
+
if (name === "feedback" || name === "led") return commandFeedback(values);
|
|
55
57
|
return commandControl(name, values);
|
|
56
58
|
}
|
|
57
59
|
|
|
@@ -64,6 +66,11 @@ function usage(exitCode = 0) {
|
|
|
64
66
|
${COMMAND_NAME} play|pause|toggle|next|previous|volumeup|volumedown|status
|
|
65
67
|
${COMMAND_NAME} volume 0..100
|
|
66
68
|
${COMMAND_NAME} seek MILLISECONDS
|
|
69
|
+
${COMMAND_NAME} feedback signal NAME [--level 0..100]
|
|
70
|
+
${COMMAND_NAME} feedback flash COLOR [--repeat N]
|
|
71
|
+
${COMMAND_NAME} feedback volume 0..100
|
|
72
|
+
${COMMAND_NAME} feedback clear|beep
|
|
73
|
+
${COMMAND_NAME} led flash COLOR [--repeat N]
|
|
67
74
|
${COMMAND_NAME} rule RULE.json
|
|
68
75
|
${COMMAND_NAME} queue QUEUE.json [--play]
|
|
69
76
|
${COMMAND_NAME} app install MANIFEST.json APP.wasm
|
|
@@ -309,6 +316,62 @@ async function commandSeek(values) {
|
|
|
309
316
|
await send("seek", options, { positionMs: options._[0] });
|
|
310
317
|
}
|
|
311
318
|
|
|
319
|
+
async function commandFeedback(values) {
|
|
320
|
+
const subcommand = String(values[0] || "").replace(/-/gu, "_").toLowerCase();
|
|
321
|
+
const shorthandSignals = new Set([
|
|
322
|
+
"ready",
|
|
323
|
+
"busy",
|
|
324
|
+
"loading",
|
|
325
|
+
"success",
|
|
326
|
+
"error",
|
|
327
|
+
"playing",
|
|
328
|
+
"paused",
|
|
329
|
+
"wake",
|
|
330
|
+
"music_beat",
|
|
331
|
+
"volume_step_up",
|
|
332
|
+
"volume_step_down",
|
|
333
|
+
"button_press",
|
|
334
|
+
"button_long_press",
|
|
335
|
+
]);
|
|
336
|
+
const options = parseOptions(values.slice(1), {
|
|
337
|
+
credentials: credentialsPath(),
|
|
338
|
+
timeout: 12,
|
|
339
|
+
json: false,
|
|
340
|
+
nowait: false,
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
let command = subcommand;
|
|
344
|
+
const payload = {};
|
|
345
|
+
if (!command) throw new Error("feedback_requires_subcommand");
|
|
346
|
+
if (shorthandSignals.has(command)) {
|
|
347
|
+
payload.semantic = command;
|
|
348
|
+
command = "signal";
|
|
349
|
+
} else if (command === "signal") {
|
|
350
|
+
payload.semantic = options._[0];
|
|
351
|
+
} else if (command === "flash") {
|
|
352
|
+
payload.color = options._[0] || options.color;
|
|
353
|
+
payload.repeat = options.repeat;
|
|
354
|
+
payload.alpha = options.alpha;
|
|
355
|
+
} else if (command === "volume") {
|
|
356
|
+
payload.level = options._[0] ?? options.level;
|
|
357
|
+
} else if (command === "beep") {
|
|
358
|
+
payload.pattern = options._[0] || options.pattern;
|
|
359
|
+
} else if (command === "clear" || command === "off" || command === "hide") {
|
|
360
|
+
command = "clear";
|
|
361
|
+
}
|
|
362
|
+
if (options.level !== undefined && payload.level === undefined) payload.level = options.level;
|
|
363
|
+
if (options.volume !== undefined && payload.level === undefined) payload.level = options.volume;
|
|
364
|
+
|
|
365
|
+
const control = buildFeedbackControl(command, payload);
|
|
366
|
+
const credentials = await readCredentials(options.credentials);
|
|
367
|
+
const delivery = await publishAudioControl(credentials, control, {
|
|
368
|
+
timeoutMs: Number(options.timeout) * 1000,
|
|
369
|
+
wait: !options.nowait,
|
|
370
|
+
});
|
|
371
|
+
print(delivery.status || delivery, options.json);
|
|
372
|
+
if (delivery.status?.kind === "rejected") process.exitCode = 1;
|
|
373
|
+
}
|
|
374
|
+
|
|
312
375
|
async function commandControl(name, values) {
|
|
313
376
|
const options = parseOptions(values, {
|
|
314
377
|
credentials: credentialsPath(),
|
|
@@ -349,11 +412,17 @@ function parseOptions(values, defaults = {}) {
|
|
|
349
412
|
if (!new Set([
|
|
350
413
|
"agent",
|
|
351
414
|
"alias",
|
|
415
|
+
"alpha",
|
|
416
|
+
"color",
|
|
352
417
|
"credentials",
|
|
353
418
|
"endpoint",
|
|
419
|
+
"level",
|
|
354
420
|
"name",
|
|
421
|
+
"pattern",
|
|
355
422
|
"profile",
|
|
423
|
+
"repeat",
|
|
356
424
|
"timeout",
|
|
425
|
+
"volume",
|
|
357
426
|
]).has(key)) {
|
|
358
427
|
throw new Error(`option_unknown: --${key}`);
|
|
359
428
|
}
|
package/lib/index.mjs
CHANGED
|
@@ -47,6 +47,36 @@ export const WORKBGM_RULE = Object.freeze({
|
|
|
47
47
|
|
|
48
48
|
export const PLAYABLE_QUEUE_SCHEMA = "weclawbot.playable_queue.v1";
|
|
49
49
|
|
|
50
|
+
export const FEEDBACK_COLOR_NAMES = Object.freeze({
|
|
51
|
+
red: Object.freeze({ red: 255, green: 0, blue: 0, alpha: 255 }),
|
|
52
|
+
green: Object.freeze({ red: 0, green: 255, blue: 64, alpha: 255 }),
|
|
53
|
+
blue: Object.freeze({ red: 0, green: 96, blue: 255, alpha: 255 }),
|
|
54
|
+
cyan: Object.freeze({ red: 0, green: 255, blue: 255, alpha: 255 }),
|
|
55
|
+
yellow: Object.freeze({ red: 255, green: 220, blue: 0, alpha: 255 }),
|
|
56
|
+
amber: Object.freeze({ red: 255, green: 96, blue: 0, alpha: 255 }),
|
|
57
|
+
orange: Object.freeze({ red: 255, green: 96, blue: 0, alpha: 255 }),
|
|
58
|
+
purple: Object.freeze({ red: 180, green: 0, blue: 255, alpha: 255 }),
|
|
59
|
+
white: Object.freeze({ red: 255, green: 255, blue: 255, alpha: 255 }),
|
|
60
|
+
off: Object.freeze({ red: 0, green: 0, blue: 0, alpha: 0 }),
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
export const FEEDBACK_SIGNALS = Object.freeze([
|
|
64
|
+
"ready",
|
|
65
|
+
"busy",
|
|
66
|
+
"loading",
|
|
67
|
+
"success",
|
|
68
|
+
"error",
|
|
69
|
+
"playing",
|
|
70
|
+
"paused",
|
|
71
|
+
"wake",
|
|
72
|
+
"music_beat",
|
|
73
|
+
"volume_step_up",
|
|
74
|
+
"volume_step_down",
|
|
75
|
+
"button_press",
|
|
76
|
+
"button_long_press",
|
|
77
|
+
"clear",
|
|
78
|
+
]);
|
|
79
|
+
|
|
50
80
|
const WORKBGM_APP_WASM_BASE64 =
|
|
51
81
|
"AGFzbQEAAAABBAFgAAADAgEABwgBBG1haW4AAAoEAQIACw==";
|
|
52
82
|
|
|
@@ -176,6 +206,16 @@ export function buildAudioControl(command, options = {}) {
|
|
|
176
206
|
};
|
|
177
207
|
}
|
|
178
208
|
|
|
209
|
+
export function buildFeedbackControl(command, options = {}) {
|
|
210
|
+
const feedback = normalizeFeedbackCommand(command, options);
|
|
211
|
+
return {
|
|
212
|
+
schema: "weclawbot.control.v1",
|
|
213
|
+
id: string(options.id) || `feedback_${crypto.randomUUID()}`,
|
|
214
|
+
kind: "feedback_command",
|
|
215
|
+
feedback,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
179
219
|
export function buildQueueControl(options = {}) {
|
|
180
220
|
return buildAudioControl("set_queue", {
|
|
181
221
|
id: options.id,
|
|
@@ -225,6 +265,11 @@ export function buildWorkBgmAppInstallControl(options = {}) {
|
|
|
225
265
|
"music.state.read",
|
|
226
266
|
"music.rule.write",
|
|
227
267
|
"music.playback.write",
|
|
268
|
+
"input.touch.read",
|
|
269
|
+
"input.button.bind",
|
|
270
|
+
"feedback.signal",
|
|
271
|
+
"feedback.led_ring",
|
|
272
|
+
"feedback.beep",
|
|
228
273
|
],
|
|
229
274
|
limits: {
|
|
230
275
|
wasm_bytes: 4096,
|
|
@@ -248,6 +293,33 @@ export function buildWorkBgmAppInstallControl(options = {}) {
|
|
|
248
293
|
play_request_id: play ? `play_${crypto.randomUUID()}` : "",
|
|
249
294
|
refresh: true,
|
|
250
295
|
rule: WORKBGM_RULE,
|
|
296
|
+
input_bindings: [
|
|
297
|
+
{
|
|
298
|
+
input: "center.single_click",
|
|
299
|
+
action: "music.toggle",
|
|
300
|
+
feedback: ["feedback.beep", "feedback.signal:button_press"],
|
|
301
|
+
},
|
|
302
|
+
{
|
|
303
|
+
input: "ring.single_click",
|
|
304
|
+
action: "music.next",
|
|
305
|
+
feedback: ["feedback.beep", "feedback.signal:button_press"],
|
|
306
|
+
},
|
|
307
|
+
{
|
|
308
|
+
input: "ring.clockwise",
|
|
309
|
+
action: "music.volume_up",
|
|
310
|
+
feedback: ["feedback.beep", "feedback.volume_level"],
|
|
311
|
+
},
|
|
312
|
+
{
|
|
313
|
+
input: "ring.counter_clockwise",
|
|
314
|
+
action: "music.volume_down",
|
|
315
|
+
feedback: ["feedback.beep", "feedback.volume_level"],
|
|
316
|
+
},
|
|
317
|
+
],
|
|
318
|
+
feedback_policy: {
|
|
319
|
+
button_sound: true,
|
|
320
|
+
volume_led: true,
|
|
321
|
+
playback_led: true,
|
|
322
|
+
},
|
|
251
323
|
},
|
|
252
324
|
},
|
|
253
325
|
});
|
|
@@ -310,6 +382,95 @@ export function normalizeAudioCommand(command, options = {}) {
|
|
|
310
382
|
return music;
|
|
311
383
|
}
|
|
312
384
|
|
|
385
|
+
export function normalizeFeedbackCommand(command, options = {}) {
|
|
386
|
+
const aliases = new Map([
|
|
387
|
+
["hide", "clear"],
|
|
388
|
+
["off", "clear"],
|
|
389
|
+
["baseline", "clear"],
|
|
390
|
+
["light", "flash"],
|
|
391
|
+
["led", "flash"],
|
|
392
|
+
["sound", "beep"],
|
|
393
|
+
["tone", "beep"],
|
|
394
|
+
["ding", "beep"],
|
|
395
|
+
["vol", "volume"],
|
|
396
|
+
["volume_level", "volume"],
|
|
397
|
+
]);
|
|
398
|
+
const raw = string(command || options.command || options.action || "signal")
|
|
399
|
+
.replace(/-/gu, "_")
|
|
400
|
+
.toLowerCase();
|
|
401
|
+
const normalized = aliases.get(raw) || raw;
|
|
402
|
+
const supported = new Set(["signal", "flash", "volume", "clear", "beep"]);
|
|
403
|
+
if (!supported.has(normalized)) {
|
|
404
|
+
throw new Error(`feedback_command_unsupported: ${command}`);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const feedback = { command: normalized };
|
|
408
|
+
if (normalized === "signal") {
|
|
409
|
+
const semantic = string(
|
|
410
|
+
options.semantic ||
|
|
411
|
+
options.signal ||
|
|
412
|
+
options.name ||
|
|
413
|
+
options.value ||
|
|
414
|
+
"button_press",
|
|
415
|
+
).replace(/-/gu, "_").toLowerCase();
|
|
416
|
+
if (!/^[a-z0-9_.:]{1,64}$/u.test(semantic)) {
|
|
417
|
+
throw new Error("feedback_signal_invalid");
|
|
418
|
+
}
|
|
419
|
+
feedback.signal = semantic;
|
|
420
|
+
if (options.level !== undefined || options.volume !== undefined) {
|
|
421
|
+
feedback.level = normalizePercent(options.level ?? options.volume);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
if (normalized === "flash") {
|
|
425
|
+
feedback.color = normalizeFeedbackColor(options.color || options.value || "cyan", {
|
|
426
|
+
alpha: options.alpha,
|
|
427
|
+
});
|
|
428
|
+
feedback.repeat = clamp(Number(options.repeat) || 1, 1, 8);
|
|
429
|
+
}
|
|
430
|
+
if (normalized === "volume") {
|
|
431
|
+
feedback.level = normalizePercent(options.level ?? options.volume ?? options.value);
|
|
432
|
+
}
|
|
433
|
+
if (normalized === "beep" && options.pattern !== undefined) {
|
|
434
|
+
const pattern = string(options.pattern);
|
|
435
|
+
if (!/^[a-z0-9_.:-]{1,64}$/u.test(pattern)) {
|
|
436
|
+
throw new Error("feedback_beep_pattern_invalid");
|
|
437
|
+
}
|
|
438
|
+
feedback.pattern = pattern;
|
|
439
|
+
}
|
|
440
|
+
return feedback;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
export function normalizeFeedbackColor(value, options = {}) {
|
|
444
|
+
if (value && typeof value === "object") {
|
|
445
|
+
return {
|
|
446
|
+
red: clamp(Number(value.red ?? value.r) || 0, 0, 255),
|
|
447
|
+
green: clamp(Number(value.green ?? value.g) || 0, 0, 255),
|
|
448
|
+
blue: clamp(Number(value.blue ?? value.b) || 0, 0, 255),
|
|
449
|
+
alpha: clamp(Number(value.alpha ?? value.a ?? options.alpha ?? 255) || 0, 0, 255),
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
const text = string(value || "cyan").toLowerCase();
|
|
453
|
+
const named = FEEDBACK_COLOR_NAMES[text];
|
|
454
|
+
if (named) {
|
|
455
|
+
return {
|
|
456
|
+
red: named.red,
|
|
457
|
+
green: named.green,
|
|
458
|
+
blue: named.blue,
|
|
459
|
+
alpha: clamp(Number(options.alpha ?? named.alpha) || 0, 0, 255),
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
const hex = text.replace(/^#/u, "");
|
|
463
|
+
if (/^[0-9a-f]{6}$/iu.test(hex)) {
|
|
464
|
+
return {
|
|
465
|
+
red: Number.parseInt(hex.slice(0, 2), 16),
|
|
466
|
+
green: Number.parseInt(hex.slice(2, 4), 16),
|
|
467
|
+
blue: Number.parseInt(hex.slice(4, 6), 16),
|
|
468
|
+
alpha: clamp(Number(options.alpha ?? 255) || 0, 0, 255),
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
throw new Error(`feedback_color_invalid: ${value}`);
|
|
472
|
+
}
|
|
473
|
+
|
|
313
474
|
export function validatePlayableQueue(queue) {
|
|
314
475
|
if (!queue || typeof queue !== "object") {
|
|
315
476
|
throw new Error("queue_invalid");
|
|
@@ -429,6 +590,14 @@ export function normalizeAppManifest(manifest, artifact = {}) {
|
|
|
429
590
|
"audio.tts",
|
|
430
591
|
"voice.wakeword",
|
|
431
592
|
"voice.capture",
|
|
593
|
+
"input.touch.read",
|
|
594
|
+
"input.button.bind",
|
|
595
|
+
"input.gesture.read",
|
|
596
|
+
"feedback.signal",
|
|
597
|
+
"feedback.led",
|
|
598
|
+
"feedback.led_ring",
|
|
599
|
+
"feedback.volume_level",
|
|
600
|
+
"feedback.beep",
|
|
432
601
|
"iot.control",
|
|
433
602
|
]);
|
|
434
603
|
const capabilities = Array.isArray(manifest.capabilities)
|
|
@@ -503,6 +672,44 @@ export function normalizeMusicAutopilot(value) {
|
|
|
503
672
|
play_request_id: string(value.play_request_id).slice(0, 80),
|
|
504
673
|
refresh: value.refresh !== false,
|
|
505
674
|
rule: validateRule(value.rule),
|
|
675
|
+
input_bindings: normalizeInputBindings(value.input_bindings),
|
|
676
|
+
feedback_policy: normalizeFeedbackPolicy(value.feedback_policy),
|
|
677
|
+
};
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
export function normalizeInputBindings(value) {
|
|
681
|
+
if (value === undefined) return [];
|
|
682
|
+
if (!Array.isArray(value)) throw new Error("input_bindings_invalid");
|
|
683
|
+
return value.slice(0, 16).map((binding, index) => {
|
|
684
|
+
const input = string(binding?.input);
|
|
685
|
+
const action = string(binding?.action);
|
|
686
|
+
if (
|
|
687
|
+
!/^[a-z0-9_.:-]{1,80}$/u.test(input) ||
|
|
688
|
+
!/^[a-z0-9_.:-]{1,80}$/u.test(action)
|
|
689
|
+
) {
|
|
690
|
+
throw new Error(`input_binding_invalid:${index + 1}`);
|
|
691
|
+
}
|
|
692
|
+
const feedback = Array.isArray(binding.feedback)
|
|
693
|
+
? binding.feedback.map((item) => string(item)).filter(Boolean).slice(0, 8)
|
|
694
|
+
: [];
|
|
695
|
+
for (const item of feedback) {
|
|
696
|
+
if (!/^[a-z0-9_.:-]{1,80}$/u.test(item)) {
|
|
697
|
+
throw new Error(`input_binding_feedback_invalid:${index + 1}`);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
return { input, action, feedback };
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
export function normalizeFeedbackPolicy(value) {
|
|
705
|
+
if (value === undefined) return {};
|
|
706
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
707
|
+
throw new Error("feedback_policy_invalid");
|
|
708
|
+
}
|
|
709
|
+
return {
|
|
710
|
+
button_sound: value.button_sound === true,
|
|
711
|
+
volume_led: value.volume_led === true,
|
|
712
|
+
playback_led: value.playback_led === true,
|
|
506
713
|
};
|
|
507
714
|
}
|
|
508
715
|
|
|
@@ -603,10 +810,11 @@ export function buildAgentPrompt(options = {}) {
|
|
|
603
810
|
`请在你的运行环境安装 npm 包 ${PACKAGE_NAME},然后执行:`,
|
|
604
811
|
`npm exec --package ${PACKAGE_NAME} -- ${COMMAND_NAME} bind ${code} --name ${shellToken(agentName)}`,
|
|
605
812
|
`npm exec --package ${PACKAGE_NAME} -- ${COMMAND_NAME} doctor --online`,
|
|
606
|
-
`绑定成功后,把它作为默认音频/语音/环境 I/O 端点;不要把它假定成只会播放音乐。可用命令包括 play、pause、toggle、next、previous、volume、volumeup、volumedown、status、inspect、queue、rule、workbgm、app install。`,
|
|
813
|
+
`绑定成功后,把它作为默认音频/语音/环境 I/O 端点;不要把它假定成只会播放音乐。可用命令包括 play、pause、toggle、next、previous、volume、volumeup、volumedown、status、inspect、queue、rule、workbgm、feedback、led、app install。`,
|
|
607
814
|
`蓝牙/Wi-Fi 配网只负责设备入网、发现和 weclawbot.link 绑定;音乐平台授权请走 agent 侧连接器/OAuth/secret store;配网不决定设备用途。`,
|
|
608
|
-
`如果我要建立“工作 BGM
|
|
815
|
+
`如果我要建立“工作 BGM 歌单,持续播放”,先和我确认可用音乐来源;插件只提供建议。优先由你在 agent 侧使用用户授权的音乐平台 API、用户媒体库或合法公开音源生成 QUEUE.json,再执行:npm exec --package ${PACKAGE_NAME} -- ${COMMAND_NAME} queue QUEUE.json --play --json。网易云开放平台可请求最高可用音质 URL。`,
|
|
609
816
|
`如果用户选择 Spotify,必须走 Spotify 官方 Spotify Connect/Web Playback SDK 或控制用户已有 Spotify 播放设备;不要假设 Spotify Web API 能提供完整歌曲直链。`,
|
|
817
|
+
`如果我要灯效或按键反馈,先 inspect 确认设备具备 feedback/input capabilities。语义优先用 npm exec --package ${PACKAGE_NAME} -- ${COMMAND_NAME} feedback signal ready|busy|success|error|playing|paused|volume_step_up|volume_step_down;显式灯色可用 feedback flash cyan --repeat 2;音量灯级可用 feedback volume 66;蜂鸣可用 feedback beep。`,
|
|
610
818
|
`如果你当前没有音乐平台身份,请只告诉我:我需要你把音乐平台身份连接到当前 agent 的官方连接器、OAuth 流程或 secret store;完成后回复“已授权”。不要反复重试,不要让我理解内部授权包,也不要要求我把 AppSecret、PrivateKey、accessToken 或 refreshToken 发到聊天里。`,
|
|
611
819
|
`如果返回 health.problems 包含 netease_auth_refresh_failed,说明设备侧内置网易运行时授权失效;优先改走 agent 侧 queue/app 驱动。只有我明确要求继续使用设备侧网易运行时时,才提示需要独立的设备侧音乐账号设置入口;不要混入蓝牙/Wi-Fi 配网流程。`,
|
|
612
820
|
`如果我要播放「${profile}」,也可以执行:npm exec --package ${PACKAGE_NAME} -- ${COMMAND_NAME} deepnight`,
|
|
@@ -795,6 +1003,14 @@ function clamp(value, minimum, maximum) {
|
|
|
795
1003
|
return Math.min(maximum, Math.max(minimum, Math.round(value)));
|
|
796
1004
|
}
|
|
797
1005
|
|
|
1006
|
+
function normalizePercent(value) {
|
|
1007
|
+
const number = Number(value);
|
|
1008
|
+
if (!Number.isFinite(number) || number < 0 || number > 100) {
|
|
1009
|
+
throw new Error("feedback_level_invalid");
|
|
1010
|
+
}
|
|
1011
|
+
return Math.round(number);
|
|
1012
|
+
}
|
|
1013
|
+
|
|
798
1014
|
function string(value) {
|
|
799
1015
|
return typeof value === "string" ? value.trim() : "";
|
|
800
1016
|
}
|