@openbrt/audioctl 0.1.10 → 0.1.12

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,16 @@ 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
86
+ audioctl firmware check --json
87
+ audioctl firmware update --yes --json
79
88
  audioctl feedback signal ready
80
89
  audioctl feedback flash cyan --repeat 2
81
90
  audioctl feedback volume 66
82
- audioctl feedback effect music_beat --bpm 76 --color amber
83
91
  audioctl feedback beep
84
92
  audioctl feedback clear
85
93
  audioctl queue ./work-bgm.queue.json --play
@@ -91,10 +99,71 @@ audioctl prompt 123456 --agent codex --alias 视听房音箱
91
99
 
92
100
  `inspect` asks the device for a redacted configuration/status snapshot over the
93
101
  existing MQTT channel. Agent scripts can read Wi-Fi connection state, BLE
94
- provisioning state, wake-word capability, playback, VM/app status, and health
95
- without receiving Wi-Fi passwords, MQTT credentials, or music-platform secrets.
102
+ provisioning state, wake-word capability, playback, VM/app status, firmware
103
+ runtime version, and health without receiving Wi-Fi passwords, MQTT
104
+ credentials, or music-platform secrets.
96
105
 
97
- `feedback`/`led` controls expose generic device feedback. Agents should prefer
106
+ `firmware check` reads the official firmware index from
107
+ `https://weclawbot.link/firmware/audio/manifest.json`, compares it with
108
+ `inspect.firmware.version`, and reports whether a host/runtime update is
109
+ available. `firmware update` is intentionally gated: without `--yes` it only
110
+ prints the update plan and `confirmation_required`. An agent must show the
111
+ version, source and notes to the user first, then run `audioctl firmware update
112
+ --yes --json` only after the user explicitly confirms. Firmware updates are for
113
+ the device host/runtime and VM manager; they do not erase Wi-Fi, MQTT binding,
114
+ music-platform settings, queues, rules, or installed VM app slots.
115
+
116
+ ```bash
117
+ audioctl firmware check --json
118
+ audioctl firmware update --json # dry plan, no mutation
119
+ audioctl firmware update --yes --json # runs only after explicit user approval
120
+ ```
121
+
122
+ `light` controls expose programmable visual surfaces. Agents should use
123
+ `audioctl light describe --json` to inspect surfaces such as `all`, `ring`, and
124
+ `center`, plus event sources and runtime limits. For non-trivial visuals, build
125
+ a `weclawbot.light.timeline.v1` file and optionally a
126
+ `weclawbot.light.bind.v1` file so the device can start/stop visuals on local
127
+ events such as `media.playing`, `media.paused`, and `media.ended`.
128
+
129
+ ```bash
130
+ audioctl light describe --json
131
+ audioctl light timeline ./hero.light.json
132
+ audioctl light bind ./hero.light-bindings.json
133
+ audioctl light clear
134
+ ```
135
+
136
+ Example timeline:
137
+
138
+ ```json
139
+ {
140
+ "schema": "weclawbot.light.timeline.v1",
141
+ "id": "hero_ambient",
142
+ "loop": true,
143
+ "stop_on": ["media.paused", "media.ended", "media.error"],
144
+ "duration_ms": 3750,
145
+ "tracks": [
146
+ {
147
+ "surface": "ring",
148
+ "keyframes": [
149
+ { "t": 0, "color": "#ff6000", "brightness": 0.18 },
150
+ { "t": 470, "color": "#ff6000", "brightness": 0.55 },
151
+ { "t": 940, "color": "#301800", "brightness": 0.12 }
152
+ ]
153
+ },
154
+ {
155
+ "surface": "center",
156
+ "keyframes": [
157
+ { "t": 0, "color": "#ffd080", "brightness": 0.08 },
158
+ { "t": 1875, "color": "#ffd080", "brightness": 0.40 },
159
+ { "t": 3750, "color": "#301800", "brightness": 0.05 }
160
+ ]
161
+ }
162
+ ]
163
+ }
164
+ ```
165
+
166
+ `feedback`/`led` controls expose simple semantic device feedback. Agents should prefer
98
167
  semantic signals (`ready`, `busy`, `success`, `error`, `playing`, `paused`,
99
168
  `volume_step_up`, `volume_step_down`, `button_press`) so each hardware adapter
100
169
  can map them to its native LED ring, small screen, haptic motor, or speaker
@@ -104,18 +173,16 @@ prompt. Explicit LED controls are also available:
104
173
  audioctl feedback signal playing
105
174
  audioctl feedback flash '#00ffff' --repeat 2
106
175
  audioctl feedback volume 42
107
- audioctl feedback effect music_beat --bpm 76 --color amber
108
176
  audioctl feedback beep
109
177
  audioctl feedback clear
110
178
  audioctl led flash amber --repeat 1
