Language: English
← PLC Bridge

PLC Bridge Integrator Guide

Wiring a controller to the bridge over EtherNet/IP, OPC UA, Modbus TCP, or MQTT Sparkplug B

PLC Bridge is one service on the plant network that sits between your controller and one or more Vorne XL boards. Your program talks only to the bridge; the bridge talks to every board. V1 and V2 board differences never reach your program.

This guide is for the PLC programmer or system integrator doing the wiring. It covers what the bridge publishes, what it accepts, and exactly what you have to build on the controller side for EtherNet/IP, OPC UA, Modbus TCP, and MQTT Sparkplug B.

Your program does not change for us. There is no add-on instruction to import, no AOI, no UDT, no firmware option, no vendor block and no rung. There are tags, nodes, registers or metrics, which you create with the names and types below, and the bridge reads and writes them like any other client. On a Logix controller they are all controller-scope base tags.

What you receive

Two things arrive together at the start of a job:

  1. The list of tags to create, generated from the site's own configuration. Which points a controller sees and which commands it may send are decided per site, so the list is specific to your plant, your board, and your protocol.
  2. This guide, which says what each of those tags means, how the types map, and how a command is handed over and answered.

Nothing else is needed, on EtherNet/IP, OPC UA, Modbus TCP, or MQTT Sparkplug B.

Ask for the tag list in the format you want

For a Logix controller the list can be handed over as a tag CSV that Studio 5000 imports directly. The format and a worked example are in the EtherNet/IP section below. An L5X export of the same tags works just as well if you prefer it.

The tag model

Everything the bridge can publish and everything it can be asked to do is one versioned list, the same for every protocol. Each protocol renders that list in its own shape: controller tags on EtherNet/IP, nodes on OPC UA, registers on Modbus, metrics on MQTT.

Point names are group.point. Channel-scoped numbers exist twice: shift.* is the board's shift channel, job.* is the current part run.

The groups.

  • board.* - board identity, liveness, firmware and clock.
  • job.* and part.* - the running job and part run: ids, targets, counts and OEE.
  • shift.* and team.* - the same counts and OEE for the shift channel, plus the team.
  • state.* and pace.* - production state, its reason, and the current cycle time.

The list handed over with this guide names every point in each group; the protocol sections below carry their types and addresses.

The commands. Fifteen, all of them optional per site:

job.set, job.tag, counts.inject_good, counts.inject_reject, rejects.submit, state.set_reason, state.start_production, state.start_down_event, changeover.start, changeover.end, shift.start, shift.end, team.set, comment.add, and plc.heartbeat.

plc.heartbeat is the one command that never reaches a board. It records that the controller side is alive and is read back out of the bridge's own status.

Types. bool, int32, uint32, double, string, and datetime (ISO 8601 UTC). Units are counts, seconds, ratios from 0.0 to 1.0, percentages, or none.

Quality, on every published point. good is a fresh value, unavailable means the board does not provide it (older firmware, or a simulator), and stale means the last poll failed and the value is the last known one. Every protocol carries quality beside the value in its own way, and every one of them lets your program tell a real zero from a value that was never read.

Read quality before you act on a number

A value the board did not provide is never invented. The bridge does not write a zero in place of a missing count, because a rung that stops the line at GOOD_CNT >= GOAL would act on that zero. Points that need newer board firmware are unavailable on an older board, so this is routine rather than exotic.

Renaming and whitelisting. A site can rename any point or command to the name your program already uses. A non-empty rename map is also a whitelist: only what it lists is reachable, and anything left out is not available under its own name either. plc.heartbeat is the one exception and stays reachable under its own name through any command whitelist that does not rename it.

Versions. The tag model and the adapter contract each carry a version. Adding a point or a command is a minor bump; renaming, removing, or changing a type is a major bump. The service can print the whole model as JSON, including both version numbers, so the controller side can be generated from it rather than transcribed.

The Vorne Access Control requirement

The board must permit the bridge's writes

Every command ends as a write to the XL board's own API. The board's Access Control settings decide whether that write is allowed. Set them to permit the bridge before commissioning, or every command you send will be recorded as failed.

An unauthorized write is final. The bridge marks that command failed, logs the endpoint and the Access Control setting that refused it, and never retries or replays it, so an unauthorized command is a command that did not happen. It does not block anything either: the commands queued behind it still run.

Two settings decide this, and they are separate:

  1. Comments. comment.add posts to the board's comments endpoint, which answers 401 unless the board's Comment Permissions are set to allow anyone, guests included. Without that setting, comments are refused and reported by name.
  2. Digital input injection. counts.inject_good and counts.inject_reject write to a digital input pin, and the board must be configured to allow API input injection in its Digital Inputs configuration. The pin numbers themselves are site configuration: without them, those two commands are refused, naming the missing setting.

Failures of this kind are visible rather than silent. The command is counted as failed in the bridge's status, the log names the endpoint and the setting, and the failed record stays for audit.

EtherNet/IP, Allen-Bradley Logix

The bridge writes points into named controller tags, on change and on an interval, and watches a set of command tags. It connects over explicit messaging on port 44818, through a CIP route path (1,0 is backplane slot 0).

