Skip to Content
PRIZM CloudBlueprintsBytes to Value

Bytes to Value

Overview

This prefab parses a raw BYTES input into a set of output ports, using a schema you define. Each top-level key in the schema becomes its own output port; a . in a key nests related fields into a MAP on one port instead of spreading them across separate ports (see Ports and Nested Keys below). It is specifically designed to work with binary data from systems that use word-based addressing, which is common in industrial datasheets for devices like PLCs, sensors, and Modbus-compatible hardware.

It allows you to copy memory addresses directly from a datasheet, and the prefab will handle the conversion to the correct byte position automatically.

How it Works

The core logic revolves around two key constants: word_size_bytes and the schema. For each field defined in the schema, the parser calculates the starting byte position using the formula:

byte_address = word_address * word_size_bytes

This allows you to think in terms of “words” or “registers” (as defined in your device’s documentation) without doing manual byte math.

Configuration

Constants

ConstantTypeDescription
word_size_bytesINTRequired. The size of a single ‘word’ in bytes. This is used as a multiplier for the word_address. Common values are 1 (for 8-bit registers), 2 (for 16-bit registers), or 4 (for 32-bit registers).
schemaJSONRequired. A JSON array of objects, where each object defines a field to be parsed from the byte array. See the Schema Fields section below for details on how to structure each object.

Schema Fields

Each object in the schema array defines one key-value pair to be parsed and sent out on an output port.

KeyTypeRequired?Description
keyStringYesThe key name for this value. The first segment (before any .) names the output port this value is sent on; further segments nest the value inside a MAP on that port, at any depth ("group.value" sends { "value": ... } out the group port). It can interpolate the value of a field parsed earlier using {other_field} syntax (e.g. "{tag}"), and a key beginning with __ marks the field as internal — parsed and usable by later fields, but left out of the output entirely (no port is created for it). See Ports, Nested Keys, Dynamic Keys and Hidden Fields below.
word_addressInteger or StringYesThe starting address of the data in “words”. This is the value you would typically copy from a technical datasheet. It can be a fixed number (122), a hex string ("0x7A"), a negative number for addressing from the end of the data (-1 is the last word), or a dynamic expression computed from fields parsed earlier ("other_field - 2"). See Dynamic Word Addresses below.
typeStringYesThe data type to parse. See the Data Types table below for all possible values.
endianStringYesThe endianness (“byte order”) for multi-byte data types. Can be le (Little-Endian) or be (Big-Endian).
lengthInteger or StringNoRequired only for STRING and BYTES types. The number of bytes to read for that field. It can be a fixed number (3), a hex string ("0x03"), or a dynamic expression computed from fields parsed earlier ("len_field" or "total - 1"). See Dynamic Lengths below.
scaleFloatNoA multiplier to apply to a numeric value after parsing. The formula is final_value = (raw_value * scale) + offset. Defaults to 1.0.
offsetFloatNoFor numeric types, an additive value to apply after parsing and scaling. The formula is final_value = (raw_value * scale) + offset. Defaults to 0.0. For a BIT field, offset instead selects which bit to read (the scale/offset math does not apply) — see Bit Fields below.
ifObjectNoA condition that decides whether this field is parsed at all. It is checked against the fields parsed before it; if the condition isn’t met, the field is skipped and left out of the output. See Conditional Fields below.

Data Types (for the type field)

ValueDescriptionSize (Bytes)
I88-bit Signed Integer1
U88-bit Unsigned Integer1
I1616-bit Signed Integer2
U1616-bit Unsigned Integer2
I3232-bit Signed Integer4
U3232-bit Unsigned Integer4
I6464-bit Signed Integer8
U6464-bit Unsigned Integer8
F3232-bit Float4
F6464-bit Float8
BOOLBoolean (true if byte is not 0)1
BITA single bit of a word, as a boolean (see Bit Fields)word (word_size_bytes)
STRINGUTF-8 Stringlength
BYTESRaw Byte Arraylength

Bit Fields (BIT)

