上一篇做了件狠事:把 area/寄存器/站号这些“协议味”彻底赶出框架,界面连协议都不认识,靠一份 JSON 配置就能驱动。当时结尾说下一篇去接第二个协议——但我先插一篇,因为这次分离结出了一个太顺手的果子,值得单独说:显示层可以做成“即插即用的构件”了。

本篇让一个仪表盘构件上线,并用一段配置拼出一块可弹出、可投大屏的看板。项目代号 talkplc,纯净室开发,只依据公开协议规范,个人时间与设备。

红利从哪来:数据是一份“按索引寻址的快照”

回顾一下上两篇搭出来的数据流:采集线程每一拍产出一份点位快照——一个按下标排列的数组,每个元素是 { 值, 有效, 状态 }。界面(或任何前端)只做一件事:按索引取值

关键就在这“按索引”三个字。它意味着:任何想显示数据的东西,只需要知道两件事——绑定哪个下标、拿到快照后取出那个值。 至于这个值是 Modbus 保持寄存器、还是以后的西门子 DB 块,显示端一概不关心。

1
2
3
4
5
6
7
采集线程 ──每拍──▶ 快照 tag[](按索引:值/有效/状态)

┌───────────┼───────────┬───────────┐
▼ ▼ ▼ ▼
点位表 仪表盘 (数字牌) (趋势曲线)
(已有) bind #2 bind #0 bind #1
└── 每个构件只认「索引 → 值」,零协议概念 ──┘

于是“加一种显示方式”这件事,成本被压到了极低。

构件的契约,只有一个方法

我给所有显示构件定了一个极小的基类。它的全部约定就是:绑定一个点位索引 + 每拍收到快照时自己去取值刷新。

1
2
3
4
5
6
7
8
9
10
/* 所有显示构件("构件")的基类。整个契约就这么大:
* 绑一个点位索引;每拍拿到快照,自己按索引取值重绘。
* —— 构件因此对协议/地址一无所知,只认「索引 + 值」。*/
class DataWidget : public QWidget {
public:
void setIndex(int i) { m_index = i; }
virtual void updateFromSnapshot(const tp_tag_t *snap, int n) = 0;
protected:
int m_index;
};

就这些。写一个新构件 = 继承它、实现 updateFromSnapshot 这一个方法。 趋势曲线、LED 状态灯、数字牌、可回写的开关……都从这里长出来,主程序一行不用改。

第一个构件:仪表盘

第一个落地的构件是圆弧仪表盘。它做的事很朴素:把值映射到一段弧上,中间显示数值+单位,下面写标签;点位无效或读失败就整体变红。

绑定与刷新加起来就几行——它只跟“索引”和“值”打交道:

1
2
3
4
5
6
7
8
void GaugeWidget::updateFromSnapshot(const tp_tag_t *snap, int n) {
if (m_index >= 0 && m_index < n) {
m_value = snap[m_index].value; // 按索引取值
m_valid = snap[m_index].valid;
m_ok = (snap[m_index].last_status == TP_OK);
}
update(); // 触发重绘那段弧
}

弧怎么画是纯 Qt 的绘图细节(一段 270° 的背景弧 + 一段按比例填充的前景弧),这里不展开。

看板 = 一堆构件 + 同一份快照

把若干构件摆进一个网格,就是一块看板。我把它做成独立的弹出窗口:可以多开、可以拖到第二块屏、可以直接甩到车间大屏上。主窗口什么都不用懂,只需每一拍把同一份快照转交给每个打开着的看板:

1
2
3
4
主窗口 onUiTick():
snap = 取快照()
点位表.刷新(snap)
for 每个打开的看板: 看板.updateFromSnapshot(snap) ← 同一份快照,扇出

看板本身也对协议一无所知,它只是个“把快照分发给旗下构件”的容器。

连布局都写进配置

既然界面已经配置化了,看板布局自然也该是数据而不是写死的代码。配置文件里加一段 dashboard 就行——声明要哪些构件、各自绑哪个点(按名字)、量程、单位:

1
2
3
4
5
6
7
8
"dashboard": {
"title": "水厂总览",
"widgets": [
{ "type": "gauge", "point": "level", "label": "液位", "unit": "", "min": 0, "max": 1000 },
{ "type": "gauge", "point": "flow", "label": "流量", "unit": "L/min", "min": 0, "max": 150 },
{ "type": "gauge", "point": "offset", "label": "偏差", "unit": "", "min": -100, "max": 100 }
]
}

界面按名字把构件绑到点位表里对应的下标,之后就靠这个下标从快照取值。换一块看板 = 换一段配置,代码一行不动。

跑起来

一段完整流程——主界面 →「加载配置」选中那份 JSON → 点位表按配置自动填好并连上 → 点「仪表板」弹出看板,三个仪表盘随采集值实时跳动,而这一切显示代码都不知道底下是什么协议:

talkplc 全流程演示:主界面加载 JSON 配置文件,点位表按配置填好并连接,再弹出仪表盘看板,三个仪表盘随采集值实时刷新

单独看弹出的那块看板——三个仪表盘的布局、绑定的点位、量程、单位全部来自配置文件

talkplc 数据看板:液位/流量/偏差三个仪表盘构件,布局与量程均来自配置

这一步换来什么

  • 加显示方式零成本:新构件只继承基类、实现一个方法,框架 / 协议 / 采集全都不动。
  • 看板即配置:要哪些表、绑哪个点、量程多少,全在 JSON 里;改看板不改代码,可整份下发。
  • 能上大屏:看板是独立窗口,可多开、可投车间监视屏。
  • 前端仍可换:构件只依赖“索引 + 值”这份纯 C 快照,所以 Qt 能画仪表盘,以后 LVGL 也能在嵌入式屏上画同样的构件——共用同一套内核。

顺着这条路,接下来能长出的构件很自然:趋势曲线(把历史值缓一段画折线)、LED / 状态灯(bool 点位)、数字牌(大字报警值)、开关按钮(绑可写点位,点一下回写)。每一个都只是 DataWidget 的一个子类。

接下来

显示这条线先探到这儿。下一篇把欠的债还上——回到协议:给框架接第二种协议(Modbus 从站,或者直接上西门子 S7),拿它真正异构的地址(DB1.DBD0 之类)狠狠拷问一下上一篇立起来的“零协议概念”接口。抽象立不立得住,接第二个协议时见真章。