The naming rule. A Logix tag name cannot contain a dot, so:

controller tag = the optional site prefix, plus the point or command name with every . replaced by _

job.good_count becomes job_good_count. A name your site chose is already a PLC name and survives unchanged: GOOD_CNT stays GOOD_CNT.

Every name is checked against the Logix grammar before the bridge opens a socket: a letter or underscore first, then letters, digits and underscores, no two underscores in a row, no trailing underscore, at most 40 characters. A name that fails, or two names that collide onto one controller tag, is a startup failure that names every problem at once rather than a write that lands in the wrong place.

Types.

tag model Logix notes
bool BOOL
int32 DINT A value outside the DINT range is a visible per-tag error, never a silent wrap.
uint32 DINT Not UDINT, which a Logix program cannot declare. The same 32 bits travel either way; a rolled-over counter reads negative.
double REAL, or LREAL where the site asks REAL is the native Logix float, and what a faceplate, a trend and a comparison instruction all take. It carries about seven significant digits.
string Logix STRING, SSTRING, or CIP STRING The Logix STRING is the default and the right answer on real hardware.
datetime the same string type ISO 8601 UTC, for example 2026-01-01T00:00:00Z, 20 characters.

Every string point is truncated to 82 characters, the Logix STRING capacity, whatever string type is in use, so one tag set fits every encoding. A truncated value is written and logged as a warning; it is never dropped. Strings are one byte per character, ISO-8859-1, so cafe and café both round trip; a character above U+00FF is a visible per-tag error naming the point and the character.

How points are written. The bridge writes; your program reads. None of these tags is ever read back by the bridge, so a rung may use them as inputs freely.

  • On change. Every point that changed is written, batched into one Multiple Service Packet where it fits.
  • On the interval. Every point is rewritten from the last snapshot, changed or not, on a fixed interval (30 seconds by default). That is what puts your controller back in step after a program download or a tag edit, without waiting for a value to change at the Vorne end.
  • On reconnect. The first write after startup, and after any link failure, is the whole snapshot rather than a change set.
  • Never a null. A tag-model point can be null; a Logix tag cannot, and the bridge does not invent one. A null is not written.

The quality companion. Because a value is written only while its quality is good, your program needs a way to tell a held value from a fresh one. Beside every point tag the bridge writes a DINT named with the same name plus _Q, on every write cycle:

quality _Q value the value tag
good 0 written
unavailable 1 left as it was
stale 2 left as it was

The companions can be turned off per site where the tag budget cannot take them. That choice is lossy and explicit: the controller then sees the last good value with no way to know how old it is, so put a board_online or heartbeat check in the program instead.

Command intake. A PLC has no way to call the bridge, so each command is a request counter your program owns and an acknowledgement pair the bridge owns:

tag type who writes it what it means
<CMD>_REQ DINT the PLC Change this to ask for the command.
<CMD>_ACK DINT the bridge The _REQ value the bridge has dealt with.
<CMD>_STS DINT the bridge What happened to it.
<CMD>_<payload key> varies the PLC One tag per payload key in use.

The handshake, step by step.

  1. Write the payload tags.
  2. Change <CMD>_REQ. Increment it, or toggle it; anything that makes it differ from its previous value is a request.
  3. Within the command poll interval (500 ms by default) the bridge reads every watched _REQ tag in one request and notices.
  4. The bridge reads that command's payload tags and submits the command.
  5. The bridge writes <CMD>_ACK equal to the _REQ value it processed, and <CMD>_STS.
  6. Your program waits for <CMD>_ACK to equal the <CMD>_REQ it wrote, then reads <CMD>_STS. Until then the request is outstanding.

Change detection is "differs from the last value processed", not "is greater than", so an incrementing counter, a toggling bit, and a counter that wraps all work, and none of them needs a rule about direction. A _REQ that does not move is not resubmitted; a _REQ that jumps by any amount is submitted exactly once.

Make the handshake tags retentive, and do not zero them on first scan

The bridge re-baselines after every reconnect, so a controller that comes back from a download with _REQ and _ACK at zero is read as history rather than as a request. A first-scan routine that zeroes those tags without the connection ever dropping is indistinguishable from your program asking for something, and the bridge will run it. Controller-scope DINTs are retentive by default; leave them that way.

The acknowledgement is also the memory. The bridge persists nothing of its own for this handshake: the two tags are the state. At startup, and whenever a command's tags become readable for the first time, the bridge reads both and baselines from them. _ACK equal to _REQ means the request was dealt with; _ACK different means your program is still waiting, and the request runs on the next poll. Baselining never submits anything.

Within one run of the bridge a _REQ value is submitted exactly once. Across a restart the bridge cannot tell "not yet handled" from "handled, but the process died before _ACK landed", so a request that was submitted and not acknowledged is submitted again by the next process. counts.inject_good is not idempotent downstream, so treat the _ACK pair as your own deduplication key: compare what you asked for against what came back, and do not raise a new _REQ while the previous one is unacknowledged.

Status codes.

