How to Write a Device Control SDK

An RFID vendor ships hardware with a firmware-defined binary protocol. Every downstream customer — a warehouse integrator, a POS vendor, an internal tool — then re-implements the same things: frame parsing, the checksum, the opcode table, the timeout behaviour, and the fact that the device ignores a command if you send it too soon.

This repository is the host-side layer (the project calls it 上位机, “the PC-side program that drives the device”): reader-sdk-core (a protocol client) and reader-sdk-example-desktop (a reference GUI). It is a good exercise, because a device SDK has to answer three questions that ordinary library code never does: how do I talk to the wire, how do I make an asynchronous command look synchronous to the caller, and how do I show the result on a screen without freezing it.

What this post is about. It walks through one RFID reader SDK and the desktop console that drives it: what the protocol looks like on the wire, why one networking framework serves both a TCP socket and a serial port, how the SDK matches a reply to the command that asked for it, and how a promise API keeps the GUI responsive. First, three words: a protocol is an agreed sequence of bytes, a frame is one message in it, an opcode is the byte that says which command a frame is about.

The situation

The device speaks a small binary protocol over either TCP or a serial line. TCP carries bytes between two machines over a network, here through a small WiFi-to-Ethernet bridge box; a serial line is a direct cable, one bit at a time, into the device. The caller — a GUI, a service, a test harness — should not care which one it gets.

Three constraints shaped the design, and all three come from talking to real hardware:

  • The device is slow to accept a new command. It needs about 120 ms to prepare after the previous one. Send too early and the command is silently dropped.
  • The device pushes a lot of data. An inventory scan emits hundreds of tag frames per second, and the decoder has to keep up.
  • The device is not a web server. There is no request id, no sequence number, no correlation token (a value that ties a reply back to the request that caused it). The reply carries an opcode and you have to know which command it belongs to.

Why this design

One pipeline for both transports

Netty does the heavy lifting. Netty is a Java networking library built around a channel (one open connection) and a pipeline (an ordered list of handlers every byte passes through). The same three handlers are installed on a TCP socket and on a serial port:

1
2
3
4
ChannelPipeline cp = socketChannel.pipeline();
cp.addLast(new ReaderEncoder());
cp.addLast(new ReaderDecoder(0xBB, 0x0D, 0x0A));
cp.addLast(new ReaderClientHandler());

addLast appends to the end of the list, so bytes flow encoder → decoder → handler: the encoder makes the bytes, the decoder parses them back into frames, the handler hands frames to the application. The three numbers in the decoder are the frame’s start byte (0xBB) and its two end bytes (0x0D, 0x0A).

TcpConnection.java:30.

The serial side has the same shape, on Netty’s RXTX channel. SerialParam extends RxtxDeviceAddress, which extends java.net.SocketAddress — the trick that lets one bootstrap.connect(option) call work for both transports. Its four parameters: baud rate (line speed in bits per second), data bits, stop bits, and parity (an extra bit to spot corruption).

The TCP side is tuned for the frame rate. The receive buffer is allocated by AdaptiveRecvByteBufAllocator(6, 100, 1024): the minimum of 6 bytes is the smallest legal frame (head + type + len + crc + end1 + end2), the initial 1024 is the largest single response, and the comment explains why the buffer is not bigger — a larger receive buffer slows the EPC read rate down.

Above this sits AbstractConnection<Option extends SocketAddress>, which owns everything not transport-specific: the command/response map, the promise plumbing, the listener registration. Two subclasses supply only a bootstrap, ReaderConnectionBuild.createSerialConnection() and createTcpConnection() are the factory entry points, and the concrete type is hidden behind IReaderConnection.

A custom decoder, because the obvious one was too slow

The frame is:

1
HEAD(0xBB) | TYPE | LEN | DATA | CRC | END1(0x0D) | END2(0x0A)

Each box is one part of a message: start byte, opcode, payload length, payload, checksum byte, two-byte terminator. Because there is a length field, Netty’s LengthFieldBasedFrameDecoder would be the obvious choice — it was not fast enough:

1
2
3
4
5
6
7
8
for (int bit = readerIndex; bit < length; bit++) {
byte aByte = in.getByte(bit);
if ((aByte & 0xFF) == this.head) { headIndex = bit; }
if (aByte == this.end1 && (bit + 1 < length)) {
byte end2 = in.getByte(bit + 1);
if (end2 == this.end2) { endIndex = bit; }
}
}

The loop walks the buffer one byte at a time looking for the two markers: in.getByte(bit) reads a byte by index, and & 0xFF turns a signed Java byte into the unsigned 0–255 so it can be compared with the head byte. When a head and a terminator have both been seen, the bytes between them are emitted.

protocol/ReaderDecoder.java:48. The source comment says the built-in delimiter decoder could not meet the required decode rate — at several hundred frames per second that is a real constraint, not a preference.

The one thing it gets wrong: the scan bound is in.capacity() rather than in.readableBytes(). It works, but it reads past the data that is actually there.

