Skip to main content

Brownfield Modbus Devices

Connecting to a Modbus device​

brownfieldDevices:
modbus:
- name: MB1
type: TCP
address: 192.168.1.40
port: 502
unitId: 1 # the Modbus unit identifier (slave id), 0..247 - default 1
pollingRate: 500 # default sampling interval in ms, per point
expose:
parent: /ua:Objects/di:DeviceSet
object: AHU # the object this connection's points are published under

expose.parent is required for a connection to publish anything: it is where the connection's device Object (expose.object, defaulting to the connection name) is created, and every point without an explicit node: is projected under it as <expose.parent>/own:<object>/own:<pointName>. A connection with no expose.parent and no point using node: has nowhere to publish, and the gateway refuses to load it (IMPORT-E031).

There are two ways to tell a connection what to read: write points: by hand, or import: a mapping file a vendor tool or a previous gateway already produced. Both are covered below; a connection may use either, or both at once (imported points and hand-written points merge, hand-written wins on a name or address clash).

points: — the native grammar​

points:
- name: SupplyAirTemperature
at: holding:10
type: int16
scale: 0.1
unit: degC
- name: CoolingValveOpen
at: coil:3
type: bool
access: RW

Each point:

fieldmeaning
namethe OPC UA browse name — [A-Za-z_][A-Za-z0-9_]*
displayNameoptional human-readable name, kept separate from name
ataddress token — see below
typebool, int16, uint16, int32, uint32, int64, uint64, float32, float64, string
countarray length (defaults to 1 — a scalar)
endiannessABCD | BADC | CDAB | DCBA — register/byte layout of a multi-register value, see below (default ABCD)
scale, offsetlinear conversion: engineering = raw * scale + offset
sqrtRangesquare-root scaling (flow sensors) — see below; mutually exclusive with scale/offset
scaleRegistera second register holding a live power-of-ten scale factor (SunSpec sunssf, LogicMachine address_scale)
unitengineering unit, free text
range{ low, high } — documents the sensor's valid range
accessRO (default) | RW | WO
writeFcPolicyauto (default) | forceMultiple — always use the "write multiple registers" function code even for a single register
invalidsentinel raw value(s) that mean "no data" — see below
bitmaskextract one bit or a contiguous bit field out of a 16-bit register
pollingRate, samplingIntervalper-point override of the connection's default
nodean explicit browse path — binds to an EXISTING Variable instead of auto-projecting a new one
valuea JSONata escape hatch — see below
descriptionfree text

Address tokens​

An address token names one Modbus table and a register/coil address in it — never a bare number, so the table is never ambiguous:

<table>:<pdu>[.bit]
modicon:<entity>

table is one of coil, discrete, input, holding. pdu is the 0-based protocol address (what actually goes on the wire). .bit (0-15, register tables only) reads one bit out of a 16-bit register — see bitmask below for reading more than one bit at once.

The alternative modicon: form spells the same address the way a PLC technician's documentation usually does — a 5- or 6-digit 1-based Modicon entity number, table encoded in the leading digit:

prefixtableexampleequivalent
0coilmodicon:000004coil:3
1discrete inputmodicon:100008discrete:7
3input registermodicon:300101input:100
4holding registermodicon:400011holding:10