_STS meaning
0 Accepted. The command reached the bridge's buffer and will be sent to the board.
1 Refused by the bridge: not whitelisted for this controller, not in the tag model, a payload the model does not allow, or a fault code that is not mapped. The reason is in the bridge log.
2 The bridge read the payload tags and the controller refused one of them, so nothing was submitted. A payload tag that does not exist, or one created at the wrong type.
3 The bridge itself failed while handling the request. Nothing was buffered.

A non-zero status is always written. A request the bridge will not run is answered, not ignored, so an integrator who built the tags always gets a code back in a tag they can see. Status 2 never means the link failed: a link failure leaves the request outstanding rather than blaming your tags.

Commands and their tags. Names below are for a site that renamed nothing. Payload tag types follow the type table above.

command _REQ, _ACK, _STS prefix required payload tags optional payload keys
job.set job_set_ job_set_job_id STRING, job_set_part_id STRING, job_set_ideal_cycle_time REAL takt_time, goal_count, job_description, part_description, down_threshold, target_labor_per_piece, start_with_changeover, changeover_reason, changeover_target
job.tag job_tag_ job_tag_job_id STRING none
counts.inject_good counts_inject_good_ none count
counts.inject_reject counts_inject_reject_ none count
rejects.submit rejects_submit_ rejects_submit_count DINT, rejects_submit_reason STRING none
state.set_reason state_set_reason_ state_set_reason_reason STRING none
state.start_production state_start_production_ none none
state.start_down_event state_start_down_event_ none none
changeover.start changeover_start_ none reason
changeover.end changeover_end_ none none
shift.start shift_start_ shift_start_shift STRING team_id, team_size
shift.end shift_end_ none none
team.set team_set_ team_set_team_id STRING, team_set_team_size DINT none
comment.add comment_add_ comment_add_text STRING target_type, target_event_id
plc.heartbeat plc_heartbeat_ none none

A required payload key always gets a tag. An optional key gets one only when the site lists it, because a controller tag is always set to something and there is no way for a PLC to say "leave this one out". List the optional keys your program actually supplies, and create only those tags.

Payload types. STRING is whichever string type the site configured; REAL follows the double type setting.

key Logix type
job_id, part_id, job_description, part_description, changeover_reason STRING
ideal_cycle_time, takt_time, down_threshold, target_labor_per_piece, changeover_target REAL
goal_count, count, team_size, target_event_id DINT
start_with_changeover BOOL
reason, shift, team_id, text, target_type STRING

Fault codes. state.set_reason and changeover.start carry a reason, and that reason is your own fault code, not a Vorne reason key. Write F17 into state_set_reason_reason; the bridge translates it through the site's fault code map before it reaches the board. A code that is not in the map is a refused command with status 1 and a log line naming the code. The bridge never guesses a reason.

The heartbeat. plc.heartbeat uses the same three tags as every other command. Increment or toggle plc_heartbeat_REQ on whatever period you like - once a second is plenty - and the bridge records liveness and writes the acknowledgement back. It never reaches a board and never takes a buffer sequence.

Do not confuse it with the board.heartbeat point, which runs the other way: the bridge increments that one on every successful board poll, so your program can see that the Vorne side is alive.

A tag that does not exist yet is retried, not written off. A command whose _REQ or _ACK tag cannot be read is reported as a failing tag and skipped, and every poll tries it again. On a Logix controller the bridge re-uploads the controller's tag list while anything it needs is missing, at most once a minute, so the worst case for commissioning a forgotten tag is one cadence rather than a bridge restart.

The tag list, as a Studio 5000 import. RSLogix 5000 and Studio 5000 both import a tag CSV through Tools > Import > Tags and Logic Comments. The format is a five-line remark header, a column header row, and one TAG row per tag:

remark,"CSV-Import-Export"
remark,"Date = Mon Sep 01 09:00:00 2026"
remark,"Version = RSLogix 5000 v20.00"
remark,"Owner = Vorne bridge"
remark,"Export Options = "
TYPE,SCOPE,NAME,DESCRIPTION,DATATYPE,SPECIFIER,ATTRIBUTES
TAG,,PLC_ONLINE,"Vorne board.online","BOOL",,"(RADIX := Decimal)"
TAG,,PLC_ONLINE_Q,"Quality of board.online: 0 good, 1 unavailable, 2 stale","DINT",,"(RADIX := Decimal)"
TAG,,GOOD_CNT,"Vorne job.good_count","REAL",,"(RADIX := Float)"
TAG,,GOOD_CNT_Q,"Quality of job.good_count: 0 good, 1 unavailable, 2 stale","DINT",,"(RADIX := Decimal)"
TAG,,JOB_ID,"Vorne job.id","STRING",,""
TAG,,JOB_ID_Q,"Quality of job.id: 0 good, 1 unavailable, 2 stale","DINT",,"(RADIX := Decimal)"
TAG,,PLC_JOB_SET_REQ,"job.set request sequence, written by the PLC","DINT",,"(RADIX := Decimal)"
TAG,,PLC_JOB_SET_ACK,"job.set acknowledged sequence, written by the bridge","DINT",,"(RADIX := Decimal)"
TAG,,PLC_JOB_SET_STS,"job.set status, written by the bridge","DINT",,"(RADIX := Decimal)"
TAG,,PLC_JOB_SET_job_id,"job.set payload job_id","STRING",,""
TAG,,PLC_JOB_SET_part_id,"job.set payload part_id","STRING",,""
TAG,,PLC_JOB_SET_ideal_cycle_time,"job.set payload ideal_cycle_time, seconds","REAL",,"(RADIX := Float)"
TAG,,PLC_ALIVE_REQ,"plc.heartbeat, increment or toggle to prove the PLC is alive","DINT",,"(RADIX := Decimal)"
TAG,,PLC_ALIVE_ACK,"plc.heartbeat acknowledged sequence","DINT",,"(RADIX := Decimal)"
TAG,,PLC_ALIVE_STS,"plc.heartbeat status","DINT",,"(RADIX := Decimal)"

