dsh-pixelbar 0.0.0-stage → 1.0.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 SilverlingFox
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,179 @@
1
- # Temporary Holding Version
1
+ # dsh-pixelbar
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 把「花再 Halo PixelBar」桌面像素屏变成 DSH 里可直呼的工具——推一行字、切场景、改屏色、调氛围灯、控音量,全部一句话搞定。
4
+
5
+ 聊天里不用再记命令、也不用先翻文档:在 DSH 里直接说「像素屏推个『在』」,`pixelbar` 工具就发过去了。
6
+
7
+ > DSH 插件外壳 + 独立可用的 Python HID 脚本,两层都能单独跑。
8
+
9
+ ## 特性
10
+
11
+ | 能力 | 说明 |
12
+ | --- | --- |
13
+ | 推文字 | 16 字符 / 48 字节上限,7 种颜色、6 种布局(含左右滚动) |
14
+ | 自动回时钟 | 推完默认 15 秒切回时钟界面,避免通知一直挂在屏上 |
15
+ | 场景切换 | clock / game / work / read / cats / dogs / memes / cyber / waves |
16
+ | 屏主题色 | 带颜色校准表,补偿这块屏的 R 偏弱、B 偏强的色偏 |
17
+ | 氛围灯 | 6 种灯效 + 颜色 + 亮度 + 速度 |
18
+ | 硬件音量 | 查询 / 设置 0–16 |
19
+ | 状态检查 | 查控制接口是否在线 |
20
+
21
+ 实测延迟(Windows 11 + Python 3.13):`status` 约 137 ms,`text` 推送约 73 ms。每次调用起一个短命 python 进程,没引入长驻桥。
22
+
23
+ ## 环境要求
24
+
25
+ - Windows(脚本用 `windowsHide` + `pythonw.exe` 规避黑框闪烁,其他平台未验证)
26
+ - 花再 Halo PixelBar 设备,USB 连接
27
+ - Python 3.8+
28
+ - `hidapi`:
29
+
30
+ ```powershell
31
+ python -m pip install hidapi
32
+ ```
33
+
34
+ - Node.js 18+(仅 DSH 插件外壳需要)
35
+
36
+ > 设备标识:`VID 0x2D99 / PID 0xA106`,控制接口 `interface=4`、`usage_page=0xFF14`、`usage=0x01`。其它型号未经实测,协议可能不同。
37
+
38
+ ## 安装(DSH 插件)
39
+
40
+ 任选一种。
41
+
42
+ ### 方式一:npm / 插件市场
43
+
44
+ 包已发布:[`dsh-pixelbar`](https://www.npmjs.com/package/dsh-pixelbar)
45
+
46
+ ```powershell
47
+ npm install dsh-pixelbar
48
+ ```
49
+
50
+ 装进 DSH profile 目录(`%USERPROFILE%\.dsh\profiles\desktop`),再把 `dsh-pixelbar` 加进该目录 `package.json` 的 `dsh.profile.bundles` 数组,重启 DSH 即可。包里的 `package.json` 声明了 `dsh.bundle: { patch: "./cordis.patch.yml" }`,DSH 会据此加载插件。
51
+
52
+ > 也可以直接在 DSH 的插件市场 / 插件管理里安装 `dsh-pixelbar`,效果相同。
53
+
54
+ ### 方式二:脚本(离线、免 npm)
55
+
56
+ 1. 双击 `apply.bat`(或 `pwsh -File apply.ps1`)
57
+
58
+ 脚本会:探测 `%USERPROFILE%\.dsh\profiles\desktop`(找不到就取唯一候选,或用 `DSH_PROFILE_DIR` 指定)→ 复制 `index.js / package.json / cordis.patch.yml / scripts/` 到 profile 的 `node_modules\dsh-pixelbar` → 把 `dsh-pixelbar` 登记进 `dsh.profile.bundles`。原 `package.json` 会备份成 `package.json.pixelbar.bak`。
59
+
60
+ 2. 重启 DSH,新会话里让助手推一句到像素屏验证。
61
+
62
+ 卸载:`rollback.bat`,再重启 DSH。
63
+
64
+ > 插件目录被复制进 `node_modules`,所以 `pnpm install` 之类操作后可能需要重跑 `apply.ps1`。
65
+
66
+ ## 在 DSH 里用
67
+
68
+ 插件注册单个工具 `pixelbar`:
69
+
70
+ | 参数 | 值 |
71
+ | --- | --- |
72
+ | `action` | `text`(默认)/ `scene` / `screen` / `light` / `volume` / `clock` / `status` |
73
+ | `text` | 要显示的文字,超过 16 字符自动截断 |
74
+ | `color` | `white` `red` `green` `blue` `yellow` `cyan` `magenta`,默认 `magenta` |
75
+ | `layout` | `left` `center` `right` `stretch` `scroll_left_to_right` `scroll_right_to_left` |
76
+ | `scene` | 见上方场景列表 |
77
+ | `hex` | `#F0B4C8` 这种六位十六进制,用于 `screen` / `light` |
78
+ | `effect` | `breathing` `color_tide` `static` `ripple` `flow` `dynamic` |
79
+ | `brightness` | `low` `medium` `high` |
80
+ | `speed` | `light` 速度 1–10 |
81
+ | `volume` | 0–16;不填 = 查询 |
82
+ | `clock_restore` | 推完多少秒回时钟,默认 15;`0` = 不回 |
83
+
84
+ 例:
85
+
86
+ - `pixelbar { "text": "在", "color": "cyan" }`
87
+ - `pixelbar { "action": "scene", "scene": "work" }`
88
+ - `pixelbar { "action": "light", "effect": "breathing", "hex": "#F0B4C8", "brightness": "high" }`
89
+
90
+ ## 配置
91
+
92
+ 默认值在 `cordis.patch.yml` 的 `config` 里,工程里对应 `index.js` 的 `DEFAULTS`:
93
+
94
+ | 键 | 默认 | 说明 |
95
+ | --- | --- | --- |
96
+ | `python` | 空 | 留空自动探测:环境变量 `DSH_PIXELBAR_PYTHON` → PATH 上的 `python3` / `python` |
97
+ | `script` | `scripts/halo_pixelbar.py` | 相对路径相对于插件目录;也可写绝对路径 |
98
+ | `clockScript` | `scripts/clock_restore.py` | 同上 |
99
+ | `defaultColor` | `magenta` | 默认文字色 |
100
+ | `restoreSeconds` | `15` | 默认回时钟秒数 |
101
+
102
+ ## 独立使用(不经 DSH)
103
+
104
+ 脚本不依赖 DSH,可以单独当命令行工具用:
105
+
106
+ ```powershell
107
+ cd scripts
108
+
109
+ python halo_pixelbar.py list # 列出控制接口
110
+ python halo_pixelbar.py text "在" --color cyan --layout center
111
+ python halo_pixelbar.py ui work # 切场景
112
+ python halo_pixelbar.py color "#F0B4C8" # 屏主题色(自动校准)
113
+ python halo_pixelbar.py color "#FF0000" --raw # 跳过校准
114
+ python halo_pixelbar.py light --effect breathing --color "#F0B4C8" --brightness high --speed 5
115
+ python halo_pixelbar.py lightpower off
116
+ python halo_pixelbar.py volume # 查询
117
+ python halo_pixelbar.py volume 10
118
+
119
+ python clock_restore.py 20 # 20 秒后回时钟
120
+ python color_calib.py # 色卡,用于目测校准
121
+ ```
122
+
123
+ ## HID 协议
124
+
125
+ 真机实测的帧格式(64 字节 HID Output Report):
126
+
127
+ ```text
128
+ 主机请求: 2E AA ED <cmd> <len-hi> <len-lo> <payload...> <checksum> <00 填充>
129
+ 设备响应: 2F BB EC <cmd> <len-hi> <len-lo> <payload...> <checksum> <00 填充>
130
+ 校验和: sum(packet[1:]) & 0xFF # 从 AA 开始求和取低 8 位
131
+ ```
132
+
133
+ | 命令 | 用途 | payload |
134
+ | --- | --- | --- |
135
+ | `0xE8` | 文字 | `00 utf8_len utf8_bytes...` |
136
+ | `0xEF` | 布局 | `01 R G B 00 02 <scroll> <mode> FF` |
137
+ | `0xEF` | 场景 | `02 R G B 00 01 <scene_id> FF FF` |
138
+ | `0xEF` | 屏色 | `03 R G B 00 00 FF FF FF` |
139
+ | `0x6B` | 氛围灯 | `13 <effect> R G B <brightness> <speed>`,**需连发 3 次、间隔 30 ms** |
140
+ | `0x66` | 查询音量 | 空 |
141
+ | `0x67` | 设置音量 | `volume` |
142
+
143
+ 枚举值:
144
+
145
+ - 场景:`clock=0 game=1 work=2 read=3 cats=4 dogs=5 memes=6 cyber=7 waves=8`
146
+ - 布局 `(scroll, mode)`:`left=(0,0) center=(0,1) right=(0,2) stretch=(0,3) scroll_left_to_right=(1,0) scroll_right_to_left=(1,1)`
147
+ - 灯效:`breathing=1 color_tide=2 static=3 ripple=4 flow=5 dynamic=6`
148
+ - 亮度:`low=0x14 medium=0x28 high=0x3C`
149
+ - 文字颜色:`white=0 red=1 green=2 blue=3 yellow=4 cyan=5 magenta=6`
150
+
151
+ ## 颜色校准
152
+
153
+ 这块屏的 R 通道增益偏低、B 偏强,直接发 sRGB 值会偏青/偏紫。`halo_pixelbar.py` 里内置一张目测校准表(`CALIBRATION`),屏色和布局 RGB 会自动查表替换;文字颜色走硬件枚举,不受影响。
154
+
155
+ `docs/color-card.html` 是配套的色卡页面:在电脑屏幕上显示标准色,和音箱屏对比,看出偏差后改 `CALIBRATION` 里的映射。
156
+
157
+ ## 已知限制
158
+
159
+ - 屏幕只有 16 字符 / 48 字节位宽,emoji 不显示,颜文字 `^_^ QAQ (T_T)` 可用。
160
+ - 每次调用新起 python 进程(约 40 ms 启动 + HID 打开),高频推送不建议。
161
+ - 场景 ID、灯效 ID 来自单台设备实测,固件不同可能错位。
162
+ - 只会用 `pythonw.exe` 规避 Windows 控制台闪窗;Linux / macOS 未做适配。
163
+
164
+ ## 排错
165
+
166
+ | 现象 | 处理 |
167
+ | --- | --- |
168
+ | `未找到 Halo PixelBar 控制接口` | 检查 USB 连接;`python scripts\halo_pixelbar.py list` 看接口是否枚举出来 |
169
+ | `[!] 需要 hid 库` | `python -m pip install hidapi` |
170
+ | 推送成功但屏上没变 | 设备可能处于音乐律动等抢占模式,先切回 `clock` 场景 |
171
+ | 插件加载了但工具报找不到脚本 | 重跑 `apply.ps1`,确认 `node_modules\dsh-pixelbar\scripts\` 存在 |
172
+
173
+ ## 致谢
174
+
175
+ HID 帧格式最初参考 [Seraph310/halo-pixelbar-mcp](https://github.com/Seraph310/halo-pixelbar-mcp) 的实测结论,后续在本机逐条验证并整理。
176
+
177
+ ## License
178
+
179
+ [MIT](LICENSE)
@@ -0,0 +1,13 @@
1
+ # dsh-pixelbar —— DSH bundle patch(挂载声明)
2
+ # apply.ps1 会把本目录复制到 profile 的 node_modules\,随 DSH 启动加载。
3
+ - insert:
4
+ - id: dsh-pixelbar
5
+ name: dsh-pixelbar
6
+ config:
7
+ # python:留空 = 自动探测(环境变量 DSH_PIXELBAR_PYTHON → PATH 上的 python3 / python)
8
+ # script / clockScript:留空 = 使用包内 scripts/ 下的脚本
9
+ # 需要指定时取消注释并改写:
10
+ # python: 'C:\Python313\python.exe'
11
+ # script: 'scripts/halo_pixelbar.py'
12
+ defaultColor: magenta
13
+ restoreSeconds: 15
package/index.js ADDED
@@ -0,0 +1,239 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { existsSync, statSync } from 'node:fs'
3
+ import { dirname, isAbsolute, join } from 'node:path'
4
+ import { fileURLToPath } from 'node:url'
5
+
6
+ export const name = 'dsh-pixelbar'
7
+ export const inject = ['tools']
8
+
9
+ const HERE = dirname(fileURLToPath(import.meta.url))
10
+
11
+ const DEFAULTS = {
12
+ // 留空 = 自动探测:环境变量 DSH_PIXELBAR_PYTHON → PATH 上的 python3 / python
13
+ python: '',
14
+ // 留空 = 用包内 scripts/ 下的脚本;相对路径相对于本文件所在目录
15
+ script: join(HERE, 'scripts', 'halo_pixelbar.py'),
16
+ clockScript: join(HERE, 'scripts', 'clock_restore.py'),
17
+ defaultColor: 'magenta',
18
+ restoreSeconds: 15,
19
+ }
20
+
21
+ const PYTHON_ENV = 'DSH_PIXELBAR_PYTHON'
22
+
23
+ const COLORS = ['white', 'red', 'green', 'blue', 'yellow', 'cyan', 'magenta']
24
+ const LAYOUTS = ['left', 'center', 'right', 'stretch', 'scroll_left_to_right', 'scroll_right_to_left']
25
+ const SCENES = ['clock', 'game', 'work', 'read', 'cats', 'dogs', 'memes', 'cyber', 'waves']
26
+ const EFFECTS = ['breathing', 'color_tide', 'static', 'ripple', 'flow', 'dynamic']
27
+ const LEVELS = ['low', 'medium', 'high']
28
+
29
+ // python.exe 是控制台程序,detached 启动时 Windows 会闪一下黑框;
30
+ // 换成 GUI 子系统的 pythonw.exe 就永远不会分配控制台窗口
31
+ function guiPython(python) {
32
+ if (!/python\.exe$/i.test(String(python))) return python
33
+ const w = String(python).replace(/python\.exe$/i, 'pythonw.exe')
34
+ try {
35
+ return existsSync(w) ? w : python
36
+ } catch (e) {
37
+ return python
38
+ }
39
+ }
40
+
41
+ // 在 PATH 里找可执行文件,跳过 WindowsApps 那种 0 字节的商店 stub
42
+ function which(cmd) {
43
+ const isWin = process.platform === 'win32'
44
+ const exts = isWin
45
+ ? String(process.env.PATHEXT || '.EXE;.COM;.BAT;.CMD').split(';').filter(Boolean)
46
+ : ['']
47
+ const names = /\.[a-z0-9]+$/i.test(cmd)
48
+ ? [cmd]
49
+ : exts.flatMap((e) => [cmd + e.toLowerCase(), cmd + e.toUpperCase()])
50
+ const dirs = String(process.env.PATH || '').split(isWin ? ';' : ':').filter(Boolean)
51
+ for (const dir of dirs) {
52
+ for (const n of names) {
53
+ const p = join(dir, n)
54
+ try {
55
+ if (existsSync(p) && statSync(p).size > 0) return p
56
+ } catch (e) {
57
+ // 无权限等,跳过
58
+ }
59
+ }
60
+ }
61
+ return null
62
+ }
63
+
64
+ function resolvePython(cfg) {
65
+ const explicit = String(cfg.python || process.env[PYTHON_ENV] || '').trim()
66
+ if (explicit) return guiPython(explicit)
67
+ const found = which('python3') || which('python')
68
+ return guiPython(found || 'python')
69
+ }
70
+
71
+ function resolveAsset(value, fallback) {
72
+ const raw = String(value || '').trim()
73
+ if (!raw) return fallback
74
+ return isAbsolute(raw) ? raw : join(HERE, raw)
75
+ }
76
+
77
+ function runPy(python, args, timeoutMs) {
78
+ return new Promise((resolve) => {
79
+ let out = ''
80
+ let err = ''
81
+ let settled = false
82
+ const done = (r) => { if (!settled) { settled = true; resolve(r) } }
83
+ let child
84
+ try {
85
+ child = spawn(python, args, {
86
+ windowsHide: true,
87
+ env: Object.assign({}, process.env, { PYTHONIOENCODING: 'utf-8', PYTHONUTF8: '1' }),
88
+ })
89
+ } catch (e) {
90
+ return done({ ok: false, out: '', err: String((e && e.message) || e) })
91
+ }
92
+ const timer = setTimeout(() => {
93
+ try { child.kill() } catch (e) { /* ignore */ }
94
+ done({ ok: false, out: out.trim(), err: err.trim() || '超时 ' + timeoutMs + 'ms' })
95
+ }, timeoutMs)
96
+ child.stdout.on('data', (d) => { out += d.toString('utf8') })
97
+ child.stderr.on('data', (d) => { err += d.toString('utf8') })
98
+ child.on('error', (e) => { clearTimeout(timer); done({ ok: false, out: out.trim(), err: String((e && e.message) || e) }) })
99
+ child.on('close', (code) => {
100
+ clearTimeout(timer)
101
+ done({ ok: code === 0, out: out.trim(), err: err.trim() })
102
+ })
103
+ })
104
+ }
105
+
106
+ function scheduleClockRestore(python, clockScript, seconds) {
107
+ try {
108
+ const child = spawn(python, [clockScript, String(seconds)], {
109
+ detached: true,
110
+ stdio: 'ignore',
111
+ windowsHide: true,
112
+ env: Object.assign({}, process.env, { PYTHONIOENCODING: 'utf-8', PYTHONUTF8: '1' }),
113
+ })
114
+ child.unref()
115
+ return true
116
+ } catch (e) {
117
+ return false
118
+ }
119
+ }
120
+
121
+ function tail(text, n) {
122
+ const t = String(text || '').trim()
123
+ const limit = n || 300
124
+ return t.length > limit ? '…' + t.slice(-limit) : t
125
+ }
126
+
127
+ export function apply(ctx, config) {
128
+ const cfg = Object.assign({}, DEFAULTS, config || {})
129
+ const python = resolvePython(cfg)
130
+ const script = resolveAsset(cfg.script, DEFAULTS.script)
131
+ const clockScript = resolveAsset(cfg.clockScript, DEFAULTS.clockScript)
132
+
133
+ const tool = {
134
+ name: 'pixelbar',
135
+ description:
136
+ '把短句推到桌面的 Halo PixelBar 像素屏(限 16 字符 / 48 字节,不要用 emoji,可用 ^_^ QAQ (T_T) 之类颜文字);' +
137
+ '推完默认 15 秒自动切回时钟。也能切场景、屏色、氛围灯、音量、查设备是否在线。',
138
+ parameters: {
139
+ type: 'object',
140
+ additionalProperties: false,
141
+ properties: {
142
+ action: {
143
+ type: 'string',
144
+ enum: ['text', 'scene', 'screen', 'light', 'volume', 'clock', 'status'],
145
+ description: '默认 text。text=推文字;scene=切场景;screen=设屏色;light=氛围灯;volume=音量;clock=立刻回时钟;status=查设备',
146
+ },
147
+ text: { type: 'string', description: 'action=text 时要显示的文字(超过 16 字符会被截断)' },
148
+ color: { type: 'string', enum: COLORS, description: '文字颜色,默认 magenta' },
149
+ layout: { type: 'string', enum: LAYOUTS, description: '可选:文字布局 left/center/right/stretch/滚动' },
150
+ scene: { type: 'string', enum: SCENES, description: 'action=scene 时的场景名' },
151
+ hex: { type: 'string', description: 'action=screen / light 时的颜色,如 #F0B4C8(屏色自动套校准表)' },
152
+ effect: { type: 'string', enum: EFFECTS, description: 'action=light 的灯效,默认 breathing' },
153
+ brightness: { type: 'string', enum: LEVELS, description: 'action=light 的亮度,默认 medium' },
154
+ speed: { type: 'number', description: 'action=light 的速度 1-10,默认 5' },
155
+ volume: { type: 'number', description: 'action=volume 的音量 0-16;不填=查询当前音量' },
156
+ clock_restore: { type: 'number', description: '推完多少秒切回时钟,默认 15;0=不回时钟' },
157
+ },
158
+ },
159
+ output: {
160
+ schema: { type: 'string' },
161
+ render(_args, value) { return [{ type: 'text', text: value }] },
162
+ },
163
+ timeoutMs: 25000,
164
+ async execute(args) {
165
+ const a = args || {}
166
+ const action = String(a.action || (a.text ? 'text' : 'status'))
167
+
168
+ if (!existsSync(script)) {
169
+ return '找不到像素屏脚本:' + script + '(检查插件是否完整安装,或在 config.script 里指定路径)'
170
+ }
171
+
172
+ if (action === 'text') {
173
+ const raw = String(a.text || '').replace(/\s+/g, ' ').trim()
174
+ if (!raw) return '(没有要推的文字)'
175
+ const shown = Array.from(raw).slice(0, 16).join('')
176
+ const color = COLORS.indexOf(a.color) >= 0 ? a.color : cfg.defaultColor
177
+ const argv = [script, 'text', shown, '--color', color]
178
+ if (LAYOUTS.indexOf(a.layout) >= 0) argv.push('--layout', a.layout)
179
+ const r = await runPy(python, argv, 12000)
180
+ if (!r.ok) return '推送失败:' + (tail(r.err) || tail(r.out) || '未知错误')
181
+ const rest = Number(a.clock_restore)
182
+ const seconds = Number.isFinite(rest) ? rest : (Number(cfg.restoreSeconds) || 15)
183
+ const restored = seconds > 0 ? scheduleClockRestore(python, clockScript, seconds) : false
184
+ const cut = raw !== shown ? '(已截到 16 字符)' : ''
185
+ let note = ''
186
+ if (seconds > 0) note = ';' + seconds + ' 秒后回时钟' + (restored ? '' : '(回时钟命令启动失败)')
187
+ return '已推到像素屏:' + shown + cut + ';颜色 ' + color + note
188
+ }
189
+
190
+ if (action === 'scene') {
191
+ if (SCENES.indexOf(a.scene) < 0) return 'scene 需为:' + SCENES.join('/')
192
+ const r = await runPy(python, [script, 'ui', a.scene], 12000)
193
+ return r.ok ? '已切场景:' + a.scene : '切场景失败:' + (tail(r.err) || tail(r.out))
194
+ }
195
+
196
+ if (action === 'screen') {
197
+ const hex = String(a.hex || '').trim()
198
+ if (!/^#?[0-9a-fA-F]{6}$/.test(hex)) return 'hex 需形如 #F0B4C8'
199
+ const r = await runPy(python, [script, 'color', hex], 12000)
200
+ return r.ok ? '已设屏色:' + (tail(r.out, 120) || hex) : '设屏色失败:' + (tail(r.err) || tail(r.out))
201
+ }
202
+
203
+ if (action === 'light') {
204
+ const h = String(a.hex || '').trim()
205
+ const hex = /^#?[0-9a-fA-F]{6}$/.test(h) ? h : '#F0B4C8'
206
+ const argv = [script, 'light',
207
+ '--effect', EFFECTS.indexOf(a.effect) >= 0 ? a.effect : 'breathing',
208
+ '--color', hex,
209
+ '--brightness', LEVELS.indexOf(a.brightness) >= 0 ? a.brightness : 'medium']
210
+ const sp = Number(a.speed)
211
+ if (Number.isFinite(sp)) argv.push('--speed', String(Math.max(1, Math.min(10, Math.round(sp)))))
212
+ const r = await runPy(python, argv, 12000)
213
+ return r.ok ? '氛围灯已设:' + tail(r.out, 120) : '氛围灯失败:' + (tail(r.err) || tail(r.out))
214
+ }
215
+
216
+ if (action === 'volume') {
217
+ const has = a.volume !== undefined && a.volume !== null && a.volume !== ''
218
+ const argv = has
219
+ ? [script, 'volume', String(Math.max(0, Math.min(16, Math.round(Number(a.volume) || 0))))]
220
+ : [script, 'volume']
221
+ const r = await runPy(python, argv, 12000)
222
+ return r.ok ? (tail(r.out, 120) || '已执行') : '音量操作失败:' + (tail(r.err) || tail(r.out))
223
+ }
224
+
225
+ if (action === 'clock') {
226
+ const r = await runPy(python, [script, 'ui', 'clock'], 12000)
227
+ return r.ok ? '已切回时钟' : '切时钟失败:' + (tail(r.err) || tail(r.out))
228
+ }
229
+
230
+ const r = await runPy(python, [script, 'list'], 12000)
231
+ if (!r.ok) return '设备未找到:' + (tail(r.err) || tail(r.out))
232
+ return '设备在线:' + tail(r.out, 200)
233
+ },
234
+ }
235
+
236
+ const register = () => ctx.tools.register(tool)
237
+ if (typeof ctx.effect === 'function') ctx.effect(register, 'dsh-pixelbar: pixelbar tool')
238
+ else register()
239
+ }
package/package.json CHANGED
@@ -1,6 +1,41 @@
1
1
  {
2
2
  "name": "dsh-pixelbar",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.1",
4
+ "description": "DSH 插件:把花再 Halo PixelBar 桌面像素屏变成常驻工具(推文字 / 切场景 / 屏色 / 氛围灯 / 音量)",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "files": [
8
+ "index.js",
9
+ "cordis.patch.yml",
10
+ "scripts",
11
+ "README.md",
12
+ "LICENSE"
13
+ ],
14
+ "keywords": [
15
+ "dsh",
16
+ "dsh-plugin",
17
+ "halo-pixelbar",
18
+ "pixelbar",
19
+ "hid",
20
+ "edifier",
21
+ "pixel-display"
22
+ ],
23
+ "homepage": "https://github.com/SilverlingFox/dsh-pixelbar#readme",
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/SilverlingFox/dsh-pixelbar.git"
27
+ },
28
+ "bugs": {
29
+ "url": "https://github.com/SilverlingFox/dsh-pixelbar/issues"
30
+ },
31
+ "author": "SilverlingFox",
32
+ "license": "MIT",
33
+ "engines": {
34
+ "node": ">=18"
35
+ },
36
+ "dsh": {
37
+ "bundle": {
38
+ "patch": "./cordis.patch.yml"
39
+ }
40
+ }
41
+ }
@@ -0,0 +1,31 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """像素屏通知延迟回时钟:推送内容后调用本脚本,delay 秒后自动切回时钟界面
4
+
5
+ 用法:
6
+ python clock_restore.py [delay秒] # 默认 15 秒
7
+ python clock_restore.py 20 # 20 秒后回时钟
8
+
9
+ 内部实现:前台 sleep 后调用 halo_pixelbar.py ui clock(脚本保持运行直到切换完成)
10
+ """
11
+ import subprocess
12
+ import sys
13
+ import time
14
+ import os
15
+
16
+ HALO = os.path.join(os.path.dirname(os.path.abspath(__file__)), "halo_pixelbar.py")
17
+
18
+
19
+ def restore_clock(delay: float = 15.0):
20
+ time.sleep(delay)
21
+ subprocess.run(
22
+ [sys.executable, HALO, "ui", "clock"],
23
+ capture_output=True, timeout=30,
24
+ )
25
+ return True
26
+
27
+
28
+ if __name__ == "__main__":
29
+ delay = float(sys.argv[1]) if len(sys.argv) > 1 else 15.0
30
+ restore_clock(delay)
31
+ print(f"[OK] 已切回时钟界面({delay} 秒后)")
@@ -0,0 +1,76 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """Halo PixelBar 颜色校准辅助:轮流显示基准色卡,供目测比对
4
+
5
+ 用法:
6
+ python color_calib.py # 完整色卡,每色停 4 秒
7
+ python color_calib.py --single "#FF8800" # 只显示一个颜色,停 10 秒
8
+ """
9
+ import os
10
+ import sys
11
+ import time
12
+
13
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
14
+ from halo_pixelbar import HaloPixelBar
15
+
16
+ # 基准色卡(目标色):纯色 + 常用色
17
+ COLOR_CARD = [
18
+ ("纯红", "#FF0000"),
19
+ ("纯绿", "#00FF00"),
20
+ ("纯蓝", "#0000FF"),
21
+ ("纯白", "#FFFFFF"),
22
+ ("青色", "#00FFFF"),
23
+ ("黄色", "#FFFF00"),
24
+ ("品红", "#FF00FF"),
25
+ ("默认粉", "#F0B4C8"),
26
+ ("橙色", "#FF8800"),
27
+ ("天蓝", "#66AFFF"),
28
+ ]
29
+
30
+ DWELL = 4 # 每个颜色停留秒数
31
+
32
+
33
+ def main():
34
+ # 支持 --single 只显示单个颜色
35
+ if len(sys.argv) > 1 and sys.argv[1] == "--single" and len(sys.argv) > 2:
36
+ show_single(sys.argv[2])
37
+ return
38
+
39
+ bar = HaloPixelBar()
40
+ try:
41
+ bar.connect()
42
+ print(f"[OK] 色卡开始,共 {len(COLOR_CARD)} 色,每色 {DWELL} 秒(总约 {len(COLOR_CARD) * DWELL}s)")
43
+ print("注意看屏幕颜色与标注的偏差!")
44
+ for name, hexc in COLOR_CARD:
45
+ bar.set_screen_color(hexc)
46
+ bar.send_text(name, layout="center")
47
+ print(f" 显示: {name} ({hexc})")
48
+ time.sleep(DWELL)
49
+ # 恢复默认
50
+ bar.set_screen_color("#F0B4C8")
51
+ bar.set_scene("clock")
52
+ print("[OK] 色卡结束,已恢复")
53
+ except Exception as e:
54
+ print(f"[FAIL] {e}")
55
+ finally:
56
+ bar.close()
57
+
58
+
59
+ def show_single(hexc: str):
60
+ """只显示一个颜色(用于二次确认)"""
61
+ bar = HaloPixelBar()
62
+ try:
63
+ bar.connect()
64
+ bar.set_screen_color(hexc)
65
+ bar.send_text(hexc, layout="center")
66
+ print(f"[OK] 已显示 {hexc},停留 10 秒")
67
+ time.sleep(10)
68
+ bar.set_scene("clock")
69
+ except Exception as e:
70
+ print(f"[FAIL] {e}")
71
+ finally:
72
+ bar.close()
73
+
74
+
75
+ if __name__ == "__main__":
76
+ main()
@@ -0,0 +1,405 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ HALO PIXELBAR 像素屏操控器(墨漓自制 v2)
5
+ =======================================
6
+ 基于 Seraph310/halo-pixelbar-mcp 实测协议(2026-07-19 真机验证)。
7
+
8
+ 帧格式(64 字节 HID Output Report,接口 usage_page=0xFF14):
9
+ 主机请求: 2E AA ED <cmd> <len-hi> <len-lo> <payload...> <checksum> <00 填充>
10
+ 设备响应: 2F BB EC <cmd> <len-hi> <len-lo> <payload...> <checksum> <00 填充>
11
+ 校验和: sum(packet[1:]) & 0xFF(从 AA 开始求和,取低 8 位)
12
+
13
+ 命令:
14
+ 0xE8 文本: payload = [0x00, utf8_len, utf8_bytes...] (文本限 16 字符/48 字节)
15
+ 0xEF 像素设置: 9 字节 payload
16
+ 布局: 01 R G B 00 02 <scroll> <mode> FF
17
+ 场景: 02 R G B 00 01 <scene_id> FF FF
18
+ 屏色: 03 R G B 00 00 FF FF FF
19
+ 0x6B 氛围灯: [0x13, effect, R, G, B, brightness, speed](需连发 3 次,间隔 30ms)
20
+ 0x66 查询音量 / 0x67 设置音量
21
+
22
+ 用法:
23
+ python halo_pixelbar.py list
24
+ python halo_pixelbar.py text "你好墨漓" --color cyan --layout center
25
+ python halo_pixelbar.py layout scroll_r2l
26
+ python halo_pixelbar.py ui clock
27
+ python halo_pixelbar.py volume 10
28
+ python halo_pixelbar.py light --effect breathing --color #F0B4C8 --brightness high
29
+ """
30
+
31
+ import sys
32
+ import time
33
+ import argparse
34
+ from enum import IntEnum
35
+
36
+ try:
37
+ import hid
38
+ HAS_HID = True
39
+ except ImportError:
40
+ HAS_HID = False
41
+
42
+ VENDOR_ID = 0x2D99
43
+ PRODUCT_ID = 0xA106
44
+ INTERFACE_NUMBER = 4
45
+ CONTROL_USAGE_PAGE = 0xFF14
46
+ CONTROL_USAGE = 0x01
47
+ PACKET_LEN = 64
48
+ FRAME_TYPE = 0xED # 帧类型字节(MCP 实测)
49
+ LIGHTING_WRITE_REPETITIONS = 3
50
+ LIGHTING_WRITE_DELAY = 0.03
51
+
52
+
53
+ class TextColor(IntEnum):
54
+ WHITE = 0
55
+ RED = 1
56
+ GREEN = 2
57
+ BLUE = 3
58
+ YELLOW = 4
59
+ CYAN = 5
60
+ MAGENTA = 6
61
+
62
+
63
+ SCENES = {
64
+ "clock": 0, "game": 1, "work": 2, "read": 3,
65
+ "cats": 4, "dogs": 5, "memes": 6, "cyber": 7, "waves": 8,
66
+ }
67
+
68
+ LAYOUTS = {
69
+ "left": (0, 0), "center": (0, 1), "right": (0, 2),
70
+ "stretch": (0, 3),
71
+ "scroll_left_to_right": (1, 0), "scroll_right_to_left": (1, 1),
72
+ }
73
+
74
+ EFFECTS = {"breathing": 1, "color_tide": 2, "static": 3, "ripple": 4, "flow": 5, "dynamic": 6}
75
+ BRIGHTNESS = {"low": 0x14, "medium": 0x28, "high": 0x3C}
76
+
77
+ DEFAULT_RGB = (0xF0, 0xB4, 0xC8) # 默认粉紫
78
+
79
+ # ============================================================
80
+ # 颜色校准表(2026-08-01 君辞目测校准)
81
+ # 屏幕特性:R 通道增益低、G 居中、B 偏强 → 含红颜色偏青/偏紫
82
+ # 格式: 目标色(大写hex) -> 补偿色(发到屏幕的值)
83
+ # ============================================================
84
+ CALIBRATION = {
85
+ "#FF0000": "#FF0303", # 红: 偏深 → 微提亮
86
+ "#00FF00": "#03FF03", # 绿: 偏深 → 微提亮
87
+ "#0000FF": "#0000FF", # 蓝: 准
88
+ # 白 #FFFFFF 跳过(君辞不用白色)
89
+ "#00FFFF": "#00FFD0", # 青: 偏蓝 → 减B
90
+ "#FFFF00": "#FF8800", # 黄: 君辞钦定(#FF8800 显示效果最像目标黄)
91
+ "#FF00FF": "#FF00D0", # 品红: 偏紫 → 减B
92
+ "#F0B4C8": "#FF6080", # 默认粉: 偏青 → R拉满压G/B(接近屏极限)
93
+ "#FF8800": "#FF6000", # 橙: 偏黄 → 减G
94
+ "#66AFFF": "#80C0FF", # 天蓝: 偏深 → 提亮
95
+ }
96
+
97
+
98
+ def apply_calibration(color_hex: str) -> str:
99
+ """应用颜色校准(精确匹配目标色,无匹配则原样返回)"""
100
+ key = color_hex.strip().upper()
101
+ if not key.startswith("#"):
102
+ key = "#" + key
103
+ return CALIBRATION.get(key, color_hex)
104
+
105
+
106
+ def _calibrate_rgb(rgb: tuple) -> tuple:
107
+ """把 RGB tuple 应用校准后转回 tuple"""
108
+ h = "#%02X%02X%02X" % tuple(rgb)
109
+ h = apply_calibration(h)
110
+ c = h.lstrip("#")
111
+ return tuple(int(c[i:i + 2], 16) for i in (0, 2, 4))
112
+
113
+
114
+ def _pad(data) -> bytes:
115
+ packet = bytes(data)
116
+ if len(packet) > PACKET_LEN:
117
+ return packet[:PACKET_LEN]
118
+ return packet + bytes(PACKET_LEN - len(packet))
119
+
120
+
121
+ def _edifier_packet(command: int, payload) -> bytes:
122
+ """MCP 实测帧格式: 2E AA ED <cmd> <len-be> <payload> <sum(packet[1:])&0xFF>"""
123
+ body = bytes(payload)
124
+ packet = bytearray([0x2E, 0xAA, FRAME_TYPE, command,
125
+ (len(body) >> 8) & 0xFF, len(body) & 0xFF])
126
+ packet.extend(body)
127
+ packet.append(sum(packet[1:]) & 0xFF)
128
+ return _pad(packet)
129
+
130
+
131
+ def build_text_packet(text: str, color: TextColor = TextColor.WHITE) -> bytes:
132
+ """文本包: 0xE8, payload = [0x00, utf8_len, utf8_bytes...](限 16 字符/48 字节)"""
133
+ text = text.strip()[:16]
134
+ tb = text.encode("utf-8")
135
+ while len(tb) > 48:
136
+ text = text[:-1]
137
+ tb = text.encode("utf-8")
138
+ return _edifier_packet(0xE8, [0x00, len(tb), *tb])
139
+
140
+
141
+ def build_layout_packet(layout: str, rgb: tuple = DEFAULT_RGB) -> bytes:
142
+ """布局包: 0xEF, payload = [01 R G B 00 02 scroll mode FF](RGB 自动校准)"""
143
+ scroll, mode = LAYOUTS[layout]
144
+ r, g, b = _calibrate_rgb(rgb)
145
+ return _edifier_packet(0xEF, [0x01, r, g, b, 0x00, 0x02, scroll, mode, 0xFF])
146
+
147
+
148
+ def build_scene_packet(scene: str, rgb: tuple = DEFAULT_RGB) -> bytes:
149
+ """场景包: 0xEF, payload = [02 R G B 00 01 scene_id FF FF](RGB 自动校准)"""
150
+ scene_id = SCENES[scene]
151
+ r, g, b = _calibrate_rgb(rgb)
152
+ return _edifier_packet(0xEF, [0x02, r, g, b, 0x00, 0x01, scene_id, 0xFF, 0xFF])
153
+
154
+
155
+ def build_screen_color_packet(color_hex: str) -> bytes:
156
+ """像素屏主题色: 0xEF, payload = [03 R G B 00 00 FF FF FF](自动应用校准)"""
157
+ color_hex = apply_calibration(color_hex)
158
+ c = color_hex.strip().lstrip("#")
159
+ rgb = tuple(int(c[i:i + 2], 16) for i in (0, 2, 4))
160
+ return _edifier_packet(0xEF, [0x03, *rgb, 0x00, 0x00, 0xFF, 0xFF, 0xFF])
161
+
162
+
163
+ def build_ambient_packet(effect: str, color_hex: str, brightness: str, speed: int) -> bytes:
164
+ """氛围灯: 0x6B, payload = [13 effect R G B brightness speed]"""
165
+ c = color_hex.strip().lstrip("#")
166
+ rgb = tuple(int(c[i:i + 2], 16) for i in (0, 2, 4))
167
+ return _edifier_packet(0x6B, [0x13, EFFECTS[effect], *rgb, BRIGHTNESS[brightness], speed])
168
+
169
+
170
+ def build_ambient_power_packet(enabled: bool) -> bytes:
171
+ return _edifier_packet(0x6B, [0x00, 0x00, 0x00, 0x00, int(enabled), 0xFF, 0xFF])
172
+
173
+
174
+ def build_volume_query_packet() -> bytes:
175
+ return _edifier_packet(0x66, [])
176
+
177
+
178
+ def build_volume_set_packet(volume: int) -> bytes:
179
+ return _edifier_packet(0x67, [max(0, min(16, volume))])
180
+
181
+
182
+ def find_devices():
183
+ """查找 Halo PixelBar 像素屏控制接口(interface=4, usage_page=FF14)"""
184
+ found = []
185
+ for d in hid.enumerate(VENDOR_ID, PRODUCT_ID):
186
+ if int(d.get("interface_number", -1)) != INTERFACE_NUMBER:
187
+ continue
188
+ if int(d.get("usage_page", 0)) != CONTROL_USAGE_PAGE:
189
+ continue
190
+ if int(d.get("usage", 0)) != CONTROL_USAGE:
191
+ continue
192
+ found.append(d)
193
+ return found
194
+
195
+
196
+ class HaloPixelBar:
197
+ def __init__(self):
198
+ self.dev = None
199
+ self.device_info = None
200
+
201
+ def connect(self):
202
+ devs = find_devices()
203
+ if not devs:
204
+ raise RuntimeError("未找到 Halo PixelBar 控制接口(检查 USB 连接)")
205
+ last_err = ""
206
+ for attempt in range(5):
207
+ for d in devs:
208
+ dev = hid.device()
209
+ try:
210
+ dev.open_path(d["path"])
211
+ r = dev.write(build_text_packet(""))
212
+ if r >= 0:
213
+ self.dev = dev
214
+ self.device_info = d
215
+ return d
216
+ dev.close()
217
+ except Exception as e:
218
+ last_err = str(e)
219
+ try:
220
+ dev.close()
221
+ except Exception:
222
+ pass
223
+ time.sleep(0.6)
224
+ raise RuntimeError(f"连接失败: {last_err}")
225
+
226
+ def send(self, packet: bytes, consume_response: bool = True) -> int:
227
+ """发送数据包;写入后尝试读取设备响应(避免响应队列堵塞)"""
228
+ if not self.dev:
229
+ raise RuntimeError("未连接")
230
+ r = self.dev.write(packet)
231
+ if r < 0:
232
+ return r
233
+ if consume_response:
234
+ try:
235
+ self.dev.read(64, timeout_ms=100)
236
+ except Exception:
237
+ pass
238
+ return r
239
+
240
+ def send_text(self, text: str, color: TextColor = TextColor.WHITE, layout: str = None):
241
+ if layout is not None:
242
+ self.send(build_layout_packet(layout))
243
+ time.sleep(0.05)
244
+ return self.send(build_text_packet(text))
245
+
246
+ def set_layout(self, layout: str):
247
+ return self.send(build_layout_packet(layout))
248
+
249
+ def set_scene(self, scene: str):
250
+ return self.send(build_scene_packet(scene))
251
+
252
+ def set_screen_color(self, color_hex: str):
253
+ return self.send(build_screen_color_packet(color_hex))
254
+
255
+ def set_light(self, effect: str, color_hex: str, brightness: str, speed: int):
256
+ pkt = build_ambient_packet(effect, color_hex, brightness, speed)
257
+ # 灯光命令需连发 3 次(间隔 30ms)才生效
258
+ last = -1
259
+ for i in range(LIGHTING_WRITE_REPETITIONS):
260
+ last = self.send(pkt, consume_response=False)
261
+ if last < 0:
262
+ break
263
+ if i + 1 < LIGHTING_WRITE_REPETITIONS:
264
+ time.sleep(LIGHTING_WRITE_DELAY)
265
+ return last
266
+
267
+ def set_light_power(self, enabled: bool):
268
+ return self.send(build_ambient_power_packet(enabled))
269
+
270
+ def get_volume(self) -> dict:
271
+ self.send(build_volume_query_packet(), consume_response=False)
272
+ try:
273
+ resp = self.dev.read(64, timeout_ms=1000)
274
+ except Exception:
275
+ return {}
276
+ if resp and bytes(resp[:4]) == bytes([0x2F, 0xBB, 0xEC, 0x66]):
277
+ payload = bytes(resp[6:6 + ((resp[4] << 8) | resp[5])])
278
+ if len(payload) >= 2:
279
+ return {"maximum": payload[0], "current": payload[1]}
280
+ return {}
281
+
282
+ def set_volume(self, volume: int):
283
+ return self.send(build_volume_set_packet(volume))
284
+
285
+ def close(self):
286
+ if self.dev:
287
+ try:
288
+ self.dev.close()
289
+ except Exception:
290
+ pass
291
+ self.dev = None
292
+
293
+
294
+ def main():
295
+ if not HAS_HID:
296
+ if "--quiet" in sys.argv:
297
+ return 0
298
+ print("[!] 需要 hid 库: pip install hidapi", file=sys.stderr)
299
+ return 1
300
+
301
+ ap = argparse.ArgumentParser(description="Halo PixelBar 像素屏操控器 v2")
302
+ ap.add_argument("--quiet", action="store_true", help="device offline: skip silently")
303
+ sub = ap.add_subparsers(dest="cmd")
304
+
305
+ sub.add_parser("list", help="列出控制接口")
306
+ p_text = sub.add_parser("text", help="显示文字")
307
+ p_text.add_argument("text", nargs="+")
308
+ p_text.add_argument("--color", default="white", help="white/red/green/blue/yellow/cyan/magenta")
309
+ p_text.add_argument("--layout", default=None, help="left/center/right/stretch/scroll_left_to_right/scroll_right_to_left")
310
+ p_layout = sub.add_parser("layout", help="设置布局")
311
+ p_layout.add_argument("layout", choices=list(LAYOUTS.keys()))
312
+ p_ui = sub.add_parser("ui", help="切换场景模式")
313
+ p_ui.add_argument("model", choices=list(SCENES.keys()))
314
+ p_color = sub.add_parser("color", help="设置像素屏主题色(自动应用校准)")
315
+ p_color.add_argument("hex", help="如 #F0B4C8")
316
+ p_color.add_argument("--raw", action="store_true", help="跳过校准,直接发原始值")
317
+ p_vol = sub.add_parser("volume", help="查询/设置硬件音量")
318
+ p_vol.add_argument("value", nargs="?", type=int, help="0-16,不填则查询")
319
+ p_light = sub.add_parser("light", help="氛围灯")
320
+ p_light.add_argument("--effect", default="breathing", choices=list(EFFECTS.keys()))
321
+ p_light.add_argument("--color", default="#F0B4C8")
322
+ p_light.add_argument("--brightness", default="medium", choices=list(BRIGHTNESS.keys()))
323
+ p_light.add_argument("--speed", type=int, default=5)
324
+ p_power = sub.add_parser("lightpower", help="氛围灯开关")
325
+ p_power.add_argument("on", choices=["on", "off"])
326
+
327
+ args = ap.parse_args()
328
+ quiet = getattr(args, "quiet", False) or "--quiet" in sys.argv
329
+ if not args.cmd:
330
+ ap.print_help()
331
+ return 1
332
+
333
+ if args.cmd == "list":
334
+ try:
335
+ devs = find_devices()
336
+ except Exception as e:
337
+ if quiet:
338
+ return 0
339
+ print(f"[FAIL] {e}")
340
+ return 1
341
+ if not devs:
342
+ if quiet:
343
+ return 0
344
+ print("未找到控制接口(检查 USB 连接)")
345
+ return 1
346
+ for d in devs:
347
+ print(f" interface={d.get('interface_number')} usage_page={d['usage_page']:04X} "
348
+ f"usage={d['usage']:02X} {d.get('product_string','')}")
349
+ return 0
350
+
351
+ bar = None
352
+ try:
353
+ bar = HaloPixelBar()
354
+ info = bar.connect()
355
+ print(f"[OK] 已连接: {info.get('product_string','')}")
356
+
357
+ if args.cmd == "text":
358
+ r = bar.send_text(" ".join(args.text), TextColor[args.color.upper()], args.layout)
359
+ print(f"[OK] 显示: {' '.join(args.text)!r} 颜色={args.color} 布局={args.layout or '保持'}")
360
+ elif args.cmd == "layout":
361
+ r = bar.set_layout(args.layout)
362
+ print(f"[OK] 布局: {args.layout}")
363
+ elif args.cmd == "ui":
364
+ r = bar.set_scene(args.model)
365
+ print(f"[OK] 场景: {args.model}")
366
+ elif args.cmd == "color":
367
+ sent = args.hex
368
+ if not args.raw:
369
+ cal = apply_calibration(args.hex)
370
+ if cal != args.hex:
371
+ print(f"[校准] {args.hex} -> {cal}")
372
+ sent = cal
373
+ r = bar.set_screen_color(sent)
374
+ print(f"[OK] 屏色: {sent}")
375
+ elif args.cmd == "volume":
376
+ if args.value is None:
377
+ v = bar.get_volume()
378
+ print(f"[OK] 音量: {v.get('current','?')}/{v.get('maximum','?')}")
379
+ else:
380
+ r = bar.set_volume(args.value)
381
+ time.sleep(0.3)
382
+ v = bar.get_volume()
383
+ print(f"[OK] 设置音量 {args.value} -> 当前 {v.get('current','?')}")
384
+ elif args.cmd == "light":
385
+ r = bar.set_light(args.effect, args.color, args.brightness, args.speed)
386
+ print(f"[OK] 氛围灯: {args.effect} {args.color}")
387
+ elif args.cmd == "lightpower":
388
+ r = bar.set_light_power(args.on == "on")
389
+ print(f"[OK] 氛围灯: {'开' if args.on == 'on' else '关'}")
390
+ return 0
391
+ except Exception as e:
392
+ if quiet:
393
+ return 0
394
+ print(f"[FAIL] {e}")
395
+ return 1
396
+ finally:
397
+ if bar is not None:
398
+ try:
399
+ bar.close()
400
+ except Exception:
401
+ pass
402
+
403
+
404
+ if __name__ == "__main__":
405
+ sys.exit(main())