@openbrt/audioctl 0.1.9 → 0.1.11
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 +95 -13
- package/agent-plugin.json +14 -4
- package/bin/audioctl.mjs +79 -4
- package/lib/index.mjs +233 -12
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -51,17 +51,19 @@ One-shot usage:
|
|
|
51
51
|
npm exec --package @openbrt/audioctl -- audioctl bind 123456 --name codex
|
|
52
52
|
```
|
|
53
53
|
|
|
54
|
-
Credentials are saved
|
|
55
|
-
|
|
56
|
-
|
|
54
|
+
Credentials are saved in an audioctl-managed local config file. Agents should
|
|
55
|
+
not inspect or parse that file; use `audioctl bind`, `doctor`, `inspect`, and
|
|
56
|
+
the control commands instead. `audioctl` serializes concurrent commands that use
|
|
57
|
+
the same local MQTT credentials, because the broker may require an exact MQTT
|
|
58
|
+
client id.
|
|
57
59
|
Agent-facing status is intentionally hardware-abstracted as
|
|
58
60
|
`audio_voice_ambient_endpoint`; agents should not infer or search for a device
|
|
59
61
|
brand/model/vendor from screenshots, BLE names, logs, or prior context.
|
|
60
62
|
MQTT is a control plane and deployment path, not a realtime feedback data plane:
|
|
61
63
|
agents should not read MQTT credentials, import `mqtt`, keep persistent MQTT
|
|
62
|
-
connections, or run BPM/while/sleep loops that repeatedly publish LED
|
|
63
|
-
Continuous effects must be started as a device-local
|
|
64
|
-
to run on the device.
|
|
64
|
+
connections, or run BPM/while/sleep loops that repeatedly publish LED/light
|
|
65
|
+
commands. Continuous effects must be started as a device-local light timeline,
|
|
66
|
+
VM app, or compatibility feedback effect, then left to run on the device.
|
|
65
67
|
|
|
66
68
|
## Commands
|
|
67
69
|
|
|
@@ -76,10 +78,14 @@ audioctl volumeup
|
|
|
76
78
|
audioctl volumedown
|
|
77
79
|
audioctl status
|
|
78
80
|
audioctl inspect --json
|
|
81
|
+
audioctl light describe --json
|
|
82
|
+
audioctl light set all --color amber --brightness 0.35
|
|
83
|
+
audioctl light timeline ./hero.light.json
|
|
84
|
+
audioctl light bind ./hero.light-bindings.json
|
|
85
|
+
audioctl light clear
|
|
79
86
|
audioctl feedback signal ready
|
|
80
87
|
audioctl feedback flash cyan --repeat 2
|
|
81
88
|
audioctl feedback volume 66
|
|
82
|
-
audioctl feedback effect music_beat --bpm 76 --color amber
|
|
83
89
|
audioctl feedback beep
|
|
84
90
|
audioctl feedback clear
|
|
85
91
|
audioctl queue ./work-bgm.queue.json --play
|
|
@@ -94,7 +100,51 @@ existing MQTT channel. Agent scripts can read Wi-Fi connection state, BLE
|
|
|
94
100
|
provisioning state, wake-word capability, playback, VM/app status, and health
|
|
95
101
|
without receiving Wi-Fi passwords, MQTT credentials, or music-platform secrets.
|
|
96
102
|
|
|
97
|
-
`
|
|
103
|
+
`light` controls expose programmable visual surfaces. Agents should use
|
|
104
|
+
`audioctl light describe --json` to inspect surfaces such as `all`, `ring`, and
|
|
105
|
+
`center`, plus event sources and runtime limits. For non-trivial visuals, build
|
|
106
|
+
a `weclawbot.light.timeline.v1` file and optionally a
|
|
107
|
+
`weclawbot.light.bind.v1` file so the device can start/stop visuals on local
|
|
108
|
+
events such as `media.playing`, `media.paused`, and `media.ended`.
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
audioctl light describe --json
|
|
112
|
+
audioctl light timeline ./hero.light.json
|
|
113
|
+
audioctl light bind ./hero.light-bindings.json
|
|
114
|
+
audioctl light clear
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Example timeline:
|
|
118
|
+
|
|
119
|
+
```json
|
|
120
|
+
{
|
|
121
|
+
"schema": "weclawbot.light.timeline.v1",
|
|
122
|
+
"id": "hero_ambient",
|
|
123
|
+
"loop": true,
|
|
124
|
+
"stop_on": ["media.paused", "media.ended", "media.error"],
|
|
125
|
+
"duration_ms": 3750,
|
|
126
|
+
"tracks": [
|
|
127
|
+
{
|
|
128
|
+
"surface": "ring",
|
|
129
|
+
"keyframes": [
|
|
130
|
+
{ "t": 0, "color": "#ff6000", "brightness": 0.18 },
|
|
131
|
+
{ "t": 470, "color": "#ff6000", "brightness": 0.55 },
|
|
132
|
+
{ "t": 940, "color": "#301800", "brightness": 0.12 }
|
|
133
|
+
]
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
"surface": "center",
|
|
137
|
+
"keyframes": [
|
|
138
|
+
{ "t": 0, "color": "#ffd080", "brightness": 0.08 },
|
|
139
|
+
{ "t": 1875, "color": "#ffd080", "brightness": 0.40 },
|
|
140
|
+
{ "t": 3750, "color": "#301800", "brightness": 0.05 }
|
|
141
|
+
]
|
|
142
|
+
}
|
|
143
|
+
]
|
|
144
|
+
}
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
`feedback`/`led` controls expose simple semantic device feedback. Agents should prefer
|
|
98
148
|
semantic signals (`ready`, `busy`, `success`, `error`, `playing`, `paused`,
|
|
99
149
|
`volume_step_up`, `volume_step_down`, `button_press`) so each hardware adapter
|
|
100
150
|
can map them to its native LED ring, small screen, haptic motor, or speaker
|
|
@@ -104,16 +154,16 @@ prompt. Explicit LED controls are also available:
|
|
|
104
154
|
audioctl feedback signal playing
|
|
105
155
|
audioctl feedback flash '#00ffff' --repeat 2
|
|
106
156
|
audioctl feedback volume 42
|
|
107
|
-
audioctl feedback effect music_beat --bpm 76 --color amber
|
|
108
157
|
audioctl feedback beep
|
|
109
158
|
audioctl feedback clear
|
|
110
159
|
audioctl led flash amber --repeat 1
|
|
111
160
|
```
|
|
112
161
|
|
|
113
|
-
`feedback effect` is
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
162
|
+
`feedback effect` is a compatibility/demo shortcut for old agents. New agent
|
|
163
|
+
work should prefer `light describe` + `light timeline` + `light bind`, because
|
|
164
|
+
named effects such as `music_beat`, `pulse`, or `breathing` do not describe the
|
|
165
|
+
actual hardware topology. `feedback effect` requires an explicit effect name;
|
|
166
|
+
calling it with no name is rejected.
|
|
117
167
|
|
|
118
168
|
Physical keys/touch surfaces are reported as VM capabilities such as
|
|
119
169
|
`input.touch.read`, `input.gesture.read`, and `input.button.bind`. A VM app can
|
|
@@ -183,3 +233,35 @@ Feedback messages use the same MQTT envelope:
|
|
|
183
233
|
Supported feedback commands are `signal`, `flash`, `volume`, `effect`, `beep`,
|
|
184
234
|
and `clear`. Devices that do not report a matching `feedback.*` capability
|
|
185
235
|
should reject the command instead of pretending to support it.
|
|
236
|
+
|
|
237
|
+
Light messages use a distinct command envelope:
|
|
238
|
+
|
|
239
|
+
```json
|
|
240
|
+
{
|
|
241
|
+
"schema": "weclawbot.control.v1",
|
|
242
|
+
"id": "light_<uuid>",
|
|
243
|
+
"kind": "light_command",
|
|
244
|
+
"light": {
|
|
245
|
+
"command": "timeline",
|
|
246
|
+
"timeline": {
|
|
247
|
+
"schema": "weclawbot.light.timeline.v1",
|
|
248
|
+
"id": "custom_visual",
|
|
249
|
+
"loop": true,
|
|
250
|
+
"stop_on": ["media.paused", "media.ended"],
|
|
251
|
+
"duration_ms": 2000,
|
|
252
|
+
"tracks": [
|
|
253
|
+
{
|
|
254
|
+
"surface": "all",
|
|
255
|
+
"keyframes": [
|
|
256
|
+
{ "t": 0, "color": "#202020", "brightness": 0.05 },
|
|
257
|
+
{ "t": 1000, "color": "#ff6000", "brightness": 0.5 }
|
|
258
|
+
]
|
|
259
|
+
}
|
|
260
|
+
]
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
Supported light commands are `describe`, `set`, `timeline`, `bind`, and
|
|
267
|
+
`clear`. Continuous light timing and event reactions run on the device VM.
|
package/agent-plugin.json
CHANGED
|
@@ -15,19 +15,19 @@
|
|
|
15
15
|
"transport_policy": {
|
|
16
16
|
"mqtt_role": "control_plane_only",
|
|
17
17
|
"agent_must_not": [
|
|
18
|
-
"不要读取或解析
|
|
18
|
+
"不要读取或解析 audioctl 本地 MQTT 凭据文件",
|
|
19
19
|
"不要直接 import mqtt、打开或保持 MQTT 长连接来控制设备",
|
|
20
20
|
"不要写 agent 端 while/sleep/BPM loop 来反复发布 feedback/LED 命令",
|
|
21
21
|
"不要把 weclawbot.link 当作实时灯效、音频分析或本地交互的数据面"
|
|
22
22
|
],
|
|
23
|
-
"continuous_feedback": "连续灯效、节拍灯、呼吸灯、动画和按键反馈必须通过 audioctl
|
|
23
|
+
"continuous_feedback": "连续灯效、节拍灯、呼吸灯、动画和按键反馈必须通过 audioctl light timeline/bind、VM app 或兼容的 feedback effect 一次下发,在设备本地运行;停止使用 audioctl light clear 或 feedback clear。"
|
|
24
24
|
},
|
|
25
25
|
"agent_decision_model": {
|
|
26
26
|
"principle": "agent 不需要也不应该知道具体品牌/产品;只基于 inspect 返回的抽象能力、VM manifest、用户目标和安全边界来推理。",
|
|
27
27
|
"allowed_reasoning": [
|
|
28
28
|
"把用户自然语言目标拆成需要的 I/O、媒体、反馈、输入绑定和持久运行需求",
|
|
29
29
|
"在用户已授权的平台、连接器、OAuth 或 secret store 中选择合法数据来源",
|
|
30
|
-
"决定使用 queue、rule、feedback、input binding 或 VM app install 的组合",
|
|
30
|
+
"决定使用 queue、rule、light timeline/bind、feedback、input binding 或 VM app install 的组合",
|
|
31
31
|
"在设备 VM 内放置需要离线、低延迟或持续运行的逻辑"
|
|
32
32
|
],
|
|
33
33
|
"not_prescribed": "插件不会也无法穷举所有 agent 方案;agent 应读取 capabilities 后自己想办法达到目标。"
|
|
@@ -75,6 +75,12 @@
|
|
|
75
75
|
"feedback.effect",
|
|
76
76
|
"feedback.volume_level",
|
|
77
77
|
"feedback.beep",
|
|
78
|
+
"light.describe",
|
|
79
|
+
"light.set",
|
|
80
|
+
"light.timeline",
|
|
81
|
+
"light.bind",
|
|
82
|
+
"light.clear",
|
|
83
|
+
"event.bind",
|
|
78
84
|
"iot.control",
|
|
79
85
|
"device.inspect",
|
|
80
86
|
"music.autopilot",
|
|
@@ -104,7 +110,7 @@
|
|
|
104
110
|
{
|
|
105
111
|
"intent": "帮我把设备灯效、音量灯和按键反馈音接上",
|
|
106
112
|
"primary_command": "audioctl feedback signal ready",
|
|
107
|
-
"agent_strategy": "先运行 audioctl inspect --json
|
|
113
|
+
"agent_strategy": "先运行 audioctl inspect --json 或 audioctl light describe --json 读取 light.surfaces、events、limits 以及 feedback/input capabilities。MQTT 是控制面,不是实时灯效数据面;不要读取 MQTT 凭据、不要 import mqtt、不要保持 MQTT 长连接、不要写 agent 端 BPM/while/sleep 循环去刷 LED。一次性语义提示可用 audioctl feedback signal/flash/volume/beep/clear;复杂灯效优先使用 audioctl light timeline 和 audioctl light bind,把外圈、中心等 surface 的 keyframes 与 media/input 事件绑定到设备本地。feedback effect 只是兼容/demo 入口,不要把 music_beat/pulse/breathing 当成能力边界。物理按键/触控属于 VM app 的 input.button.bind 能力,具体绑定由 agent 根据用户目标和 capabilities 自行规划。",
|
|
108
114
|
"if_missing_capability": "不要伪造灯效或按键能力;如果 inspect 未报告对应 capability,只说明当前设备适配未提供该能力,并建议安装或升级设备 VM。"
|
|
109
115
|
},
|
|
110
116
|
{
|
|
@@ -134,6 +140,10 @@
|
|
|
134
140
|
"name": "feedback",
|
|
135
141
|
"usage": "audioctl feedback signal NAME | flash COLOR | volume 0..100 | effect NAME [--bpm N] [--color COLOR] | clear | beep"
|
|
136
142
|
},
|
|
143
|
+
{
|
|
144
|
+
"name": "light",
|
|
145
|
+
"usage": "audioctl light describe | set SURFACE --color COLOR [--brightness 0..1] | timeline TIMELINE.json | bind BINDINGS.json | clear"
|
|
146
|
+
},
|
|
137
147
|
{
|
|
138
148
|
"name": "led",
|
|
139
149
|
"usage": "audioctl led flash COLOR [--repeat N]"
|
package/bin/audioctl.mjs
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
buildAgentPrompt,
|
|
15
15
|
buildAudioControl,
|
|
16
16
|
buildFeedbackControl,
|
|
17
|
+
buildLightControl,
|
|
17
18
|
buildQueueControl,
|
|
18
19
|
buildWorkBgmAppInstallControl,
|
|
19
20
|
expandPath,
|
|
@@ -54,6 +55,7 @@ async function run(name, values) {
|
|
|
54
55
|
if (name === "deepnight") return commandDeepnight(values);
|
|
55
56
|
if (name === "volume") return commandVolume(values);
|
|
56
57
|
if (name === "seek") return commandSeek(values);
|
|
58
|
+
if (name === "light") return commandLight(values);
|
|
57
59
|
if (name === "feedback" || name === "led") return commandFeedback(values);
|
|
58
60
|
return commandControl(name, values);
|
|
59
61
|
}
|
|
@@ -73,6 +75,11 @@ function usage(exitCode = 0) {
|
|
|
73
75
|
${COMMAND_NAME} feedback effect NAME [--bpm N] [--color COLOR]
|
|
74
76
|
${COMMAND_NAME} feedback clear|beep
|
|
75
77
|
${COMMAND_NAME} led flash COLOR [--repeat N]
|
|
78
|
+
${COMMAND_NAME} light describe
|
|
79
|
+
${COMMAND_NAME} light set SURFACE --color COLOR [--brightness 0..1]
|
|
80
|
+
${COMMAND_NAME} light timeline TIMELINE.json
|
|
81
|
+
${COMMAND_NAME} light bind BINDINGS.json
|
|
82
|
+
${COMMAND_NAME} light clear
|
|
76
83
|
${COMMAND_NAME} rule RULE.json
|
|
77
84
|
${COMMAND_NAME} queue QUEUE.json [--play]
|
|
78
85
|
${COMMAND_NAME} app install MANIFEST.json APP.wasm
|
|
@@ -81,7 +88,7 @@ function usage(exitCode = 0) {
|
|
|
81
88
|
${COMMAND_NAME} manifest
|
|
82
89
|
|
|
83
90
|
Options:
|
|
84
|
-
--credentials FILE
|
|
91
|
+
--credentials FILE override audioctl-managed local credential file
|
|
85
92
|
--endpoint URL default: ${DEFAULT_ENDPOINT}
|
|
86
93
|
--timeout SECONDS default: 12
|
|
87
94
|
--json machine-readable output
|
|
@@ -318,6 +325,57 @@ async function commandSeek(values) {
|
|
|
318
325
|
await send("seek", options, { positionMs: options._[0] });
|
|
319
326
|
}
|
|
320
327
|
|
|
328
|
+
async function commandLight(values) {
|
|
329
|
+
const subcommand = String(values[0] || "").replace(/-/gu, "_").toLowerCase();
|
|
330
|
+
if (!subcommand) throw new Error("light_requires_subcommand");
|
|
331
|
+
const options = parseOptions(values.slice(1), {
|
|
332
|
+
credentials: credentialsPath(),
|
|
333
|
+
timeout: 12,
|
|
334
|
+
json: false,
|
|
335
|
+
nowait: false,
|
|
336
|
+
});
|
|
337
|
+
let control;
|
|
338
|
+
if (subcommand === "describe" || subcommand === "capabilities") {
|
|
339
|
+
control = buildLightControl("describe");
|
|
340
|
+
} else if (subcommand === "set") {
|
|
341
|
+
control = buildLightControl("set", {
|
|
342
|
+
surface: options._[0] || options.surface || "all",
|
|
343
|
+
color: options.color || options._[1],
|
|
344
|
+
brightness: options.brightness ?? options.level,
|
|
345
|
+
alpha: options.alpha,
|
|
346
|
+
});
|
|
347
|
+
} else if (subcommand === "timeline") {
|
|
348
|
+
const file = String(options._[0] || "");
|
|
349
|
+
if (!file) throw new Error("light_timeline_requires_file");
|
|
350
|
+
control = buildLightControl("timeline", {
|
|
351
|
+
timeline: JSON.parse(await fs.readFile(file, "utf8")),
|
|
352
|
+
});
|
|
353
|
+
} else if (subcommand === "bind") {
|
|
354
|
+
const file = String(options._[0] || "");
|
|
355
|
+
if (!file) throw new Error("light_bind_requires_file");
|
|
356
|
+
control = buildLightControl("bind", {
|
|
357
|
+
bindings: JSON.parse(await fs.readFile(file, "utf8")),
|
|
358
|
+
});
|
|
359
|
+
} else if (subcommand === "clear" || subcommand === "off" || subcommand === "hide") {
|
|
360
|
+
control = buildLightControl("clear");
|
|
361
|
+
} else {
|
|
362
|
+
throw new Error(`light_subcommand_unsupported: ${subcommand}`);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const credentials = await readCredentials(options.credentials);
|
|
366
|
+
const delivery = await publishAudioControl(credentials, control, {
|
|
367
|
+
timeoutMs: Number(options.timeout) * 1000,
|
|
368
|
+
wait: !options.nowait,
|
|
369
|
+
});
|
|
370
|
+
if (control.light.command === "describe" && options.json) {
|
|
371
|
+
const light = delivery.status?.inspect?.light || delivery.status?.light || {};
|
|
372
|
+
process.stdout.write(`${JSON.stringify(redactAgentFacingValue(light))}\n`);
|
|
373
|
+
} else {
|
|
374
|
+
print(delivery.status || delivery, options.json);
|
|
375
|
+
}
|
|
376
|
+
if (delivery.status?.kind === "rejected") process.exitCode = 1;
|
|
377
|
+
}
|
|
378
|
+
|
|
321
379
|
async function commandFeedback(values) {
|
|
322
380
|
const subcommand = String(values[0] || "").replace(/-/gu, "_").toLowerCase();
|
|
323
381
|
const shorthandSignals = new Set([
|
|
@@ -329,12 +387,12 @@ async function commandFeedback(values) {
|
|
|
329
387
|
"playing",
|
|
330
388
|
"paused",
|
|
331
389
|
"wake",
|
|
332
|
-
"music_beat",
|
|
333
390
|
"volume_step_up",
|
|
334
391
|
"volume_step_down",
|
|
335
392
|
"button_press",
|
|
336
393
|
"button_long_press",
|
|
337
394
|
]);
|
|
395
|
+
const shorthandEffects = new Set(["music_beat", "pulse", "breathing"]);
|
|
338
396
|
const options = parseOptions(values.slice(1), {
|
|
339
397
|
credentials: credentialsPath(),
|
|
340
398
|
timeout: 12,
|
|
@@ -345,11 +403,26 @@ async function commandFeedback(values) {
|
|
|
345
403
|
let command = subcommand;
|
|
346
404
|
const payload = {};
|
|
347
405
|
if (!command) throw new Error("feedback_requires_subcommand");
|
|
348
|
-
if (
|
|
406
|
+
if (shorthandEffects.has(command)) {
|
|
407
|
+
payload.effect = command;
|
|
408
|
+
payload.bpm = options.bpm;
|
|
409
|
+
payload.color = options.color;
|
|
410
|
+
payload.alpha = options.alpha;
|
|
411
|
+
command = "effect";
|
|
412
|
+
} else if (shorthandSignals.has(command)) {
|
|
349
413
|
payload.semantic = command;
|
|
350
414
|
command = "signal";
|
|
351
415
|
} else if (command === "signal") {
|
|
352
|
-
|
|
416
|
+
const semantic = String(options._[0] || "").replace(/-/gu, "_").toLowerCase();
|
|
417
|
+
if (shorthandEffects.has(semantic)) {
|
|
418
|
+
payload.effect = semantic;
|
|
419
|
+
payload.bpm = options.bpm;
|
|
420
|
+
payload.color = options.color;
|
|
421
|
+
payload.alpha = options.alpha;
|
|
422
|
+
command = "effect";
|
|
423
|
+
} else {
|
|
424
|
+
payload.semantic = options._[0];
|
|
425
|
+
}
|
|
353
426
|
} else if (command === "flash") {
|
|
354
427
|
payload.color = options._[0] || options.color;
|
|
355
428
|
payload.repeat = options.repeat;
|
|
@@ -421,6 +494,7 @@ function parseOptions(values, defaults = {}) {
|
|
|
421
494
|
"alias",
|
|
422
495
|
"alpha",
|
|
423
496
|
"bpm",
|
|
497
|
+
"brightness",
|
|
424
498
|
"color",
|
|
425
499
|
"credentials",
|
|
426
500
|
"endpoint",
|
|
@@ -430,6 +504,7 @@ function parseOptions(values, defaults = {}) {
|
|
|
430
504
|
"pattern",
|
|
431
505
|
"profile",
|
|
432
506
|
"repeat",
|
|
507
|
+
"surface",
|
|
433
508
|
"timeout",
|
|
434
509
|
"volume",
|
|
435
510
|
]).has(key)) {
|
package/lib/index.mjs
CHANGED
|
@@ -48,6 +48,8 @@ export const WORKBGM_RULE = Object.freeze({
|
|
|
48
48
|
});
|
|
49
49
|
|
|
50
50
|
export const PLAYABLE_QUEUE_SCHEMA = "weclawbot.playable_queue.v1";
|
|
51
|
+
export const LIGHT_TIMELINE_SCHEMA = "weclawbot.light.timeline.v1";
|
|
52
|
+
export const LIGHT_BINDINGS_SCHEMA = "weclawbot.light.bind.v1";
|
|
51
53
|
|
|
52
54
|
export const FEEDBACK_COLOR_NAMES = Object.freeze({
|
|
53
55
|
red: Object.freeze({ red: 255, green: 0, blue: 0, alpha: 255 }),
|
|
@@ -71,7 +73,6 @@ export const FEEDBACK_SIGNALS = Object.freeze([
|
|
|
71
73
|
"playing",
|
|
72
74
|
"paused",
|
|
73
75
|
"wake",
|
|
74
|
-
"music_beat",
|
|
75
76
|
"volume_step_up",
|
|
76
77
|
"volume_step_down",
|
|
77
78
|
"button_press",
|
|
@@ -85,6 +86,24 @@ export const FEEDBACK_EFFECTS = Object.freeze([
|
|
|
85
86
|
"breathing",
|
|
86
87
|
]);
|
|
87
88
|
|
|
89
|
+
export const LIGHT_SURFACES = Object.freeze([
|
|
90
|
+
"all",
|
|
91
|
+
"ring",
|
|
92
|
+
"center",
|
|
93
|
+
]);
|
|
94
|
+
|
|
95
|
+
export const LIGHT_EVENTS = Object.freeze([
|
|
96
|
+
"media.playing",
|
|
97
|
+
"media.paused",
|
|
98
|
+
"media.ended",
|
|
99
|
+
"media.error",
|
|
100
|
+
"volume.changed",
|
|
101
|
+
"input.center.single_click",
|
|
102
|
+
"input.ring.single_click",
|
|
103
|
+
"input.ring.clockwise",
|
|
104
|
+
"input.ring.counter_clockwise",
|
|
105
|
+
]);
|
|
106
|
+
|
|
88
107
|
const WORKBGM_APP_WASM_BASE64 =
|
|
89
108
|
"AGFzbQEAAAABBAFgAAADAgEABwgBBG1haW4AAAoEAQIACw==";
|
|
90
109
|
|
|
@@ -224,6 +243,16 @@ export function buildFeedbackControl(command, options = {}) {
|
|
|
224
243
|
};
|
|
225
244
|
}
|
|
226
245
|
|
|
246
|
+
export function buildLightControl(command, options = {}) {
|
|
247
|
+
const light = normalizeLightCommand(command, options);
|
|
248
|
+
return {
|
|
249
|
+
schema: "weclawbot.control.v1",
|
|
250
|
+
id: string(options.id) || `light_${crypto.randomUUID()}`,
|
|
251
|
+
kind: "light_command",
|
|
252
|
+
light,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
227
256
|
export function buildQueueControl(options = {}) {
|
|
228
257
|
return buildAudioControl("set_queue", {
|
|
229
258
|
id: options.id,
|
|
@@ -425,6 +454,12 @@ export function normalizeFeedbackCommand(command, options = {}) {
|
|
|
425
454
|
options.value ||
|
|
426
455
|
"button_press",
|
|
427
456
|
).replace(/-/gu, "_").toLowerCase();
|
|
457
|
+
if (FEEDBACK_EFFECTS.includes(semantic)) {
|
|
458
|
+
return normalizeFeedbackCommand("effect", {
|
|
459
|
+
...options,
|
|
460
|
+
effect: semantic,
|
|
461
|
+
});
|
|
462
|
+
}
|
|
428
463
|
if (!/^[a-z0-9_.:]{1,64}$/u.test(semantic)) {
|
|
429
464
|
throw new Error("feedback_signal_invalid");
|
|
430
465
|
}
|
|
@@ -450,14 +485,16 @@ export function normalizeFeedbackCommand(command, options = {}) {
|
|
|
450
485
|
feedback.pattern = pattern;
|
|
451
486
|
}
|
|
452
487
|
if (normalized === "effect") {
|
|
453
|
-
const
|
|
488
|
+
const effectValue =
|
|
454
489
|
options.effect ||
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
490
|
+
options.semantic ||
|
|
491
|
+
options.signal ||
|
|
492
|
+
options.name ||
|
|
493
|
+
options.value;
|
|
494
|
+
if (!effectValue) {
|
|
495
|
+
throw new Error("feedback_effect_requires_name");
|
|
496
|
+
}
|
|
497
|
+
const effect = string(effectValue).replace(/-/gu, "_").toLowerCase();
|
|
461
498
|
if (!/^[a-z0-9_.:]{1,64}$/u.test(effect)) {
|
|
462
499
|
throw new Error("feedback_effect_invalid");
|
|
463
500
|
}
|
|
@@ -509,6 +546,181 @@ export function normalizeFeedbackColor(value, options = {}) {
|
|
|
509
546
|
throw new Error(`feedback_color_invalid: ${value}`);
|
|
510
547
|
}
|
|
511
548
|
|
|
549
|
+
export function normalizeLightCommand(command, options = {}) {
|
|
550
|
+
const aliases = new Map([
|
|
551
|
+
["capabilities", "describe"],
|
|
552
|
+
["description", "describe"],
|
|
553
|
+
["info", "describe"],
|
|
554
|
+
["off", "clear"],
|
|
555
|
+
["hide", "clear"],
|
|
556
|
+
["stop", "clear"],
|
|
557
|
+
["frame", "set"],
|
|
558
|
+
["timeline_start", "timeline"],
|
|
559
|
+
["animation", "timeline"],
|
|
560
|
+
["animate", "timeline"],
|
|
561
|
+
["bindings", "bind"],
|
|
562
|
+
["event_bind", "bind"],
|
|
563
|
+
]);
|
|
564
|
+
const raw = string(command || options.command || options.action || "describe")
|
|
565
|
+
.replace(/-/gu, "_")
|
|
566
|
+
.toLowerCase();
|
|
567
|
+
const normalized = aliases.get(raw) || raw;
|
|
568
|
+
const supported = new Set(["describe", "set", "timeline", "bind", "clear"]);
|
|
569
|
+
if (!supported.has(normalized)) {
|
|
570
|
+
throw new Error(`light_command_unsupported: ${command}`);
|
|
571
|
+
}
|
|
572
|
+
const light = { command: normalized };
|
|
573
|
+
if (normalized === "set") {
|
|
574
|
+
light.surface = normalizeLightSurface(options.surface || options.target || "all");
|
|
575
|
+
light.color = normalizeFeedbackColor(options.color || options.value || "white", {
|
|
576
|
+
alpha: options.alpha,
|
|
577
|
+
});
|
|
578
|
+
light.brightness = normalizeUnit(options.brightness ?? options.level ?? 1, {
|
|
579
|
+
defaultValue: 1,
|
|
580
|
+
name: "light_brightness_invalid",
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
if (normalized === "timeline") {
|
|
584
|
+
light.timeline = validateLightTimeline(options.timeline);
|
|
585
|
+
}
|
|
586
|
+
if (normalized === "bind") {
|
|
587
|
+
light.bindings = validateLightBindings(options.bindings || options.binding);
|
|
588
|
+
}
|
|
589
|
+
return light;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
export function validateLightTimeline(value) {
|
|
593
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
594
|
+
throw new Error("light_timeline_invalid");
|
|
595
|
+
}
|
|
596
|
+
const tracksInput = Array.isArray(value.tracks) ? value.tracks : [];
|
|
597
|
+
if (tracksInput.length < 1 || tracksInput.length > 8) {
|
|
598
|
+
throw new Error("light_timeline_tracks_invalid");
|
|
599
|
+
}
|
|
600
|
+
let maxTime = 0;
|
|
601
|
+
const tracks = tracksInput.map((track, trackIndex) => {
|
|
602
|
+
const surface = normalizeLightSurface(track?.surface || track?.target || "all");
|
|
603
|
+
const keyframesInput = Array.isArray(track?.keyframes) ? track.keyframes : [];
|
|
604
|
+
if (keyframesInput.length < 1 || keyframesInput.length > 64) {
|
|
605
|
+
throw new Error(`light_timeline_keyframes_invalid:${trackIndex + 1}`);
|
|
606
|
+
}
|
|
607
|
+
const keyframes = keyframesInput.map((frame, frameIndex) => {
|
|
608
|
+
if (!frame || typeof frame !== "object" || Array.isArray(frame)) {
|
|
609
|
+
throw new Error(`light_timeline_keyframe_invalid:${trackIndex + 1}:${frameIndex + 1}`);
|
|
610
|
+
}
|
|
611
|
+
const t = clamp(Number(frame.t ?? frame.time_ms ?? frame.timeMs) || 0, 0, 600_000);
|
|
612
|
+
maxTime = Math.max(maxTime, t);
|
|
613
|
+
return {
|
|
614
|
+
t,
|
|
615
|
+
color: normalizeFeedbackColor(frame.color || frame.value || "white", {
|
|
616
|
+
alpha: frame.alpha,
|
|
617
|
+
}),
|
|
618
|
+
brightness: normalizeUnit(frame.brightness ?? frame.level ?? 1, {
|
|
619
|
+
defaultValue: 1,
|
|
620
|
+
name: "light_keyframe_brightness_invalid",
|
|
621
|
+
}),
|
|
622
|
+
};
|
|
623
|
+
}).sort((a, b) => a.t - b.t);
|
|
624
|
+
return { surface, keyframes };
|
|
625
|
+
});
|
|
626
|
+
const durationMs = clamp(
|
|
627
|
+
Number(value.durationMs ?? value.duration_ms) || Math.max(maxTime, 1000),
|
|
628
|
+
100,
|
|
629
|
+
600_000,
|
|
630
|
+
);
|
|
631
|
+
return {
|
|
632
|
+
schema: LIGHT_TIMELINE_SCHEMA,
|
|
633
|
+
id: normalizeIdentifier(value.id || value.timeline_id || `timeline_${crypto.randomUUID()}`, {
|
|
634
|
+
name: "light_timeline_id_invalid",
|
|
635
|
+
maximum: 80,
|
|
636
|
+
}),
|
|
637
|
+
loop: value.loop === true,
|
|
638
|
+
stop_on: normalizeLightEvents(value.stop_on || value.stopOn || ["media.paused", "media.ended"]),
|
|
639
|
+
duration_ms: durationMs,
|
|
640
|
+
tracks,
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
export function validateLightBindings(value) {
|
|
645
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
646
|
+
throw new Error("light_bindings_invalid");
|
|
647
|
+
}
|
|
648
|
+
const input = Array.isArray(value.bindings) ? value.bindings : [];
|
|
649
|
+
if (input.length > 32) throw new Error("light_bindings_too_large");
|
|
650
|
+
return {
|
|
651
|
+
schema: LIGHT_BINDINGS_SCHEMA,
|
|
652
|
+
bindings: input.map((binding, index) => {
|
|
653
|
+
const timeline = binding?.timeline ? validateLightTimeline(binding.timeline) : null;
|
|
654
|
+
const events = normalizeLightEvents(binding?.events ?? binding?.event);
|
|
655
|
+
const actionRaw = string(binding?.action || (timeline ? "light.timeline.start" : "light.clear"))
|
|
656
|
+
.replace(/-/gu, "_")
|
|
657
|
+
.toLowerCase();
|
|
658
|
+
const actionAliases = new Map([
|
|
659
|
+
["clear", "light.clear"],
|
|
660
|
+
["off", "light.clear"],
|
|
661
|
+
["stop", "light.timeline.stop"],
|
|
662
|
+
["timeline.start", "light.timeline.start"],
|
|
663
|
+
["timeline_start", "light.timeline.start"],
|
|
664
|
+
["start", "light.timeline.start"],
|
|
665
|
+
["timeline.stop", "light.timeline.stop"],
|
|
666
|
+
["timeline_stop", "light.timeline.stop"],
|
|
667
|
+
]);
|
|
668
|
+
const action = actionAliases.get(actionRaw) || actionRaw;
|
|
669
|
+
if (!new Set(["light.clear", "light.timeline.start", "light.timeline.stop"]).has(action)) {
|
|
670
|
+
throw new Error(`light_binding_action_invalid:${index + 1}`);
|
|
671
|
+
}
|
|
672
|
+
const normalized = {
|
|
673
|
+
events,
|
|
674
|
+
action,
|
|
675
|
+
timeline_id: string(binding?.timeline_id || binding?.timelineId || timeline?.id || "").slice(0, 80),
|
|
676
|
+
};
|
|
677
|
+
if (timeline) normalized.timeline = timeline;
|
|
678
|
+
return normalized;
|
|
679
|
+
}),
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function normalizeLightSurface(value) {
|
|
684
|
+
const surface = string(value).replace(/-/gu, "_").toLowerCase();
|
|
685
|
+
if (!LIGHT_SURFACES.includes(surface)) {
|
|
686
|
+
throw new Error(`light_surface_unsupported: ${value}`);
|
|
687
|
+
}
|
|
688
|
+
return surface;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function normalizeLightEvents(value) {
|
|
692
|
+
const events = Array.isArray(value) ? value : [value];
|
|
693
|
+
return events.map(normalizeLightEvent).slice(0, 16);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function normalizeLightEvent(value) {
|
|
697
|
+
const event = string(value).replace(/-/gu, "_").toLowerCase();
|
|
698
|
+
if (!LIGHT_EVENTS.includes(event)) {
|
|
699
|
+
throw new Error(`light_event_unsupported: ${value}`);
|
|
700
|
+
}
|
|
701
|
+
return event;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function normalizeIdentifier(value, options = {}) {
|
|
705
|
+
const text = string(value);
|
|
706
|
+
const maximum = Number(options.maximum) || 80;
|
|
707
|
+
if (!/^[a-z0-9][a-z0-9_.:-]*$/u.test(text) || text.length > maximum) {
|
|
708
|
+
throw new Error(options.name || "identifier_invalid");
|
|
709
|
+
}
|
|
710
|
+
return text;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
function normalizeUnit(value, options = {}) {
|
|
714
|
+
const number = Number(value);
|
|
715
|
+
if (!Number.isFinite(number) || number < 0 || number > 1) {
|
|
716
|
+
if (value === undefined && options.defaultValue !== undefined) {
|
|
717
|
+
return options.defaultValue;
|
|
718
|
+
}
|
|
719
|
+
throw new Error(options.name || "unit_value_invalid");
|
|
720
|
+
}
|
|
721
|
+
return Math.round(number * 1000) / 1000;
|
|
722
|
+
}
|
|
723
|
+
|
|
512
724
|
export function validatePlayableQueue(queue) {
|
|
513
725
|
if (!queue || typeof queue !== "object") {
|
|
514
726
|
throw new Error("queue_invalid");
|
|
@@ -637,6 +849,12 @@ export function normalizeAppManifest(manifest, artifact = {}) {
|
|
|
637
849
|
"feedback.effect",
|
|
638
850
|
"feedback.volume_level",
|
|
639
851
|
"feedback.beep",
|
|
852
|
+
"light.describe",
|
|
853
|
+
"light.set",
|
|
854
|
+
"light.timeline",
|
|
855
|
+
"light.bind",
|
|
856
|
+
"light.clear",
|
|
857
|
+
"event.bind",
|
|
640
858
|
"iot.control",
|
|
641
859
|
]);
|
|
642
860
|
const capabilities = Array.isArray(manifest.capabilities)
|
|
@@ -852,13 +1070,13 @@ export function buildAgentPrompt(options = {}) {
|
|
|
852
1070
|
`npm exec --package ${PACKAGE_NAME} -- ${COMMAND_NAME} bind ${code} --name ${shellToken(agentName)}`,
|
|
853
1071
|
`npm exec --package ${PACKAGE_NAME} -- ${COMMAND_NAME} doctor --online`,
|
|
854
1072
|
`这台设备对你是抽象硬件产品:${ABSTRACT_HARDWARE_TYPE}。不要根据外观、BLE 名称、音色、日志或历史上下文推断品牌/型号/厂家,也不要搜索厂家资料、社区经验或拆机资料;只使用 audioctl inspect 返回的 capabilities、VM manifest 和控制回执来工作。`,
|
|
855
|
-
`绑定成功后,把它作为默认音频/语音/环境 I/O 端点;不要把它假定成只会播放音乐。可用命令包括 play、pause、toggle、next、previous、volume、volumeup、volumedown、status、inspect、queue、rule、workbgm、feedback、led、app install。`,
|
|
856
|
-
`当我给出目标时,请你在 capabilities、安全边界和用户授权范围内自行规划实现路径;插件只声明能力和边界,不穷举具体玩法。需要离线、低延迟或持续运行的逻辑,应通过 queue/rule/VM app
|
|
857
|
-
`MQTT/weclawbot.link 对你只是控制面和部署通道,不是实时灯效数据面。不要读取 MQTT 凭据,不要直接 import mqtt,不要保持 MQTT 长连接,也不要写循环脚本按 BPM 反复发布 feedback。连续灯效/节拍/动画必须用 audioctl
|
|
1073
|
+
`绑定成功后,把它作为默认音频/语音/环境 I/O 端点;不要把它假定成只会播放音乐。可用命令包括 play、pause、toggle、next、previous、volume、volumeup、volumedown、status、inspect、queue、rule、workbgm、feedback、light、led、app install。`,
|
|
1074
|
+
`当我给出目标时,请你在 capabilities、安全边界和用户授权范围内自行规划实现路径;插件只声明能力和边界,不穷举具体玩法。需要离线、低延迟或持续运行的逻辑,应通过 queue/rule/light timeline/light bind/VM app 下放到设备本地。`,
|
|
1075
|
+
`MQTT/weclawbot.link 对你只是控制面和部署通道,不是实时灯效数据面。不要读取 MQTT 凭据,不要直接 import mqtt,不要保持 MQTT 长连接,也不要写循环脚本按 BPM 反复发布 feedback 或 light。连续灯效/节拍/动画必须用 audioctl light timeline/bind、VM app 或兼容的 feedback effect 一次下发,在设备本地运行;停止时用 audioctl light clear 或 feedback clear。`,
|
|
858
1076
|
`蓝牙/Wi-Fi 配网只负责设备入网、发现和 weclawbot.link 绑定;音乐平台授权请走 agent 侧连接器/OAuth/secret store;配网不决定设备用途。`,
|
|
859
1077
|
`音乐来源由你和用户协商,插件只做建议。你可以在 agent 侧使用用户授权的音乐平台 API、用户媒体库、合法公开音源或其他合规来源生成队列/规则/VM app。网易云开放平台可请求最高可用音质 URL。`,
|
|
860
1078
|
`如果用户选择 Spotify,必须走 Spotify 官方 Spotify Connect/Web Playback SDK 或控制用户已有 Spotify 播放设备;不要假设 Spotify Web API 能提供完整歌曲直链。`,
|
|
861
|
-
`灯效、音量灯、蜂鸣和按键/触控反馈要先 inspect 确认 feedback/input capabilities
|
|
1079
|
+
`灯效、音量灯、蜂鸣和按键/触控反馈要先 inspect 或 light describe 确认 feedback/input/light capabilities。一次性语义反馈可用 feedback signal/flash/volume/beep/clear;复杂灯效请优先生成 light timeline 和 light bind,把外圈、中心等 surface 与 media/input 事件绑定到设备本地。feedback effect 只作为兼容/demo 入口,不要把 music_beat/pulse/breathing 当成能力边界。具体按键绑定请根据用户目标和 capabilities 自行规划,不要套固定模板。`,
|
|
862
1080
|
`如果你当前没有音乐平台身份,请只告诉我:我需要你把音乐平台身份连接到当前 agent 的官方连接器、OAuth 流程或 secret store;完成后回复“已授权”。不要反复重试,不要让我理解内部授权包,也不要要求我把 AppSecret、PrivateKey、accessToken 或 refreshToken 发到聊天里。`,
|
|
863
1081
|
`如果返回 health.problems 包含 netease_auth_refresh_failed,说明设备侧内置网易运行时授权失效;优先改走 agent 侧 queue/app 驱动。只有我明确要求继续使用设备侧网易运行时时,才提示需要独立的设备侧音乐账号设置入口;不要混入蓝牙/Wi-Fi 配网流程。`,
|
|
864
1082
|
`如果我要播放「${profile}」,也可以执行:npm exec --package ${PACKAGE_NAME} -- ${COMMAND_NAME} deepnight`,
|
|
@@ -986,6 +1204,9 @@ function redactAgentFacingScalar(value, keyPath) {
|
|
|
986
1204
|
if (typeof value !== "string") return value;
|
|
987
1205
|
const last = keyPath[keyPath.length - 1];
|
|
988
1206
|
if (last === "device_type") return ABSTRACT_HARDWARE_TYPE;
|
|
1207
|
+
if (["credentials", "control_topic", "status_topic", "event_topic"].includes(last)) {
|
|
1208
|
+
return "[redacted]";
|
|
1209
|
+
}
|
|
989
1210
|
if (last === "model" || last === "brand" || last === "manufacturer" || last === "product") {
|
|
990
1211
|
return "";
|
|
991
1212
|
}
|