Every other row follows the same shape: the name from the tables above, its Logix type, (RADIX := Decimal) for BOOL and DINT, (RADIX := Float) for REAL and LREAL, and an empty attribute for STRING.

The file is written for your site rather than generated blindly, because the tag set depends on which points and commands the site exposes.

What the bridge reports back. One connection per controller and never more than one; one exchange at a time. If a tag does not exist, or exists at the wrong type, that tag is reported as failing by name along with the controller's own CIP status, and the other tags keep working. A tag clears itself the moment a read or write of it succeeds, with no restart and no acknowledgement step.

First contact with hardware. Open with the controller tag list enabled, read the list, read and write one Logix STRING point, and confirm one command's handshake end to end. Those are the parts a simulator cannot prove.

OPC UA

The bridge runs an OPC UA server. Any client that can browse, read, and write variables reads every point and issues every command. No OPC UA method calls are involved, so a SCADA package restricted to plain tag reads and writes is enough.

The endpoint defaults to port 4840 and resource path /UA/PlcBridge, so the endpoint URL is opc.tcp://<bridge host>:4840/UA/PlcBridge.

Address space. Everything lives in namespace 1. Every node id is a string id built from names that do not change between restarts, so a client that stored a tag path keeps working after the bridge restarts.

Objects
  PlcBridge                               ns=1;s=PlcBridge
    <board>                               ns=1;s=PlcBridge/<board>
      <point>                             ns=1;s=PlcBridge/<board>/<point>
      Commands                            ns=1;s=PlcBridge/<board>/Commands
        <command>                         ns=1;s=PlcBridge/<board>/Commands/<command>
          Request                         ns=1;s=PlcBridge/<board>/Commands/<command>/Request
          Accepted, Reason, Sequence, CommandId, CompletedAt

<point> and <command> are the names the site configured, verbatim. A point the site excludes has no node at all. Commands is reserved: a site that tried to expose a point under that name is refused at startup rather than shadowing the folder.

Point variables are read only. A write to one answers BadNotWritable.

Data types and quality. bool is Boolean, int32 is Int32, uint32 is UInt32, double is Double, string is String, and datetime is DateTime. Each variable's Description attribute carries the unit and the source endpoint, so browsing the server tells you where a number comes from. The source timestamp of a value is when the board reported it, not when the server wrote it.

quality StatusCode what a client sees
good Good a fresh value
stale UncertainLastUsableValue the last known value, after a failed poll
unavailable BadNotSupported the board does not provide this point

A stale value is never Good, and the two non-Good codes are different, so you can tell "the link broke" from "this board never had it". A point whose value is null is published as BadNoData with no value rather than as a zero that would read as a measurement. Before the first publish a point reads BadWaitingForInitialData.

A value the declared type cannot carry is refused on that point alone and the rest of the update still lands: the wrong runtime type is BadTypeMismatch, an integer out of range or with a fraction is BadOutOfRange, and a timestamp that is not an ISO 8601 instant is BadInvalidTimestamp. A fractional value for an integer point is refused rather than truncated.

Commands. Write one JSON object, as a String, to the command's Request node. Writing the same request again invokes the command again, so a client pulses a command by writing whenever it wants one. An empty write, or {}, is an empty payload, which is how a command that takes none is invoked.

One JSON node rather than one node per field is deliberate: the JSON object carries the types you chose, unchanged, all the way to the bridge.

The five result nodes beside Request hold the receipt for the last request written to it:

  • Accepted (Boolean): whether the bridge buffered it.
  • Reason (String): why it was refused, empty when accepted.
  • Sequence (UInt32): the buffer position it took. 0 when refused, and for plc.heartbeat, which never reaches a board.
  • CommandId (String) and CompletedAt (DateTime): the bridge's id for it, and when the receipt came back.

A refused write also answers the write itself, so a client learns without reading anything back:

what happened write status code
accepted Good
anonymous session, writes not open BadUserAccessDenied
the request is not a JSON object BadInvalidArgument
the bridge refused the command BadRequestNotAllowed

Worked example: start a part run on board line-1. Write to ns=1;s=PlcBridge/line-1/Commands/job.set/Request:

{ "job_id": "J-4471", "part_id": "P-22", "ideal_cycle_time": 2.5, "goal_count": 1200 }

job_id and part_id are strings, ideal_cycle_time and goal_count are numbers, and that is exactly what the bridge receives. The write answers Good, and ns=1;s=PlcBridge/line-1/Commands/job.set/Sequence then holds the buffer position the command took. Leaving out part_id answers BadRequestNotAllowed and puts the reason on the Reason node.

Security defaults.

  • Anonymous sessions may connect, browse, and read. That is what a client does first, and points carry no credentials.
  • Anonymous sessions may not write. A command starts jobs, injects counts, and changes production state on a real line, and an endpoint on a plant LAN is reachable by anything on that LAN. Anonymous writes are off by default and answer BadUserAccessDenied; turn them on only for a bench or simulator endpoint.
  • Writing a command needs a configured account. Enforcement is on the node itself, through OPC UA role permissions, so a write from an unauthenticated session is refused by the server before the bridge sees it.
  • The default endpoint set offers None and Basic256Sha256, with modes None and SignAndEncrypt. A configuration that declares accounts while offering only None is refused at startup, naming both settings, because those accounts could never authenticate and the endpoint would come up looking secured while refusing every write.
  • The server certificate is generated on first start and reused after that, in a directory outside any working tree so a cleanup cannot force every client to re-trust the server. Deployed, that directory is the service's own persistent state volume, so the certificate outlives a replacement container and your client does not have to trust the bridge again after a deployment.
  • Client certificates are accepted automatically. Authority to write comes from the account, not the certificate.

Connecting from Ignition.

  1. Config, OPC Client, OPC Connections, Create new OPC UA Connection.
  2. Discovery endpoint URL: opc.tcp://<bridge host>:4840/UA/PlcBridge.
  3. Security policy None and security mode None to get browsing working first, or Basic256Sha256 with SignAndEncrypt for a production line.
  4. Authentication: Anonymous is enough to read. To write commands, choose Username and Password and enter one of the configured accounts.
  5. If Ignition quarantines the bridge's certificate, approve it once under OPC Connections, Certificates.
  6. Browse to PlcBridge and drag the board folder into a tag provider. Ignition stores each tag as its node id, so the tags survive a bridge restart.

Several boards on one endpoint. The bridge configures one adapter per board, and adapters that agree on every endpoint setting share one server: the PlcBridge folder then holds one folder per board. An adapter that names the same address and port but differs in any endpoint setting is refused, naming the settings that differ, rather than joining a server whose security is whichever one started first.

Modbus TCP

One register map serves two roles. By default the bridge is a Modbus TCP server: it listens on port 502 and your PLC or HMI polls it. In client role the bridge connects out to the controller and mirrors the same map into that device's holding registers instead, for the many controllers that can only be a Modbus server themselves.

Everything lives in holding registers (FC3 to read, FC6 and FC16 to write). In server role the point area and the quality bitmap are served as input registers (FC4) at the same addresses as well, so a master that reads only input registers still sees every value. There are no coils and no discrete inputs.

Addresses here are zero-based protocol addresses, the number that goes on the wire. Most PLC toolchains show the conventional one-based 4xxxx and 3xxxx names instead.

Layout.

area registers contents
point area 0 to 1023 one block per tag model section, 256 registers each
quality bitmap 1024 to 1031 one bit per point, 16 points per register
command blocks 2000 and up one mailbox per command, 300 registers apart

A read must lie inside one region: the point area plus the quality bitmap, which are contiguous and can be polled in one sweep, or a single command block. A read that leaves the map, or straddles the gap between two regions, is answered with exception 02 rather than with zeros. Registers a point does not use read as zero and are reserved for later points in that section.

Addresses never move. A retired point leaves its registers reserved and a new point is appended inside its own section. Moving, narrowing, or re-encoding anything already published is a major bump of the map version and a site visit for every PLC that reads this bridge.

How a value is stored. Every register goes on the wire big-endian, as Modbus requires. A 32-bit value takes two registers, high word first; the word order setting swaps the two words, never the two bytes inside a word, and never a string.

tag type registers encoding
bool 1 0 or 1
int32 2 two's complement
uint32 2 unsigned
double 2 IEEE 754 binary32 (float32)
datetime 2 unsigned Unix seconds, UTC
string 32 64 ASCII characters, two per register, high byte first, NUL padded

Counts, ratios, percentages, and seconds are published as float32. A count is exact up to 16,777,216 pieces, and ratios, percentages, and cycle times lose nothing a line acts on. Above 16,777,216 a count is rounded to the nearest value float32 can hold, which is the one place a register is not exactly what the board reported.

A string longer than 64 characters is truncated and a non-ASCII character becomes ?; both are logged as warnings, once per point per run, so a mangled asset name shows up in the log and not only on the PLC. A datetime outside 1970-01-01 to 2106-02-07 does not fit unsigned Unix seconds and reads as zero with its quality bit clear.

The quality bitmap. One bit per point, in the order of the point tables: bit 0 of register 1024 is the first point, bit 15 the sixteenth, bit 0 of register 1025 the seventeenth. 1 means good.