Correlation by opcode, because the protocol has no id

This surprises people coming from HTTP. The device’s reply carries no request id. Instead, each command knows which reply opcode it waits for — by convention request | 0x80, where setting the bit 128 turns a command’s opcode into its reply’s opcode, so a command with type byte 0x07 is answered by type byte 0x87.

1
2
3
4
5
6
7
protected void send(Protocol protocol) {
try { Thread.sleep(120); } catch (InterruptedException e) { e.printStackTrace(); }
ReaderProtocol frame = protocol.writeProtocol();
if (frame.getCrc() == 0) { HexTools.crc16(frame); }
writeChannel.writeAndFlush(frame);
protocolHandlerMap.put(protocol.resultType(), protocol);
}

Four things happen in order: the thread sleeps 120 ms to let the device prepare; writeProtocol() packs the command into bytes; HexTools.crc16 fills in the checksum if it is still zero; and the frame is flushed before the handler is registered in protocolHandlerMap, the map from reply opcode to the waiting command. The original also throws UnconnectedException when there is no channel.

AbstractConnection.java:294.

The map is keyed by opcode, so only one in-flight request per opcode is supported. Send the same command twice before the first reply arrives and the second overwrites the first handler, and the first promise never completes.

The order matters: the frame is flushed before the handler is registered, so a fast reply can be dispatched before the map contains the entry. The 120 ms sleep hides this in practice.

The promise pattern

Every command returns a future instead of a value, because the device is slow and the caller should not block. A promise is an object that holds a result that has not arrived yet and hands it to whoever asks for it later. The behaviour that makes it usable is the late-subscriber replay:

1
2
3
4
5
6
7
8
public void then(Callback<T> callback) {
boolean fire = false;
synchronized(this) {
next = callback;
if (latch.getCount() == 0) { fire = true; }
}
if (fire) { if (error != null) callback.onFailure(error) else callback.onSuccess(value); }
}

then attaches a callback. The latch is a CountDownLatch, a one-shot counter that reaches zero when the result is set, so getCount() == 0 means “the answer already arrived” — and then the callback fires at once with the stored value or error. Without that replay, the race between “the reply lands” and “the caller attaches a handler” would lose the result.

Promise.java:64. The promise has one continuation slot (next), a value or an error field, and a CountDownLatch for the blocking await() path. It is completed on the Netty event loop thread by the response dispatcher: when a frame arrives, the dispatcher looks up the reply’s opcode in protocolHandlerMap and calls readProtocol on the command it finds. Errors follow a convention: the first data byte of a reply is 0x01 for success, anything else a failure code that becomes a SetException / GetException / OutOfRangeException.

There are two error channels in total: per-command rejection through the promise, and device-level errors through a separate 0xFF frame that arrives at any time and is delivered to an IListenter<ErrorEvent>.

The desktop app

The desktop module is JavaFX 8 (FXML + CSS), not Swing. JavaFX is Java’s desktop toolkit: the screen is described in XML (FXML), styled with CSS, and updated by binding Java objects to widgets. The window is a reader console: a toolbar (start/stop scan, single scan, read tag data, connect/disconnect, device settings), an EPC filter field, a TableView of antenna / EPC / PC / RSSI / count / timestamp columns, antenna on-off checkboxes, read and write power, a beep toggle, and a status bar with the connection state, the last message, a clock and the firmware version. Three dialogs: connection (TCP host/port or serial port with baud/databits/stopbits/parity), tag operations (read/write, QT parameters for Impinj Monza 4QT, lock/unlock with kill passwords), and device configuration (GPIO in/out, frequency hop list, Gen2 Q algorithm, frequency region, work times, EAS, heartbeat, and more).

The interesting part is the threading, because it is where GUI code usually goes wrong. A GUI has one UI thread that draws the screen, and everything else must stay off it.

Connect happens off the UI thread, on a small thread pool, and the promise is awaited with a deadline of 10 seconds: the controller submits a task to that pool, calls tcpConnection.connect(inetSocketAddress).await(10, TimeUnit.SECONDS), and stores the command in an AtomicReference (a holder several threads may read and write safely). If the connect does not finish in ten seconds, the await returns no result.

Tag events arrive on the Netty event loop — the thread that owns the socket — and are pushed through a small thread pool, then marshalled onto the FX thread with Platform.runLater(...): the listener bumps a counter, formats the time, and finds the first tag mapper whose EPC matches the tag’s. Platform.runLater is JavaFX for “run this bit of code on the UI thread, later”.

Command callbacks need the same marshalling, because the promise completes on the I/O thread: every then() callback that touches a control wraps its body in Platform.runLater.

The data-binding approach is the other JavaFX lesson: TagMapper wraps IntegerProperty / StringProperty / DoubleProperty, so the TableView binds directly to the model and no cell is rendered by hand.

Trade-offs

