@openbrt/audioctl 0.1.10 → 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 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 to `~/.config/weclawbot/audio-mqtt.json` by default.
55
- `audioctl` serializes concurrent commands that use the same local MQTT
56
- credentials, because the broker may require an exact MQTT client id.
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 commands.
63
- Continuous effects must be started as a device-local effect or VM app, then left
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
- `feedback`/`led` controls expose generic device feedback. Agents should prefer
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,18 +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 for continuous device-local effects such as beat/pulse or
114
- breathing animations. It sends one control message; the timing loop belongs on
115
- the device, not in the agent process. `feedback clear` stops the current
116
- device-local effect and clears the indicator. Beat-style names such as
117
- `music_beat` are treated as effects, not as a signal that should be published in
118
- a loop.
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.
119
167
 
120
168
  Physical keys/touch surfaces are reported as VM capabilities such as
121
169
  `input.touch.read`, `input.gesture.read`, and `input.button.bind`. A VM app can
@@ -185,3 +233,35 @@ Feedback messages use the same MQTT envelope:
185
233
  Supported feedback commands are `signal`, `flash`, `volume`, `effect`, `beep`,
186
234
  and `clear`. Devices that do not report a matching `feedback.*` capability
187
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
- "不要读取或解析 ~/.config/weclawbot/audio-mqtt.json 里的 MQTT 凭据",
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 feedback effect 或 VM app 一次下发,在设备本地运行;停止使用 audioctl feedback clear。"
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 确认设备报告 feedback.* input.* capabilities。MQTT 是控制面,不是实时灯效数据面;不要读取 MQTT 凭据、不要 import mqtt、不要保持 MQTT 长连接、不要写 agent 端 BPM/while/sleep 循环去刷 LED。一次性语义反馈可用 audioctl feedback signal/flash/volume/beep/clear;连续灯效、节拍灯、呼吸灯和动画必须通过 audioctl feedback effect VM app 一次下发并在设备本地运行。物理按键/触控属于 VM app 的 input.button.bind 能力,具体绑定由 agent 根据用户目标和 capabilities 自行规划。",
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 default: ${DEFAULT_CREDENTIALS_PATH}
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([
@@ -436,6 +494,7 @@ function parseOptions(values, defaults = {}) {
436
494
  "alias",
437
495
  "alpha",
438
496
  "bpm",
497
+ "brightness",
439
498
  "color",
440
499
  "credentials",
441
500
  "endpoint",
@@ -445,6 +504,7 @@ function parseOptions(values, defaults = {}) {
445
504
  "pattern",
446
505
  "profile",
447
506
  "repeat",
507
+ "surface",
448
508
  "timeout",
449
509
  "volume",
450
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 }),
@@ -84,6 +86,24 @@ export const FEEDBACK_EFFECTS = Object.freeze([
84
86
  "breathing",
85
87
  ]);
86
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
+
87
107
  const WORKBGM_APP_WASM_BASE64 =
88
108
  "AGFzbQEAAAABBAFgAAADAgEABwgBBG1haW4AAAoEAQIACw==";
89
109
 
@@ -223,6 +243,16 @@ export function buildFeedbackControl(command, options = {}) {
223
243
  };
224
244
  }
225
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
+
226
256
  export function buildQueueControl(options = {}) {
227
257
  return buildAudioControl("set_queue", {
228
258
  id: options.id,
@@ -455,14 +485,16 @@ export function normalizeFeedbackCommand(command, options = {}) {
455
485
  feedback.pattern = pattern;
456
486
  }
457
487
  if (normalized === "effect") {
458
- const effect = string(
488
+ const effectValue =
459
489
  options.effect ||
460
- options.semantic ||
461
- options.signal ||
462
- options.name ||
463
- options.value ||
464
- "pulse",
465
- ).replace(/-/gu, "_").toLowerCase();
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();
466
498
  if (!/^[a-z0-9_.:]{1,64}$/u.test(effect)) {
467
499
  throw new Error("feedback_effect_invalid");
468
500
  }
@@ -514,6 +546,181 @@ export function normalizeFeedbackColor(value, options = {}) {
514
546
  throw new Error(`feedback_color_invalid: ${value}`);
515
547
  }
516
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
+
517
724
  export function validatePlayableQueue(queue) {
518
725
  if (!queue || typeof queue !== "object") {
519
726
  throw new Error("queue_invalid");
@@ -642,6 +849,12 @@ export function normalizeAppManifest(manifest, artifact = {}) {
642
849
  "feedback.effect",
643
850
  "feedback.volume_level",
644
851
  "feedback.beep",
852
+ "light.describe",
853
+ "light.set",
854
+ "light.timeline",
855
+ "light.bind",
856
+ "light.clear",
857
+ "event.bind",
645
858
  "iot.control",
646
859
  ]);
647
860
  const capabilities = Array.isArray(manifest.capabilities)
@@ -857,13 +1070,13 @@ export function buildAgentPrompt(options = {}) {
857
1070
  `npm exec --package ${PACKAGE_NAME} -- ${COMMAND_NAME} bind ${code} --name ${shellToken(agentName)}`,
858
1071
  `npm exec --package ${PACKAGE_NAME} -- ${COMMAND_NAME} doctor --online`,
859
1072
  `这台设备对你是抽象硬件产品:${ABSTRACT_HARDWARE_TYPE}。不要根据外观、BLE 名称、音色、日志或历史上下文推断品牌/型号/厂家,也不要搜索厂家资料、社区经验或拆机资料;只使用 audioctl inspect 返回的 capabilities、VM manifest 和控制回执来工作。`,
860
- `绑定成功后,把它作为默认音频/语音/环境 I/O 端点;不要把它假定成只会播放音乐。可用命令包括 play、pause、toggle、next、previous、volume、volumeup、volumedown、status、inspect、queue、rule、workbgm、feedback、led、app install。`,
861
- `当我给出目标时,请你在 capabilities、安全边界和用户授权范围内自行规划实现路径;插件只声明能力和边界,不穷举具体玩法。需要离线、低延迟或持续运行的逻辑,应通过 queue/rule/VM app/feedback effect 下放到设备本地。`,
862
- `MQTT/weclawbot.link 对你只是控制面和部署通道,不是实时灯效数据面。不要读取 MQTT 凭据,不要直接 import mqtt,不要保持 MQTT 长连接,也不要写循环脚本按 BPM 反复发布 feedback。连续灯效/节拍/动画必须用 audioctl feedback effect 或 VM app 一次下发,在设备本地运行;停止时用 audioctl feedback clear。`,
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。`,
863
1076
  `蓝牙/Wi-Fi 配网只负责设备入网、发现和 weclawbot.link 绑定;音乐平台授权请走 agent 侧连接器/OAuth/secret store;配网不决定设备用途。`,
864
1077
  `音乐来源由你和用户协商,插件只做建议。你可以在 agent 侧使用用户授权的音乐平台 API、用户媒体库、合法公开音源或其他合规来源生成队列/规则/VM app。网易云开放平台可请求最高可用音质 URL。`,
865
1078
  `如果用户选择 Spotify,必须走 Spotify 官方 Spotify Connect/Web Playback SDK 或控制用户已有 Spotify 播放设备;不要假设 Spotify Web API 能提供完整歌曲直链。`,
866
- `灯效、音量灯、蜂鸣和按键/触控反馈要先 inspect 确认 feedback/input capabilities。一次性反馈可用 feedback signal/flash/volume/beep/clear;连续反馈用 feedback effect VM app 一次下发,由设备本地运行。具体按键绑定请根据用户目标和 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 自行规划,不要套固定模板。`,
867
1080
  `如果你当前没有音乐平台身份,请只告诉我:我需要你把音乐平台身份连接到当前 agent 的官方连接器、OAuth 流程或 secret store;完成后回复“已授权”。不要反复重试,不要让我理解内部授权包,也不要要求我把 AppSecret、PrivateKey、accessToken 或 refreshToken 发到聊天里。`,
868
1081
  `如果返回 health.problems 包含 netease_auth_refresh_failed,说明设备侧内置网易运行时授权失效;优先改走 agent 侧 queue/app 驱动。只有我明确要求继续使用设备侧网易运行时时,才提示需要独立的设备侧音乐账号设置入口;不要混入蓝牙/Wi-Fi 配网流程。`,
869
1082
  `如果我要播放「${profile}」,也可以执行:npm exec --package ${PACKAGE_NAME} -- ${COMMAND_NAME} deepnight`,
@@ -991,6 +1204,9 @@ function redactAgentFacingScalar(value, keyPath) {
991
1204
  if (typeof value !== "string") return value;
992
1205
  const last = keyPath[keyPath.length - 1];
993
1206
  if (last === "device_type") return ABSTRACT_HARDWARE_TYPE;
1207
+ if (["credentials", "control_topic", "status_topic", "event_topic"].includes(last)) {
1208
+ return "[redacted]";
1209
+ }
994
1210
  if (last === "model" || last === "brand" || last === "manufacturer" || last === "product") {
995
1211
  return "";
996
1212
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openbrt/audioctl",
3
- "version": "0.1.10",
3
+ "version": "0.1.11",
4
4
  "description": "Generic WeClawBot audio/voice endpoint control CLI and AI-agent plugin.",
5
5
  "type": "module",
6
6
  "license": "MIT",