The bit is 0 when the point is stale, unavailable, excluded by this site's configuration, not published yet, or holds a value the encoding cannot represent. A stale or unavailable point keeps its last value in its registers, so a PLC can hold the last known reading; an excluded or never-published point reads as zero.

Read the quality bit before acting on a value. A zero register with the bit clear means "no value", not "zero pieces".

Command mailboxes. Every command has a mailbox whether or not the site exposes it; the bridge enforces the whitelist and refuses one the site did not expose. Each block starts at its base address with this fixed header, then its arguments in payload order:

field offset registers encoding written by
EXECUTE 0 1 uint16 PLC
RESULT 1 1 uint16 bridge
RESULT_COUNT 2 1 uint16 bridge
SEQUENCE 3 2 uint32 bridge
ERROR_CODE 5 1 uint16 bridge
ERROR_TEXT 6 32 string bridge
ARGS_PRESENT 38 1 uint16 PLC
  • EXECUTE: write 1 to run the command. The bridge holds it at 1 while it works and clears it to 0 once the result is written.
  • RESULT: 0 never run, 1 accepted, 2 refused, 3 in progress (client role only: the bridge claims a request by writing RESULT 3 before it runs it).
  • RESULT_COUNT: incremented once per handled execute, wrapping from 65535 to 0. Watch it change rather than counting it.
  • SEQUENCE: the buffer position the bridge gave the command when it accepted it, otherwise 0.
  • ERROR_CODE: 0 ok, 1 refused by the bridge, 2 arguments that could not be decoded, 3 the bridge could not take the command, 4 the bridge restarted while the request was in flight (client role only, and see the warning under Client role below).
  • ERROR_TEXT: the refusal reason or the decode error, truncated to 64 characters. Empty on accept.
  • ARGS_PRESENT: bit i is optional argument i, counting only the optional arguments in block order. A required argument is always read.

Only EXECUTE, ARGS_PRESENT, and the argument registers may be written. A write that touches any other register of a block, or the point area or the quality bitmap, is answered with exception 02.

The handshake.

  1. Write the argument registers (FC16, or FC6 one at a time).
  2. Write ARGS_PRESENT with a bit set for each optional argument you supplied.
  3. Write EXECUTE = 1 (FC6).
  4. Poll EXECUTE. While it reads 1 the bridge is working, and a write into that block is answered with exception 06.
  5. When EXECUTE reads 0, read RESULT, ERROR_CODE, SEQUENCE, ERROR_TEXT, and RESULT_COUNT.

Accepted means the bridge buffered the command, not that the board executed it. SEQUENCE is that buffer position, per board, and it restarts at 1 when the bridge process restarts: it tells you an execute was taken, never that a piece was counted.

Arguments stay in their registers between commands, so a second execute repeats the previous values unless you rewrite them. A string argument the bridge reads as empty is refused with ERROR_CODE 2 rather than sent on as an empty value. Do not rewrite a block's arguments while EXECUTE is still 1: the arguments are read once, when the bridge picks the request up, and anything written during that window belongs to the request already in flight.

Worked example: start a job (job.set, block base 2000). Job J-1000, part P-42, ideal cycle time 12.5 s, and the optional goal count 500:

1. FC16 at 2039, the 32 registers of job_id:
   0x4A2D ("J-"), 0x3130 ("10"), 0x3030 ("00"), then 29 zeros.
2. FC16 at 2071, part_id the same way:
   0x502D ("P-"), 0x3432 ("42"), then 30 zeros.
3. FC16 at 2103 with 0x4148, 0x0000: 12.5 as float32, high word first.
4. FC16 at 2107 with 0x0000, 0x01F4: 500 as int32.
5. FC6 at 2038 with 0x0002: goal_count is optional argument 1 and no
   other optional argument is supplied.
6. FC6 at 2000 with 1.
7. Poll 2000 until it reads 0, then read 2001 (RESULT), 2003
   (SEQUENCE), and 2005 (ERROR_CODE).

One FC16 carries at most 123 registers, so ARGS_PRESENT and every argument of job.set take two write requests. Every other block, and every command block header, fits in one.

Worked example: a fault code (state.set_reason, block base 3500). A PLC has fault numbers, not Vorne reason keys, so it writes its own code as ASCII and the bridge translates it through the site's fault code map:

1. FC16 at 3539 with 0x3137 ("17") and 31 zeros.
2. FC6 at 3500 with 1.
3. When 3500 reads 0, RESULT is 1 and the board is put into the state
   behind the mapped reason.

A fault code the map does not list is refused with RESULT 2, ERROR_CODE 1, and ERROR_TEXT naming the code and the codes that are mapped. The bridge never guesses a reason.

Where the blocks are. Blocks are 300 registers apart, with 39 registers of header before the first argument:

command base registers
job.set 2000 242
job.tag 2300 71
counts.inject_good 2600 41
counts.inject_reject 2900 41
rejects.submit 3200 73
state.set_reason 3500 71
state.start_production 3800 39
state.start_down_event 4100 39
changeover.start 4400 71
changeover.end 4700 39
shift.start 5000 105
shift.end 5300 39
team.set 5600 73
comment.add 5900 105
plc.heartbeat 6200 39

