Serial Studio 脚本宏(Macros)技术设计全解析:在 API 终端内运行 JS/Lua 自动化脚本
【免费下载链接】Serial-StudioOpen-source telemetry dashboard. Supports UART, BLE, MQTT, Modbus, CAN Bus and more.项目地址: https://gitcode.com/GitHub_Trending/se/Serial-Studio
本文基于 Serial Studio 仓库内的规格文档 0046-script-macros/plan.md(配套 spec.md 与 tasks.md)展开。该特性为 API 终端(Macros 窗口)引入"迷你 VBA"式脚本能力:用户可在内置代码编辑器中编写多行 JavaScript 或 Lua 宏,通过工具栏一键加载、保存、校验、执行与停止,脚本可调用与终端单条命令完全相同的完整 SDK/
apiCall命令面,且输出与错误实时汇入共享终端回滚区。读完本文,你将掌握该特性的需求来源、整体架构、JS/Lua 双执行路径、中断与看门狗机制、文件持久化约定以及验收标准,并能对照仓库源码定位每一处关键实现。
一、背景与动机:为什么需要"脚本宏"
该特性的直接前身是规格 0045 的 API 终端。0045 验证了"命令发现 + 执行"的闭环,但首次实机使用便暴露了它的天花板:
- 单行输入对真实命令无能为力——例如
workspace.addTile自动填充的 JSON 骨架比窗口还宽; - 每次 Enter 只能执行一条命令,无法表达任何用户逻辑(遍历数据集、检查响应、分支、组合调用);
- 应用虽然早已内嵌 JS 与 Lua SDK(
apiCall()及完整 SDK 被安装进每个脚本引擎),但用户只能在绑定项目生命周期的地方(帧解析器、数据变换、控制脚本、MQTT 发布器)触达它,缺少一种针对运行中实例编写并执行临时自动化的途径——也就是文档中所说的 "the VBA of Serial Studio"。
在此之前,自动化用户只能通过 TCP/gRPC 在进程外写脚本,每次调用都要付出连接建立、序列化和往返延迟的成本;而进程内宏可以在零额外开销的情况下使用同一命令面(见 spec.md 的 Problem/Motivation 一节)。
二、需求全景:Goals、Non-Goals 与 R1–R9
Goals(目标)
- 用户在 API 终端窗口内的真实代码编辑器中编写多行 JS/Lua 脚本(语法高亮、等宽字体),且可用完整 SDK/
apiCall命令面; - 工具栏驱动宏的完整生命周期:加载、保存、校验、执行、清空——脚本可在磁盘上往返,且不运行即可检查;
- 脚本输出(结果、
print/console.log、带行号的错误)与单命令输出一同进入终端回滚区; - 失控脚本(死循环)可中断,绝不能永久卡死应用;
- 单命令输入行行为完全不变——宏是新增能力,不是替代品。
Non-Goals(明确不做)
- 不做 QML 生成插件层(作为未来扩展预留,但本设计不得阻碍它,也不在此交付 QML 执行能力);
- 不做后台/定时/开机自启执行——宏只在用户按下执行时运行;
- 不新增任何 API 命令,不改变线上(wire)接口;
- 不替代项目级控制脚本、帧解析器、变换、MQTT 发布器脚本——宏是用户级、临时性的;
- 不做宏市场/分享格式,仅保留磁盘上的纯脚本文件。
需求 R1–R9 要点
| 编号 | 内容 |
|---|---|
| R1 | 终端窗口获得脚本编辑区(标签页或面板),承载带 JS 语法高亮的既有内嵌编辑器组件,语言可在 JS/Lua 间切换 |
| R2 | 脚本以既有引擎相同的 SDK/apiCall环境运行:所有注册命令可达、响应形状一致、Trusted 来源,分发层与 0045 单命令路径语义一致 |
| R3 | 工具栏动作:加载(文件对话框,纯.js/.lua)、保存/另存为、校验(不执行仅解析,错误带行号)、执行、清空;执行中禁用 Execute |
| R4 | 脚本输出进入终端回滚区:显式 print/log 调用、脚本最终值(若有)、带行号的运行时错误 |
| R5 | 运行中的宏可中断:UI 提供停止控件,且已有看门狗纪律约束挂起宏;中断/崩溃的宏绝不能让宿主崩溃或卡死 |
| R6 | 宏与终端单命令一样针对实时应用状态执行(同一事实线程、相同破坏性动词对等——宏只能做 socket API 能做的事) |
| R7 | 未保存的编辑器内容在同一会话内关闭/重开窗口后仍保留;加载/清空前提示丢弃未保存改动 |
| R8 | 与 0045 相同的构建可用性:GPL 构建中同样存在且可用 |
| R9 | 0045 表面(发现面板、文档、补全、单行输入、历史)保持完整、行为不变 |
三、整体架构:从计划到落地
plan.md 给出的 Approach 一句话概括为:API 终端的右侧面板变成"Terminal / Script"双标签栈。Script 标签页托管一个新的、与项目无关的内嵌代码编辑器DataModel::MacroEditor(ControlScriptEditor的兄弟组件,去掉 ProjectModel 耦合,带 JS/Lua 高亮切换),其上是小型窗口内工具栏(语言下拉 + 加载/保存/校验/运行/停止/清空)。执行则经由一个新的、可被 QML 实例化的DataModel::MacroRunner门面完成。
落地后的文件布局与 plan.md 的规划一致,只是实现时按维护者指示做了一次重命名(见 tasks.md 的 T5/T8 记录):窗口文件最终为 app/qml/Dialogs/Macros.qml,命令为app.macros,原先的app.apiTerminal命令与图标被移除,0045 的终端表面以该窗口的 Terminal 标签页形式延续。
三个新 C++ 类的实际落点如下:
| 类 | 实际位置 | 职责 |
|---|---|---|
DataModel::MacroWorker | core/Pipeline/DataModel/Scripting/MacroWorker.h /.cpp | 工作线程 JS 运行器 |
DataModel::MacroRunner | core/Pipeline/DataModel/Scripting/MacroRunner.h /.cpp | QML 可实例化门面:线程所有者、Lua 路径、校验、文件对话框 |
DataModel::MacroEditor | core/Ui/ProjectEditor/Editors/MacroEditor.h /.cpp | 项目无关的内嵌代码编辑器 |
这三个类通过 app/src/Misc/ModuleManager.cpp 的registerQmlTypes()以qmlRegisterType<DataModel::MacroEditor>("SerialStudio", 1, 0, "MacroEditor")、qmlRegisterType<DataModel::MacroRunner>(...)注册为 QML 类型(非单例,采用 TerminalBridge 模式)。plan.md 特别强调:除此之外再无其他改动——无 API handler、无 manifest、无生成面、无注册表条目(工具栏是窗口局部 QML,不属于 0028 规范的命令表面)。
四、双执行路径:JS 走工作线程,Lua v1 走 GUI 同步
4.1 Execute(JS):工作线程 + 每次全新引擎
QML 中的 Run 按钮 →MacroRunner.runJs(text)→ 通过队列连接(queued connection)把run(source)投递到工作线程 → 在全新的QJSEngine中执行(注入 SDK prelude 与ControlApiBridge的__ss_bridge)→ 顶层求值 →finished/scriptError/logMessage再经队列信号回传给 QML → 追加进回滚区。
关键实现细节(MacroWorker.h):
- 每个
apiCall通过BlockingQueuedConnection编组到 GUI 线程,进入ControlApiMarshaller::dispatch——与应用内其他一切路径共用同一条分发主干; print/console.log经由桥的 log 路径(prelude 映射)路由,因此宏运行期间输出就能持续流入回滚区;JsWatchdog在整个运行期间武装。
plan.md 还特意指出工作线程引擎的桥接纪律:worker 引擎只能拿到__ss_bridge。把直接辅助桥(__ss、__ss_db……)安装到工作线程引擎上是 scripting.md 中具名的线程缺陷,这里绝不发生。
4.2 Execute(Lua v1):GUI 同步 + MASKCOUNT 截止时间
MacroRunner.runLua(text)在 GUI 线程同步执行:新建lua_State,调用ScriptApiCall::installAll(GUI 线程上安装直接桥是正确的),并挂上LUA_MASKCOUNT+QDeadlineTimer钩子,以 30 秒的宽松预算作为防挂起边界。v1 文档明确将其记为阻塞式执行。
4.3 Stop:仅对 JS 宏有效
- Stop 仅在 JS 宏运行时启用;
MacroRunner.stop()→JsWatchdog立即截止时间武装 →JsWatchdogThread(20 ms 轮询)调用setInterrupted(true)——这是唯一允许调用该方法的文件;- 错误以中断消息形式呈现在回滚区。
这里的核心差异在于:ControlApiBridge只响应进程级关闭标志,因此 Stop 无法打断一个长delay();而宏专用的MacroApiBridge(MacroWorker.h)是**停止感知(stop-aware)**的变体——它的delay()、call()、writeAndWait()都会检查停止标志,这正是"VBA 式"宏(含延时、设备往返等待)在不冻结应用的前提下能被 Stop 打断的关键。
4.4 Verify:只编译、不执行
校验采用controlscript.dryRun模型的"一次性引擎、仅编译"模式:
- JS:在函数包裹下解析,或直接检查
QJSEngine::evaluate的错误(带行号); - Lua:
luaL_loadstring(只加载,绝不调用)。
规格已决议 Verify 为纯解析(parse-only),无任何副作用。
4.5 每次执行全新引擎
MacroWorker在每次运行后释放引擎(releaseEngine(),见 MacroWorker.h 声明)。崩溃或中断的运行不会留下任何状态,保证结果可复现。
五、编辑器实现:三个不变量与项目解耦
MacroEditor是ControlScriptEditor的兄弟组件,离屏QCodeEditor管道逐字复制,但彻底去掉了 ProjectModel/ProjectEditor 成员(宏与项目无关)。它提供纯text属性(get/set)、language属性(0 = JS,1 = Lua)切换高亮器、isModified/setModified,以及标准编辑槽(cut/copy/paste/undo/redo/selectAll/clear)。
plan.md 与 tasks.md(T3)强调必须保留宿主管道的三个已知受保护不变量:
renderWidget()首先调用syncWidgetPosition()(先同步位置再渲染);event()将ShortcutOverride转发给内嵌控件(因此无需在窗口层新增Shortcut,规避与 SmartDialog 已拥有的 Close 产生歧义);keyPressEvent在补全弹窗可见时重路由到completer()->popup()。
此外,输入处理器中直接调用renderWidget()(不能有 timer-tick 延迟)。
六、数据模型与持久化:纯文件,无项目 JSON
plan.md 的 Data model & persistence 一节非常明确:
- 无项目 JSON、无
Keys::、无数据库; - 宏文件是磁盘上的纯
.js/.lua文件; - 默认目录为
<AppDataLocation>/Macros/(首字母大写,与兄弟应用数据目录命名一致,首次使用时创建);文件对话框允许自由导航; - 未保存的编辑器文本会话内保留(QML 属性缓存),加载/清空前提示(R7)。
任务实施时进一步细化(见 tasks.md T6):由于 DialogLoader 在关闭时销毁条目,草稿实际存于app.macroDraft/app.macroDraftLanguage(main.qml),由窗口的onClosing写入、Component.onCompleted恢复。
文件对话框采用 C++QFileDialog(与既有编辑器importFile()一致),并遵守 macOS 重入规则:所有对话框后置工作通过 queued invoke 延迟出fileSelected。
七、线程与热路径影响
plan.md 的专项评估结论:
- 不触碰热路径:所有引擎工作均由用户发起,无逐帧工作,空闲的 Script 标签页零开销;宏的
apiCall与终端/控制脚本一样落在 GUI 线程,不新增帧路径接触; - 新增跨线程信号/槽:是,且被严格控制——QML→worker 的
run(queued)、worker→QML 的finished/scriptError/logMessage(queued)、apiCall编组(BlockingQueuedConnection,复用既有ControlApiMarshaller模式而非重新实现); - 线程加入规则:
MacroRunner析构函数中先quit()+wait()加入工作线程,再释放 worker 资源(driver thread-join 规则),并镜像 ControlScriptWorker 的requestShutdown式关闭守卫; - 无缓存热路径标志的新输入,时间戳所有权不动;
--benchmark-hotpath预期无变化(由 CI 门 AC10 确认)。
实际头文件印证了这一点:MacroRunner.h 中持有QThread* m_thread、MacroWorker* m_worker、ControlApiMarshaller* m_marshaller,并具名删除了拷贝/移动构造(= delete)。
八、设计权衡:为什么这样选
plan.md 用一张决策表总结了六项关键取舍:
| 决策点 | 备选方案 | 选择及理由 |
|---|---|---|
| JS 执行线程 | 工作线程(MacroWorker) vs GUI 同步 | 工作线程——GUI 事件循环被阻塞时 Stop 无从谈起;"VBA"式宏(延时、设备往返)绝不能冻结应用;marshaller/桥已存在 |
| Lua v1 线程 | GUI 同步 + MASKCOUNT 截止 vs 工作线程 + 新 Lua 编组 | GUI 同步——Lua 工作线程需要在强制展开(forced-unwind)表面铺设新的离线程桥接管道;规格决议 Lua v1 为有界阻塞 |
| Worker 复用 | 新建MacroWorker兄弟 vs 复用ControlScriptWorker | 兄弟——控制脚本按连接强制重启的生命周期与 setup()/loop() 拆分与一次性宏相抵触;可共享部分(marshaller、桥、看门狗)本就是类而非 worker |
| 编辑器 | 新建MacroEditorvs 复用ControlScriptEditor | 新兄弟——ControlScriptEditor 读写 ProjectModel,宏与项目无关;管道复制、耦合剔除、高亮器可切换 |
| 输出路由 | 共享回滚区 + 自动切标签 vs 每标签独立输出视图 | 共享回滚区(规格 R4)——命令与宏共用一个输出时间线;自动切换保证可见性且不复制视图 |
| 加载/保存对话框 | MacroRunner 内 C++ QFileDialog vs QML FileDialog | C++——与既有编辑器importFile()一致,macOSfileSelected延迟逻辑集中在单一受审地点 |
九、风险与缓解
plan.md 明确列出的风险清单及对策:
- 离线程桥误用(scripting.md 具名线程缺陷):worker 引擎只装
__ss_bridge,任务中设审查检查点; - 看门狗 lint:
setInterrupted(true)只保留在JsWatchdogThread.cpp,停止被表达为立即截止时间的arm(); - 编辑器管道回归:三个不变量从
ControlScriptEditor逐字复制,任务在编辑期点名它们; - 阻塞队列死锁(worker 阻塞于 GUI 而 GUI 等待 worker):GUI 线程绝不等待 worker;停止是异步的,join 仅发生在析构(并带
requestShutdown式守卫用于应用关闭); - 宏关闭自身窗口:worker 只存活到
MacroRunner析构 join;信号为队列投递,迟到信号命中已销毁接收者时由 auto-disconnect 安全处理; - Lua GUI 冻结:由 MASKCOUNT 截止时间限制并文档化;JS 是推荐语言且为默认项;
- macOS 文件对话框重入:所有对话框后置工作经 queued invoke 延迟。
T9 的 6 代理审查还修复了若干细节:堆上 QThread + warn-and-abandon、7 秒 join 预算(大于 5 秒看门狗)、闩锁式 teardown 标志、GUI 侧停止复位、停止门控的看门狗重武装;Lua try 块加宽以涵盖__tostring/setup 恐慌;保存改用QSaveFile;verify()设为 const;SerialStudio::ScriptLanguage锚定等。
十、测试与验收:AC1–AC10
plan.md 的测试计划将验证分为四层:
- 单元测试:无——
tests/scripts/解析器无改动; - 集成测试(维护者运行):无新增——既有 API 集成套件仍是分发路径之网;
- 应用内(维护者):AC1(JS 宏循环 + print)、AC2(Lua 变体)、AC3(校验语法错误 + 行号、拒绝运行)、AC4(保存/清空/加载往返)、AC5(
while(true){}经 Stop/看门狗停止、应用此后健康)、AC6(抛异常宏,后续运行正常)、AC7(0045 回归)、AC8(GPL 构建); - 静态检查(AC9):
code-verify.py --check覆盖所有新/改文件;registry-verify.py(应为 no-op——无注册表面);sanitize 管道;交接前对新 C++ 做qt-cpp-review; - 热路径(AC10):CI
--benchmark-hotpath门禁,预期无变化。
tasks.md 的 T1–T9 将上述拆为可独立验证的顺序任务,且全部标记完成,spec.md 状态已置为done。
十一、实战示例:窗口内置的 Hello World 宏
打开 Macros 窗口后,编辑器会按当前语言自动填充一个"Hello world"起始宏(定义于 app/qml/Dialogs/Macros.qml)。JS 版本如下,它演示了宏的核心使用模式:
// 调用 api.getCommands const reply = apiCall("api.getCommands") if (!reply.ok) throw new Error(reply.error) // 获取命令列表 const commands = reply.result.commands console.log("Hello from Serial Studio! " + commands.length + " commands available.\n") // 打印前几条命令 for (let i = 0; i < Math.min(5, commands.length); ++i) { const cmd = commands[i] console.log(" " + cmd.name + " - " + cmd.description + "\n") }对应的 Lua 版本结构完全平行:
local reply = apiCall("api.getCommands") if not reply.ok then error(reply.error) end local commands = reply.result.commands print("Hello from Serial Studio! " .. #commands .. " commands available.\n") for i = 1, math.min(5, #commands) do local cmd = commands[i] print(" " .. cmd.name .. " - " .. cmd.description .. "\n") end两个版本都展示了三条要点:所有 API 命令经apiCall(method, params)可达;响应永远是{ ok: true, result: ... }或{ ok: false, error: "..." }的统一对象/表;SDK 包装器已预加载,因此api.getCommands()等价于上面的apiCall。点击播放按钮后,输出会显示在 Terminal 标签页中(Execute 自动切换标签,且先回显一行> [macro] run (js))。
十二、总结
0046 脚本宏特性把 Serial Studio 从"逐条命令的终端"升级为"可编程的自动化工作台":JS 宏经工作线程运行、每次全新引擎、停止感知的MacroApiBridge保证可中断性;Lua v1 以 GUI 同步 + MASKCOUNT 截止时间提供有界阻塞的执行;Verify 只编译不执行;输出统一汇入共享回滚区。整个过程复用既有 marshaller/看门狗/编辑器管道,不新增任何 API 命令,也不触碰热路径——用 plan.md 的话说,是"以既有脚本引擎纪律复用为前提,不引入新的引擎架构"。
如果你想深入源码,推荐按此顺序阅读:先看 plan.md 与 spec.md 建立全貌,再读 MacroWorker.h(停止感知桥与 worker 生命周期)、MacroRunner.h(门面与信号面)、MacroEditor.h(编辑器不变量),最后对照 Macros.qml 查看 QML 侧的标签页、工具栏与草稿缓存接线。
【免费下载链接】Serial-StudioOpen-source telemetry dashboard. Supports UART, BLE, MQTT, Modbus, CAN Bus and more.项目地址: https://gitcode.com/GitHub_Trending/se/Serial-Studio
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考