Connecting Siemens S7 and Modbus PLCs to OPC UA

Walk into almost any factory and you'll find the same thing: machines that are ten, fifteen, twenty years old, running perfectly, and speaking everything except OPC UA. A Siemens S7-300 here, a fleet of Modbus power meters there, a robot cell talking EtherNet/IP. None of it is going to be replaced any time soon — and it shouldn't be. It works.

The question almost nobody phrases correctly is "how do I migrate to OPC UA?" You don't migrate. You bridge. Your existing PLCs keep doing their job, and something in front of them speaks OPC UA to the rest of the world. This guide is about how that bridge actually works — the parts the datasheets gloss over — and the two ways to build it: the hard way that most teams try first, and the way we'd recommend after a decade of watching the hard way go wrong.

First, what "speaking OPC UA" really means

Here's the trap. People assume bridging S7 or Modbus to OPC UA is a transport problem — read a value over here, expose it over there. It isn't. It's a modelling problem.

S7 and Modbus give you numbers at addresses. OPC UA gives you meaning: a typed object with a browse name, an engineering unit, a data type, a range, an access level, maybe an alarm condition. A Modbus holding register is 40001. An OPC UA node is Press42/HydraulicPressure, a Double, in bar, range 0–250, read-only. The gap between those two is the entire job. Move the bytes and skip the model, and you've built a more expensive Modbus — not an OPC UA server.

Keep that in mind, because it's exactly the part the "quick gateway" approach quietly drops.

The Siemens S7 side

S7 PLCs (S7-300, S7-400, S7-1200, S7-1500) are reached over the S7 communication protocol, usually via the RFC1006 / ISO-on-TCP wrapper on port 102. To pull a value you generally need three things:

  • The rack and slot of the CPU (a classic gotcha — an S7-300 is typically rack 0 / slot 2, while an S7-1500 is rack 0 / slot 1).
  • The memory area: a data block (DB), or the process-image areas (inputs I, outputs Q, flags/markers M).
  • The address and type within that area — e.g. DB10.DBD0 is a 4-byte real starting at byte 0 of DB10.
  S7 address           meaning
  ─────────────        ───────────────────────────
  DB10.DBX0.0          DB10, byte 0, bit 0  → Bool
  DB10.DBW2            DB10, word at byte 2 → Int
  DB10.DBD4            DB10, dword at byte 4 → Real / DInt
  M12.3                marker byte 12, bit 3 → Bool

Two things bite newcomers. First, optimized block access on the S7-1200/1500: if a DB is marked "optimized," its members no longer sit at fixed byte offsets, and classic DBx.DBDy addressing fails. You either turn off optimized access for that DB or read by symbolic name. Second, endianness and packing — S7 is big-endian, a Real is IEEE-754, and Bools are packed into bytes. Get the byte order wrong and your 23.5 °C reads as 4.2 × 10³¹.

The Modbus side

Modbus is gloriously simple, which is also its problem — it pushes all the meaning onto you. There are four data tables, addressed by function code:

TableFunction codeTypeAccess
CoilsFC01 / FC051-bitread/write
Discrete inputsFC021-bitread-only
Input registersFC0416-bitread-only
Holding registersFC03 / FC06 / FC1616-bitread/write

A register is 16 bits. That's it. Want a 32-bit float or a counter? You read two registers and reassemble them — and now you're guessing word order (big-endian, little-endian, or one of the two byte-swapped variants every vendor seems to pick differently). Then there's the off-by-one trap: the wire address 0 is documented as 40001 in most manuals. And scaling lives nowhere in the protocol — a register holding 235 might mean 23.5 °C, or 235 kPa, or a raw ADC count. The device manual is the only source of truth, and you encode it by hand.

The n×m problem, and why the first attempt hurts

So you wire it up. A Python script with python-snap7 for the S7s, pymodbus for the meters, and a small OPC UA server library to expose the results. For one machine, on a good afternoon, it works. You demo it. Everyone's happy.