A BIT field extracts a single bit and emits it as a boolean. Unlike the fixed-size numeric types, it reads the entire word (word_size_bytes bytes) at the address, assembles those bytes into an unsigned integer using the field’s endian, and returns whether one specific bit of that integer is set. This mirrors how bit/coil addressing works on Modbus registers and similar hardware.

The bit to read is given by the offset field, which is reused for this purpose on BIT fields — the usual scale/offset arithmetic does not apply. Details:

  • offset is required for BIT fields — you must say which bit to read.
  • Bit 0 is the least significant bit of the assembled integer, bit 1 the next, and so on up to word_size_bytes * 8 - 1.
  • An index outside the word (>= word_size_bytes * 8) causes the field to be skipped.
  • Because the bytes are assembled with endian, byte order decides which physical byte a high bit lands in for multi-byte words (see the second example).

Like any other field, a parsed BIT can be referenced by later fields — its value is 1 when set and 0 when clear inside word_address/length expressions and if conditions.

Example — flags in a single byte

word_size_bytes

1

schema

[ { "key": "power_on", "word_address": 0, "type": "BIT", "endian": "be", "offset": 0 }, { "key": "fault", "word_address": 0, "type": "BIT", "endian": "be", "offset": 7 } ]

With input [0x01] (0b0000_0001):

  • power_on reads bit 0 = 1true.
  • fault reads bit 7 = 0false.

The output is { "power_on": true, "fault": false }.

Example — a bit within a 16-bit register (endian)

For a 16-bit register (word_size_bytes: 2), bit 15 is the most significant bit of the assembled 16-bit value — but which physical byte that is depends on endian.

word_size_bytes

2

schema

[ { "key": "coil_le", "word_address": 0, "type": "BIT", "endian": "le", "offset": 15 }, { "key": "coil_be", "word_address": 0, "type": "BIT", "endian": "be", "offset": 15 } ]

With input [0x00, 0x80]:

  • coil_le: little-endian assembles the word as 0x8000, so bit 15 = 1true.
  • coil_be: big-endian assembles the word as 0x0080, so bit 15 = 0false.

The output is { "coil_le": true, "coil_be": false }.

Dynamic Word Addresses

Most fields use a fixed word_address copied straight from a datasheet, but the address can also be computed at parse time. There are three forms beyond a plain positive number:

FormExampleWhat it does
Hex string"0x7A"The same fixed address, written in hexadecimal.
Negative number-1Counts from the end of the data. -1 is the last word, -2 the second-to-last, and so on. Handy when a packet has a fixed-size trailer (like a checksum) but a variable-length body.
Expression string"other_field - 2"An arithmetic expression evaluated against the fields parsed before it.

Expressions

When word_address is a string that isn’t a plain hex/decimal number, it is treated as an arithmetic expression. The expression can reference any field parsed earlier in the schema by its key, combined with numbers and the usual operators (+, -, *, /). The result is the word address to read from — and just like a literal, a negative result counts from the end of the data.

Because expressions only see fields above them, the field they reference must appear earlier in the schema array. If the field is not calculated (e.g. skipped or not defined yet), it will default to 0.

A nested key (one containing a .) cannot be referenced here — a dot is not valid in an expression variable name. To drive an address from a value you also want nested, parse it twice: once into a flat __scratch field for the expression to use, and once into the nested key for the output. See Nested Keys below.

This is what makes variable-layout packets parseable: read a length or offset field first, then use it to locate the next field.

word_size_bytes

1

schema

[ { "key": "header_len", "word_address": 0, "type": "U8", "endian": "be" }, { "key": "payload", "word_address": "header_len", "type": "U16", "endian": "be" }, { "key": "checksum", "word_address": -2, "type": "U16", "endian": "be" } ]

With input [0x04, 0xAA, 0xBB, 0xCC, 0x12, 0x34, 0x9F, 0x7E]:

  • header_len reads bytes[0] = 4.
  • payload uses word_address: "header_len" = 4, so it reads the U16 at bytes[4..6] = 0x1234 = 4660.
  • checksum uses word_address: -2, reading the last 2 bytes bytes[6..8] = 0x9F7E = 40830.

