第四篇接第二种协议时立过一个标准:新协议进来,框架和界面一行不改。S7 通过了考试。但界面本身一直是块心病——协议树、点表、收发帧监视,全是写死在 MainWindow 里的。这篇把界面也变成数据:原来是我写一个监控软件,现在监控软件的页面由组态软件拼出来。

项目代号 talkplc,个人时间与设备,自研实现,不碰任何厂商代码。

起点:一份”代码即界面”的监视器

talkplc 的监视窗口长这样:左边协议树,右边串口参数、八列点表、收发帧监视,顶上一颗连接状态灯。功能没毛病,但每加一个显示需求都要改 C++ 重新编译。界面是代码,不是数据。

组态软件(WinCC、组态王、Ignition 这类)的路子正好相反:界面是一份配置文件,运行器加载它、按配置采集和显示。好处人人都知道。但真下手做的时候,问题一个一个冒出来。

设计器画的东西,运行器怎么认?这需要一个双方都认的东西,也就是契约。构件(数值、仪表、按钮这些)怎么加才不用改一堆地方?这个问题协议层已经答过一次:注册表。还有表格、设备列表这种”长得像表”的东西,是一个一个写,还是有别的办法?

第一步:契约先行,别管界面

设计器和运行器是两个角色,工控惯例是组态环境和运行环境分开。但它们之间只需要一个东西:工程文件。所以第一刀切在数据上。

1
2
3
4
工程 project = connections[] + screens[]
connections[i] 一个 tp_conn_config + 一个 id(协议/链路/点表,全是已有的)
screens[i] 画布尺寸 + widgets[](构件数组)
widgets[j] type + rect + 绑定(conn id + 点名) + 各自属性

构件绑定用名字(连接 id 加点名)而不是索引,稳定、可读,手改 JSON 也不容易错,运行时再解析成索引。旧的单连接配置文件自动视作一个 id=default 的连接,向前兼容白送。

有了契约,程序形态反而是小事。一个 exe 两个模式,--design 进设计器(产出工程),--screen 进运行器(消费工程)。设计器只产不跑,设计态不连设备,构件画占位;运行器只跑不编辑。将来要拆成两个程序,或者把运行器下沉到嵌入式(LVGL),契约一个字不用改。

内核照旧零改。运行器核心 tp_runtime 是个纯 C 引擎,吃一份工程,每连接自动开一个会话,每拍把值递给渲染层。Qt 只是它的一个前端。

构件自己报属性,属性面板谁也不认识

设计器里最容易被低估的是属性面板。我的做法是让每个构件自己声明有哪些属性(绑定、量程、颜色、动作),面板只负责把声明渲染成编辑行、把编辑写回去:

1
2
3
4
5
6
7
QList<WidgetProp> GaugeItem::typeProps() const {
QList<WidgetProp> L;
L << wpMake("unit", "单位", WP_Text)
<< wpMake("min", "最小", WP_Real)
<< wpMake("max", "最大", WP_Real);
return L;
}

面板是通用的,加构件不用改面板。这句话后来被我自己打脸了一次。画面跳转按钮有个 bug:属性面板显示”目标画面:画面1”,存盘后却是空的,跳转永远没反应。查下来是两个不起眼的事凑在一起——下拉框显示着第一项,但这个值从没被写进构件。你看到的,和你存下来的,不是一回事。修法是把”所见即所存”定成面板的硬规则:值为空时,把显示着的默认项真正提交进去。

这个 bug 比功能本身值钱。通用 UI 的坑,往往就藏在显示和数据的缝隙里。

加构件,和加协议一个待遇

前四篇最得意的设计是协议注册表 tp_registry:名字到工厂,加协议等于加一行,框架核心零改。那构件凭什么例外?

1
2
3
4
5
6
7
8
9
10
11
// gui/screen/items/TableItem.cpp 文件尾
static ScreenItem *makeTable(int s, int w, const tp_screen_widget_t &d)
{ return new TableItem(s, w, d); }
namespace {
struct TableRegistrar {
TableRegistrar() {
ItemInfo ii = { "table", "表格", 70, 360, 260, paintTableIcon, makeTable };
itemRegistryAdd(ii);
}
} tableReg_;
}

每个构件一个自包含文件,文件尾一个静态注册器把自己登记进 ItemRegistry,登记的内容是类型名、调色板标签、默认尺寸、图标、工厂。设计器的调色板和工厂只读注册表。工厂 switch、调色板数组、图标 if/else,这些我在第一版里亲手写过的东西,全都不存在了。

