跳到主要内容

点表与采集

点表就是把设备里的寄存器、线圈、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 倍传入了原始寄存器值,而点声明了 scaleWriteAsync 要传工程值,例如 80.0 而不是 800

下一步:把点表写进文件看 JSON 配置,S7 地址示例看 Siemens S7,CIP 标签示例看 EtherNet/IP,电表数据项示例看 DL/T 645,远动 IOA 示例看 IEC104。