Note: Addresses are in words, so an expression result and any negative offset are still multiplied by word_size_bytes to find the byte position — exactly like a literal address.

Dynamic Lengths

For STRING and BYTES fields, the length doesn’t have to be a fixed number. It can also be a hex string ("0x03") or an expression evaluated against the fields parsed before it — exactly like a dynamic word_address. This is what lets you parse length-prefixed data, where one field tells you how many bytes the next field occupies.

Unlike word_address, length is always a raw byte count — it is not multiplied by word_size_bytes. The expression can reference earlier fields by their key along with numbers and the usual operators (+, -, *, /). If it resolves to a negative or non-numeric value, or if it would read past the end of the data, the field is skipped.

As with word_address, a nested key cannot appear in a length expression — keep a flat __scratch copy of the value beside the nested one. See Nested Keys below.

word_size_bytes

1

schema

[ { "key": "name_len", "word_address": 0, "type": "U8", "endian": "be" }, { "key": "name", "word_address": 1, "type": "STRING", "endian": "be", "length": "name_len" } ]

With input [0x03, 0x61, 0x62, 0x63, 0x64]:

  • name_len reads bytes[0] = 3.
  • name uses length: "name_len" = 3, so it reads 3 bytes starting at offset 1 — bytes[1..4] = [0x61, 0x62, 0x63] = "abc". The trailing 0x64 is left untouched.

To read everything after a fixed-size header, combine a length expression with another field’s value — e.g. "total_len - 4" for a 4-byte header.

Conditional Fields (if)

Sometimes a field should only be read when the data calls for it — for example, a packet whose payload differs depending on a leading “message type” byte. Add an if condition to a field and it is parsed only when that condition is true; otherwise the field is skipped and never appears in the output.

The condition is checked against the fields parsed so far — every field that comes earlier in the schema array. Because the parser works top to bottom, a field’s if can reference any field above it, but not one below it (a forward reference is treated as “not met”, so the field is skipped). If the condition can’t be evaluated for any reason, the field is simply skipped — nothing breaks.

The condition uses the same syntax as the Condition prefab (and, or, and eval), with one difference: a receiver here names a field key parsed earlier in this schema, not an input port. The quickest summary:

TypeWhat it means
andAll of the sub-conditions must be true
orAt least one sub-condition must be true
evalCompare one already-parsed field (receiver) against a fixed value (with its datatype), another field’s value (value_receiver), or by length

A receiver can also name a nested field, by its full dotted key exactly as written in the schema — "receiver": "status.code" reads the field keyed "status.code" above it. See Nested Keys below.

See the Condition page for the full set of operators, comparison modes, and length checks.

Example — a tagged packet

A device sends a 1-byte msg_type header followed by a payload whose meaning depends on that header: type 1 carries a temperature reading, type 2 carries a status string. Only the field matching the header is emitted.

word_size_bytes

1

schema

[ { "key": "msg_type", "word_address": 0, "type": "U8", "endian": "be" }, { "key": "temperature", "word_address": 1, "type": "I16", "endian": "be", "if": { "type": "eval", "receiver": "msg_type", "operator": "==", "datatype": "INT", "value": 1 } }, { "key": "status", "word_address": 1, "type": "STRING", "endian": "be", "length": 4, "if": { "type": "eval", "receiver": "msg_type", "operator": "==", "datatype": "INT", "value": 2 } } ]

With input [0x01, 0x00, 0xC8], msg_type is 1, so only temperature is parsed and the output is { "msg_type": 1, "temperature": 200 } — the status field is left out. With input [0x02, 0x4F, 0x4B, 0x41, 0x59], msg_type is 2, so the output is { "msg_type": 2, "status": "OKAY" } instead.

Dynamic Keys

A field’s key doesn’t have to be a fixed string. Wrapping another field’s name in curly braces — {other_field} — substitutes that field’s value into the key at parse time. This is what lets you emit a value under a name that the packet itself carries, such as a tag or register name read from an earlier field.

