Skip to main content

Points and Acquisition

The point table names protocol addresses as business values. A Modbus register, S7 DB address, FINS memory address, Host Link word, EtherNet/IP tag, DL/T 645 data item, or IEC104 IOA can become a point such as temperature.

The acquisition loop polls those points on an interval, so you do not need to write your own while true, Task.Delay, retry, and UI refresh code.

Complete Example​

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.Changed += (_, e) =>
{
Console.WriteLine($"{e.Current.QualifiedName} = {e.Current.Value}");
};

await app.StartAsync();
var temperature = app.Points.Get<double>("temperature");

Write by Point Name​

Writable points can be operated by business name and engineering value:

await app.Points.WriteAsync("setpoint", 80.0);
await app.Points.WriteAsync("heater", true);

Zeus finds the owning device, reverses scale when needed, sends the protocol write, and updates the point table. If the write fails, the point snapshot gets an Error and the exception is still thrown to the caller.

Point typeWritableNotes
Holding registerYes, after .Writable(...) or JSON writable: truescale means callers pass engineering values
CoilYes, after explicit writable markerPass true / false
Input register, discrete input, S7 input areaNoProtocol/data area is read-only
Custom conversion without reversible scaleNoZeus cannot reliably invert it

S7, FINS, Host Link, EtherNet/IP, DL/T 645, and IEC104 use the same app.Points.WriteAsync(...) model for writable areas, tags, data items, commands, or setpoints.

Why Not Write Your Own Loop​

A manual loop works at first:

while (true)
{
var values = await oven.ReadHoldingRegistersAsync(0, 2);
await Task.Delay(500);
}

Real apps quickly need merged consecutive reads, stale-value retention after failures, multiple devices, UI change notifications, alarms, and write-back. The point table centralizes those behaviors.

Names​

Use the short name if it is unique:

var value = app.Points.Get<double>("temperature");

Use the qualified name when multiple devices expose the same point:

var ovenTemperature = app.Points.Get<double>("oven.temperature");

Every successful sample refreshes UpdatedAt. When the protocol layer supplies a source-side timestamp, SourceTimestamp keeps that original sample time; otherwise it usually matches the local receive time. A successful poll with the same value refreshes the timestamps but does not raise per-point Changed. Use the snapshot time fields to decide whether data is still refreshing, and use Changed to react to value, error, quality, or alarm-state changes.

Alarm Limits​

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} alarm: {snapshot.AlarmState}");
}

Alarm limits are evaluated after scaling.

Subscribe To Changes: Per Point Or Per Batch​

The point table itself is UI-agnostic. Console apps, services, WinForms, and WPF all use the same two events on IPointTable:

  • Changed: raised once per changed point, possibly on the acquisition thread.
  • BatchChanged: raised once at the end of a poll (or an explicit batch), with every change from that round.

Here, "changed" means the value, error, quality, alarm state, or similar point state changed. A poll that reads the same value successfully still refreshes UpdatedAt / SourceTimestamp, but Changed remains quiet. In no-change rounds, BatchChanged may contain an empty Changes list. Runtime device or point additions and removals also raise BatchChanged with no point changes so table projections can reload app.Points.All.

Batching does not make the bus faster; it reduces how often downstream code is interrupted by point names. With many points at a high rate, subscribe to BatchChanged first, then update a table, overview, or store from the callback. Do not subscribe to Changed eighty times for one table.

EventUse whenAvoid when
ChangedOne point, debugging a single value, checking a point after write-backRefreshing a whole table, overview, or trend store
BatchChangedUpdating a group of points, writing a database or file, refreshing a large tableReading only three or five points (filter e.Changes instead of also subscribing per point)

How to choose:

  • A few process values: subscribe to Changed, or just call Get / TryGet.
  • Many points in one poll: subscribe to BatchChanged and walk e.Changes. Do not Clear/Add on every Changed.
  • A large table plus two or three key values: batch the table; those two or three points may still use Changed, or you can pick them out of the same e.Changes.
  • In-progress write-back text: do not share one mutable string with the whole-table snapshot. A poll must not overwrite what the operator is typing.

Tune the poll interval and how many points you handle at once before switching APIs. Batching starts to matter when the interval is already around 100 ms and many points change.

Desktop apps that reference Zeus.Presentation.* can attach these events to helpers: a few labels use BindText / AsBindingSource("temperature") (they subscribe to Changed); a DataGrid uses AsTableBindingSource (it subscribes to BatchChanged). Those helpers are presentation adapters, not the point-table core. Console and service apps should not reference them; subscribe to the events directly. AsTableBindingSource copies the whole table each poll and raises PropertyChanged for All; with only a few points this is often heavier than updating a few labels. For a larger, faster table, patch from e.Changes inside BatchChanged.

Desktop UI​

WinForms binds controls directly:

app.Points.BindText("temperature", temperatureLabel, value => $"{value:F1} C");

WPF exposes binding sources from the ViewModel and lets XAML bind to them:

var ui = app.Bind(WpfUiDispatcher.Current());
Temperature = ui.Point("temperature", value => $"{value:F1} C");
<TextBlock Text="{Binding Temperature.Text}" />

The UI only owns display formatting; acquisition threading and protocol details stay inside Zeus. These APIs live in Zeus.Presentation.*, not in the point-table core.

Desktop grids can use the presentation-layer table projection so you do not Clear/Add on every Changed:

Points = app.Points.AsTableBindingSource(WpfUiDispatcher.Current());

Console apps, services, and custom-drawn UIs should skip that helper and subscribe to BatchChanged directly.

When you maintain your own DataGrid or point list, handle e.Changes.Count == 0 as a possible structure-refresh signal and reread app.Points.All.

Trend charts, reports, and long-term audit data are owned by the application. For many points or a whole table, subscribe to BatchChanged, then append samples to your database, file, or chart series.

Alarm Queue​

Points with lowAlarmLimit / highAlarmLimit enter app.Alarms when they go out of range. One open record is kept per point; returning to normal clears it.

The alarm queue supports acknowledge, assignment, shelving, and suppression. If a shelved alarm expires while the point is still outside its limits, it returns to the active queue and raises Changed. Suppressed points do not create new active alarms. When suppression is removed and the point is still out of range, Zeus creates an active alarm immediately instead of waiting for the next acquisition round.

app.Alarms.AcknowledgePoint("temperature", "operator");
app.Alarms.AcknowledgeAll("operator");

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 can push alarms to a list control with BindAlarms; WPF should expose app.Alarms.AsAlarmBindingSource(WpfUiDispatcher.Current()) from the ViewModel.

Next: JSON Configuration, Siemens S7, EtherNet/IP, DL/T 645, or IEC104.