Reading tags and deduplication
This page takes you from "the reader is connected" to "the application receives a clean stream of tag events": run the read loop, understand why the data pours in the way it does, and filter it into business events you can act on. The Python and Web Serial samples live right on this page; those two packages ship a continuous read loop, so for most projects this page is all the tag-reading you need.
How reading works
RFID inventory does not follow the familiar request and response model. You start the inventory once, and the reader keeps pushing tag data at you until you stop it. Three consequences run through this whole page:
- The same tag reports many times. A tag sitting still in the read zone produces data on every scan cycle, potentially hundreds of times per second. Collapsing those reads into one event is your application's job, using the three layers at the end of this page.
- Data arrives as a continuous stream. The Python and Web Serial packages receive and reassemble that stream before invoking your callback, so you never think about it. If you write Go, Rust, or C++ and need a continuous loop, use the samples in the receive loop appendix.
- There is no tag-departed event. The reader only reports what it currently sees, never what disappeared. "The tag left the zone" is a timeout you implement, not a notification you receive.
The start-reading sequence
Configure while the reader is idle, and only then start the inventory:
- Open the serial port and initialize the connection.
- Call
query_rfid_ability()to learn the device's real power range and antenna count. - Set the antenna mask and per-port power.
- Set the RF band and the channel list permitted at the installation site.
- Set the reader-side filter window.
- Start the inventory and handle tag data in the callback.
- When done: stop the inventory, let the SDK drain what is pending, close the port.
Changing configuration mid-read: stop first
The reader only accepts configuration commands while idle. Send one while the inventory runs and the command reaches a busy device, while its response gets tangled into the tag data flowing back on the same serial port.
Several Python methods stop the inventory for you before doing their work: query_reader_power, set_rf_band, configure_baseband, query_baseband_profile, and the EPC writing paths. configure_reader_power leaves you in control, so to change power mid-session, stop first yourself:
reader.stop_inventory()
reader.is_idle() # sends STOP and waits for the hardware to settle
reader.configure_reader_power({1: 28}, persistence=False)
reader.start_inventory_with_mode(antenna_mask=[1], callback=on_tag)is_idle() requests the stop, waits for the reader to confirm, then waits an extra settle_delay (0.5 s by default) for the hardware to settle. Skip that wait and the power change tends not to stick.
The general rule: treat power, antennas, band, channels, and profile as session configuration. Settle the values before reading starts, and only change them mid-session when the business genuinely requires it.
Reading with Python
The SDK runs the receive loop itself and invokes your callback on its own thread.
import logging
import queue
import threading
import time
from nrn import create_reader
class Deduper:
"""Drop repeated reads of the same EPC inside a sliding window."""
def __init__(self, window_s: float = 2.0):
self._window = window_s
self._seen: dict[str, float] = {}
self._lock = threading.Lock()
def accept(self, epc: str) -> bool:
now = time.monotonic()
with self._lock:
last = self._seen.get(epc)
self._seen[epc] = now
if last is not None and now - last < self._window:
return False
cutoff = now - self._window * 10
for stale in [k for k, seen_at in self._seen.items() if seen_at < cutoff]:
del self._seen[stale]
return True
events: queue.Queue = queue.Queue()
dedupe = Deduper(window_s=2.0)
reader = create_reader("/dev/ttyUSB0", log_level=logging.INFO)
reader.open()
try:
reader.Connect_Reader_And_Initialize()
reader.configure_reader_power({1: 25}, persistence=False)
reader.set_filter_settings(repeated_time_ms=200, rssi_threshold=0)
def on_tag(tag):
# Runs on the SDK's inventory thread. Keep it short: filter, then hand off.
epc = tag.get("epc")
if not epc or not dedupe.accept(epc):
return
events.put(tag)
reader.start_inventory_with_mode(antenna_mask=[1], callback=on_tag)
deadline = time.monotonic() + 10
while time.monotonic() < deadline:
try:
tag = events.get(timeout=0.5)
except queue.Empty:
continue
print(tag["epc"], tag["rssi"], tag["antenna_id"])
reader.stop_inventory()
finally:
reader.close()on_tag runs on the SDK's inventory thread, not your main thread. Heavy work there, like database writes or HTTP calls, blocks the receiving and drops tags. Filter and push onto a queue; let another thread do the heavy lifting.
Reading with TypeScript and Web Serial
The SDK runs an async receive loop and invokes your callback on the browser's event loop, so callbacks are single-threaded and need no locking.
import { createNRNReader, type TagData } from "@nextwaves/nrn-sdk";
class Deduper {
private seen = new Map<string, number>();
constructor(private readonly windowMs = 2000) {}
accept(epc: string): boolean {
const now = performance.now();
const last = this.seen.get(epc);
this.seen.set(epc, now);
if (last !== undefined && now - last < this.windowMs) {
return false;
}
const cutoff = now - this.windowMs * 10;
for (const [key, seenAt] of this.seen) {
if (seenAt < cutoff) this.seen.delete(key);
}
return true;
}
}
const dedupe = new Deduper(2000);
const reader = createNRNReader({ baudrate: 115200 });
// Must run inside a click or another user gesture.
await reader.connect();
await reader.startInventory([1], (tag: TagData) => {
if (!dedupe.accept(tag.epc)) return;
console.log(tag.epc, tag.rssi, tag.antenna_id);
});
// Later, from your stop button:
await reader.stopInventory();
await reader.disconnect();The callback shares the event loop with your UI. Push tags into a store or a batching array and render on a cadence; do not call setState per tag.
Go, Rust, and C++: use the appendix
These three packages ship the full query, configuration, and tag-parsing surface. For continuous inventory, you run the receive loop yourself, following the complete per-language samples in the receive loop appendix. The loop structure, the deduplication, and every rule on this page apply unchanged; the only difference is that you hold the receiving loop.
Deduplication
Apply it in three layers. Each removes a different kind of duplicate, and none replaces the others:
Layer 1: reader-side filter
The reader can suppress repeated reads before they ever reach your host, saving serial bandwidth and CPU:
reader.set_filter_settings(repeated_time_ms=200, rssi_threshold=0)repeated_time_msis rounded down to a multiple of 10 ms: values below 10 disable the filter,250stays250, and255becomes250.rssi_thresholdtakes a raw value from 0 to 255, not dBm; raw 128 is roughly -65 dBm. Zero disables the threshold.- This is a device setting, not a business rule: it cannot express "one event per pallet per shift".
Keep this window short, a few hundred milliseconds. Set it long and the reader hides tags that genuinely re-entered the zone, and the host has no way to recover that information.
Layer 2: service-side window
This is the Deduper in every sample above. Pick the window length from the business event, not from the read rate:
| Read point | Window | Why |
|---|---|---|
| Dock door | 2 to 5 seconds | One pallet passing is one event |
| Conveyor | 0.5 to 1 second | Items follow each other quickly |
| Handheld count | the whole session | Each EPC counts once per count sheet |
| Checkout counter | 1 to 2 seconds | The basket changes while staff work |
There are two window styles with different behavior, and mixing them up is the most common mistake in this area:
- A sliding window, as in the samples above, refreshes the timestamp on every read: a tag sitting in the zone fires one event, and fires again only after being absent for a full window. Use it to mean "goods just arrived".
- A fixed window does not refresh the timestamp on suppressed reads: a tag sitting in the zone fires once per window. Use it as a heartbeat meaning "goods still here".
Bound the map's size. Every sample evicts entries older than ten windows, because an EPC map that only grows is a slow memory leak in your service once it runs for weeks.
Layer 3: business idempotency
The two layers above only reduce duplicates; they never eliminate them, because a reader restart, a reconnect, or your own process restarting wipes the windows clean. Events that reach the stock ledger must therefore be safe to apply twice.
The pattern every web developer knows: give each event a natural key such as (epc, read point, business window) and make the write depend on it, in the spirit of INSERT ... ON CONFLICT DO NOTHING. A duplicate then becomes a no-op instead of a second stock movement. This is the only layer that survives a process restart, so treat the other two as optimizations and this one as the ground you stand on.
Operational guidance
Track two numbers side by side: unique EPCs per minute and total reads per minute. Total reads climbing while unique EPCs stay flat: power is too high or the dedup window too short. Unique EPCs dropping while total reads stay steady: a strong tag nearby may be drowning out weaker ones, see the RFID deployment guide.
Read next
- Appendix: writing your own receive loop when using Go, Rust, or C++.
- SDK reference for the full method list.
- Production checklist before going live.