加一个构件 = 加一个文件 + CMake 一行。和加协议一个待遇。

一段弯路:专门构件的坑

接下来我犯了这篇里最大的错,值得原样记下来。

做设备管理页(左边设备列表,右边选中设备的点表和参数)的时候,我按老习惯写了三个专门构件类:TableItem(点表,四列写死)、DeviceListItem(设备列表)、DevParamsItem(参数卡带编辑按钮)。每个都要新类、工厂分支、调色板注册、图标、默认尺寸。注册表明明已经把成本降到”加一个文件”了,我还在往一个文件里堆类。手上有了新工具,脑袋还在旧习惯里。

想通之后全部推倒。点表、设备列表、参数卡,本质是同一种东西,一张表,只是数据源不同。于是通用表格有了 source 属性:conn 是整连接点表,可以子集选点;devices 是设备清单,行点击可以选中设备;devattr 是选中设备的属性卡。列用 columns 配,行点击动作用 row_action 配。参数卡上那个”编辑参数”按钮,就是普通按钮构件的一个动作(action=params)。

1
2
{ "type": "table", "source": "devices", "columns": "dot,id,proto",
"row_action": "select", "rect": [40, 96, 300, 660] }

上面这七行,就是原来 DeviceListItem 那 150 行类的全部。后来我给自己定了个规矩:再遇到表状的东西,先想能不能用通用表格配出来,能配就不写类。

@:让右侧跟着左侧走

master-detail 还差一口气:左边点了一台设备,右边的点表、仪表怎么跟着换?

给构件绑定加一个特殊值,conn="@",意思是”跟随运行器当前选中的设备”。选中状态放在 tp_runtime 里,绑定解析每次即时求值,@ 解析成选中的连接 id。任何构件绑上 @ 就自动联动:跨设备同名点位(比如都叫 temp)无缝跟随,不同名的显示无效。

参数编辑也顺下来了。画面上一个”编辑参数”按钮(绑 @),点了弹出该设备当前值预填的表单。表单字段来自协议清单 protocols.json,每种协议声明自己有哪些可编辑参数,又是一层配置。确定之后改配置、断开重连、自动存回工程 JSON。运行中的监视页面可以改波特率、换 IP,改完状态灯闪一下”连接中”就回来了。

验收:把监视器拼回来

说再多,验收标准就一条:原来写死的那个监视页面,现在能用设计器拼出来。

用组态拼出来的监视页面:标题、点表构件实时值、输入框写入 777 后点表与液位仪表立刻跟随

标题是文本构件,点表是通用表格绑连接,写入行是输入框绑点位,仪表盘是三个 gauge。原来几百行 C++ 的界面,现在是四十来行 JSON。点表里每一行的值和状态是活的,仪表的指针在动。

再往上叠一层,就是完整的设备管理页:

设备管理画面:左侧点选设备,右侧点表、仪表、参数卡全部跟着切换,参数对话框当前值预填

三台设备三种协议(S7、Modbus RTU、Omron NJ,全部跑内置仿真从站),点左边的行,右边整块跟着换;参数卡上改个采集周期,重连生效,存盘留痕。而它本身,也是设计器里拖出来的:

组态设计器:注册表驱动的构件调色板、画面树、设备树、画布

没有硬件怎么验证

老规矩,sim 链路:三台仿真从站一起跑,画面上的值就是活的。纯 C 的契约层(工程读写、绑定解析、选中、重连)在 ctest 里跑,一遍全绿;GUI 层人工过——画面来回切几轮看跳转,设备列表点几行看右侧跟不跟,参数改个采集周期看状态灯闪一下”连接中”再回来。没有 PLC 也能把整条画面逻辑过一遍,这套方法从第一篇用到现在。

接下来

四篇立住了数据面,协议是外挂的;这一篇立住了表达面。工程 JSON 是设计器和运行器之间唯一的东西;构件自描述加注册表,加构件和加协议一样便宜;表状界面是配置不是类型,这条弯路比任何功能都值钱;@ 一个字符,master-detail 免费送。

后面排得上号的:趋势曲线(window_s 字段从第一版起就躺在契约里等着被用)、设计器里一键生成设备页、把纯 C 运行器搬到 LVGL 上。契约不变,换个前端而已。

每加一层都回来对照一次:改动越小,说明当初的抽象越对。