Skip to main content

Migrating a Modbus device: tables: → points:

An existing brownfieldDevices.modbus connection written the original way — raw tables: polled, decoded per-variable by hand-written JSONata in mapping: — keeps working; nothing forces a rewrite. Migrate a connection when you want the benefits points: grammar gives for free: address-token validation at load instead of a typo silently reading the wrong register, a poll plan the gateway derives instead of one you maintain by hand, and the ability to import a vendor mapping file at all (import: only ever produces points:, never tables:/mapping:).

Before and after​

The two configurations below read the same two values off the same device.

before: tables: + mapping:
brownfieldDevices:
modbus:
- name: MB1
type: TCP
address: 127.0.0.1
port: 8502
unitId: 1
tables:
- { type: holdingRegisters, start: 3000, length: 10, pollingRate: 100 }

mapping:
- node: /di:DeviceSet/own:MyObject/own:MyInt32
value: $modbusGetInt32BE("MB1", 3000)
after: points:
brownfieldDevices:
modbus:
- name: MB1
type: TCP
address: 127.0.0.1
port: 8502
pollingRate: 100
expose:
parent: /ua:Objects/di:DeviceSet
object: MyObject
points:
- name: MyInt32
at: holding:3000
type: int32
endianness: ABCD # BE ≡ ABCD — see the endianness table on the main Modbus page

Point by point, translating the old shape:

  1. Drop tables:. points: derives its own poll plan from the address tokens and pollingRates it finds — nothing to hand-maintain, and nothing to keep from silently drifting out of sync with mapping: the way a hand-written table range could.
  2. Give the connection an expose:. This is new: tables:/mapping: named the destination node explicitly on every mapping: entry (node: /di:DeviceSet/own:MyObject/own:MyInt32); points: instead projects each point under expose.parent/own:<object>/own:<pointName> by default. expose.object here (MyObject) reproduces the old path exactly; omit it to default to the connection name instead.
  3. Turn each mapping: entry into a point. Read the JSONata expression for what it decodes: modbusGetInt32BE("MB1", 3000) is address holding:3000, type int32, layout ABCD (BE means ABCD — see the endianness table on the main page for the others). A modbusGetFloat32(..., "CDAB") call becomes type: float32, endianness: CDAB; a scale/offset formula ($mb.value / 10.0 + 3.14) becomes scale: 0.1, offset: 3.14 directly on the point.
  4. Anything that does not translate stays a value: escape hatch. A formula reading more than one register/table, or doing something scale/sqrtRange/scaleRegister genuinely cannot express, is still legal JSONata — just move it onto the individual point's own value: field (same syntax, see The escape hatch on the main page) instead of a connection-wide mapping: entry. Migrate what translates cleanly; leave the rest exactly as it was.

Doing it a point at a time​

A connection does not have to move in one step. points: and tables: coexist on the same connection (the loader keeps both working), and mapping: entries for variables the migrated points now also publish simply become redundant — remove them once their points: equivalent is confirmed working, not before.

Verifying the result with dump-config​

However a connection got its points — hand-written, migrated, imported, overridden — dump-config prints what the gateway actually derived from the configuration: the poll plan it built, the projected nodes, and the exact generated binding expression for every point, with points:/import: themselves stripped back out (what's left is what a tables:+mapping: config would have had to spell by hand):

node_modules/.bin/opcua-omni-edge dump-config -c config.yaml

The output opens with a banner marking it a debug artifact — do not deploy or edit it as a configuration (secrets and certificates are redacted, and derived: true markers are stripped so a pasted-back copy reads as hand-written, which it is not). Read it to answer exactly the two questions a migration raises:

  • "What poll plan did the gateway derive?" — check the connection's tables: in the dump against what you expected from the address tokens and polling rates you wrote.
  • "What does this point actually evaluate to at runtime?" — check the generated mapping: entry for the point's binding expression; compare it, argument for argument, against the hand-written JSONata it replaced.

If the two don't match — a CDAB where you expected ABCD, a scaleRegister firing you didn't set — the mismatch is almost always the endianness table being read backwards (BE/LE axis vs. word-swap axis are NOT the same question) or expose.object differing from the old hand-written node: path. dump-config exits non-zero with the diagnostics on stderr if the configuration itself is invalid — fix those before comparing anything.

Two related commands, useful while migrating a larger fleet:

  • opcua-omni-edge migrate-config config.yaml — a DIFFERENT migration: the v3→v4 schema shape (nodesets: → namespaces:+imports:, instance: → instances:). Unrelated to tables:/points:; run it first if the configuration predates v4 at all, before touching Modbus.
  • opcua-omni-edge convert-mapping <file> --write config.yaml --device MB1 — up-converts a vendor mapping FILE (not an existing tables:/mapping: connection) straight into points:; the CLI form of the editor's "Convert" button. See Link vs. Convert on the main page.