The reference works just like expressions and conditions: it can only see fields parsed earlier in the schema (a field must appear above the one that references it). If the referenced field wasn’t parsed — because it’s defined later or its if condition wasn’t met — the field with the dynamic key is skipped. You can mix literal text with references ("reading_{tag}"), and a plain {} with nothing inside is treated as literal braces, not a placeholder.

Values are rendered into the key using their natural text form: strings pass through as-is, numbers and booleans use their obvious representation, and bytes become lowercase hex.

A substituted value is always one key segment, even when it contains a .. Only a dot you type in the schema nests the output (see Nested Keys); a dot arriving in device data is just part of the name. With key: "map.{tag}" and a tag of "a.b", the result is a map port carrying { "a.b": ... } } — never { "map": { "a": { "b": ... } } }. This keeps the shape of the output fixed by the schema, so it cannot change from one packet to the next.

If a reference renders to an empty string — which happens when the referenced field holds a list or a map — the key would have an empty segment, so the field is skipped instead.

word_size_bytes

1

schema

[ { "key": "tag_len", "word_address": 0, "type": "U8", "endian": "be" }, { "key": "tag", "word_address": 1, "type": "STRING", "endian": "be", "length": "tag_len" }, { "key": "{tag}", "word_address": "tag_len + 1", "type": "U8", "endian": "be" } ]

With input [0x03, 0x61, 0x62, 0x63, 0x2A]:

  • tag_len reads bytes[0] = 3.
  • tag reads 3 bytes starting at offset 1 — "abc".
  • The last field has key: "{tag}", so its output key becomes the value of tag"abc". It reads bytes[4] = 0x2A = 42.

The resulting output is { "tag_len": 3, "tag": "abc", "abc": 42 }.

Nested Keys (.)

A . in a key nests the value inside a MAP on the port named by the first segment, instead of naming a port literally with the full key. "output_map.hello" sends a MAP { "hello": ... } out the output_map port. There is no flag to turn this on — a dot in a key always means nesting.

Nesting is not limited to one level: "a.b.c" sends a MAP out the a port shaped { "b": { "c": ... } }. This is the natural home for a group of related fields that would otherwise scatter across separate top-level ports, mixed in with a protocol’s fixed fields.

word_size_bytes

1

schema

[ { "key": "status.code", "word_address": 0, "type": "U8", "endian": "be" }, { "key": "status.retries", "word_address": 1, "type": "U8", "endian": "be" }, { "key": "raw", "word_address": 2, "type": "U8", "endian": "be" } ]

With input [0x02, 0x05, 0xFF]:

  • status.code reads bytes[0] = 2, placed at statuscode.
  • status.retries reads bytes[1] = 5, placed beside it under the same parent.
  • raw has no dot, so it goes straight out its own top-level port.

Two ports are produced: status, carrying { "code": 2, "retries": 5 }, and raw, carrying 255.

Building a parent from device data

A dynamic key can supply part of the path. Only the literal text you type is split on ., so "map.{__tag}" always means “the key named by __tag, inside the map port” — the port comes from the schema, the leaf from the packet.

[ { "key": "__tag_len", "word_address": 0, "type": "U8", "endian": "be" }, { "key": "__tag", "word_address": 1, "type": "STRING", "endian": "be", "length": "__tag_len" }, { "key": "map.{__tag}", "word_address": "__tag_len + 1", "type": "U8", "endian": "be" } ]

With input [0x03, 0x61, 0x62, 0x63, 0x2A] the map port carries { "abc": 42 }. This is the recommended shape for tag fan-out: every tag lands under one port rather than scattered across separate top-level ports.

Rules to know

  • A later field wins a collision. If one field is keyed "a" and a later one "a.b", the later one turns a’s port into a MAP and the earlier value is dropped. The same applies in reverse — "a.b" followed by "a" leaves a as a plain value. Both cases are logged.
  • A parent is never emitted empty. A MAP is only sent out a port when a field actually lands inside it, so if every child is skipped (by an if, by being out of bounds, or by being hidden) that port is left absent rather than carrying {}.
  • A numeric segment is a key, not an index. "readings.0" and "readings.1" produce a readings port carrying { "0": ..., "1": ... } — a map with the keys "0" and "1". Nesting always builds maps; it never builds a list.
  • Every segment must be non-empty. ".a", "a..b" and "a." name no parent or leaf, so the editor rejects them and the parser drops the field.
  • Nested keys cannot be used in expressions. See Dynamic Word Addresses and Dynamic Lengths.