The register-by-register address of every point and every argument is in the map handed over with this guide. It is generated from the bridge's own code, so it cannot drift from what the service serves.

Exceptions.

code meaning when
02 illegal data address a read or write outside the map, a read that straddles two regions, or a write into a register the bridge owns
06 server device busy a write into a command block whose EXECUTE is still 1

Every exception is also logged with the function code, address, length, and unit id. A bad request is never answered with a silently ignored write or with zeros.

Client role, for a controller that cannot poll. The bridge connects out to the PLC, mirrors the point area and the quality bitmap into that device's holding registers, and polls the command mailboxes there. It is the same map, offset by a configurable base address: the point area starts at the base, the quality bitmap at base plus 1024, and job.set at base plus 2000. Encodings, block layout, header fields, the ARGS_PRESENT rule, and the handshake your program follows are all unchanged. Two mailbox codes are added, RESULT 3 and ERROR_CODE 4, and they exist only here.

The PLC must provide 6239 holding registers from the base address onward, readable and writable by the bridge. The base may be 0 to 59297, because the whole map has to end below 65536. Registers in the gaps between blocks are never touched, but the bank still has to span the range.

On every successful connect the bridge writes the whole image, 1032 registers, in chunks. After that each update writes only what changed, and a point is always written whole, so a 32-bit value or a string is never left half updated.

The one thing client role does not have is exception 06: the bridge is the master, so there is no request from a PLC to refuse while a block is busy.

The claim, and the two codes that come with it. The mailboxes live in your PLC here, not in the bridge, so the record that a request has been picked up is kept in your registers: a block whose EXECUTE is raised and whose RESULT is not 3 is claimed first, by one write of RESULT 3, and only a claim the PLC accepted is followed by a decode and a submit. That is the only place RESULT 3 appears.

Once a block's command has been submitted, that block is answered until its EXECUTE is seen at 0, and an answered block is never submitted a second time. An answer write the PLC refuses, or a connection lost between the submit and the answer, costs a retried answer on the next poll rather than a second command on the board.

In client role, ERROR_CODE 4 means unknown, not "did not happen"

On its first poll after starting, the bridge reads a block holding EXECUTE with RESULT 3 as a request an earlier process had claimed. Whether that process submitted it went with it, so the request is never submitted: it is answered RESULT 2, ERROR_CODE 4, SEQUENCE 0, with RESULT_COUNT advanced.

A PLC program must treat ERROR_CODE 4 as "unknown", not as "did not happen". The request may or may not have reached the board. Check the board before raising EXECUTE again, and never blindly re-raise a count injection: a counts.inject_good that ran twice double counts. A command that only sets state, such as job.set or state.set_reason, is safe to repeat.

In server role the mailboxes live in the bridge, so there is nothing to claim: a restart clears them, and a PLC waiting on a request that was in flight sees RESULT_COUNT never advance.

EXECUTE going back to 0 is what says the result is ready

In both roles, wait for EXECUTE to read 0 and then read the result fields. Do not poll RESULT while EXECUTE is still 1: the bridge clears EXECUTE once the result is written, and that is the only signal that the answer in those registers belongs to the request you raised.

In client role that is a guarantee rather than a convention. EXECUTE and the result fields change in ONE Modbus transaction, a single FC16 of 38 registers from the block base carrying EXECUTE 0 in front of RESULT, RESULT_COUNT, SEQUENCE, ERROR_CODE and ERROR_TEXT, so a PLC can never see the result fields with EXECUTE still raised, nor a cleared EXECUTE with the previous request's result behind it. The write stops short of ARGS_PRESENT: the arguments belong to the PLC and the bridge never writes them.

MQTT Sparkplug B

The bridge publishes the tag model to an MQTT broker as Sparkplug B: NBIRTH and DBIRTH on connect, DDATA on change, and NDEATH through the last will. Sparkplug DCMD and NCMD become commands. A plain JSON topic mode is available as an explicit opt-in for a consumer that does not speak Sparkplug; nothing selects it automatically.

Topics. The standard layout, so a Sparkplug host such as Ignition, HiveMQ, or Chariot discovers the bridge with no configuration:

spBv1.0/<group>/NBIRTH/<edge>            node birth
spBv1.0/<group>/NDEATH/<edge>            node death, and the last will
spBv1.0/<group>/NCMD/<edge>              node command, subscribed
spBv1.0/<group>/DBIRTH/<edge>/<device>   device birth
spBv1.0/<group>/DDATA/<edge>/<device>    device data
spBv1.0/<group>/DCMD/<edge>/<device>     device command, subscribed

With group Plant7 and board line-1 that is spBv1.0/Plant7/NBIRTH/PlcBridge and spBv1.0/Plant7/DBIRTH/PlcBridge/line-1.

There is no DDEATH. Sparkplug asks for one only when an edge node loses connection with an attached device, and this device is the board it serves, with no lifecycle apart from the node's. The specification already requires a host to mark every device under a node offline the moment the NDEATH arrives. A topic a host is told to watch and that is never published on is worse than no topic at all.

