hx.modbus doc

Overview

The ModbusExt implements connector support for Modbus protocol.

A connector describes its device one of two ways: a register map CSV, or the xeto spec which models the device. See Register Map and Addr Specs.

Supported Protocols

ModbusExt supports two protocols for accessing Modbus slave devices:

TCP/IP

To setup a connector to access a Modbus slave over TCP/IP, we use the modbus-tcp URI scheme:

dis: "Modbus TCP/IP Connector"
conn
modbusConn
uri: `modbus-tcp://host/`
modbusSlave: 1
modbusRegMapUri: `data/modbus/xxx.csv`

RTU over TCP/IP

To access the slave using serial RTU over a TCP connection, use the modbus-rtutcp scheme:

dis: "Modbus RTU over TCP/IP Connector"
conn
modbusConn
uri: `modbus-rtutcp://host/`
modbusSlave: 1
modbusRegMapUri: `data/modbus/xxx.csv`

Register Map

A connector may define a register map using the modbusRegMapUri tag. The register map specifies which points get read/written out of a Modbus slave. The register map URI must be a path relative to the project's home directory or can be a fan:// URI to a CSV file bundled in a pod.

A connector whose points address their registers from a spec needs no register map - see Addr Specs.

Register maps are simple CSV files:

name, addr,  data, rw, scale, dis,  unit, tags
ping, 40001, u2,   r,  ,      Ping, ,
do0,  00017, bit,  rw, ,      DO-0, ,
do1,  00017, bit,  rw, ,      DO-1, ,
ai0,  40001, u2,   r,  ,      AI-0, ,
ai1,  40002, u2,   r,  *10,   AI-1, kW,   power

Addressing

Register address are defined with addr column and specified using Modbus convention, where the leading digit selects the register type and the rest is the 1-based register number. Both the 5-digit form and the 6-digit extended form are accepted:

0xxxx  Coil              00001-09999    0xxxxx  000001-065536
1xxxx  Discrete Input    10001-19999    1xxxxx  100001-165536
3xxxx  Input Register    30001-39999    3xxxxx  300001-365536
4xxxx  Holding Register  40001-49999    4xxxxx  400001-465536

The two are equivalent where they overlap: 40001 and 400001 are both holding register 1. Registers above 9999 need the extended form, as do addresses authored in a spec - see Addr Specs.

Ping

A valid ping register is required in order to test connectivity to slave. A connector configured by device spec names a point instead - see Ping Point.

Data Type

The data column specifies how register data is modeled.

bit  Bool
u1   Unsigned  8-bit Int
u2   Unsigned 16-bit Int
u4   Unsigned 32-bit Int
s1   Signed  8-bit Int
s2   Signed 16-bit Int
s4   Signed 32-bit Int
s8   Signed 64-bit Int
f4   32-bit Float
f8   64-bit Float

Bit Mask

The bit data type supports a position notation for cases where bits are packed into input or holding registers. The position is 0 to 15:

name, addr,  data,  rw
do0,  40101, bit:0, rw
do1,  40101, bit:1, rw
do2,  40101, bit:2, rw

Word and Byte Order

If register data is not stored in network byte order, you can specify the order using a suffix:

u4le   Unsigned 32-bit Int  Little endian byte and word order
u4leb  Unsigned 32-bit Int  Little endian byte order only
u4lew  Unsigned 32-bit Int  Little endian word order only

Word order only applies to types which span more than one register, so on a 16-bit type u2lew is the same as u2, and u2le the same as u2leb.

Read/Write

Read and write permissions are configured using rw column:

rw  Register may be read and written
r   Register is read-only
w   Register is write-only

Scale Factor

An optional scale factor can be applied to registers using the scale column. The scale format is ([op] [num])+ where the factor is a numeric constant.

add:    +1.5
minus:  -0.25
mult:   *10
div:    /1000

You can also chain multiple scaling factors together:

chained: +32768 /10

Every term needs its operator: a bare 0.1 is not a scale and fails the row rather than loading the register unscaled.

Dis, Unit, Tags

The dis, unit, and tags column allow optional pre-configuration for the point during the learn process:

name, addr,  data, rw, dis,  unit, tags
ai1,  40002, u2,   r,  AI-1, kW,   power foo bar

The unit must be a known unit symbol or name; an unrecognized one fails the row rather than loading the register unitless.

Current Points

Modbus proxy points are configured with the modbusCur tag, which names a register in the connector's register map:

point
modbusConnRef: @conn
modbusCur: ai0

A point may instead address its register directly from a spec - see Addr Specs.

Writable Points

Modbus proxy points are configured to write to remote system points via the modbusWrite tag:

point
writable
modbusConnRef: @conn
modbusWrite: ao5

A point may instead address its register directly from a spec - see Addr Specs.

Addr Specs

A point may address its registers from an equip template instead of a register map. Author a ModbusAddr on the point using the modbusCurAddr and modbusWriteAddr globals:

