Modbus
The Modbus module reads and writes coils and registers without hand-building function codes, CRC, or MBAP headers.
Three Names to Keep Separate
| Name | Example | Meaning |
|---|---|---|
| Channel name | bus | Zeus name for the communication line: serial, TCP, UDP, or virtual |
| Device name | oven | Zeus name for this device |
| Unit id | unitId: 1 | Modbus slave address / unit identifier |
Many setup issues come from mixing these names. bus is not a COM port, oven is not the Modbus unit id, and unitId is not the Zeus device name.
Start with a Virtual Slave
var memory = new ModbusSlaveMemory();
memory.HoldingRegisters[0] = 185;
await using var app = ZeusHost.Create(builder =>
{
builder.AddVirtualChannel("bus", new ModbusSlaveResponder(1, ModbusTransport.Rtu, memory));
builder.AddModbusRtu("oven", "bus", unitId: 1, points: map =>
{
map.HoldingRegister("temperature", 0, 0.1);
});
});
await app.StartAsync();
var oven = app.Devices.Get<ModbusDevice>("oven");
var values = await oven.ReadHoldingRegistersAsync(0, 1);
var temperature = app.Points.Get<double>("temperature");
Run sample: samples/Zeus.Samples.Console.Modbus.
Field Serial RTU
builder.AddSerialPort("bus", "COM3", 9600);
builder.AddModbusRtu("oven", "bus", unitId: 1);
The second argument of AddModbusRtu is still the Zeus channel name bus, not COM3.
Field TCP
Modbus TCP commonly uses port 502:
builder.AddTcpClient("bus", "192.168.1.10", 502);
builder.AddModbusTcp("oven", "bus", unitId: 1);
unitId can still matter when a TCP gateway routes to an RTU bus.
Read/Write APIs
var oven = app.Devices.Get<ModbusDevice>("oven");
var holding = await oven.ReadHoldingRegistersAsync(address: 0, quantity: 2);
var input = await oven.ReadInputRegistersAsync(address: 0, quantity: 2);
var coils = await oven.ReadCoilsAsync(address: 0, quantity: 8);
var discrete = await oven.ReadDiscreteInputsAsync(address: 0, quantity: 8);
await oven.WriteSingleRegisterAsync(address: 1, value: 200);
await oven.WriteSingleCoilAsync(address: 2, value: true);
await oven.WriteMultipleRegistersAsync(address: 10, values: [100, 200]);
await oven.WriteMultipleCoilsAsync(address: 20, values: [true, false, true]);
await oven.MaskWriteRegisterAsync(address: 30, andMask: 0xFFF0, orMask: 0x0005);
Supported diagnostic and extended operations include exception status 07, diagnostics return query data 08, report server id 11, mask write 16, and read/write multiple registers 17.
Addressing Starts at 0
Zeus uses the wire-protocol address, which starts at 0.
| Manual notation | Usually in Zeus |
|---|---|
40001 | address: 0 using holding register APIs |
40002 | address: 1 |
30001 | address: 0 using input register APIs |
00001 | address: 0 using coil APIs |
If a manual explicitly uses 0-based addresses, follow the manual.
Point Acquisition and Write-Back
builder.AddAcquisition(TimeSpan.FromMilliseconds(500));
builder.AddModbusRtu("oven", "bus", unitId: 1, points: map =>
{
map.HoldingRegister("temperature", 0, 0.1);
map.HoldingRegister("setpoint", 1, 0.1).Writable("setpoint");
map.Coil("heater", 2).Writable("heater");
});
var temperature = app.Points.Get<double>("temperature");
await app.Points.WriteAsync("setpoint", 80.0);
await app.Points.WriteAsync("heater", true);
WriteAsync receives engineering values. With scale: 0.1, writing 80.0 stores raw register value 800.
Common Issues
| Symptom | Likely cause | Fix |
|---|---|---|
| Timeout | Unit id, serial parameters, RTU/TCP mode, or field wiring is wrong | Verify the host with a virtual slave, then check field settings |
| Illegal data address | Address base or quantity is wrong | Check whether 40001 should become 0 |
| CRC failure | Baud/parity/stop bits mismatch or line noise | Compare with a serial tool trace |
| TCP transaction id mismatch | Concurrent requests on one TCP channel | Serialize requests or split channels |
| Value is 10x too large | Missing scale | Use HoldingRegister(..., 0.1) |
| Write-back rejected | Point is not writable or target area is read-only | Mark holding registers/coils with .Writable |
Next: Points and Acquisition.