Why Your SCADA Client Shows an OPC UA Struct as a Blob — and How to Decode It
A PLC exposes a UDT over OPC UA and your client shows a ByteString. What an ExtensionObject actually holds, and how clients decode structured DataTypes.
You browse a Siemens S7-1500 or CODESYS server, find the tag the controls engineer promised you, and instead of a nice float you get a single node with a DataType you have never heard of — and a value that reads as ByteString or just Unknown. Nothing is broken. The PLC published a structure, and your client does not know how to take it apart.
This trips people up because most SCADA clients are fluent in the 25 built-in types (Int16, Float, DateTime, String…) and treat everything else as opaque bytes. A structured DataType — a PLC UDT, a Siemens type, a custom struct — travels on the wire wrapped in an ExtensionObject, and the ExtensionObject is deliberately self-describing only if you go read the description.
What is actually in that value
An ExtensionObject is three parts (OPC UA Part 6, binary encoding):
- a TypeId — the NodeId of the encoding node, not the DataType itself. This is the "Default Binary" (or Default XML / Default JSON) encoding object for that struct.
- an encoding byte — 0 means no body, 1 means a ByteString body follows, 2 means XML.
- the body — the struct's fields serialized back to back.
The part everyone gets wrong: OPC UA binary encoding has no padding and no alignment. A struct of { Bool, Int32, Float } is 1 + 4 + 4 = 9 bytes, not padded to 12. Fields are written in declaration order. Strings and arrays are length-prefixed with an Int32. If you ever hand-parse a body and your offsets drift by a few bytes, this is why — you brought C struct alignment habits to a format that has none.
So the wire tells you which type (via TypeId) and the raw bytes, but not the field layout. That layout lives somewhere else, and where it lives depends on how modern the server is.
The two ways a client learns the layout
Modern servers (OPC UA 1.04+): the DataTypeDefinition attribute. Every DataType node can carry a DataTypeDefinition attribute — AttributeId 23. For a struct it holds a StructureDefinition: the ordered fields, each with a name, DataType NodeId, and valueRank. Read that one attribute and a generic client can build a decoder at runtime with no prior knowledge of the type. This is the whole reason 1.04 added it. If you control the client, prefer one that reads AttributeId 23 — it makes UDTs "just work" against any compliant server.
Older servers: the type dictionary. Before 1.04, the layout lived in a DataTypeDictionary node in the OPC Binary namespace — a chunk of BSD (Binary Schema Description) XML holding every custom type on the server. The client downloads that blob, parses the XML, matches your struct by its DataTypeDescription node, and builds a codec. It works, but it is a lot of moving parts, and I have seen servers ship a dictionary that disagrees with what they actually encode. When they disagree, trust the wire and fix the server.
Two wrinkles worth knowing before you debug a field that "reads wrong":
- Optional fields (
StructureWithOptionalFields) put a 32-bit bit mask at the front of the body — one bit per optional field. A cleared bit means that field is simply absent from the bytes, so every field after it shifts. Miss the mask and everything downstream decodes as garbage. - Unions (
StructureType = Union) start with a 32-bit SwitchField selecting which single member is present. Only that member's bytes follow.
What to actually do at commissioning
If your SCADA platform can read DataTypeDefinition, point it at the struct and move on. If it can't — and plenty of older HMIs can't — you have three options, roughly in order of how much I trust them:
- Flatten in the server. Expose the struct's members as individual scalar nodes (
Motor1.Speed,Motor1.Running) instead of one struct node. On an S7-1500 you can often just map the members; in CODESYS you add them to the symbol configuration individually. The SCADA client sees plain floats and bools and never touches an ExtensionObject. Boring, and it always works. - Use a client that decodes. Ignition's OPC UA driver, UaExpert, and most modern stacks read the definition and present the fields. If your platform is one of them, you are done.
- Hand-parse the ByteString. Last resort. You read the raw body and slice fields yourself. Only do this if the type is frozen and version-locked, because the day someone adds a field to the UDT and rebuilds, every offset moves and your parser returns plausible-but-wrong numbers with no error. Add the DataTypeVersion to whatever you build so at least a change trips an alarm instead of silently corrupting data.
The failure mode that costs you a night is #3 done casually: a struct that decoded fine for six months, a UDT change nobody told SCADA about, and a screen full of numbers that are all subtly off because a Bool got inserted at the top and shoved a 32-bit mask into everyone's math. If you can flatten at the server, flatten — a struct on the wire is a convenience for the PLC programmer and a liability for the integrator.