Vav : Equip {
  points: {
    zoneTemp: ZoneAirTempSensor {
      modbusCurAddr:   { addr: "400101", encoding: "s2", scale: "*0.1" }
      modbusWriteAddr: { addr: "400201", encoding: "s2" }
    }
  }
}

Binding the template to a connector sets modbusCur and modbusWrite to the qname of the point spec. The tag selects which addr global is read off it, and the connector takes the encoding, scale and byte order from there:

point
modbusConnRef: @conn
modbusCur:   "acme.vav::Vav.points.zoneTemp"
modbusWrite: "acme.vav::Vav.points.zoneTemp"

Any value containing "::" is resolved as a qname; anything else is a register map name, so the two styles can be mixed on one connector.

Field Mapping

The spec fields map onto the register map columns as follows:

ModbusAddr Register map Notes
addr addr Must be 6-digit extended Modicon
encoding data Combined with bitIndex and byteOrder
bitIndex data Only applies to bit encoding
byteOrder data Suffix, be adds none
scale scale Requires a leading operator such as *0.1
dis dis

There is no unit field on the addr; the point's own unit tag is applied to the value.

Device Spec

A connector whose points address their registers this way names the spec which models the device with modbusDeviceSpec, and needs no modbusRegMapUri:

modbusConn
uri: `modbus-tcp://host/`
modbusSlave: 1
modbusDeviceSpec: "acme.vav::Vav"
modbusPingAddr: "zoneTemp"

Learn

With a device spec configured, connLearn() walks that spec's points rather than a register map. The root level is a folder per register type in use - coils, discrete inputs, input registers, holding registers - and expanding one lists its points in register order. Each learned point carries its dis, kind and unit from the point spec, and addresses its registers by qname.

A connector with a register map is learned the same way, grouping its registers into the same folders.

Ping Point

There is no ping register without a register map, so a connector configured by device spec reads one of that spec's points instead. modbusPingAddr names it, such as zoneTemp, and its modbusCurAddr is the register read.

History

History synchronization is not supported by Modbus. You will need to use collection to store history.

Block Reads

Modbus connectors will attempt to optimize consecutive registers into a single block read when possible. This behavior can be tuned by adding the modbusBlockGap and modbusBlockMax tags to your modbusConn rec, or by configuring them on the connector's associated conn tuning rec.

modbusBlockGap

By default, blocks require all points to be in truly consecutive registers:

// block 1
40001 u2
40002 u2
40003 u2

// block 2
40005 u2

If you have a small gap in an otherwise sequential run of registers, you can tune to maintain a single read by adding the modbusBlockGap tag to your conn rec. This tag specifies the maximum number of registers that can be skipped for a single block:

modbusConn
modbusBlockGap: 1

// block 1
40001 u2
40002 u2
40003 u2
40005 u2  // 40004 will be read but discarded

modbusBlockMax

By default, blocks are limited to 100 total registers (which includes skipped registers from modbusBlockGap). To force a connector to split blocks into smaller or larger chunks, add the modbusBlockMax tag to your conn rec:

modbusConn
modbusBlockMax: 2

// block 1
40001 u2
40002 u2

// block 2
40003 u2
40004 u2

Be aware a block read failure will result in the entire block's points being marked as fault.

Frame Delay

Every conn that shares a uri shares a single connection to that endpoint, and their transactions are serialized onto it back-to-back with no gap. That is usually what you want, but it breaks a multi-drop TCP-to-RTU gateway: the gateway needs turnaround time to switch the RS-485 bus direction, and the slaves require roughly 3.5 character-times of silence before the next frame.

The failure looks like this. The first request in a poll cycle is answered normally, and the second request - whichever unit it happens to target - is never answered at all. Because the frame is discarded on the serial side rather than rejected by a slave, no Modbus exception code comes back; the read simply times out. Enabling a single slave on the gateway masks the problem, since ConnTuning.pollTime then supplies the gap by accident.

Use modbusFrameDelay to force a minimum silence between transactions:

modbusConn
uri: `modbus-tcp://gateway/`
modbusFrameDelay: 50ms

Applied to a connTuning rec it paces every conn that references it, which is usually the better way to configure a whole gateway:

dis: "Gateway Tuning"
connTuning
modbusFrameDelay: 50ms

A value on the conn rec takes precedence over the tuning rec. The default is 0sec, which preserves the original back-to-back behavior.

The delay is measured from the end of one transaction to the start of the next, and it applies to every frame on the wire - including the read-modify-write pair issued for a bit-mask register.

Note the delay happens inside the window bounded by modbusReadTimeout and modbusWriteTimeout (both of which default to Conn.actorTimeout). Those timeouts cover queue wait plus pacing plus the transaction itself, so on a gateway with N slaves they must be large enough to cover the full serialized cycle or the polls will still time out.

Haxall 4.0.7 ∙ 27-Oct-2026 09:10 EDT