Every QoS and retain flag is fixed by the specification, so there is nothing to configure and the adapter is conformant out of the box.

Metric names and datatypes. Metric names are the names the site configured, or the tag names where it renamed nothing. DBIRTH declares both points and commands, because a Sparkplug host will not let an operator write to a metric it never saw in a birth.

A point metric carries the current value and a quality property holding good, unavailable, or stale, which is the only thing separating a real zero from a board that never answered. A command metric carries an empty string and a payloadKeys property listing its payload keys, with optional ones marked ?.

tag model type Sparkplug datatype
bool Boolean
int32 Int32
uint32 UInt64
double Double
string String
datetime DateTime, epoch milliseconds

uint32 is widened to UInt64 on purpose. UInt64 travels through the same wire field in every JavaScript Sparkplug codec and in the reference Java encoder, and the whole uint32 range fits without loss, so it is the one representation that round trips everywhere. board.heartbeat is the only point this affects.

A value bound for an integer datatype is checked for being a whole number, and for range, before it is encoded, because protobuf checks neither: it would truncate 1.5 to 1 and wrap 2147483648 to -2147483648, and neither is distinguishable from a real reading. int32 must be -2147483648 to 2147483647 and uint32 0 to 4294967295, which is the tag model's own bound rather than UInt64's. A value that fails is left out of the payload altogether, never truncated, wrapped, clamped, or substituted, and the rest of the same birth or data message is published as usual.

A point whose value is null is published with Sparkplug's own is_null flag set and no value, in DBIRTH and DDATA alike, so a subscriber can tell "never reported" from "reported zero" without even reading the quality property. Substituting a value would be the contract violation.

Commands in: DCMD and NCMD. One metric invokes one command.

  • Metric name: the command name, exactly as DBIRTH declared it.
  • Metric type: String, or Text, which some hosts send.
  • Metric value: a JSON object of the command's payload keys. An empty string means no payload.

Publishing one Sparkplug payload to spBv1.0/Plant7/DCMD/PlcBridge/line-1 holding one metric:

name:  PLC_JOB_SET
type:  String
value: {"job_id":"J-1","part_id":"P-9","ideal_cycle_time":12.5}

In Ignition that is a single string tag write on the metric the DBIRTH declared. Several metrics in one DCMD invoke several commands, each submitted on its own. Nothing is guessed: a non-string metric, a null metric, a value that is not JSON, and JSON that is not an object are each refused with a reason naming the metric.

On the NCMD topic, the standard Node Control/Rebirth as a Boolean true republishes NBIRTH and then DBIRTH from the latest snapshot. A false is a deliberate no-op, and any other type is refused as malformed rather than assumed to be true.

seq and bdSeq. seq is one byte, 0 to 255, wrapping. NBIRTH is the only message that resets it to 0; DBIRTH and DDATA each take the next value. That is how a subscriber notices two DDATA messages arriving out of order or one going missing: a gap or a repeat is the signal, and a seq of 0 means the previous numbering ended.

bdSeq is per broker session, carried in NBIRTH and in the NDEATH the will delivers, so a host can match a death to the birth it is holding. A reconnect inside one session keeps the same bdSeq and republishes the births.

Across a process restart the counter starts at 0 again unless the site configures a file to persist it in, so bdSeq alone does not distinguish the session before a restart from the one after. A host must treat any NBIRTH as the start of a new session, which the specification already requires of it.

What a failure looks like. A lost connection is reported as degraded with the reason and the reconnect interval, logged once per transition, and the reconnect republishes NBIRTH and DBIRTH from the latest snapshot, so nothing the board reported while offline is lost. A broker that refuses the credentials, or that cannot be reached within the connect timeout, is a startup error naming the host and port.

A malformed command does not change health, because a healthy state means the broker link is up. It is logged with the metric and the reason, and counted. A command the bridge refuses is logged with the reason and counted the same way.

A clean shutdown publishes NDEATH itself rather than relying on the will, so a planned stop is as visible to a host as a crash.

Identity beside Sync Bridge. Sync Bridge, the suite's ingest service, can sit on the same broker under the same group id, which is safe because Sparkplug addresses a node by group and edge node. What is not safe is reusing its edge node or its client id, and the configuration refuses both of those names outright.

Commissioning checklist

  1. Confirm the board's Access Control settings permit the bridge's writes, including Comment Permissions if comment.add is in scope and API input injection if the count injection commands are.
  2. Create the tags, nodes, registers, or metric subscriptions from the list handed over with this guide, at the types the protocol section above gives.
  3. Confirm the points arrive: read board.online, board.heartbeat, and one count, and confirm the quality companion, quality bit, or status code beside each of them reads good.
  4. Confirm one command end to end. plc.heartbeat is the safest, as it never reaches the board. Then run one real command and read the status back.
  5. Confirm your fault codes. Send one code that is mapped and one that is not, and check that the second is refused with a status your program can see.
  6. Pull the plug. Stop the bridge, or the board, and confirm your program sees quality go stale rather than reading held values as fresh.