全栈自造 Status Deck(三):桌面端上位机程序开发
Status Deck 的屏幕不是数据源。它不应该保存 API Key,不应该解析 Codex 会话,也不应该承担系统级采集任务。它只是离开发者最近的一块显示终端。
真正承担"把电脑状态变成设备状态"的,是桌面端上位机:一个运行在 macOS 菜单栏或 Windows 托盘中的 Go 常驻程序。它负责发现 ESP32、维护 BLE 连接、采集本机数据、决定同步频率,并把结果安全地发送到屏幕。
这篇不讨论理想架构,只复盘 Status Deck 当前 desktop/ 中已经存在的实现,以及这套实现解决了哪些实际问题。
1. 桌面端不是第二个仪表盘
如果电脑上再做一个很大的仪表盘窗口,Status Deck 就变成了"桌面仪表盘的缩略屏"。这不是我想要的产品形态。
桌面端默认是安静的 Agent:平时只占用一个状态栏图标,设备不在线时自动等待,连接成功后自动同步。用户只有在需要扫描设备、查看日志、设置开机自启或退出时,才需要打开菜单。
当前入口在 desktop/cmd/status-deck/main.go:
status-deck 默认启动托盘 / 菜单栏应用
status-deck tray 显式启动托盘模式
status-deck scan 单独扫描 BLE,排查蓝牙能力
status-deck run 无界面 Agent,便于观察连接和同步日志
status-deck version 输出版本号
run 不是另一套旧同步逻辑。它和托盘模式共用相同的 internal/app.Agent,区别只是没有菜单 UI。这个约束很重要:调试命令与最终产品必须走同一条 BLE、采集和同步链路,否则"CLI 能用、托盘不能用"会成为长期隐患。
2. 当前真实调用链
桌面端不是从 main 直接开始采集 CPU,也不是托盘菜单直接写 BLE。当前调用关系如下:
cmd/status-deck/main.go
|
+--> tray.App 状态栏菜单、日志、用户点击
|
+--> app.Agent 组装非 UI 运行时
|
+--> ble.Client 扫描、连接、订阅、心跳、写入
|
+--> statussync 调度、变化检测、首次全量同步
|
+--> systeminfo
+--> codexusage
其中 internal/app/agent.go 很小,但它解决了一个很典型的桌面程序问题:依赖初始化不要散落在托盘回调、CLI 子命令和将来的设置窗口里。
它在一个地方创建 ble.Client、systeminfo.Collector、codexusage.LocalProvider 和 statussync.SystemService。托盘层只能通过 Agent 做启动、扫描、读取事件、触发首次同步和关闭这些有限动作。这样 UI 不知道系统信息如何获得,也不用理解 GATT 特征值。
2.1 分层的关键不是目录,而是依赖方向
这个项目的目录并不多,但每层都有明确的依赖方向:
tray ----------> app.Agent
|
statussync ------> Publisher 接口 <------ ble.Client
|
systeminfo / codexusage
最关键的是 statussync 并不依赖完整的 ble.Client,而是只依赖一个最小接口:
type Publisher interface {
IsConnected() bool
Send(ctx context.Context, messageType string, payload any) error
}
这意味着同步层只关心两件事:设备现在能不能接收,以及怎样发送一条类型明确的消息。它不关心 GATT Service UUID、扫描地址、特征值指针或通知订阅。
这个设计有实际收益,而不是为了让代码"看起来像分层":后续如果增加 USB 串口调试桥、WebSocket 模拟设备,甚至 MQTT 网关,只要实现 Publisher,现有采集和调度逻辑就可以继续使用。反过来,ble.Client 也不应该出现 CollectCPU、ReadCodexUsage 之类的业务方法。
2.2 生命周期由一个 Context 统一收束
托盘应用创建一个生命周期 context.Context,启动 BLE 维护循环和同步服务时传入它。用户点击"退出"后,程序先取消 Context,再关闭 BLE 客户端和日志文件。
点击退出
-> cancel lifecycle context
-> 重连循环结束
-> 心跳 ticker 结束
-> 各同步任务结束
-> 主动断开 BLE
-> 关闭日志文件
这比在每个 goroutine 里各自维护一个 stop 标记更可靠。以后新增 Git 扫描、Docker 查询或服务器请求时,也应使用同一份或从它派生的 Context;这样常驻程序退出时不会留下后台任务。
3. BLE 层:把断线当成正常状态
ESP32 在这个项目里是 BLE Peripheral,电脑是 Central。设备不能主动"连接电脑";电脑端必须扫描广播、选择目标并发起连接。
internal/ble/client.go 中的一次完整连接不是简单调用 Connect,而是:
启用蓝牙适配器
-> 扫描附近设备
-> 用 Service UUID 或设备名筛选 Status Deck
-> 保留扫描到的地址
-> 建立连接
-> 发现 Service、RX、TX 特征值
-> 订阅 TX notify
-> 向 RX 写入 hello
-> 发送 connected 事件给上层
只有 hello 成功写入后,桌面端才发出 connected 事件。这样托盘层收到"已连接"时,协议会话已经具备发送业务消息的条件,而不是只有底层链路碰巧连上。
连接维护循环会立即开始第一次尝试;连接失败后按默认 5 秒间隔重试。已经连接时,默认每 10 秒发送一次 heartbeat。心跳不是业务数据,它只是让设备知道桌面 Agent 仍在工作。
现实中常见的状态包括:开发板没上电、电脑睡眠后恢复、蓝牙临时关闭、固件重启、设备离开信号范围。它们都不应该让应用弹出一串错误窗口。当前托盘菜单只显示"正在寻找设备"或"正在重连",详细错误留在日志中。
3.1 扫描结果不等于可连接设备
扫描阶段会记录设备地址,连接阶段再使用这个地址建立链路。这看起来多了一步,但不能省:在 BLE API 中,广播结果、设备名称、RSSI 和真正可建立连接的地址并不是同一个生命周期对象。
筛选规则采用"双条件":优先检查专用 Service UUID,同时保留设备名 Status Deck 作为补充。只依赖名称容易误连同名设备;只依赖服务 UUID 又可能受到部分平台广播信息不完整的影响。二者结合更适合开发阶段的设备发现。
当前版本仍是单设备 MVP:自动连接扫描结果中的第一台匹配设备,托盘菜单可以展示 RSSI,但尚未实现"保存某一台设备""忘记设备"或多设备选择。这一点必须写清楚,否则读者会误以为已有完整设备管理。
3.2 连接状态和桌面业务在线状态是两回事
BLE 连接存在,并不一定说明桌面端业务仍在正常工作。电脑休眠、应用异常或后台任务卡死时,物理链路可能暂时没有立刻断开。
因此协议里保留 heartbeat。ESP32 端会单独判断"桌面端最近是否还有消息",显示屏可以区分:
BLE 已连接 + 有心跳 桌面 Agent 正常
BLE 已连接 + 心跳超时 链路可能仍在,业务端已失联
BLE 未连接 等待电脑重新扫描并连接
这是硬件状态卡和普通网络客户端很不一样的地方:展示层不能只依据 socket 或 GATT 是否存在来判断数据是否可信。
4. 两把锁和一个事件流
Status Deck 的同步任务是并发的,但蓝牙写入不能并发。
ble.Client 中有两把关键锁:
| 锁 | 保护对象 | 原因 |
|---|---|---|
scanMu | 手动扫描与后台重连扫描 | 同一个蓝牙适配器不能被两次扫描同时占用 |
writeMu | hello、心跳和全部业务消息 | 防止 GATT 写入交错、分片混乱或底层库报错 |
这意味着 CPU、内存、Codex 三个采集任务可以同时准备数据;但最终写 RX 特征值时,仍然按照顺序发送。这个模型比"所有事情只用一个大锁"更合理:慢采集不会堵住 UI,BLE 却始终保持可预测。
BLE 状态会通过带缓冲的事件通道交给上层:connecting、connected、disconnected、connect_failed、notification。事件写入采用非阻塞策略,避免底层蓝牙回调被状态栏 UI 卡住。
事件通道的代价是极端情况下可能丢掉一次展示事件,但这是有意识的取舍。对于托盘标题来说,"正在连接"丢一条并不致命,后续的"已连接"或"连接失败"会覆盖它;而如果为了保证每条展示事件都送达,反过来阻塞底层蓝牙回调,风险会更大。
5. GATT 不是 TCP:长 JSON 必须认真处理
BLE 的一次 GATT 写入长度会受 MTU、操作系统蓝牙栈和实现差异影响。不能假设"一个 JSON 字符串总能一次写进去"。
桌面端当前策略是:
<= 480 bytes:直接写入 RX 特征值。> 480 bytes:封装为多个chunk消息。- 每个分片的数据部分最多
128 bytes。
每个分片仍是完整 JSON,携带原始消息 ID、当前序号、总分片数和数据内容。ESP32 端的 BleChunkAssembler 先重组,再交给原有消息解析流程。
这里有一个很容易被忽略的细节:不能从 UTF-8 字符中间切开。Codex 重置时间可能是"9月19日",中文字符占多个字节;若直接按字节截断后转换字符串,重组得到的内容可能已经损坏。splitUTF8 会回退到合法字符边界再分片。
5.1 业务层不应该手写 JSON 信封
同步服务发送业务数据时调用的是:
publisher.Send(ctx, "system.update", payload)
而不是在每个采集任务里手写版本号、消息 ID、时间戳和来源。BLE 客户端会统一封装为类似下面的消息:
{
"v": 1,
"id": "desktop_...",
"type": "system.update",
"ts": 1789538400000,
"source": "desktop",
"target": "device",
"payload": {
"memory": {
"totalMB": 32768,
"usedMB": 17420,
"usedPercent": 53.2
}
}
}
统一信封使协议版本和追踪字段不会散落在业务代码里。以后增加 git.update、process.update 或 docker.update 时,新增 Provider 不需要重新发明消息格式。
设备端当前会通过 TX notify 返回 ACK 或错误。桌面端把可读通知写入文件日志,遇到非 UTF-8 字节则记录十六进制。这类日志在排查"到底是桌面端写错、BLE 截断,还是固件解析失败"时,比只打印"同步失败"有用得多。
6. 同步不是一个全局定时器
最早做状态卡时,很容易写出这样的循环:每两秒采集 CPU、内存、磁盘、电量、Codex,然后全部发送。
它能跑,但不适合常驻程序。CPU 变化频繁,磁盘容量通常几小时不变,Codex 用量又需要读取会话文件。把它们绑成一个大 payload,只会增加系统调用和 BLE 流量。
因此当前同步逻辑集中在 internal/statussync/system.go,按数据域独立调度:
| 数据域 | 检查间隔 | 最长静默时间 | 实际消息 |
|---|---|---|---|
| CPU / GPU | 2 秒 | 10 秒 | system.update |
| 内存 | 5 秒 | 30 秒 | system.update |
| 磁盘 / 电源 | 30 秒 | 5 分钟 | system.update |
| Codex 用量 | 15 秒 | 1 分钟 | codex.update |
"检查"不等于"发送"。服务会先序列化 payload,与上一次已发送内容比较:内容变了就推送;内容没变也不会无限静默,超过 MaxSilence 后仍会补发一次。
这样做有两个结果:屏幕上的 CPU 仍有实时感,电脑空闲时却不会每两秒传递一份完全相同的 JSON。
6.1 Interval、MaxSilence 与发送判定
每个数据域的发送条件可以抽象为:
本次不存在历史发送记录
或 payload 与上次不同
或 距离上次成功发送 >= MaxSilence
或 当前是重连后的强制同步
这里的"成功发送"很重要。只有 BLE 写入成功后,lastSent 才会更新。若写入失败,下一轮不会因为错误地认为"已经发过"而跳过重试。
变化检测没有为 CPU、内存、磁盘、Codex 各写一套比较器,而是比较 JSON 序列化后的 payload。前提是 payload 必须稳定:字段顺序由结构体保证,未更新字段通过 omitempty 省略,百分比经过统一取整,避免浮点数微小波动让屏幕无意义地闪烁。
这种方案对当前规模足够直接。等将来出现大数组、无序 Map 或高频日志流时,才需要为特定数据域设计更精细的比较策略。
6.2 局部 system.update 是必要的
性能任务只发送 CPU 和 GPU,内存任务只发送内存:
{
"cpu": { "usagePercent": 18.5 },
"gpu": { "available": true, "usagePercent": 38.0 }
}
在 Go 中,SystemPayload 用指针字段和 omitempty 表示"这次没有更新该字段"。这与"该字段为零"完全不同。
如果每次更新都把未采集的磁盘、电量序列化成空值,ESP32 就会把仍有效的旧数据清掉。设备端的 DeviceStatusStore 因此采取合并更新:只覆盖本次 payload 中出现的数据域。
6.3 采集单位与展示单位不必相同
系统采集层仍保留 bytes 级原始数据,方便未来更大屏幕按 GB、TB 或图表精度展示;但无线消息中的容量统一转换为 MB。这样既减少 JSON 长度,也避免小屏幕显示一串无意义的超长整数。
同样,百分比在桌面端发送前保留一位小数。它比整数百分比更平滑,又不会像多位浮点数一样持续制造"数值在跳动"的视觉噪声。
7. 一个真实问题:重连后为什么屏幕仍要等待
首次实现 SyncNow() 时,连接成功后确实会主动采集四类数据,但它复用了常态的变化检测逻辑。
问题发生在重连场景:
设备断开
-> 屏幕清空为初始化页
-> 设备重新连接
-> 桌面端发现 CPU、内存数值和上次发送的一样
-> 去重逻辑跳过发送
-> 屏幕只能等待下一次数据变化或 MaxSilence 到期
这不是定时器写得慢,而是"桌面端缓存正确"和"设备端已丢失显示状态"之间的语义不一致。
现在的 SyncNow() 会在收到 EventConnected 后:
- 并行触发性能、内存、磁盘电源、Codex 四个采集任务。
- 给它们传入
force=true。 - 绕过"内容未变则跳过"的常态去重。
- 仍由
writeMu把实际 GATT 写入串行化。
所以重连后的首屏不再等待 2 秒、5 秒、15 秒或 30 秒的调度周期;即使数据没有变化,也会重新发送完整快照。首轮结束后,系统自动回到节制的增量同步模式。
这条经验很值得保留:变化检测针对传输效率,连接恢复针对状态一致性,两者不能混为一谈。
7.1 并行采集,串行发送
首次全量同步时,性能、内存、磁盘电源和 Codex 会并行开始采集。这样最慢的数据源不会阻塞其他数据先到屏幕。
但"并行采集"不等于"并行写 BLE"。最终每次 Send 仍会经过 ble.Client.writeMu。这个组合满足两个目标:首屏尽可能快,同时保持 GATT 写入的顺序与原子性。
如果将来有一个网络 Provider 需要数秒才能返回,它最多让自己的页面晚到,不会拖住 CPU 和内存的首次显示。
8. 系统信息采集:把平台差异留在采集层
internal/systeminfo/Collector 不承担同步策略,它只返回本机数据。当前按性能、内存、磁盘和电源拆成几个小方法:
CollectPerformance() CPU + GPU
CollectMemory() 物理内存
CollectDisk() 默认系统盘
CollectPower() 电池 / 充电状态
内存和磁盘主要由 gopsutil 获得。磁盘路径会按平台选择:Windows 默认 C:\\,其他平台默认 /。第一版故意只显示系统盘,不假装已经解决"多块磁盘如何分页、如何选择、如何展示网络盘"的产品问题。
CPU 是高频数据,GPU 查询通常更重。当前 Collector 对 GPU 结果做了 5 秒缓存,避免在每个 CPU 周期都触发较重的平台查询。电源信息则允许不可用:台式机、虚拟机或不支持电池查询的平台会得到 Available=false,这不是整个系统采集失败。
平台能力通过 *_darwin.go、*_other.go 这样的文件隔离。这样同步层只处理统一的 GPU、Power 结构,不需要出现"如果是 macOS 就执行某命令"的分支。以后补 Windows 专属电池或 GPU 实现时,也不会污染 BLE 或 UI 代码。
9. Codex 用量:不读 Token,不调私有接口
Codex 用量不是通过抓取网页、保存登录凭据或调用未公开接口获得的。
internal/codexusage/provider.go 只读取 Codex CLI 已经写入本机的会话 JSONL:
$CODEX_HOME/sessions/**/*.jsonl
如果没有设置 CODEX_HOME,它会通过 os.UserHomeDir() 和 filepath.Join() 找到当前用户的 ~/.codex/sessions。因此这段逻辑不依赖 macOS 路径格式,在 Windows 上也会落到当前用户目录下的 .codex\sessions。
实现有几个刻意的限制:
- 最多检查最近修改的 80 个会话文件。
- 每个文件只读取末尾 1 MiB,不把大 JSONL 全部读入内存。
- 使用 5 秒缓存,避免常驻 Agent 频繁扫描历史记录。
- 只接受
event_msg.token_count中的payload.rate_limits。 - 额度重置时间已过、但本地没有新事件时,返回不可用而不是展示过期额度。
最后一点尤其重要。available: false 的意思是"当前没有可信数据",不是"额度为 0"。屏幕端应该显示"暂无本地用量数据",不能把采集失败伪装成用户已经耗尽额度。
9.1 读取本地 JSONL 时的兼容与过期判定
会话事件中的额度窗口字段并不是一个值得假设永远不变的外部契约。因此解析器同时兼容 primary/secondary、primary_window/secondary_window、five_hour/weekly 等命名,并优先根据窗口时长区分 5 小时与周额度。
重置时间也同时接受 Unix 秒、Unix 毫秒和 RFC3339 时间字符串。最后会执行一个简单但关键的判断:若 resetAt <= now,这份本地快照已经跨过重置周期,必须失效。
这解释了一个经常被误解的现象:用户看见"不可用",不一定是 Windows 不支持,也不一定是账户没有额度;可能只是 Codex 在重置后还没有产生新的本地会话事件。对状态卡来说,展示未知比展示错误的"实时额度"更诚实。
10. 日志与托盘交互:常驻程序需要可观察性
没有窗口的程序最容易变成黑盒。Status Deck 启动后会通过 os.UserCacheDir() 创建日志目录:
<用户缓存目录>/Status Deck/logs/status-deck.log
日志包含 BLE 连接、扫描、通知内容、采集失败、推送失败和开机自启操作等事件,并带有微秒级时间戳。托盘菜单中的"调试日志"不是另起一个日志系统,而是打开或跟踪同一份文件。
托盘菜单当前承担的是轻量控制面:
状态图标
├── 当前连接状态
├── 最近同步 / 设备响应
├── 设备管理
│ ├── 扫描设备
│ └── 当前设备与 RSSI
├── 设置
│ ├── 开机自启
│ └── 调试日志
└── 退出
这个菜单故意不承担复杂配置表单。Status Deck 已经有物理屏幕,桌面端的职责是让后台状态可观察、可恢复,而不是再复制一个大型控制台。
11. Windows 不是把 macOS 二进制重新编译一下
跨平台难点主要不在 Go 语法,而在操作系统能力的边界。
Status Deck 已经在代码中隔离了几个平台差异:
internal/ble/adapter_init_windows.go Windows 蓝牙适配器初始化兼容处理
internal/autostart/autostart_darwin.go macOS LaunchAgent
internal/autostart/autostart_windows.go Windows 当前用户 Run 注册表项
internal/tray/icon_windows.go Windows 托盘 ICO 图标
例如 Windows 的托盘图标需要 ICO,直接把 PNG 交给托盘库可能出现"菜单存在、图标不显示"。因此项目用 cmd/icon-gen 从同一份绘制逻辑生成 Windows .ico 和 macOS .icns,避免两套 Logo 长期漂移。
11.1 一个 Windows 蓝牙初始化陷阱
Windows 适配器初始化曾出现过一个很迷惑的错误:菜单能够打开,扫描设备时却返回"函数不正确"。问题不在硬件,也不在 Service UUID。
根源是底层 WinRT 初始化。tinygo.org/x/bluetooth 调用 RoInitialize 后,若当前线程已经初始化,Windows 会返回 S_FALSE。这表示"已初始化,可继续使用",但某些封装会把任何非零 HRESULT 都转换成 Go error。
因此 adapter_init_windows.go 会识别这个特定返回码,并把它当作成功;其他错误仍然照常返回。这个补丁的意义不在于记住一个 Windows 常量,而是提醒一个跨平台桌面程序的基本事实:底层 API 的"非零返回"不总等于失败,必须先理解平台语义。
11.2 开机自启是系统集成,不是 Checkbox
开机自启也不是菜单勾选状态:
- macOS 写入
~/Library/LaunchAgents/com.statusdeck.desktop.plist。 - Windows 写入
HKCU\Software\Microsoft\Windows\CurrentVersion\Run。
两者都记录当前可执行文件路径。因此应用移动位置后,需要关闭再开启一次开机自启,才能更新路径。
Windows 使用当前用户注册表,不需要管理员权限;macOS 使用当前用户的 LaunchAgent,同样不要求系统级安装。这里选择"当前用户"而不是全局服务,是因为 Status Deck 的数据来源本来就属于当前登录用户:Codex 会话、系统缓存目录、用户配置和桌面蓝牙权限。
12. 打包产物和"能运行"是两件事
项目没有要求用户安装 Go。
macOS 打包脚本会生成:
desktop/dist/Status Deck.app
desktop/dist/Status-Deck-macos.zip
Windows PowerShell 脚本会生成:
desktop/dist/Status Deck.exe
desktop/dist/Status-Deck-windows.zip
ZIP 是分发文件,.app 和 .exe 才是实际运行产物。Windows 构建使用 -H=windowsgui,避免双击后额外弹出控制台窗口;脚本会把 ICO 嵌入 EXE,所以最终用户不需要携带一份单独图标文件。
当前 macOS 打包使用 ad-hoc 签名,适合开发和自测。真正对外分发仍需要 Developer ID 签名与 notarization;这一点不能用"已经打包成功"来掩盖。
12.1 自动构建不等于自动发布
仓库中已经有手动触发的 GitHub Actions:只允许在 main 分支运行,并行构建 macOS 和 Windows 产物。工作流会执行 go test ./...,之后上传两个 ZIP artifact。
它解决的是"我能否在干净环境中稳定得到两个平台的安装包";它没有替代代码签名、版本发布说明、安装器、自动更新或杀毒软件信誉建立。把这些阶段区分开,才能避免把一次 CI 构建误认为完整的软件发布体系。
13. 当前边界:哪些功能还没有
Status Deck 桌面端已经具备:
- macOS 菜单栏与 Windows 托盘常驻。
- BLE 扫描、连接、断线重连、心跳、通知订阅。
- CPU、GPU、内存、磁盘、电源的分域同步。
- Codex 本地额度读取与不可用状态表达。
- 重连后的强制初始全量同步。
- macOS / Windows 开机自启。
- 两个平台的打包脚本与 GitHub Actions 手动构建。
但它还没有:
- 多设备选择、记住指定设备、忘记设备。
- Git、Docker、语言进程、日程、服务器健康等数据 Provider。
- 图形化设置窗口;当前设置主要在托盘菜单中完成。
- macOS 正式公证签名和 Windows 代码签名。
- 完整的跨平台硬件矩阵测试。
这些不是缺点清单,而是下一阶段的边界。现在继续加"数据源"前,先保持 Provider -> statussync -> BLE -> 设备状态存储 这条链路不被破坏,后面接入 Git、Docker 或开发进程信息时才不会重新变成一团逻辑。
13.1 下一批 Provider 应怎样接入
例如未来增加"当前开发环境"页面,不应该在 tray/app.go 中扫描进程后直接 client.Send()。正确路径应该是:
internal/processinfo 或 internal/devactivity
-> 返回明确的数据结构和可用性状态
-> statussync 新增独立 TaskSchedule
-> 使用 process.update / environment.update
-> ESP32 新增对应状态存储与页面渲染
这个顺序看起来比"读到数据就发出去"多几层,但它把采集频率、错误语义、无线传输和显示状态拆开了。以后加入 Java、Go、Python、Node、Docker 等多种开发进程时,才不会让一个判断函数膨胀成无法维护的总控模块。
对于 Git 信息也一样。用户往往有多个本地仓库,Status Deck 不应该偷偷扫描整块硬盘后随便挑一个仓库显示。更合理的设计是先定义"当前工作上下文"来自哪里,例如 IDE 活动窗口、手动固定仓库或用户配置;确定语义后再写 Provider。
Demo成果展示
以下是 Status Deck 桌面端的实际运行效果展示。
PC桌面端上位机开发调试日志
ESP32下位机 0.96寸屏临时测试
以上是 Status Deck 在当前开发阶段的演示效果。系统功能仍在不断完善中,后续篇章将展开 ESP32 固件开发的详细讲解,涵盖硬件采购、电路连线、实物制作等完整流程,如果你对此感兴趣,请关注我一下,注意后续文章更新,硬件材料还在快递中