111
179
  ```
112
180
 
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.
181
+ `feedback effect` is a compatibility/demo shortcut for old agents. New agent
182
+ work should prefer `light describe` + `light timeline` + `light bind`, because
183
+ named effects such as `music_beat`, `pulse`, or `breathing` do not describe the
184
+ actual hardware topology. `feedback effect` requires an explicit effect name;
185
+ calling it with no name is rejected.
119
186
 
120
187
  Physical keys/touch surfaces are reported as VM capabilities such as
121
188
  `input.touch.read`, `input.gesture.read`, and `input.button.bind`. A VM app can
@@ -185,3 +252,65 @@ Feedback messages use the same MQTT envelope:
185
252
  Supported feedback commands are `signal`, `flash`, `volume`, `effect`, `beep`,
186
253
  and `clear`. Devices that do not report a matching `feedback.*` capability
187
254
  should reject the command instead of pretending to support it.
255
+
256
+ Light messages use a distinct command envelope:
257
+
258
+ ```json
259
+ {
260
+ "schema": "weclawbot.control.v1",
261
+ "id": "light_<uuid>",
262
+ "kind": "light_command",
263
+ "light": {
264
+ "command": "timeline",
265
+ "timeline": {
266
+ "schema": "weclawbot.light.timeline.v1",
267
+ "id": "custom_visual",
268
+ "loop": true,
269
+ "stop_on": ["media.paused", "media.ended"],
270
+ "duration_ms": 2000,
271
+ "tracks": [
272
+ {
273
+ "surface": "all",
274
+ "keyframes": [
275
+ { "t": 0, "color": "#202020", "brightness": 0.05 },
276
+ { "t": 1000, "color": "#ff6000", "brightness": 0.5 }
277
+ ]
278
+ }
279
+ ]
280
+ }
281
+ }
282
+ }
283
+ ```
284
+
285
+ Supported light commands are `describe`, `set`, `timeline`, `bind`, and
286
+ `clear`. Continuous light timing and event reactions run on the device VM.
287
+
288
+ Firmware update messages also use the same MQTT envelope, but carry only an
289
+ official release descriptor. The device downloads the artifact itself from
290
+ `weclawbot.link`, enforces HTTPS, checks SHA-256 and byte size, installs from a
291
+ staging directory, restarts local services, and reports health through
292
+ `inspect`.
293
+
294
+ ```json
295
+ {
296
+ "schema": "weclawbot.control.v1",
297
+ "id": "firmware_<uuid>",
298
+ "kind": "firmware_update",
299
+ "firmware": {
300
+ "schema": "weclawbot.firmware.update.v1",
301
+ "confirmed_by_user": true,
302
+ "release": {
303
+ "schema": "weclawbot.firmware.release.v1",
304
+ "device_class": "audio_voice_ambient_endpoint",
305
+ "runtime": "rokid_music_runtime",
306
+ "version": "0.1.1",
307
+ "channel": "stable",
308
+ "artifact": {
309
+ "url": "https://weclawbot.link/firmware/audio/rokid-music-runtime-0.1.1.tar",
310
+ "sha256": "<64 hex chars>",
311
+ "bytes": 123456
312
+ }
313
+ }
314
+ }
315
+ }
316
+ ```
package/agent-plugin.json CHANGED
@@ -15,20 +15,22 @@
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
- "不要把 weclawbot.link 当作实时灯效、音频分析或本地交互的数据面"
21
+ "不要把 weclawbot.link 当作实时灯效、音频分析或本地交互的数据面",
22
+ "不要在未获得用户明确确认时执行固件更新;agent 只能先发现新版并说明变更、版本和来源"
22
23
  ],
23
- "continuous_feedback": "连续灯效、节拍灯、呼吸灯、动画和按键反馈必须通过 audioctl feedback effect 或 VM app 一次下发,在设备本地运行;停止使用 audioctl feedback clear。"
24
+ "continuous_feedback": "连续灯效、节拍灯、呼吸灯、动画和按键反馈必须通过 audioctl light timeline/bind、VM app 或兼容的 feedback effect 一次下发,在设备本地运行;停止使用 audioctl light clear 或 feedback clear。"
24
25
  },
25
26
  "agent_decision_model": {
26
27
  "principle": "agent 不需要也不应该知道具体品牌/产品;只基于 inspect 返回的抽象能力、VM manifest、用户目标和安全边界来推理。",
27
28
  "allowed_reasoning": [
28
29
  "把用户自然语言目标拆成需要的 I/O、媒体、反馈、输入绑定和持久运行需求",
29
30
  "在用户已授权的平台、连接器、OAuth 或 secret store 中选择合法数据来源",
30
- "决定使用 queue、rule、feedback、input binding 或 VM app install 的组合",
31
- "在设备 VM 内放置需要离线、低延迟或持续运行的逻辑"
31
+ "决定使用 queue、rule、light timeline/bind、feedback、input binding 或 VM app install 的组合",
32
+ "在设备 VM 内放置需要离线、低延迟或持续运行的逻辑",
33
+ "运行 audioctl firmware check 发现官网固件新版,并在用户确认后运行 audioctl firmware update --yes"
32
34
  ],
33
35
  "not_prescribed": "插件不会也无法穷举所有 agent 方案;agent 应读取 capabilities 后自己想办法达到目标。"
34
36
  },
@@ -75,8 +77,15 @@
75
77
  "feedback.effect",
76
78
  "feedback.volume_level",
77
79
  "feedback.beep",
80
+ "light.describe",
81
+ "light.set",
82
+ "light.timeline",
83
+ "light.bind",
84
+ "light.clear",
85
+ "event.bind",
78
86
  "iot.control",
79
87
  "device.inspect",
88
+ "firmware.update",
80
89
  "music.autopilot",
81
90
  "music.set_rule",
82
91
  "music.set_queue",
@@ -104,13 +113,20 @@
104
113
  {
105
114
  "intent": "帮我把设备灯效、音量灯和按键反馈音接上",
106
115
  "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 自行规划。",
116
+ "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
117
  "if_missing_capability": "不要伪造灯效或按键能力;如果 inspect 未报告对应 capability,只说明当前设备适配未提供该能力,并建议安装或升级设备 VM。"
109
118
  },
110
119
  {
111
120
  "intent": "让音箱作为语音遥控、家庭智能或语音互动端点",
112
121
  "primary_command": "audioctl app install MANIFEST.json APP.wasm",
113
122
  "agent_strategy": "不要把设备假定为音乐播放器。先运行 audioctl inspect --json 读取设备能力,再生成声明最小 capabilities 的 VM app。语音、TTS、家居控制等能力必须通过 manifest capabilities 显式声明;设备侧只执行授权范围内的 I/O。"
123
+ },
124
+ {
125
+ "intent": "检查并升级设备固件",
126
+ "primary_command": "audioctl firmware check --json",
127
+ "agent_strategy": "先运行 audioctl firmware check --json,从 https://weclawbot.link/firmware/audio/manifest.json 发现最新官方固件和当前设备版本。只向用户展示版本、来源、release notes 和风险提示;不得自行执行更新。只有用户明确确认后,才运行 audioctl firmware update --yes --json。固件更新是设备 host/runtime 更新,不等同于 VM app install;更新过程中不要读取 MQTT 凭据或私钥。",
128
+ "if_update_available": "向用户确认:官网发现新固件,是否现在更新?说明版本、预计会重启部分本地服务、不会清除 Wi-Fi/MQTT/音乐平台/VM app 配置。",
129
+ "if_user_confirms": "运行 audioctl firmware update --yes --json,并在完成后运行 audioctl inspect --json 验证 firmware.version、health 和 vm 状态。"
114
130
  }
115
131
  ],
116
132
  "commands": [
@@ -134,6 +150,10 @@
134
150
  "name": "feedback",
135
151
  "usage": "audioctl feedback signal NAME | flash COLOR | volume 0..100 | effect NAME [--bpm N] [--color COLOR] | clear | beep"
136
152
  },
153
+ {
154
+ "name": "light",
155
+ "usage": "audioctl light describe | set SURFACE --color COLOR [--brightness 0..1] | timeline TIMELINE.json | bind BINDINGS.json | clear"
156
+ },
137
157
  {
138
158
  "name": "led",
139
159
  "usage": "audioctl led flash COLOR [--repeat N]"
@@ -154,6 +174,10 @@
154
174
  "name": "app install",
155
175
  "usage": "audioctl app install MANIFEST.json APP.wasm"
156
176
  },
177
+ {
178
+ "name": "firmware",
179
+ "usage": "audioctl firmware check [--manifest URL] | firmware update [--manifest URL] --yes"
180
+ },
157
181
  {
158
182
  "name": "workbgm",
159
183
  "usage": "audioctl workbgm --play"
package/bin/audioctl.mjs CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  COMMAND_NAME,
8
8
  DEFAULT_CREDENTIALS_PATH,
9
9
  DEFAULT_ENDPOINT,
10
+ DEFAULT_FIRMWARE_MANIFEST_URL,
10
11
  DEEPNIGHT_RULE,
11
12
  PACKAGE_NAME,
12
13
  bindAgent,
@@ -14,11 +15,15 @@ import {
14
15
  buildAgentPrompt,
15
16
  buildAudioControl,
16
17
  buildFeedbackControl,
18
+ buildFirmwareUpdateControl,
19
+ buildLightControl,
17
20
  buildQueueControl,
18
21
  buildWorkBgmAppInstallControl,
19
22
  expandPath,
20
23
  normalizeCredentials,
24
+ planFirmwareUpdate,
21
25
  publishAudioControl,
26
+ readFirmwareIndex,
22
27
  readCredentials,
23
28
  redactAgentFacingValue,
24
29
  remediationForStatus,
@@ -47,6 +52,7 @@ async function run(name, values) {
47
52
  if (name === "inspect") return commandInspect(values);
48
53
  if (name === "prompt") return commandPrompt(values);
49
54
  if (name === "manifest") return commandManifest();
55
+ if (name === "firmware") return commandFirmware(values);
50
56
  if (name === "app") return commandApp(values);
51
57
  if (name === "queue") return commandQueue(values);
52
58
  if (name === "rule") return commandRule(values);
@@ -54,6 +60,7 @@ async function run(name, values) {
54
60
  if (name === "deepnight") return commandDeepnight(values);
55
61
  if (name === "volume") return commandVolume(values);
56
62
  if (name === "seek") return commandSeek(values);
63
+ if (name === "light") return commandLight(values);
57
64
  if (name === "feedback" || name === "led") return commandFeedback(values);
58
65
  return commandControl(name, values);
59
66
  }
@@ -73,6 +80,13 @@ function usage(exitCode = 0) {
73
80
  ${COMMAND_NAME} feedback effect NAME [--bpm N] [--color COLOR]
74
81
  ${COMMAND_NAME} feedback clear|beep
75
82
  ${COMMAND_NAME} led flash COLOR [--repeat N]
83
+ ${COMMAND_NAME} light describe
84
+ ${COMMAND_NAME} light set SURFACE --color COLOR [--brightness 0..1]
85
+ ${COMMAND_NAME} light timeline TIMELINE.json
86
+ ${COMMAND_NAME} light bind BINDINGS.json
87
+ ${COMMAND_NAME} light clear
88
+ ${COMMAND_NAME} firmware check [--manifest URL]
89
+ ${COMMAND_NAME} firmware update [--manifest URL] [--yes]
76
90
  ${COMMAND_NAME} rule RULE.json
77
91
  ${COMMAND_NAME} queue QUEUE.json [--play]
78
92
  ${COMMAND_NAME} app install MANIFEST.json APP.wasm
@@ -81,8 +95,9 @@ function usage(exitCode = 0) {
81
95
  ${COMMAND_NAME} manifest
82
96
 
83
97
  Options:
84
- --credentials FILE default: ${DEFAULT_CREDENTIALS_PATH}
98
+ --credentials FILE override audioctl-managed local credential file
85
99
  --endpoint URL default: ${DEFAULT_ENDPOINT}
100
+ --manifest URL default: ${DEFAULT_FIRMWARE_MANIFEST_URL}
86
101
  --timeout SECONDS default: 12
87
102
  --json machine-readable output
88
103
  --nowait publish without waiting for device status
@@ -201,6 +216,78 @@ async function commandManifest() {
201
216
  process.stdout.write(`${JSON.stringify(manifest, null, 2)}\n`);
202
217
  }
203
218
 
219
+ async function commandFirmware(values) {
220
+ const subcommand = String(values[0] || "check").replace(/-/gu, "_").toLowerCase();
221
+ if (!new Set(["check", "update", "upgrade"]).has(subcommand)) {
222
+ throw new Error(`firmware_subcommand_unsupported: ${subcommand}`);
223
+ }
224
+ const options = parseOptions(values.slice(1), {
225
+ credentials: credentialsPath(),
226
+ manifest: process.env.WEC_FIRMWARE_MANIFEST || DEFAULT_FIRMWARE_MANIFEST_URL,
227
+ channel: "stable",
228
+ timeout: subcommand === "check" ? 20 : 120,
229
+ json: false,
230
+ nowait: false,
231
+ yes: false,
232
+ force: false,
233
+ });
234
+ const credentials = await readCredentials(options.credentials);
235
+ const firmwareIndex = await readFirmwareIndex(options.manifest, {
236
+ timeoutMs: Number(options.timeout) * 1000,
237
+ });
238
+ const statusDelivery = await publishAudioControl(credentials, buildAudioControl("status"), {
239
+ timeoutMs: Number(options.timeout) * 1000,
240
+ });
241
+ if (statusDelivery.status?.kind === "rejected") {
242
+ print(statusDelivery.status, options.json);
243
+ process.exitCode = 1;
244
+ return;
245
+ }
246
+ const inspect = statusDelivery.status?.inspect || statusDelivery.status || {};
247
+ const plan = planFirmwareUpdate(firmwareIndex, inspect, {
248
+ channel: options.channel,
249
+ force: options.force,
250
+ });
251
+ if (subcommand === "check") {
252
+ print(plan, options.json);
253
+ return;
254
+ }
255
+ if (!plan.supported) {
256
+ print({
257
+ ...plan,
258
+ update_started: false,
259
+ reason: "device_does_not_advertise_firmware.update",
260
+ }, options.json);
261
+ process.exitCode = 1;
262
+ return;
263
+ }
264
+ if (!plan.release || !plan.update_available) {
265
+ print({
266
+ ...plan,
267
+ update_started: false,
268
+ }, options.json);
269
+ return;
270
+ }
271
+ if (!options.yes) {
272
+ print({
273
+ ...plan,
274
+ update_started: false,
275
+ confirmation_required: true,
276
+ next_step: `Ask the user to confirm, then run: ${COMMAND_NAME} firmware update --yes`,
277
+ }, options.json);
278
+ return;
279
+ }
280
+ const control = buildFirmwareUpdateControl(plan.release, {
281
+ yes: true,
282
+ });
283
+ const delivery = await publishAudioControl(credentials, control, {
284
+ timeoutMs: Number(options.timeout) * 1000,
285
+ wait: !options.nowait,
286
+ });
287
+ print(delivery.status || delivery, options.json);
288
+ if (delivery.status?.kind === "rejected") process.exitCode = 1;
289
+ }
290
+
204
291
  async function commandRule(values) {
205
292
  const options = parseOptions(values, {
206
293
  credentials: credentialsPath(),
@@ -318,6 +405,57 @@ async function commandSeek(values) {
318
405
  await send("seek", options, { positionMs: options._[0] });
319
406
  }
320
407
 
408
+ async function commandLight(values) {
409
+ const subcommand = String(values[0] || "").replace(/-/gu, "_").toLowerCase();
410
+ if (!subcommand) throw new Error("light_requires_subcommand");
411
+ const options = parseOptions(values.slice(1), {
412
+ credentials: credentialsPath(),
413
+ timeout: 12,
414
+ json: false,
415
+ nowait: false,
416
+ });
417
+ let control;
418
+ if (subcommand === "describe" || subcommand === "capabilities") {
419
+ control = buildLightControl("describe");
420
+ } else if (subcommand === "set") {
421
+ control = buildLightControl("set", {
422
+ surface: options._[0] || options.surface || "all",
423
+ color: options.color || options._[1],
424
+ brightness: options.brightness ?? options.level,
425
+ alpha: options.alpha,
426
+ });
427
+ } else if (subcommand === "timeline") {
428
+ const file = String(options._[0] || "");
429
+ if (!file) throw new Error("light_timeline_requires_file");
430
+ control = buildLightControl("timeline", {
431
+ timeline: JSON.parse(await fs.readFile(file, "utf8")),
432
+ });
433
+ } else if (subcommand === "bind") {
434
+ const file = String(options._[0] || "");
435
+ if (!file) throw new Error("light_bind_requires_file");
436
+ control = buildLightControl("bind", {
437
+ bindings: JSON.parse(await fs.readFile(file, "utf8")),
438
+ });
439
+ } else if (subcommand === "clear" || subcommand === "off" || subcommand === "hide") {
440
+ control = buildLightControl("clear");
441
+ } else {
442
+ throw new Error(`light_subcommand_unsupported: ${subcommand}`);
443
+ }
444
+
445
+ const credentials = await readCredentials(options.credentials);
446
+ const delivery = await publishAudioControl(credentials, control, {
447
+ timeoutMs: Number(options.timeout) * 1000,
448
+ wait: !options.nowait,
449
+ });
450
+ if (control.light.command === "describe" && options.json) {
451
+ const light = delivery.status?.inspect?.light || delivery.status?.light || {};
452
+ process.stdout.write(`${JSON.stringify(redactAgentFacingValue(light))}\n`);
453
+ } else {
454
+ print(delivery.status || delivery, options.json);
455
+ }
456
+ if (delivery.status?.kind === "rejected") process.exitCode = 1;
457
+ }
458
+
321
459
  async function commandFeedback(values) {
322
460
  const subcommand = String(values[0] || "").replace(/-/gu, "_").toLowerCase();
323
461
  const shorthandSignals = new Set([
@@ -427,7 +565,7 @@ function parseOptions(values, defaults = {}) {
427
565
  continue;
428
566
  }
429
567
  const key = value.slice(2);
430
- if (key === "json" || key === "online" || key === "nowait" || key === "play") {
568
+ if (key === "json" || key === "online" || key === "nowait" || key === "play" || key === "yes" || key === "force") {
431
569
  options[key] = true;
432
570
  continue;
433
571
  }
@@ -436,15 +574,19 @@ function parseOptions(values, defaults = {}) {
436
574
  "alias",
437
575
  "alpha",
438
576
  "bpm",
577
+ "brightness",
439
578
  "color",
440
579
  "credentials",
441
580
  "endpoint",
442
581
  "effect",
582
+ "channel",
443
583
  "level",
584
+ "manifest",
444
585
  "name",
445
586
  "pattern",
446
587
  "profile",
447
588
  "repeat",
589
+ "surface",
448
590
  "timeout",
449
591
  "volume",
450
592
  ]).has(key)) {
package/lib/index.mjs CHANGED
@@ -8,6 +8,8 @@ import mqtt from "mqtt";
8
8
  export const PACKAGE_NAME = "@openbrt/audioctl";
9
9
  export const COMMAND_NAME = "audioctl";
10
10
  export const DEFAULT_ENDPOINT = "https://weclawbot.link/byoa";
11
+ export const DEFAULT_FIRMWARE_MANIFEST_URL =
12
+ "https://weclawbot.link/firmware/audio/manifest.json";
11
13
  export const DEFAULT_CREDENTIALS_PATH = path.join(
12
14
  os.homedir(),
13
15
  ".config",
@@ -48,6 +50,10 @@ export const WORKBGM_RULE = Object.freeze({
48
50
  });
49
51
 
50
52
  export const PLAYABLE_QUEUE_SCHEMA = "weclawbot.playable_queue.v1";
53
+ export const LIGHT_TIMELINE_SCHEMA = "weclawbot.light.timeline.v1";
54
+ export const LIGHT_BINDINGS_SCHEMA = "weclawbot.light.bind.v1";
55
+ export const FIRMWARE_INDEX_SCHEMA = "weclawbot.firmware.index.v1";
56
+ export const FIRMWARE_RELEASE_SCHEMA = "weclawbot.firmware.release.v1";
51
57
 
52
58
  export const FEEDBACK_COLOR_NAMES = Object.freeze({
53
59
  red: Object.freeze({ red: 255, green: 0, blue: 0, alpha: 255 }),
@@ -84,6 +90,24 @@ export const FEEDBACK_EFFECTS = Object.freeze([
84
90
  "breathing",
85
91
  ]);
86
92
 
93
+ export const LIGHT_SURFACES = Object.freeze([
94
+ "all",
95
+ "ring",
96
+ "center",
97
+ ]);
98
+
99
+ export const LIGHT_EVENTS = Object.freeze([
100
+ "media.playing",
101
+ "media.paused",
102
+ "media.ended",
103
+ "media.error",
104
+ "volume.changed",
105
+ "input.center.single_click",
106
+ "input.ring.single_click",
107
+ "input.ring.clockwise",
108
+ "input.ring.counter_clockwise",
109
+ ]);
110
+
87
111
  const WORKBGM_APP_WASM_BASE64 =
88
112
  "AGFzbQEAAAABBAFgAAADAgEABwgBBG1haW4AAAoEAQIACw==";
89
113
 
@@ -223,6 +247,16 @@ export function buildFeedbackControl(command, options = {}) {
223
247
  };
224
248
  }
225
249
 
250
+ export function buildLightControl(command, options = {}) {
251
+ const light = normalizeLightCommand(command, options);
252
+ return {
253
+ schema: "weclawbot.control.v1",
254
+ id: string(options.id) || `light_${crypto.randomUUID()}`,
255
+ kind: "light_command",
256
+ light,
257
+ };
258
+ }
259
+
226
260
  export function buildQueueControl(options = {}) {
227
261
  return buildAudioControl("set_queue", {
228
262
  id: options.id,
@@ -254,6 +288,20 @@ export function buildAppInstallControl(options = {}) {
254
288
  };
255
289
  }
256
290
 
291
+ export function buildFirmwareUpdateControl(release, options = {}) {
292
+ const firmwareRelease = normalizeFirmwareRelease(release);
293
+ return {
294
+ schema: "weclawbot.control.v1",
295
+ id: string(options.id) || `firmware_${crypto.randomUUID()}`,
296
+ kind: "firmware_update",
297
+ firmware: {
298
+ schema: "weclawbot.firmware.update.v1",
299
+ release: firmwareRelease,
300
+ confirmed_by_user: options.confirmedByUser === true || options.yes === true,
301
+ },
302
+ };
303
+ }
304
+
257
305
  export function buildWorkBgmAppInstallControl(options = {}) {
258
306
  const play = options.play === true || options.autoplay === true;
259
307
  return buildAppInstallControl({
@@ -455,14 +503,16 @@ export function normalizeFeedbackCommand(command, options = {}) {
455
503
  feedback.pattern = pattern;
456
504
  }
457
505
  if (normalized === "effect") {
458
- const effect = string(
506
+ const effectValue =
459
507
  options.effect ||
460
- options.semantic ||
461
- options.signal ||
462
- options.name ||
463
- options.value ||
464
- "pulse",
465
- ).replace(/-/gu, "_").toLowerCase();
508
+ options.semantic ||
509
+ options.signal ||
510
+ options.name ||
511
+ options.value;
512
+ if (!effectValue) {
513
+ throw new Error("feedback_effect_requires_name");
514
+ }
515
+ const effect = string(effectValue).replace(/-/gu, "_").toLowerCase();
466
516
  if (!/^[a-z0-9_.:]{1,64}$/u.test(effect)) {
467
517
  throw new Error("feedback_effect_invalid");
468
518
  }
@@ -514,6 +564,317 @@ export function normalizeFeedbackColor(value, options = {}) {
514
564
  throw new Error(`feedback_color_invalid: ${value}`);
515
565
  }
516
566
 
567
+ export function normalizeLightCommand(command, options = {}) {
568
+ const aliases = new Map([
569
+ ["capabilities", "describe"],
570
+ ["description", "describe"],
571
+ ["info", "describe"],
572
+ ["off", "clear"],
573
+ ["hide", "clear"],
574
+ ["stop", "clear"],
575
+ ["frame", "set"],
576
+ ["timeline_start", "timeline"],
577
+ ["animation", "timeline"],
578
+ ["animate", "timeline"],
579
+ ["bindings", "bind"],
580
+ ["event_bind", "bind"],
581
+ ]);
582
+ const raw = string(command || options.command || options.action || "describe")
583
+ .replace(/-/gu, "_")
584
+ .toLowerCase();
585
+ const normalized = aliases.get(raw) || raw;
586
+ const supported = new Set(["describe", "set", "timeline", "bind", "clear"]);
587
+ if (!supported.has(normalized)) {
588
+ throw new Error(`light_command_unsupported: ${command}`);
589
+ }
590
+ const light = { command: normalized };
591
+ if (normalized === "set") {
592
+ light.surface = normalizeLightSurface(options.surface || options.target || "all");
593
+ light.color = normalizeFeedbackColor(options.color || options.value || "white", {
594
+ alpha: options.alpha,
595
+ });
596
+ light.brightness = normalizeUnit(options.brightness ?? options.level ?? 1, {
597
+ defaultValue: 1,
598
+ name: "light_brightness_invalid",
599
+ });
600
+ }
601
+ if (normalized === "timeline") {
602
+ light.timeline = validateLightTimeline(options.timeline);
603
+ }
604
+ if (normalized === "bind") {
605
+ light.bindings = validateLightBindings(options.bindings || options.binding);
606
+ }
607
+ return light;
608
+ }
609
+
610
+ export function validateLightTimeline(value) {
611
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
612
+ throw new Error("light_timeline_invalid");
613
+ }
614
+ const tracksInput = Array.isArray(value.tracks) ? value.tracks : [];
615
+ if (tracksInput.length < 1 || tracksInput.length > 8) {
616
+ throw new Error("light_timeline_tracks_invalid");
617
+ }
618
+ let maxTime = 0;
619
+ const tracks = tracksInput.map((track, trackIndex) => {
620
+ const surface = normalizeLightSurface(track?.surface || track?.target || "all");
621
+ const keyframesInput = Array.isArray(track?.keyframes) ? track.keyframes : [];
622
+ if (keyframesInput.length < 1 || keyframesInput.length > 64) {
623
+ throw new Error(`light_timeline_keyframes_invalid:${trackIndex + 1}`);
624
+ }
625
+ const keyframes = keyframesInput.map((frame, frameIndex) => {
626
+ if (!frame || typeof frame !== "object" || Array.isArray(frame)) {
627
+ throw new Error(`light_timeline_keyframe_invalid:${trackIndex + 1}:${frameIndex + 1}`);
628
+ }
629
+ const t = clamp(Number(frame.t ?? frame.time_ms ?? frame.timeMs) || 0, 0, 600_000);
630
+ maxTime = Math.max(maxTime, t);
631
+ return {
632
+ t,
633
+ color: normalizeFeedbackColor(frame.color || frame.value || "white", {
634
+ alpha: frame.alpha,
635
+ }),
636
+ brightness: normalizeUnit(frame.brightness ?? frame.level ?? 1, {
637
+ defaultValue: 1,
638
+ name: "light_keyframe_brightness_invalid",
639
+ }),
640
+ };
641
+ }).sort((a, b) => a.t - b.t);
642
+ return { surface, keyframes };
643
+ });
644
+ const durationMs = clamp(
645
+ Number(value.durationMs ?? value.duration_ms) || Math.max(maxTime, 1000),
646
+ 100,
647
+ 600_000,
648
+ );
649
+ return {
650
+ schema: LIGHT_TIMELINE_SCHEMA,
651
+ id: normalizeIdentifier(value.id || value.timeline_id || `timeline_${crypto.randomUUID()}`, {
652
+ name: "light_timeline_id_invalid",
653
+ maximum: 80,
654
+ }),
655
+ loop: value.loop === true,
656
+ stop_on: normalizeLightEvents(value.stop_on || value.stopOn || ["media.paused", "media.ended"]),
657
+ duration_ms: durationMs,
658
+ tracks,
659
+ };
660
+ }
661
+
662
+ export function validateLightBindings(value) {
663
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
664
+ throw new Error("light_bindings_invalid");
665
+ }
666
+ const input = Array.isArray(value.bindings) ? value.bindings : [];
667
+ if (input.length > 32) throw new Error("light_bindings_too_large");
668
+ return {
669
+ schema: LIGHT_BINDINGS_SCHEMA,
670
+ bindings: input.map((binding, index) => {
671
+ const timeline = binding?.timeline ? validateLightTimeline(binding.timeline) : null;
672
+ const events = normalizeLightEvents(binding?.events ?? binding?.event);
673
+ const actionRaw = string(binding?.action || (timeline ? "light.timeline.start" : "light.clear"))
674
+ .replace(/-/gu, "_")
675
+ .toLowerCase();
676
+ const actionAliases = new Map([
677
+ ["clear", "light.clear"],
678
+ ["off", "light.clear"],
679
+ ["stop", "light.timeline.stop"],
680
+ ["timeline.start", "light.timeline.start"],
681
+ ["timeline_start", "light.timeline.start"],
682
+ ["start", "light.timeline.start"],
683
+ ["timeline.stop", "light.timeline.stop"],
684
+ ["timeline_stop", "light.timeline.stop"],
685
+ ]);
686
+ const action = actionAliases.get(actionRaw) || actionRaw;
687
+ if (!new Set(["light.clear", "light.timeline.start", "light.timeline.stop"]).has(action)) {
688
+ throw new Error(`light_binding_action_invalid:${index + 1}`);
689
+ }
690
+ const normalized = {
691
+ events,
692
+ action,
693
+ timeline_id: string(binding?.timeline_id || binding?.timelineId || timeline?.id || "").slice(0, 80),
694
+ };
695
+ if (timeline) normalized.timeline = timeline;
696
+ return normalized;
697
+ }),
698
+ };
699
+ }
700
+
701
+ function normalizeLightSurface(value) {
702
+ const surface = string(value).replace(/-/gu, "_").toLowerCase();
703
+ if (!LIGHT_SURFACES.includes(surface)) {
704
+ throw new Error(`light_surface_unsupported: ${value}`);
705
+ }
706
+ return surface;
707
+ }
708
+
709
+ function normalizeLightEvents(value) {
710
+ const events = Array.isArray(value) ? value : [value];
711
+ return events.map(normalizeLightEvent).slice(0, 16);
712
+ }
713
+
714
+ function normalizeLightEvent(value) {
715
+ const event = string(value).replace(/-/gu, "_").toLowerCase();
716
+ if (!LIGHT_EVENTS.includes(event)) {
717
+ throw new Error(`light_event_unsupported: ${value}`);
718
+ }
719
+ return event;
720
+ }
721
+
722
+ export async function readFirmwareIndex(source = DEFAULT_FIRMWARE_MANIFEST_URL, options = {}) {
723
+ const location = string(source) || DEFAULT_FIRMWARE_MANIFEST_URL;
724
+ let payload;
725
+ if (/^https?:\/\//iu.test(location)) {
726
+ const response = await fetch(location, {
727
+ headers: { accept: "application/json" },
728
+ signal: AbortSignal.timeout(Math.max(1000, Number(options.timeoutMs) || 15_000)),
729
+ });
730
+ if (!response.ok) {
731
+ throw new Error(`firmware_manifest_http_${response.status}`);
732
+ }
733
+ payload = await response.json();
734
+ } else if (location.startsWith("file://")) {
735
+ payload = JSON.parse(await fs.readFile(new URL(location), "utf8"));
736
+ } else {
737
+ payload = JSON.parse(await fs.readFile(expandPath(location), "utf8"));
738
+ }
739
+ return normalizeFirmwareIndex(payload, location);
740
+ }
741
+
742
+ export function normalizeFirmwareIndex(value, source = DEFAULT_FIRMWARE_MANIFEST_URL) {
743
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
744
+ throw new Error("firmware_manifest_invalid");
745
+ }
746
+ const releasesInput = Array.isArray(value.releases) ? value.releases : [];
747
+ if (value.schema !== FIRMWARE_INDEX_SCHEMA || releasesInput.length < 1) {
748
+ throw new Error("firmware_manifest_invalid");
749
+ }
750
+ return {
751
+ schema: FIRMWARE_INDEX_SCHEMA,
752
+ device_class: string(value.device_class || ABSTRACT_HARDWARE_TYPE).slice(0, 80),
753
+ channel: string(value.channel || "stable").slice(0, 40),
754
+ generated_at: string(value.generated_at).slice(0, 80),
755
+ source: string(source).slice(0, 240),
756
+ releases: releasesInput.map(normalizeFirmwareRelease),
757
+ };
758
+ }
759
+
760
+ export function normalizeFirmwareRelease(value) {
761
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
762
+ throw new Error("firmware_release_invalid");
763
+ }
764
+ const artifact = value.artifact && typeof value.artifact === "object" ? value.artifact : {};
765
+ const url = string(artifact.url || value.url);
766
+ if (!/^https:\/\/(?:www\.)?weclawbot\.link\/firmware\//iu.test(url)) {
767
+ throw new Error("firmware_artifact_url_invalid");
768
+ }
769
+ const sha256 = string(artifact.sha256 || value.sha256).toLowerCase();
770
+ if (!/^[0-9a-f]{64}$/u.test(sha256)) {
771
+ throw new Error("firmware_artifact_sha256_invalid");
772
+ }
773
+ const bytes = Math.floor(Number(artifact.bytes || artifact.size || value.bytes || value.size) || 0);
774
+ if (bytes < 1 || bytes > 96 * 1024 * 1024) {
775
+ throw new Error("firmware_artifact_size_invalid");
776
+ }
777
+ const version = string(value.version).replace(/^v/iu, "");
778
+ if (!/^[0-9]+(?:\.[0-9]+){1,3}(?:[-+][A-Za-z0-9_.-]+)?$/u.test(version)) {
779
+ throw new Error("firmware_version_invalid");
780
+ }
781
+ return {
782
+ schema: FIRMWARE_RELEASE_SCHEMA,
783
+ device_class: string(value.device_class || ABSTRACT_HARDWARE_TYPE).slice(0, 80),
784
+ runtime: string(value.runtime || "rokid_music_runtime").slice(0, 80),
785
+ version,
786
+ channel: string(value.channel || "stable").slice(0, 40),
787
+ host_api: string(value.host_api || "weclawbot.host.v0").slice(0, 80),
788
+ released_at: string(value.released_at).slice(0, 80),
789
+ min_current_version: string(value.min_current_version || "0.0.0").replace(/^v/iu, "").slice(0, 40),
790
+ requires_user_confirmation: value.requires_user_confirmation !== false,
791
+ notes: Array.isArray(value.notes)
792
+ ? value.notes.map((note) => string(note).slice(0, 180)).filter(Boolean).slice(0, 12)
793
+ : [],
794
+ artifact: {
795
+ url,
796
+ sha256,
797
+ bytes,
798
+ format: string(artifact.format || "tar").slice(0, 32),
799
+ },
800
+ };
801
+ }
802
+
803
+ export function compareVersions(a, b) {
804
+ const parse = (value) => string(value)
805
+ .replace(/^v/iu, "")
806
+ .split(/[-+]/u)[0]
807
+ .split(".")
808
+ .map((part) => Number.parseInt(part, 10) || 0);
809
+ const left = parse(a);
810
+ const right = parse(b);
811
+ const length = Math.max(left.length, right.length, 3);
812
+ for (let index = 0; index < length; index += 1) {
813
+ const difference = (left[index] || 0) - (right[index] || 0);
814
+ if (difference !== 0) return difference > 0 ? 1 : -1;
815
+ }
816
+ return 0;
817
+ }
818
+
819
+ export function planFirmwareUpdate(index, inspect = {}, options = {}) {
820
+ const firmwareIndex = normalizeFirmwareIndex(index, index?.source || DEFAULT_FIRMWARE_MANIFEST_URL);
821
+ const deviceClass =
822
+ string(inspect.device_type) ||
823
+ string(inspect.device?.abstract_hardware) ||
824
+ ABSTRACT_HARDWARE_TYPE;
825
+ const channel = string(options.channel || firmwareIndex.channel || "stable");
826
+ const currentVersion =
827
+ string(inspect.firmware?.version || inspect.device?.firmware_version || "0.0.0")
828
+ .replace(/^v/iu, "") || "0.0.0";
829
+ const capabilities = new Set(Array.isArray(inspect.capabilities) ? inspect.capabilities : []);
830
+ const candidates = firmwareIndex.releases
831
+ .filter((release) => release.device_class === deviceClass)
832
+ .filter((release) => !channel || release.channel === channel)
833
+ .filter((release) => compareVersions(currentVersion, release.min_current_version || "0.0.0") >= 0)
834
+ .sort((a, b) => compareVersions(b.version, a.version));
835
+ const release = candidates[0] || null;
836
+ const supported = capabilities.size === 0 || capabilities.has("firmware.update");
837
+ const updateAvailable = Boolean(release) &&
838
+ (options.force === true || compareVersions(release.version, currentVersion) > 0);
839
+ return {
840
+ schema: "weclawbot.firmware.plan.v1",
841
+ device_class: deviceClass,
842
+ current_version: currentVersion,
843
+ channel,
844
+ supported,
845
+ update_available: updateAvailable,
846
+ latest_version: release?.version || "",
847
+ release,
848
+ source: firmwareIndex.source,
849
+ requires_user_confirmation: Boolean(updateAvailable && release?.requires_user_confirmation !== false),
850
+ summary: release
851
+ ? (updateAvailable
852
+ ? `Firmware ${release.version} is available for ${deviceClass}.`
853
+ : `Firmware ${currentVersion} is up to date for ${deviceClass}.`)
854
+ : `No firmware release found for ${deviceClass} on ${channel}.`,
855
+ };
856
+ }
857
+
858
+ function normalizeIdentifier(value, options = {}) {
859
+ const text = string(value);
860
+ const maximum = Number(options.maximum) || 80;
861
+ if (!/^[a-z0-9][a-z0-9_.:-]*$/u.test(text) || text.length > maximum) {
862
+ throw new Error(options.name || "identifier_invalid");
863
+ }
864
+ return text;
865
+ }
866
+
867
+ function normalizeUnit(value, options = {}) {
868
+ const number = Number(value);
869
+ if (!Number.isFinite(number) || number < 0 || number > 1) {
870
+ if (value === undefined && options.defaultValue !== undefined) {
871
+ return options.defaultValue;
872
+ }
873
+ throw new Error(options.name || "unit_value_invalid");
874
+ }
875
+ return Math.round(number * 1000) / 1000;
876
+ }
877
+
517
878
  export function validatePlayableQueue(queue) {
518
879
  if (!queue || typeof queue !== "object") {
519
880
  throw new Error("queue_invalid");
@@ -642,6 +1003,12 @@ export function normalizeAppManifest(manifest, artifact = {}) {
642
1003
  "feedback.effect",
643
1004
  "feedback.volume_level",
644
1005
  "feedback.beep",
1006
+ "light.describe",
1007
+ "light.set",
1008
+ "light.timeline",
1009
+ "light.bind",
1010
+ "light.clear",
1011
+ "event.bind",
645
1012
  "iot.control",
646
1013
  ]);
647
1014
  const capabilities = Array.isArray(manifest.capabilities)
@@ -857,15 +1224,16 @@ export function buildAgentPrompt(options = {}) {
857
1224
  `npm exec --package ${PACKAGE_NAME} -- ${COMMAND_NAME} bind ${code} --name ${shellToken(agentName)}`,
858
1225
  `npm exec --package ${PACKAGE_NAME} -- ${COMMAND_NAME} doctor --online`,
859
1226
  `这台设备对你是抽象硬件产品:${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。`,
1227
+ `绑定成功后,把它作为默认音频/语音/环境 I/O 端点;不要把它假定成只会播放音乐。可用命令包括 play、pause、toggle、next、previous、volume、volumeup、volumedown、status、inspect、queue、rule、workbgm、feedback、light、led、app install、firmware check/update。`,
1228
+ `当我给出目标时,请你在 capabilities、安全边界和用户授权范围内自行规划实现路径;插件只声明能力和边界,不穷举具体玩法。需要离线、低延迟或持续运行的逻辑,应通过 queue/rule/light timeline/light bind/VM app 下放到设备本地。`,
1229
+ `MQTT/weclawbot.link 对你只是控制面和部署通道,不是实时灯效数据面。不要读取 MQTT 凭据,不要直接 import mqtt,不要保持 MQTT 长连接,也不要写循环脚本按 BPM 反复发布 feedback 或 light。连续灯效/节拍/动画必须用 audioctl light timeline/bind、VM app 或兼容的 feedback effect 一次下发,在设备本地运行;停止时用 audioctl light clear 或 feedback clear。`,
863
1230
  `蓝牙/Wi-Fi 配网只负责设备入网、发现和 weclawbot.link 绑定;音乐平台授权请走 agent 侧连接器/OAuth/secret store;配网不决定设备用途。`,
864
1231
  `音乐来源由你和用户协商,插件只做建议。你可以在 agent 侧使用用户授权的音乐平台 API、用户媒体库、合法公开音源或其他合规来源生成队列/规则/VM app。网易云开放平台可请求最高可用音质 URL。`,
865
1232
  `如果用户选择 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 自行规划,不要套固定模板。`,
1233
+ `灯效、音量灯、蜂鸣和按键/触控反馈要先 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
1234
  `如果你当前没有音乐平台身份,请只告诉我:我需要你把音乐平台身份连接到当前 agent 的官方连接器、OAuth 流程或 secret store;完成后回复“已授权”。不要反复重试,不要让我理解内部授权包,也不要要求我把 AppSecret、PrivateKey、accessToken 或 refreshToken 发到聊天里。`,
868
1235
  `如果返回 health.problems 包含 netease_auth_refresh_failed,说明设备侧内置网易运行时授权失效;优先改走 agent 侧 queue/app 驱动。只有我明确要求继续使用设备侧网易运行时时,才提示需要独立的设备侧音乐账号设置入口;不要混入蓝牙/Wi-Fi 配网流程。`,
1236
+ `固件更新由 agent 端发现新版、用户确认后执行:先运行 audioctl firmware check --json,只展示官网来源、当前版本、新版本和 release notes;只有用户明确同意升级后,才运行 audioctl firmware update --yes --json。固件只从 weclawbot.link 官方 manifest 下载,更新设备 host/runtime,不等同于 VM app,也不要用固件更新去实现普通应用需求。`,
869
1237
  `如果我要播放「${profile}」,也可以执行:npm exec --package ${PACKAGE_NAME} -- ${COMMAND_NAME} deepnight`,
870
1238
  `不要向设备写入全局音乐平台私钥;平台授权优先保存在 agent 运行环境。只有在我明确授权时,才把最小范围、可轮换、可撤销的 app-scoped 凭据随 VM app 写入设备。`,
871
1239
  ].filter(Boolean).join("\n");
@@ -991,6 +1359,9 @@ function redactAgentFacingScalar(value, keyPath) {
991
1359
  if (typeof value !== "string") return value;
992
1360
  const last = keyPath[keyPath.length - 1];
993
1361
  if (last === "device_type") return ABSTRACT_HARDWARE_TYPE;
1362
+ if (["credentials", "control_topic", "status_topic", "event_topic"].includes(last)) {
1363
+ return "[redacted]";
1364
+ }
994
1365
  if (last === "model" || last === "brand" || last === "manufacturer" || last === "product") {
995
1366
  return "";
996
1367
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openbrt/audioctl",
3
- "version": "0.1.10",
3
+ "version": "0.1.12",
4
4
  "description": "Generic WeClawBot audio/voice endpoint control CLI and AI-agent plugin.",
5
5
  "type": "module",
6
6
  "license": "MIT",