点表与采集
点表就是把设备里的寄存器、线圈、MC 软元件、S7 DB/M/I/Q 区、FINS 内存区、Host Link 字区、MEWTOCOL 数据区/接点区、EtherNet/IP 标签、DL/T 645 数据项或 IEC104 IOA,命名成业务能理解的点。比如把 Modbus 地址 0、S7 DB1.DBD0、CIP 标签 Temperature、DL/T 645 数据项 0x00000000 或 IEC104 IOA 100 命名为 temperature。
采集循环就是 Zeus 按固定间隔帮你读取这些点。你不需要自己写 while true、Task.Delay、重试和界面刷新。
先看完整例子
var memory = new ModbusSlaveMemory();
memory.HoldingRegisters[0] = 185;
memory.HoldingRegisters[1] = 200;
await using var app = ZeusHost.Create(builder =>
{
builder.AddAcquisition(TimeSpan.FromMilliseconds(500));
builder.AddVirtualChannel("bus", new ModbusSlaveResponder(1, ModbusTransport.Rtu, memory));
builder.AddModbusRtu("oven", "bus", unitId: 1, points: map =>
{
map.HoldingRegister("temperature", 0, 0.1, new PointAlarmLimits(high: 80));
map.HoldingRegister("setpoint", 1, 0.1).Writable("setpoint");
map.Coil("heater", 2).Writable("heater");
});
});
app.Points.BatchChanged += (_, e) =>
{
Console.WriteLine($"changed points: {e.Changes.Count}");
};
await app.StartAsync();
var temperature = app.Points.Get<double>("temperature");
按点名写回
点表不只是采集结果的公告栏。设定值、开关这类操作点声明为可写后,界面只需要点名和工程值:
await app.Points.WriteAsync("setpoint", 80.0);
await app.Points.WriteAsync("heater", true);
框架会找到所属设备、按 scale 反算寄存器值、发出 Modbus 写请求,并立刻更新点表。写失败时该点的 Error 会带上原因,同时仍抛出异常,方便按钮逻辑提示操作员。
| 点类型 | 能否写回 | 说明 |
|---|---|---|
| 保持寄存器 | 可以,需 .Writable(...) 或 JSON writable: true | 有 scale 时传入工程值 |
| 线圈 | 可以,需显式标为可写 | 传入 true / false |
| 输入寄存器、离散输入、S7 I 区 | 不可以 | 协议或数据区只读 |
使用 raw => ... 自定义换算、未提供 scale | 不可以 | 框架无法可靠反算 |
S7、FINS、Host Link、MEWTOCOL、EtherNet/IP、DL/T 645 和 IEC104 点同样支持点名写回。S7 的 DB/M/Q 区、FINS / Host Link 可写内存区、MEWTOCOL DT/LD/FL/Y/R/L 区、EtherNet/IP 标签、DL/T 645 数据项或 IEC104 命令/设点声明 .Writable(...) 或 JSON writable: true 后,可以通过同一套 app.Points.WriteAsync(...) 下发;S7 I 区和 MEWTOCOL X 输入区保持只读。
未标为可写的点调用 WriteAsync 会立刻失败,不会发到总线上。自定义设备实现 IPointWriter 后,同一套点名写回即可复用。
这段代码怎么理解
| 代码 | 含义 |
|---|---|
AddAcquisition(500ms) | 每 500 ms 采集一轮 |
HoldingRegister("temperature", 0, 0.1, new PointAlarmLimits(high: 80)) | 地址 0 的保持寄存器叫 temperature,按工程值判断高报 |
HoldingRegister("setpoint", 1, 0.1).Writable("setpoint") | 设定值可按点名写回,80.0 会反算成寄存器 800 |
Coil("heater", 2).Writable("heater") | 地址 2 的线圈可按点名开关 |
app.Points.Changed | 每个变化的点触发一次;调试单点、只关心某一个点时用 |
app.Points.BatchChanged | 一轮采集或一批发布结束后触发一次;整表刷新、总貌、趋势落盘时用 |
app.Points.Get<double>("temperature") | 读取当前最新温度;点不存在或尚无值时抛出 |
app.Points.TryGet("temperature", out var snapshot) | 安全读取快照,未登记或短名冲突时返回 false |
app.Points.TryGetDouble("temperature", out var value) | 把带 scale 的工程值、整数寄存器等读成 double |
app.Points.TryGet<T>("temperature", out var value) | 按期望类型读取;不存在、尚无值或类型不匹配时返回 false |
app.Points.Subscribe("temperature", handler) | 只订一个点的 Changed,释放返回值即退订 |
为什么不要自己写循环
你当然可以自己写:
while (true)
{
var values = await oven.ReadHoldingRegistersAsync(0, 2);
await Task.Delay(500);
}
但实际项目很快会遇到:
- 多个点地址连续,应该合并读取。
- 某轮读取失败,界面还要保留上一次成功值。
- 多个设备需要统一间隔。
- UI 要订阅变化,而不是到处主动拉取。
Zeus 的点表采集就是为这些场景准备的。
点名怎么查
如果整个宿主里只有一个 temperature,可以直接写:
var value = app.Points.Get<double>("temperature");
如果多台设备都有 temperature,使用完整名:
var ovenTemperature = app.Points.Get<double>("oven.temperature");
var tankTemperature = app.Points.Get<double>("tank.temperature");
短名规则固定为:短名在全宿主唯一时可用;一旦冲突,Get / Get<T> 必须使用 设备.点 限定名。TryGet / TryGet<T> / TryGetDouble 对冲突或不存在返回 false,不会抛。
ZeusHost.Create 完成后点定义已经进入点表,所以可以先构造 ViewModel 和 DataGrid。此时快照的 Value、UpdatedAt 为空;StartAsync 只负责打开通道并开始轮询。
每次成功采样都会刷新快照的 UpdatedAt;协议层提供源侧时间时,SourceTimestamp 会保留原始采样时间,否则通常等于本地接收时间。值没有变化时,点表仍会更新这两个时间戳,但不会触发逐点 Changed,避免把稳定轮询放大成界面闪烁。需要判断“数据是否还在刷新”时读取快照时间;需要判断“值或错误是否变化”时订 Changed。
报警限
数值点可以配置低报或高报阈值。快照的 AlarmState 会返回 Disabled、Unknown、Normal、Low 或 High,IsAlarmed 会在低报或高报时返回 true。
map.HoldingRegister("temperature", 0, 0.1, new PointAlarmLimits(low: 10, high: 80));
var snapshot = app.Points.Get("temperature");
if (snapshot.IsAlarmed)
{
Console.WriteLine($"{snapshot.QualifiedName} 报警:{snapshot.AlarmState}");
}
报警限按最终写入点表的值判断。上例中原始寄存器 850 会先换算成 85.0,再触发高报。
一组同类点请各自声明,用稳定点名区分,例如 cellVoltage01 到 cellVoltage16。读取时用短名或 设备.点 限定名;需要一次处理多个点时订 BatchChanged,按 e.Changes 或 app.Points.All 筛选。
尚无有效值怎么办
StartAsync 返回后,第一轮采集可能还在进行。稳妥写法是订阅批次事件:
app.Points.BatchChanged += (_, e) =>
{
foreach (var change in e.Changes)
{
var snapshot = change.Current;
Console.WriteLine($"{snapshot.QualifiedName} = {snapshot.Value}");
}
};
调试单点变化时再订阅 Changed:
app.Points.Changed += (_, e) =>
{
if (e.Current.Error is null)
{
Console.WriteLine($"{e.Current.QualifiedName} = {e.Current.Value}");
}
else
{
Console.WriteLine($"{e.Current.QualifiedName} 采集失败:{e.Current.Error}");
}
};
或者使用 TryGet:
if (app.Points.TryGet<double>("temperature", out var value))
{
Console.WriteLine(value);
}
订阅变化:逐点还是整批
点表本身与界面无关。控制台、服务、WinForms、WPF 都只面对 IPointTable 上的两个事件:
Changed:每个变化的点触发一次,可能在采集线程上发出。BatchChanged:一轮采集(或一次显式批次)结束时触发一次,带着本轮全部变化。
这里的“变化”指值、错误、质量或报警状态等快照内容变化。值没变但采样成功时,UpdatedAt / SourceTimestamp 会刷新,Changed 不会触发;如果这一轮没有任何变化,BatchChanged 的 e.Changes 可能为空。运行中新增或移除设备时,点定义会进入或离开 app.Points.All,也会触发一次 BatchChanged 让整表绑定源重新读取结构。
省的不是总线时间,是下游被点名打断的次数。高频、多点时,先订 BatchChanged,再在回调里自己更新表格、总貌或落盘。不要给一张大表订八十次 Changed。
| 事件 | 适合 | 不适合 |
|---|---|---|
Changed | 只关心某一个点、调试单点、写回后看那一个点 | 整表刷新、总貌、趋势落盘 |
BatchChanged | 一轮结束刷一组点、写库、写文件、刷大表 | 只读三五个点(自己过滤 e.Changes 即可,不必再订逐点) |
怎么选:
- 只读几个业务点:订
Changed,或直接Get/TryGet。 - 一轮要处理很多点:订
BatchChanged,按e.Changes处理。不要在每个Changed里清空重加。 - 又要大表、又要两三个关键点:整表走
BatchChanged;那两三个点仍可订Changed,或从同一批e.Changes里挑出来。 - 写回后的待编辑值:不要和整表快照共用一份可变文本。采集刷新不应盖掉操作员正在输入的内容。
高频首先该调的是采集间隔和一次处理的点数,不是先换 API。间隔已经到 100 ms、点又多,再按批处理才有意义。
桌面项目若使用 Zeus.Presentation.*,可以把这两个事件接到现成辅助方法上:几个 Label 用 BindText / AsBindingSource("temperature")(内部订 Changed);DataGrid 用 AsTableBindingSource(内部订 BatchChanged)。那是展示层适配,不是点表内核。控制台和服务不要引用它们,直接订事件即可。AsTableBindingSource 每轮会拷贝整表并对 All 打一次 PropertyChanged;点很少时往往比改几个 Label 更重。真要更高频、更大表,在 BatchChanged 里按 e.Changes 打补丁。
接到桌面界面
WinForms 直接绑定控件:
app.Points.BindText("temperature", temperatureLabel, value => $"{value:F1} ℃");
WPF 通过 ViewModel 暴露绑定源,XAML 绑定属性:
var ui = app.Bind(WpfUiDispatcher.Current());
Temperature = ui.Point("temperature", value => $"{value:F1} ℃");
<TextBlock Text="{Binding Temperature.Text}" />
这样界面只关心显示格式,不需要关心采集线程和设备协议。这些 API 在 Zeus.Presentation.*,不是点表内核。
桌面大表可以用展示层的整表投影,避免在每个 Changed 里手动 Clear/Add:
Points = app.Points.AsTableBindingSource(WpfUiDispatcher.Current());
控制台、服务和自绘界面不要走这条路径,直接订 BatchChanged。
趋势图、报表或长期审计数据由业务层自己保存。多点或整表请订阅 BatchChanged,在一轮采集完成后自行追加到数据库、文件或图表序列:
app.Points.BatchChanged += (_, e) =>
{
foreach (var change in e.Changes)
{
var snapshot = change.Current;
// 追加到数据库、文件或图表序列。
}
};
报警队列
配置了 lowAlarmLimit / highAlarmLimit 的点越限后,会进入 app.Alarms。同一点同时只保留一条未复归记录;值回到正常范围后自动复归。
foreach (var alarm in app.Alarms.Active)
{
Console.WriteLine($"{alarm.QualifiedName} {alarm.AlarmState} {alarm.Status} {alarm.Severity}");
}
app.Alarms.AcknowledgePoint("temperature", "operator");
app.Alarms.AcknowledgeAll("operator");
PointAlarmRecord 带限定名、短名、设备名、严重等级、区域和责任人。列表按 QualifiedName、Severity、Area 显示即可。
操作员队列支持确认、指派、搁置和抑制。搁置到期后,如果点仍然越限,会自动回到活动队列并触发 Changed;抑制期间不会生成新的活动报警,解除抑制时如果点当前仍越限,会立即产生活动报警,不必等下一轮采集。
var alarm = app.Alarms.Active[0];
app.Alarms.Acknowledge(alarm.Id, "operator");
app.Alarms.Shelve(alarm.Id, DateTimeOffset.Now.AddMinutes(10));
app.Alarms.Unshelve(alarm.Id);
app.Alarms.Suppress("temperature");
app.Alarms.Unsuppress("temperature");
WinForms 可以把活动报警推到列表控件:
app.Alarms.BindAlarms(this, records =>
{
AlarmList.Items.Clear();
foreach (var alarm in records)
{
AlarmList.Items.Add($"{alarm.PointName} {alarm.AlarmState}");
}
});
WPF 中把报警队列放进 ViewModel:
Alarms = app.Alarms.AsAlarmBindingSource(WpfUiDispatcher.Current());
采集优化
Zeus 会尽量把同一设备上连续的地址合并读取。
| 点声明 | 读取效果 |
|---|---|
| 地址 0、1、2 连续 | 可以合并成一次读取 |
| 地址 0、100 分散 | 通常需要分开读取 |
| 不同设备 | 分别按设备读取 |
总线较慢时,优先让常用点地址连续,并适当加大采集间隔。
运行中增删设备
app.AddModbusRtu("dryer", "bus", points: map => map.HoldingRegister("humidity", 1));
await app.RemoveDeviceAsync("dryer");
新设备会立即登记点定义,并在下一轮纳入采集;卸载后点表会摘除该设备的点。宿主停止后再启动时,若 PollImmediately 为 true,会立刻再采一轮。
点定义新增或摘除时,整表绑定源会收到 BatchChanged 并重新读取 All。如果你自己维护 DataGrid 或点位清单,也应在 BatchChanged 中处理 e.Changes.Count == 0 的结构刷新场景。
常见问题
| 现象 | 原因 | 处理 |
|---|---|---|
尚无有效值 | 第一轮采集还没成功 | 订阅 Changed,或稍后再 Get |
| 点名冲突 | 多台设备有同名点 | 使用 设备名.点名 |
| 界面值不更新 | 没声明点,或采集循环没启用 | 确认 AddAcquisition 和 points 都写了 |
| DataGrid 闪烁或 UI 卡 | 每个 Changed 都刷新整表 | 整表订 BatchChanged;桌面可用 AsTableBindingSource。几个 Label 仍订 Changed |
| 某轮失败但界面还有旧值 | Zeus 保留上一次成功值 | 查看点快照里的 Error |
| 总线被采集打满 | 间隔太短或地址太分散 | 加大间隔,合并连续地址 |
是只读点,不能写回 | 声明时没标可写,或该数据区本身只读 | 对保持寄存器/线圈调用 .Writable,或在 JSON 里写 writable: true |
| 写回后值差 10 倍 | 传入了原始寄存器值,而点声明了 scale | WriteAsync 要传工程值,例如 80.0 而不是 800 |
下一步:把点表写进文件看 JSON 配置,S7 地址示例看 Siemens S7,CIP 标签示例看 EtherNet/IP,电表数据项示例看 DL/T 645,远动 IOA 示例看 IEC104。