(entity = pdu + 1 — that's the one place the off-by-one lives). Use whichever spelling matches what's in front of you; the gateway and dump-config (see the migration guide) always print back the canonical table:pdu form.

Register layout (endianness) of multi-register values​

Modbus transmits each 16-bit register big-endian, but says nothing about how a 32/64-bit value spans consecutive registers — that is entirely up to the device. For a value whose natural big-endian byte order is A B C D:

endiannessregister ordermeaning
ABCDhigh word, low wordbig-endian, the default
BADChigh word, low word, bytes swapped within each register
CDABlow word, high wordword-swapped — by far the most common non-default layout (energy meters, and Kepware's own default)
DCBAlow word, high word, bytes swapped within each registerfully byte-reversed

Worked example. 12.5 as an IEEE-754 float32 is 0x41480000 — bytes A B C D = 41 48 00 00. A device that transmits the natural, big-endian register order publishes register 0 = 0x4148, register 1 = 0x0000: that reads correctly with the default endianness: ABCD. A device that word-swaps (transmits the LOW word first — Kepware calls this "First Word Low") instead publishes register 0 = 0x0000, register 1 = 0x4148:

- name: FlowRate
at: holding:3002 # registers 3002 (0x0000) and 3003 (0x4148) on the wire
type: float32
endianness: CDAB # word-swapped: reconstructs 12.5

Picking the wrong layout does not fail loudly — it decodes to a plausible but wrong number. If a value is off by roughly a factor of 65536, or looks like garbage that occasionally lands near the right magnitude, suspect the layout before the scale.

Scaling​

Three ways to turn a raw integer into an engineering value, in increasing order of how much the sensor itself does the work:

  • scale/offset — the common case: engineering = raw * scale + offset.
  • sqrtRange — some flow meters transmit the square root of the measured value to spread resolution evenly across the range; requires rawLow, rawHigh, scaledLow, scaledHigh and is mutually exclusive with scale/offset on the same point.
  • scaleRegister — an address token (register/holding table only, no .bit) of a SECOND register holding a live power-of-ten exponent, read on every poll and applied as engineering = raw * 10^scaleRegisterValue — the pattern SunSpec's sunssf and LogicMachine's address_scale use.

Sentinels​

A device often reserves specific raw values to mean "no reading" — commonly 0x7FFF, -32768, or a table-specific magic number. List them under invalid: and the gateway publishes BadDataUnavailable instead of the literal decoded number:

- name: ExternalTempSensor
at: input:220
type: int16
invalid: [-32768, 32767] # both ends of the type's range, a common vendor convention

A sentinel above 2^53 (only possible for int64/uint64) must be spelled as a hex or decimal STRING ("0xFFFFFFFFFFFFFFFF"), not a YAML number — a plain number that large silently loses precision on the way through YAML.

Bit fields​

bitmask extracts one bit or a contiguous run of bits out of a 16-bit register — for a device that packs several booleans, or a small status code, into one word. It requires a 16-bit type (int16/uint16/bool) and is mutually exclusive with the address token's own .bit suffix (two ways to say the same thing).

The escape hatch: value:​

If the grammar above genuinely cannot express a point — a calculation the sqrtRange/scaleRegister shapes don't cover, a read from more than one register/table combined by formula — a point may carry its own JSONata value:, in exactly the syntax the advanced raw-mapping section below documents:

- name: DerivedEfficiency
at: holding:100 # still required — validated, but unused when value: is set
type: float32
value: |
(
$num := $modbusGetHolding("MB1", 100, "float32", "ABCD");
$den := $modbusGetHolding("MB1", 102, "float32", "ABCD");
{ "value": $num.value / $den.value, "statusCode": $num.statusCode }
)

Reach for this last: every point using value: opts out of the address-token validation, the derived poll-plan optimizer, and the format-drift protection convert-mapping/re-linking give a point declared normally.

What the expression returns decides the published status, precisely:

the expressionpublished status
throwsBadConfigurationError — the expression itself is broken; fix it, this will never self-heal
evaluates to undefined (a path miss — a typo'd field, an absent key)BadNoData — it ran fine and found nothing; distinct from a broken expression
returns an explicit value (including explicit null)Good, with that value
returns { value, statusCode }passed through as-is — the shape every modbusGet* helper returns

:::warning A conditional with no else returns undefined, not Good cond ? x (no third branch) evaluates to undefined on the false path — the same as a path miss, so it now publishes BadNoData rather than Good. Write cond ? x : null to mean "deliberately nothing" and keep Good; undefined and an explicit null are different answers to different questions, even though both used to look the same on the wire. :::

Importing a vendor mapping file​

Instead of declaring points by hand, link a mapping file a vendor tool, an export from a previous gateway, or a system integrator's own spreadsheet already produced:

brownfieldDevices:
modbus:
- name: MB1
type: TCP
address: 192.168.1.40
port: 502
expose:
parent: /ua:Objects/di:DeviceSet
object: AHU
import:
- file: profiles/tags.csv # relative to THIS config file, never elsewhere
fieldmeaning
filepath to the mapping file, relative to the configuration file's own directory
formatauto (default) | one of the formats below — force it when detection is ambiguous
addressBaseauto (default) | 0 | 1 — override the format's own base-address convention
endiannessoverride the format's default register layout for every point it produces
kepwareZeroBasedboolean — see the Kepware caveat below
include, excludeglob patterns on the source names — take or drop a subset
overridespatch individual points after reading — see Binding an imported point to an existing variable

Supported formats​

formattypical sourcenotes
normal-csvNormal Framework profiles, most hand-written CSV exports0-based addresses
kepware-csvKEPServerEX tag export1-based Modicon entities; see the caveat below
se-logicmachineLogicMachine / spaceLYNK / Wiser for KNX0-based; the file's own read_swap becomes the point's endianness
modbus-xml-6digitModbus-XML exports using 6-digit Modicon addressing
sunspecSunSpec model XMLbase-40000 register convention — the reader warns (does not refuse) when it cannot confirm the base from the file alone; verify with a preview read
asercom-edsASERCOM EDS device profilesnot yet implemented — declared in the schema, refused at read time (IMPORT-E032) until it lands

The format is detected from the file's own shape (root XML element, JSON keys, CSV header row); declare format: explicitly when a file's shape is ambiguous or the auto-detection is wrong.

:::warning Kepware: zero-based addressing and word order A KEPServerEX CSV export lists 1-based Modicon entity numbers. The driver's own "Zero-Based Addressing" device setting (Kepware ships it ON by default) makes the driver actually transmit entity − 1 on the wire — the classical Modicon convention, and this reader's default too. If that setting was turned OFF for this device, say so: kepwareZeroBased: false.

Separately, Kepware's Modbus drivers default to "First Word Low" — endianness: CDAB — not the protocol's natural ABCD. This reader defaults to CDAB to match; override with endianness: if the device setting was changed. :::

:::warning A versionless device-profile JSON is refused, never guessed Some tools (EdgeX among them) can export a device-profile JSON with no version marker and no format-identifying keys at all. Rather than guess which of the JSON-based readers it might be, the sniffer refuses it outright (IMPORT-E010, "matches no known profile shape") — pick the closest reader explicitly with format: and, if the field names differ from what that reader expects, use include:/exclude:/hand-written points: for the rest rather than fighting the importer. :::

Two rules hold for every format:

  • Hand-written wins. A points: entry with the same name, or an overlapping address, as an imported one silences the imported one — the load reports which point stood down for which (IMPORT-W020).
  • Nothing is dropped silently. A construct the point model cannot carry yet (a deadband, enum value labels) produces a warning naming it, not a quietly incomplete import. A construct the model cannot represent at all (Kepware BCD, LogicMachine quad10k) is refused rather than approximated.

Imported files are confined to the configuration file's own directory: absolute paths, .. escapes, and symlinks resolving outside it are all rejected.

Editing a linked file reloads the server. Started with --watch (or --watch --poll in a container), the runtime watches every linked mapping file alongside the configuration file itself — in linked mode the mapping file IS the source of truth, so replacing it with a corrected export takes effect exactly like editing the configuration does. Without --watch, restart to pick the change up.

There are two ways to bring an imported file's points in, and they mean different things about who owns them afterwards:

  • Link (import:, above) — the file stays the source of truth and is re-read at every load. Correcting a vendor export is dropping the new file in and restarting; nothing to re-run. The points are not editable by hand — the file is.
  • Convert — the points are materialized into points: ONCE, and you own them from that moment: rename, rescale, delete rows, the source file is never consulted again. From the CLI: convert-mapping <file> --write config.yaml --device MB1 (add --append/--replace if the connection already has points); the editor's import wizard's "Convert" button does the same thing through the same code path.

Pick Link when the vendor file is the thing that will get corrected over time (a live export from an asset-management tool, a file a colleague maintains) and staying in sync matters more than hand-tuning any one point. Pick Convert when you need to rename points to match your own naming convention, hand-tune scaling per point, or the source will never be touched again.

Binding an imported point to an existing variable​

By default every imported point is auto-projected as a NEW Variable under the connection's expose.parent. To bind one onto an EXISTING Variable instead — the CoffeeMachine companion type's BoilerTempWater, an AHU type's SupplyAirTemperature — patch it with overrides::

import:
- file: profiles/ahu.csv
overrides:
- point: SupplyAirTemperature # matches the imported point's name (glob allowed)
node: /ua:Objects/di:DeviceSet/own:AHU/own:SupplyAirTemperature

node is the field most worth overriding this way, but the same mechanism patches any point field the grammar above documents — scale, unit, invalid, and so on — everything except the address itself (at), which comes from the file by definition. In the editor, the Brownfield panel's "Bind to…" on any imported point writes exactly this.

An overrides: entry whose point: pattern matches nothing is not an error, but it does nothing, silently, unless something is checking — the gateway logs it (IMPORT-W022) at load, and the editor underlines it on the connection card.

Advanced: raw JSONata mapping​

Before points: existed, a Modbus connection was configured as raw register tables: to poll, with a hand-written JSONata expression per OPC UA variable doing the decoding. points: covers everything this can do with none of the formula-writing, and is what new configuration should use — but the escape hatch above (value: on a point) uses this exact same JSONata vocabulary, and a whole connection can still be configured this way when every single point needs a bespoke transform.

brownfieldDevices:
modbus:
- name: MB1
type: TCP
address: 127.0.0.1
port: 8502
unitId: 1
tables:
- { type: coils, start: 1000, length: 32, pollingRate: 10000 }
- { type: discreteInputs, start: 2000, length: 10, pollingRate: 10000 }
- { type: holdingRegisters, start: 3000, length: 10, pollingRate: 100 }

mapping:
- node: /di:DeviceSet/own:MyObject/own:MyInt32
value: $modbusGetInt32BE("MB1", 3000)
- node: /di:DeviceSet/own:MyObject/own:MyFloat1
value: $modbusGetFloat32BE("MB1", 3001)
# a hand-written formula: scale-and-offset the point grammar could express directly
- node: /di:DeviceSet/own:MyObject/own:MyFloat2
value: |
(
$mb := $modbusGetInt16("MB1", 2002);
{ "value": $mb.value / 10.0 + 3.14, "statusCode": $mb.statusCode }
)
  • tables: declares which register ranges to poll and how often — type is coils, discreteInputs, holdingRegisters or inputRegisters; tables must not overlap, and the protocol caps any one table at 2000 registers.
  • Every modbusGet* function returns { value, statusCode }.
functionreads
modbusGetHoldingRegister, modbusGetDiscreteInput, modbusGetCoilone raw register/bit
modbusGetInt16, modbusGetFloat32, modbusGetInt32, modbusGetUint32, modbusGetFloat64a typed value; Float32/Int32/Uint32/Float64 take an optional trailing layout argument
modbusGetHolding(conn, pdu, type, layout?), modbusGetInput(conn, pdu, type, layout?)typed, table-explicit — what points:-generated bindings actually call
modbusMapSentinelmaps a raw "no data" sentinel to BadDataUnavailable
modbusGet<Type>BE / ...LEBE ≡ layout ABCD; LE ≡ layout DCBA (full byte reversal) — a word-swapped device needs "CDAB" passed explicitly, LE does NOT mean that
modbusGet<Type>ArrayBE / ...LEarray-of-Int16/Uint16/Int32/Uint32/Float32/Float64 variants

An unrecognised layout string publishes BadInvalidArgument on the variable — never a silently wrong value.

OmniEdge automatically converts whatever value: returns to the OPC UA Variable's own DataType (Int32, Float, ...), including detecting an array/matrix target and converting each element.

Reference​

Modbus tableaccesswidth
Coil (discrete output)RW1 bit
Discrete input (status input)RO1 bit
Holding registerRW16 bit
Input registerRO16 bit

Modbus has no self-describing register map — this information always comes from the device manufacturer's documentation.

See also: Migrating from tables:/mapping: to points:.