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
| Constant | Type | Description |
|---|---|---|
word_size_bytes | INT | Required. 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). |
schema | JSON | Required. 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.
| Key | Type | Required? | Description |
|---|---|---|---|
key | String | Yes | The 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_address | Integer or String | Yes | The 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. |
type | String | Yes | The data type to parse. See the Data Types table below for all possible values. |
endian | String | Yes | The endianness (“byte order”) for multi-byte data types. Can be le (Little-Endian) or be (Big-Endian). |
length | Integer or String | No | Required 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. |
scale | Float | No | A multiplier to apply to a numeric value after parsing. The formula is final_value = (raw_value * scale) + offset. Defaults to 1.0. |
offset | Float | No | For 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. |
if | Object | No | A 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)
| Value | Description | Size (Bytes) |
|---|---|---|
I8 | 8-bit Signed Integer | 1 |
U8 | 8-bit Unsigned Integer | 1 |
I16 | 16-bit Signed Integer | 2 |
U16 | 16-bit Unsigned Integer | 2 |
I32 | 32-bit Signed Integer | 4 |
U32 | 32-bit Unsigned Integer | 4 |
I64 | 64-bit Signed Integer | 8 |
U64 | 64-bit Unsigned Integer | 8 |
F32 | 32-bit Float | 4 |
F64 | 64-bit Float | 8 |
BOOL | Boolean (true if byte is not 0) | 1 |
BIT | A single bit of a word, as a boolean (see Bit Fields) | word (word_size_bytes) |
STRING | UTF-8 String | length |
BYTES | Raw Byte Array | length |
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:
offsetis required forBITfields — you must say which bit to read.- Bit
0is the least significant bit of the assembled integer, bit1the next, and so on up toword_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
1schema
[
{
"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_onreads bit0=1→true.faultreads bit7=0→false.
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
2schema
[
{
"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 as0x8000, so bit15=1→true.coil_be: big-endian assembles the word as0x0080, so bit15=0→false.
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:
| Form | Example | What it does |
|---|---|---|
| Hex string | "0x7A" | The same fixed address, written in hexadecimal. |
| Negative number | -1 | Counts 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
1schema
[
{
"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_lenreadsbytes[0]=4.payloadusesword_address: "header_len"=4, so it reads the U16 atbytes[4..6]=0x1234=4660.checksumusesword_address: -2, reading the last 2 bytesbytes[6..8]=0x9F7E=40830.
Note: Addresses are in words, so an expression result and any negative offset are still multiplied by
word_size_bytesto 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
1schema
[
{
"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_lenreadsbytes[0]=3.nameuseslength: "name_len"=3, so it reads 3 bytes starting at offset 1 —bytes[1..4]=[0x61, 0x62, 0x63]="abc". The trailing0x64is 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:
| Type | What it means |
|---|---|
and | All of the sub-conditions must be true |
or | At least one sub-condition must be true |
eval | Compare 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
1schema
[
{
"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
1schema
[
{
"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_lenreadsbytes[0]=3.tagreads 3 bytes starting at offset 1 —"abc".- The last field has
key: "{tag}", so its output key becomes the value oftag—"abc". It readsbytes[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
1schema
[
{
"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.codereadsbytes[0]=2, placed atstatus→code.status.retriesreadsbytes[1]=5, placed beside it under the same parent.rawhas 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 turnsa’s port into aMAPand the earlier value is dropped. The same applies in reverse —"a.b"followed by"a"leavesaas a plain value. Both cases are logged. - A parent is never emitted empty. A
MAPis only sent out a port when a field actually lands inside it, so if every child is skipped (by anif, 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 areadingsport 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
1schema
[
{
"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
keyin theschema(the segment before any.). A key without a.sends its parsed value directly out that port. A key with a.nests the value inside aMAPsent 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
2schema
[
{
"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 atword_address0. Byte position =0 * 2 = 0. Reads 2 bytes[0x01, 0x9A]as a Big-Endian U16, which is410.temperature: Starts atword_address2. Byte position =2 * 2 = 4. Reads 4 bytes[0x43, 0x48, 0x00, 0x00]as a Big-Endian F32, which is200.0. The final value is(200.0 * 0.1) - 5.0 = 15.0.device_id: Starts atword_address4. Byte position =4 * 2 = 8. Readslengthof 3 bytes from that position:[0x61, 0x62, 0x63], which is the UTF-8 string"abc".