@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 +146 -17
- package/agent-plugin.json +30 -6
- package/bin/audioctl.mjs +144 -2
- package/lib/index.mjs +382 -11
- 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,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,
|
|
95
|
-
without receiving Wi-Fi passwords, MQTT
|
|
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
|
-
`
|
|
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
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
"不要读取或解析
|
|
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
|
|
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
|
|
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
|
|
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
|
|
506
|
+
const effectValue =
|
|
459
507
|
options.effect ||
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
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
|
|
862
|
-
`MQTT/weclawbot.link 对你只是控制面和部署通道,不是实时灯效数据面。不要读取 MQTT 凭据,不要直接 import mqtt,不要保持 MQTT 长连接,也不要写循环脚本按 BPM 反复发布 feedback。连续灯效/节拍/动画必须用 audioctl
|
|
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
|
|
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
|
}
|