Skip to main content

Speak the same binary schema as your gateway

Industrial gateways and PLCs often publish binary protobuf on MQTT while the broker maps plant tags (Modbus, S7, OPC UA) to plain topics. LoT models let you decode those commands, run normal tag logic in the middle, and encode telemetry back — without a separate transformation service.
Dynamic protobuf encoding and decoding in LoT is beta. Prefer PUBLISH MODEL for binary MQTT output. Match your vendor .proto with PROTO_TAG and report issues with that layout and your broker version.
Wire numbers are mailbox slots, not labels. Protobuf finds a field by its number (PROTO_TAG), not by the LoT or .proto field name. If the slot numbers drift, the message still arrives — it just lands in the wrong boxes, or looks empty.

When to use this

Use protobuf models when:
  • a gateway or SCADA system publishes raw protobuf bytes on MQTT;
  • you must emit protobuf that a vendor decoder already understands;
  • field numbers in the .proto are sparse (for example 101) or nested.
Use JSON models and GET JSON when payloads are JSON.
Start from the direction you need: decode inbound bytes, publish outbound bytes, or both.

Quick Start

Read top-level fields from a binary command payload with GET PROTO. Declare every command arm so unused submessages keep their wire numbers:
The Action reads command_type from the triggering payload:

Where protobuf applies

Define protobuf models for edge topics. Use GET PROTO to read and PUBLISH MODEL to write. Tag reads and writes stay on your existing OT routes.

Match your .proto

To stay aligned with a hand-written schema (including non-contiguous field numbers and nested messages):
  1. PROTO_TAG — wire numbers must match the .proto (not field names).
  2. Nested messages you fill on publishADD MODEL ChildName "field" where ChildName is a DEFINE MODEL.
  3. Reserved wire slotsADD OBJECT "field" PROTO_TAG n when the parent must keep empty submessage fields so later tags stay aligned.
GET PROTO and GET PROTOBUF are the same verb. PROTO and PROTOBUF are the same format keyword.

Type mapping

A field is only decoded when the wire type the sender used matches the type your model implies. Get this wrong and that field is skipped — there is no error on the wire. ADD INT is a varint. Proto fixed32, sfixed32, fixed64, and sfixed64 use a different wire encoding and have no LoT keyword (ADD FIXED32 does not exist). If those types appear in the vendor schema, declaring them as ADD INT will skip or misread the field.

What happens on a mismatch

The decoder follows the protobuf unknown-field rule rather than failing the whole message: A skipped field is absent, and GET PROTO returns null for it. There is no error log — an unexpected null almost always means a wire-type mismatch.

How field numbers are assigned

The broker does not ship a plant schema. Numbers come from your DEFINE MODEL: If the .proto uses field 101 but LoT omits PROTO_TAG 101, the next sequential number is used and the gateway decoder fails.

Inbound: GET PROTO

Prefer IN PAYLOAD on the triggering topic. Use the other sources when the bytes are already cached or wrapped as Base64: Pass USING MODEL "Name" unless a DEFINE MODEL … WITH TOPIC already registered that schema on the topic. AS INT / DOUBLE / STRING / BOOL / BYTES selects the LoT type after decode. Read from the last payload stored on another topic:
Or decode Base64 that arrived as JSON:
Notes that matter in practice:
  • Each GET PROTO decodes the payload from scratch. Reading two fields means two independent passes.
Do not use IF PAYLOAD IS PROTO to detect binary MQTT. Call GET PROTO … IN PAYLOAD instead.

Parent command with reserved field numbers

Vendor .proto files often declare several command arms but send only one. List every submessage slot on the parent so unused fields do not shift wire numbers. Use ADD OBJECT for unused slots. When publishing a filled nested message, use ADD MODEL on the parent instead.

Middle: tags stay JSON

Between command and telemetry, most OT setups already have tag values on topics — from a DEFINE ROUTE mapping, a collapsed JSON model, or single-value publishes. That step stays JSON or plain text unless your plant standard requires protobuf on those topics too.

Outbound: PUBLISH MODEL

Topic vs format

The next PUBLISH MODEL to the same topic merges: binary for PROTO only; JSON from the main topic for JSON / BOTH. KEEP + protobuf updates the byte cache without per-field JSON fan-out. Trigger-based models (WITH TOPIC + AS TRIGGER) still auto-publish JSON on the base topic. Use PUBLISH MODEL when the edge must see binary.

Response envelope with sparse tags

Alarms and events often use high field numbers (101, 102). Tag them explicitly; use ADD MODEL for nested bodies you fill on publish. MessageHeader and ProcessValues match Quick Start — the new pieces are AlarmEvent and field 101:
LoT names are for PUBLISH MODEL / GET PROTO. Wire numbers come only from PROTO_TAG. Set message_type to the enum integer your .proto expects.

Raw bytes fields

Some messages carry opaque bytes (recipe blob, certificate, file chunk). Assign them with BYTES FROM BASE64:
Then the Action fills payload from a Base64 JSON field:
To forward an entire protobuf payload unchanged (no field access), republish PAYLOAD as-is instead of decoding with GET PROTO.

Common pitfalls

ADD OBJECT when publishing a populated nested message produces JSON-in-bytes. Use ADD MODEL.
Events at 101+ need an explicit tag. Omitting unused command slots on the parent also shifts later wire indexes.
device is a LoT keyword, so SET "device" may parse but interpolating that name does not. Use device_id. If an Action fails with Unexpected '<name>' where IDENTIFIER was expected, rename the variable.

Troubleshooting

GET PROTO returns "null" rather than raising, so a wrong schema looks like missing data. To confirm what is on the wire, decode a captured payload with a protobuf raw decoder (protoc --decode_raw). It reports field numbers and wire types without needing the schema — that pair must match your DEFINE MODEL.
Beta limitations
  • Trigger-based models auto-publish JSON only — use PUBLISH MODEL for binary.
  • No native google.protobuf.Timestamp; carry timestamps as STRING or Unix INT.
  • The sidecar topic for BOTH is a /protobuf suffix on the destination topic (not /proto).
  • No protobuf groups (wire types 3 and 4) — a group stops decoding of that message.
  • Packed repeated scalars are skipped so decoding can continue; they are not expanded into LoT arrays.

Next Steps

Publishing Models

Control when and where COLLAPSED models publish, including protobuf bytes.

GET PROTO in Actions

Full GET PROTO syntax, sources, and types.
Last modified on September 4, 2026