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:
| field | meaning |
|---|---|
name | the OPC UA browse name — [A-Za-z_][A-Za-z0-9_]* |
displayName | optional human-readable name, kept separate from name |
at | address token — see below |
type | bool, int16, uint16, int32, uint32, int64, uint64, float32, float64, string |
count | array length (defaults to 1 — a scalar) |
endianness | ABCD | BADC | CDAB | DCBA — register/byte layout of a multi-register value, see below (default ABCD) |
scale, offset | linear conversion: engineering = raw * scale + offset |
sqrtRange | square-root scaling (flow sensors) — see below; mutually exclusive with scale/offset |
scaleRegister | a second register holding a live power-of-ten scale factor (SunSpec sunssf, LogicMachine address_scale) |
unit | engineering unit, free text |
range | { low, high } — documents the sensor's valid range |
access | RO (default) | RW | WO |
writeFcPolicy | auto (default) | forceMultiple — always use the "write multiple registers" function code even for a single register |
invalid | sentinel raw value(s) that mean "no data" — see below |
bitmask | extract one bit or a contiguous bit field out of a 16-bit register |
pollingRate, samplingInterval | per-point override of the connection's default |
node | an explicit browse path — binds to an EXISTING Variable instead of auto-projecting a new one |
value | a JSONata escape hatch — see below |
description | free 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:
| prefix | table | example | equivalent |
|---|---|---|---|
0 | coil | modicon:000004 | coil:3 |
1 | discrete input | modicon:100008 | discrete:7 |
3 | input register | modicon:300101 | input:100 |
4 | holding register | modicon:400011 | holding: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:
endianness | register order | meaning |
|---|---|---|
ABCD | high word, low word | big-endian, the default |
BADC | high word, low word, bytes swapped within each register | |
CDAB | low word, high word | word-swapped — by far the most common non-default layout (energy meters, and Kepware's own default) |
DCBA | low word, high word, bytes swapped within each register | fully 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; requiresrawLow,rawHigh,scaledLow,scaledHighand is mutually exclusive withscale/offseton 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 asengineering = raw * 10^scaleRegisterValue— the pattern SunSpec'ssunssfand LogicMachine'saddress_scaleuse.
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 expression | published status |
|---|---|
| throws | BadConfigurationError — 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
| field | meaning |
|---|---|
file | path to the mapping file, relative to the configuration file's own directory |
format | auto (default) | one of the formats below — force it when detection is ambiguous |
addressBase | auto (default) | 0 | 1 — override the format's own base-address convention |
endianness | override the format's default register layout for every point it produces |
kepwareZeroBased | boolean — see the Kepware caveat below |
include, exclude | glob patterns on the source names — take or drop a subset |
overrides | patch individual points after reading — see Binding an imported point to an existing variable |
Supported formats
format | typical source | notes |
|---|---|---|
normal-csv | Normal Framework profiles, most hand-written CSV exports | 0-based addresses |
kepware-csv | KEPServerEX tag export | 1-based Modicon entities; see the caveat below |
se-logicmachine | LogicMachine / spaceLYNK / Wiser for KNX | 0-based; the file's own read_swap becomes the point's endianness |
modbus-xml-6digit | Modbus-XML exports using 6-digit Modicon addressing | |
sunspec | SunSpec model XML | base-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-eds | ASERCOM EDS device profiles | not 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.
Link vs. Convert
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/--replaceif 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 —typeiscoils,discreteInputs,holdingRegistersorinputRegisters; tables must not overlap, and the protocol caps any one table at 2000 registers.- Every
modbusGet*function returns{ value, statusCode }.
| function | reads |
|---|---|
modbusGetHoldingRegister, modbusGetDiscreteInput, modbusGetCoil | one raw register/bit |
modbusGetInt16, modbusGetFloat32, modbusGetInt32, modbusGetUint32, modbusGetFloat64 | a 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 |
modbusMapSentinel | maps a raw "no data" sentinel to BadDataUnavailable |
modbusGet<Type>BE / ...LE | BE ≡ layout ABCD; LE ≡ layout DCBA (full byte reversal) — a word-swapped device needs "CDAB" passed explicitly, LE does NOT mean that |
modbusGet<Type>ArrayBE / ...LE | array-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 table | access | width |
|---|---|---|
| Coil (discrete output) | RW | 1 bit |
| Discrete input (status input) | RO | 1 bit |
| Holding register | RW | 16 bit |
| Input register | RO | 16 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:.