Hidden Fields (__ prefix)

Fields whose key begins with a double underscore (__) are internal-only. They are parsed and behave like any other field — later fields can reference them in word_address expressions, length expressions, if conditions, and dynamic keys — but they are removed from the final output and no port is created for them.

This keeps scratch fields (packet lengths, offsets, opcodes, type discriminators) out of the emitted output when they only exist to help locate or gate the fields you actually care about.

For a nested key, only the first segment decides this. "__scratch.x" starts with __, so the whole __scratch port is hidden. "map.__tmp" does not hide anything — the port is map, so a visible __tmp is emitted inside that port’s map.

Combining this with dynamic keys, the previous example can be rewritten so only the meaningful value is emitted:

word_size_bytes

1

schema

[ { "key": "__tag_len", "word_address": 0, "type": "U8", "endian": "be" }, { "key": "__tag", "word_address": 1, "type": "STRING", "endian": "be", "length": "__tag_len" }, { "key": "{__tag}", "word_address": "__tag_len + 1", "type": "U8", "endian": "be" } ]

With the same input [0x03, 0x61, 0x62, 0x63, 0x2A], both __tag_len and __tag are used while parsing — for the string length and the dynamic key — but dropped afterward. The output is just { "abc": 42 } (a single abc port carrying 42).

Ports

  • Inputs: input:BYTES - Receives the raw byte array to be parsed.
  • Outputs: One output port per top-level key in the schema (the segment before any .). A key without a . sends its parsed value directly out that port. A key with a . nests the value inside a MAP sent out the port named by its first segment, and sibling fields sharing that first segment merge into the same map (see Nested Keys). Throughout this page, a full set of output ports is shown together as a single JSON object for brevity — each top-level key in that object is a port name, and its value is what that port carries.

Example

Imagine a sensor sends a 12-byte data packet. The datasheet specifies that the memory is addressed in 16-bit words (word_size_bytes: 2).

Sample Input (BYTES): [0x01, 0x9A, 0x00, 0x01, 0x43, 0x48, 0x00, 0x00, 0x61, 0x62, 0x63, 0x00]

Datasheet Information:

  • Word Address 0: Device Status (U16)
  • Word Address 2: Temperature (F32), value must be multiplied by 0.1 and then have 5 subtracted.
  • Word Address 4: Device ID (String), 3 bytes long.

Configuration

word_size_bytes

2

schema

[ { "key": "device_status", "word_address": 0, "type": "U16", "endian": "be" }, { "key": "temperature", "word_address": 2, "type": "F32", "endian": "be", "scale": 0.1, "offset": -5.0 }, { "key": "device_id", "word_address": 4, "type": "STRING", "endian": "be", "length": 3 } ]

Resulting Output (Ports)

Based on the input bytes and the schema, three output ports are produced — device_status, temperature, and device_id — shown together below as a single object:

{ "device_status": 410, "temperature": -2.9999999821186066, "device_id": "abc" }

Calculation Walkthrough:

  • device_status: Starts at word_address 0. Byte position = 0 * 2 = 0. Reads 2 bytes [0x01, 0x9A] as a Big-Endian U16, which is 410.
  • temperature: Starts at word_address 2. Byte position = 2 * 2 = 4. Reads 4 bytes [0x43, 0x48, 0x00, 0x00] as a Big-Endian F32, which is 200.0. The final value is (200.0 * 0.1) - 5.0 = 15.0.
  • device_id: Starts at word_address 4. Byte position = 4 * 2 = 8. Reads length of 3 bytes from that position: [0x61, 0x62, 0x63], which is the UTF-8 string "abc".
Last updated on