NRN reader protocol
This page describes the wire protocol the Nextwaves SDK implements. You need it in three situations: building your own tooling, decoding a capture, or debugging a reader that answers with an error notification. For normal integration, the SDK already handles everything on this page.
This is not the tag-to-reader air-interface specification. For that behavior, consult the current GS1 Gen2 UHF RFID standard. For host-to-reader commands, the firmware manual shipped with the target reader remains authoritative.
Serial connection
| Setting | Value |
|---|---|
| Interface | RS-232 or CP210x USB-to-serial |
| Baud rate | 115200 |
| Data bits | 8 |
| Stop bits | 1 |
| Parity | None |
| Flow control | None |
Frame structure
5A | PCW(4) | [Address(1) when RS485] | Length(2, big-endian) | Data(N) | CRC16(2, big-endian)| Field | Size | Meaning |
|---|---|---|
| Header | 1 byte | Always 0x5A |
| PCW | 4 bytes | Protocol Control Word |
| Address | 1 byte | Present only when the RS485 flag is set |
| Length | 2 bytes | Payload length, big-endian |
| Data | N bytes | Command-specific payload |
| CRC16 | 2 bytes | Checksum over every byte after the header, big-endian |
The CRC covers the PCW, the optional address, the length, and the data. It does not cover the 0x5A header.
Protocol Control Word
byte 0: protocol type 0x00
byte 1: protocol version 0x01
byte 2: flags and category bit 5 = RS485, bit 4 = notify, low nibble = category
byte 3: message id within the categoryCategory and message id together form the SDK MID constant: category 0x02 with message id 0x10 is READ_EPC_TAG = 0x0210. The notify bit distinguishes a reader-initiated notification from a response to your command, which is why a tag notification for category 0x02 arrives with byte 2 equal to 0x12.
CRC16
The checksum is CRC16 with polynomial 0x1021 and initial value 0x0000, computed over every byte after the header and transmitted big-endian.
def crc16(data: bytes) -> int:
crc = 0
for byte in data:
crc ^= byte << 8
for _ in range(8):
crc = ((crc << 1) ^ 0x1021) & 0xFFFF if crc & 0x8000 else (crc << 1) & 0xFFFF
return crcRecompute the checksum whenever you change any byte of a frame. Never copy a checksum from a sample frame onto a modified one.
Worked example
READ_EPC_TAG on antenna 1, continuous mode, as built by the Python SDK:
5A 0001 0210 0005 00000001 01 F487
│ │ │ │ │ │ └─ CRC16 over 0001021000050000000101
│ │ │ │ │ └──── continuous flag: 0x01
│ │ │ │ └───────────── antenna mask: antenna 1
│ │ │ └────────────────── payload length: 5
│ │ └─────────────────────── category 0x02, message id 0x10
│ └──────────────────────────── protocol type 0x00, version 0x01
└─────────────────────────────── headerA tag notification for the same session:
5A 0001 1210 0016 0008 3000112233445566 3000 01 01 80 08 000E0BD4 09 40 B44F
│ │ │ │ │ └ RSSI └ freq └ phase
│ │ │ │ └─ antenna port 1
│ │ │ └────── PC word 0x3000
│ │ └─────────────────────── EPC, 8 bytes
│ └──────────────────────────── EPC length: 8
└────────────────────────────────────── notify bit set, category 0x02, id 0x10The optional parameters decode to RSSI 0x80 = 128 raw = -65 dBm, frequency 0x000E0BD4 = 920532 kHz = 920.532 MHz, and phase 0x40 = 64 raw, which the SDK reports as 64 / 128 × 2π = 3.1416 radians.
Inventory response payload
EPC length: 2 bytes, big-endian
EPC data: N bytes
PC: 2 bytes
Antenna ID: 1 byte
Optional parameters, each introduced by a PID byte:
0x01 RSSI 1 byte, raw 0-255
0x02 Reading result 1 byte
0x03 TID 2-byte length followed by data
0x04 Tag data area 2-byte length followed by data
0x05 Reserved area 2-byte length followed by data
0x06 Sub-antenna 1 byte
0x07 UTC read time 8 bytes
0x08 Frequency 4 bytes, kHz, big-endian
0x09 Phase 1 byte, raw 0-128The SDK converts RSSI to dBm, frequency to MHz, and phase to radians before it reaches your callback. The 1.0.0 tag structure carries the parameters listed above; parameters 0x02 and 0x04 through 0x07 will be added in a later release, and self-written tooling that needs them can parse the payload directly from this table.
Message ids
| Constant | Value | Purpose |
|---|---|---|
QUERY_INFO | 0x0100 | Serial number, firmware, uptime |
QUERY_RFID_ABILITY | 0x1000 | Power range, antenna count, frequency and protocol lists |
READ_EPC_TAG | 0x0210 | Start EPC inventory |
WRITE_EPC_TAG | 0x0211 | Write the EPC bank |
PHASE_INVENTORY | 0x0214 | Inventory with phase reporting |
STOP_INVENTORY | 0x02FF | Stop inventory |
CONFIGURE_READER_POWER | 0x0201 | Set transmit power per antenna |
QUERY_READER_POWER | 0x0202 | Read transmit power per antenna |
SET_RF_BAND | 0x0203 | Set regional band |
QUERY_RF_BAND | 0x0204 | Read regional band |
SET_WORKING_FREQUENCY | 0x0205 | Set the working channel list |
QUERY_WORKING_FREQUENCY | 0x0206 | Read the working channel list |
SET_FILTER_SETTINGS | 0x0209 | Reader-side duplicate window and RSSI cutoff |
QUERY_FILTER_SETTINGS | 0x020A | Read filter settings |
CONFIG_BASEBAND | 0x020B | Session, Q, inventory flag, profile |
QUERY_BASEBAND | 0x020C | Read baseband configuration |
READER_POWER_CALIBRATION | 0x0103 | Power calibration |
CONFIGURE_GPO | 0x0109 | Set a general-purpose output |
QUERY_GPI | 0x010A | Read a general-purpose input |
CONFIGURE_GPI_TRIGGER | 0x010B | Configure an input trigger |
BUZZER_SWITCH | 0x011E | Control the buzzer |
READ_END | 0x1231 | Inventory ended |
ERROR_NOTIFICATION | 0x00 | Reader-initiated error |
A technical note for hand-built commands: CONFIGURE_ANTENNA_ENABLE (0x0203) and QUERY_ANTENNA_ENABLE (0x0202) share their numeric values with the RF band and power constants, so disambiguate by the payload you send with them, never by the constant name.
Antennas
Up to 32 ports are addressed by a 32-bit bitmask, antenna n at bit n - 1, transmitted big-endian:
00 00 00 01 -> antenna 1
00 00 00 03 -> antennas 1 and 2
00 00 00 0F -> antennas 1 to 4
80 00 00 49 -> antennas 1, 4, 7, and 32The reader scans enabled ports sequentially and reports the port on every tag notification. Preserve antenna_id end to end: it is how you know which physical area each read came from.
Power and RSSI
query_rfid_ability returns the reader's own minimum and maximum power in dBm together with the antenna count. Configure within that range; the SDK does not clamp.
Raw RSSI converts to dBm as:
dBm = -100 + round((raw × 70) / 255)| Raw | dBm |
|---|---|
| 0 | -100 |
| 64 | -82 |
| 128 | -65 |
| 200 | -45 |
| 255 | -30 |
This conversion formula is specific to the reader model; before applying it to another model, verify it against that model's firmware.
Frequency
frequency MHz = 920.0 + channelIndex × 0.5Tag notifications report frequency in kHz as a 32-bit value, which the SDK converts to MHz. Configure only the channels and power permitted by the equipment certification and the current rules at the deployment location.
Recommended command order
- Open the port and send the connect-and-initialize handshake.
QUERY_INFOfor identity,QUERY_RFID_ABILITYfor capability.- Set the antenna mask and per-port power.
- Set the verified regional band and channel list.
- Set the filter window.
- Send
READ_EPC_TAG. - Buffer incoming bytes, split frames on the
0x5Aheader, validate CRC, and parse notifications. - Send
STOP_INVENTORY, drain pending frames, then close the port.
Bytes from a serial port do not arrive aligned to frames. Always buffer and re-scan: extract_valid_frames in the Python package does this, and a custom receive loop must do the same.
EPC Gen2 memory
| Bank | ID | Typical content |
|---|---|---|
| Reserved | 00 | 32-bit kill password and 32-bit access password |
| EPC | 01 | 96-bit or longer identifier |
| TID | 10 | Read-only chip and manufacturer identity |
| User | 11 | Chip-dependent application memory |
Writing at this layer demands four things, and skipping any one is a risk: singulation so exactly one tag sits in the zone, access-password handling when the tag is locked, a read-back after every write, and safeguards around the kill command because it cannot be undone. These concepts are explained in the glossary.
Frame utilities in the SDK
Every package ships frame-layer helpers so you can build tooling, load capture files, or run your own receive loop without rewriting the framing code:
| Utility | Python | TypeScript | Go | Rust | C++ |
|---|---|---|---|---|---|
| Build a frame | build_frame | NRNUtils.buildFrame | BuildFrame | build_frame | build_frame |
| Parse a frame | parse_frame | NRNUtils.parseFrame | ParseFrame | parse_frame | parse_frame |
| Split a byte stream into frames | extract_valid_frames | NRNUtils.extractValidFrames | per the receive loop appendix | per the receive loop appendix | per the receive loop appendix |
| Parse a tag notification | parse_epc | internal | ParseEPC | parse_epc | parse_epc |
| CRC16 | crc16_ccitt | NRNUtils.crc16CCITT | CRC16CCITT | crc16_ccitt | crc16_ccitt |
| Raw RSSI to dBm | calculate_rssi | NRNUtils.calculateRSSI | CalculateRSSI | calculate_rssi | calculate_rssi |
| Channel index to MHz | calculate_frequency | NRNUtils.calculateFrequency | CalculateFrequency | calculate_frequency | calculate_frequency |
parse_frame verifies the CRC and errors or returns invalid on a bad checksum, so use it instead of comparing CRCs yourself.
Test vectors
The repository ships shared fixtures under sdk/nation/testdata/:
frames/holds hex-encoded request, response, and notification frames in both CRC families.crc_vectors.jsonholds CRC inputs and expected values for both polynomials plus the CCITT-FALSE reference.rssi_vectors.jsonholds raw-to-dBm pairs.antenna_masks.jsonholds antenna-list-to-mask pairs.
Run your own parser against these before you point it at a reader.