Netty for a 115200-baud serial line is a real trade. The payoff: one pipeline, one codec and one handler serve both links, and the RXTX channel is a drop-in because it extends Netty’s OioByteStreamChannel. The costs: netty-all drags in the whole reactor stack (HTTP, DNS, epoll) for a serial port; the RXTX transport is Netty’s legacy OIO path, deprecated in later Netty versions; and RXTX itself is a 1990s-era project, unmaintained for years, whose macOS build is 32-bit only and cannot load on a modern Apple Silicon JVM. A modern alternative is jSerialComm behind a small Channel-shaped adapter.

There is no reflection-based serialisation, and that is the right call. Every command hand-packs its own bytes:

1
2
3
4
5
byte data3 = (byte) gen2.getqValue().getValue();
byte data2 = (byte) (((gen2.getStartQ() << 4) & 0xF0) | (gen2.getMinQ() & 0x0F));
byte data1 = (byte) ((gen2.getMaxQ() << 4) & 0xF0);
byte data0 = (byte) ((gen2.getSelect().getValue() << 6) | (gen2.getSession().getValue() << 4) | (gen2.getTarget().getValue() << 3));
return new ReaderProtocol(0x07, 0x04, new byte[]{data3, data2, data1, data0});

Each of these four bytes packs several small settings into eight bits: << 4 shifts a value left four bits so it sits in the high half, & 0xF0 keeps the high four bits, & 0x0F keeps the low four, and | (bitwise OR) glues two halves together. The last line builds the frame: opcode 0x07, payload length 4, and the four packed bytes.

ReaderClient.java:293. An annotation-driven codec would break the first time the firmware’s field order differs from a Java bean’s natural layout — and for Gen2 and LockParam it does. The cost is duplication: the same bit-twiddling appears in writeProtocol() and readProtocol(), and ReaderClient has grown to nearly 2,000 lines of near-identical anonymous Protocol classes.

The “CRC” is not a CRC. HexTools.crc16 is a plain additive byte sum of type + len + data, and the receiver discards the CRC byte it reads. Integrity is delegated to the firmware, which sends a 0xFF frame with a CRC_ERROR code when it does not like what it got. The test file still notes that the vendor’s documentation has the checksum byte wrong.

then() has no timeout variant. await(n, unit) exists on Future, but the callback form does not, so a command whose response never arrives leaves the promise pending forever and the GUI never updates — with no error shown to the user. For a device on a lossy serial line or behind a WiFi bridge, a per-command deadline is not optional.

And the 120 ms sleep runs on the UI thread. send() sleeps before writing, and send() is called from the GUI’s command methods, so every button press freezes the interface for 120 ms.

What I would do differently

Return CompletableFuture. The whole promise package is vendored FuseSource code — Apache-2.0 header, 2010–2012 copyright — and it implements one continuation, no timeout and no executor choice. CompletableFuture has all of that, is in the JDK since Java 8, and the desktop module already targets Java 8. A thenAcceptAsync(callback, executor) would also let the SDK offer a UI-thread executor instead of making every caller remember Platform.runLater.

Fix the correlation. A request id in the frame — the protocol has room for it in LEN/DATA — or a per-command queue instead of a map keyed by opcode would remove the “one in-flight request per opcode” limit. And the handler registration must happen before the flush, not after.

Give the SDK a real timeout. Every command should carry a deadline and reject with a TimeoutException. The timeout = 3000 field in AbstractConnection suggests the intent existed and was never wired up.

Move the 120 ms device-busy delay into the transport, where it belongs, as a scheduled write rather than a Thread.sleep on the caller’s thread. That one change removes the UI freeze and the thread-safety question together.

Split ReaderClient. Sixty-plus command methods in one 2,000-line class, each with two hand-written protocol implementations, is the part that will hurt when the vendor adds a command. One class per command, or a table-driven codec, would fix it.

And test the parts that are pure logic. The encoder, the decoder, the checksum and the promise have no dependencies on hardware and no tests at all — the only test class in the repository is a manual integration harness that needs a real reader on a hard-coded IP, and the build is configured with <skipTests>true</skipTests>, so even that never runs. A unit test for the decoder would have caught the capacity()-versus-readableBytes() bug at once.

The part of this project I would keep exactly as it is: the layering. Transport, protocol, promise and domain are separated; the factory hides the concrete connection type; the Protocol strategy makes each command self-contained; the GUI never touches a byte; and the entity/ model is a deliverable in its own right, because it tells a customer what the device can be asked to do — in their language, with the device’s quirks documented in Javadoc (“只适用于单独模块,不适用设备” — applies only to the standalone module, not to the device; “仅对 Impinj 的 Monza 4QT 标签有效” — valid only for Impinj Monza 4QT tags). That knowledge only comes from sitting with real hardware, and it is the reason an SDK is worth more than a protocol document.

References

  • sdk-sk4 — the SDK this post describes
  • Netty — the networking framework behind the SDK’s pipelines
  • RXTX — the serial-port library behind the RXTX channel
  • Java 8 API documentation — SocketAddress, CountDownLatch, CompletableFuture
  • TCP (RFC 793) — the protocol under the TCP transport