Then reality scales it. You have n protocols and m consumers (SCADA, historian, MES, a cloud pipeline, an AI model), and the glue code multiplies. Each new device family means new addressing quirks, new reconnection logic, new type coercion. The gateway becomes a bespoke application that one person understands, with the device map buried in source code:

  The hand-rolled gateway, six months in:
  ─────────────────────────────────────────
  • 2,400 lines of Python nobody wants to touch
  • the register map lives in if/elif branches
  • reconnection works… except for the S7-1500
  • no certificates ("we'll add security later")
  • the one engineer who wrote it just left

None of this is a skills problem. It's that the meaning of the data — the model — is tangled up with the plumbing. Change a scaling factor and you redeploy an application. That's the wrong place for configuration to live.

The configuration-driven approach

The alternative is to treat the bridge as configuration over a proven engine, not as an application you write. Declare your devices, declare how their raw points map onto a standards-compliant OPC UA address space, and let a single hardened server expose one secure endpoint to everything downstream. That's the design behind OPC UA Omni-Edge: the model is data, not code.

port: 4840
nodesets:
  - di          # OPC UA Devices Integration companion spec
  - machinery   # OPC UA for Machinery companion spec

instances:
  - browseName: Press42
    organizedBy: /di:DeviceSet
    typeDefinition: a:HydraulicPressType

brownfieldDevices:
  s7:
    - { name: plc1, address: 192.168.1.10, rack: 0, slot: 1 }
  modbus:
    - { name: mb1, type: TCP, address: 192.168.1.20, port: 502 }

mapping:
  # S7 Real (big-endian IEEE-754) → engineering units, in Kelvin
  - opcua: /di:DeviceSet/Press42/a:Temperature
    type: s7
    point: plc1/DB10.DBD0:REAL
    jsonata: value * 0.1 + 273.15
  # Modbus holding register, scaled to bar
  - opcua: /di:DeviceSet/Press42/a:Pressure
    type: modbus
    point: mb1/holding:40001:INT16
    jsonata: value / 10

A few things are doing real work here. The jsonata line is where scaling, unit conversion and endianness handling live — declaratively, next to the point, not in compiled code. The nodesets pull in Companion Specifications (Devices Integration, Machinery, and dozens of industry ones like PackML or Euromap), so your press shows up as a machine that any vendor's client already understands — not a flat bag of tags. And because it's one OPC UA server, you get the standard's security model for free: X.509 certificates, encryption, signing, role-based access. Security stops being the thing you "add later."

When the scaling factor changes, you edit one line and hot-reload. No redeploy, no 2,400-line application, no tribal knowledge.

Hand-rolled vs configuration-driven

Hand-rolled gatewayConfiguration-driven (Omni-Edge)
Device mapBuried in source codeDeclarative YAML, version-controlled
New protocolNew code, new bugsNew section in config
Scaling / endiannessHardcoded per pointjsonata next to the mapping
Data modelFlat tags, usuallyCompanion Specs (DI, Machinery, …)
Security"later"OPC UA certificates by default
Who can maintain itThe authorAnyone who can read YAML
Hot changesRedeploy the appHot reload

Where this leaves you

If you're bridging one machine for a proof of concept, a script is fine — write it, learn the addressing quirks, ship the demo. The moment it becomes infrastructure that other systems depend on, the economics flip. The cost isn't writing the gateway; it's owning it for five years as devices, consumers and people change around it.

The honest summary: S7 and Modbus aren't hard to read. They're hard to model well, securely, at scale — and that's the part that decides whether your OPC UA layer is an asset or a liability. Push the model into configuration, stand it on an engine that already handles the protocol nastiness and the security, and the brownfield stops being a migration project and becomes a config file.

We built Omni-Edge because we kept being called in to rescue the other approach. If you're staring at a plant full of S7s and Modbus devices and a mandate to make them speak OPC UA, that's exactly the conversation we like having.


Bridging brownfield devices to OPC UA? OPC UA Omni-Edge turns S7, Modbus, EtherNet/IP, IO-Link and more into a single secure OPC UA endpoint from one YAML file. Talk to Sterfive — we're the team that maintains node-opcua, and we're happy to help you design the bridge properly the first time.