P2P telemetry and messaging for ham radio devices over a local network.
- Device discovery via UDP broadcast (no mDNS, no broker, no router required)
- Transport via a minimal CoAP implementation built into the library
- Supports ESP32 (WiFiUDP) and ATMEGA2560 + Ethernet shield (EthernetUDP)
- No dynamic memory allocation — safe for long-running embedded devices
Building a device? INTEGRATION.md is the normative, form-independent profile for settings, publish/subscribe roles and diagnostics — the convergence target for a functionally uniform implementation across the device family.
Currently used in these devices
- IP-rotator
- ESP32 QRPlog for IC-705
- TrxNet Monitor
- IP Antenna Matrix Controller up to 16x4
- Open Interface III
- ESP32 DIN rail module (Config: TrxNetSwitch)
- 3D print WX station
- ESP32 e-ink display
- EXPERT 1K-FA linear amplifier web console
- NodeRed
Copy the TrxNet/ folder into your Arduino libraries directory:
~/Arduino/libraries/TrxNet/
Arduino IDE will find it automatically on next restart.
// Type and ID are separate so each can come from EEPROM / config menu.
// NET_ID 0x00 is reserved as "disabled" sentinel — do not use as a real device ID.
char deviceType[16] = "705"; // device type prefix, e.g. "OI3", "705", "ROT"
char deviceId[8] = "01";
char deviceName[TRXNET_MAX_DEVICE_NAME]; // assembled in setup()
WiFiUDP udp; // or EthernetUDP udp;
TrxNet net(udp); // name not known at global init time
void onFreq(const char* from, const uint8_t* data, size_t len) {
uint32_t freq;
memcpy(&freq, data, sizeof(freq));
}
void setup() {
// ... connect WiFi or Ethernet first ...
snprintf(deviceName, sizeof(deviceName), "%s.%s", deviceType, deviceId);
net.begin(deviceName);
net.subscribe("/freq", onFreq);
}
void loop() {
net.loop(); // must be called every iteration
uint32_t freq = 14250000UL;
net.publish("/freq", (uint8_t*)&freq, sizeof(freq));
}TrxNet net(UDP& udp, uint16_t port = 5683);| Parameter | Description |
|---|---|
udp |
WiFiUDP or EthernetUDP instance. Must outlive TrxNet. |
port |
UDP port used for both discovery and CoAP. All devices in the network must use the same port. Default: 5683. |
The device name is not passed to the constructor — it may not be known yet at global init time (e.g. when it is loaded from EEPROM or set via a config menu). Pass it to begin() instead.
Sets the device name, starts the UDP socket, and broadcasts a discovery probe.
Call once, after WiFi/Ethernet is connected and the device name is known.
| Parameter | Description |
|---|---|
name |
Device identity string assembled at runtime, e.g. "705.01" or "OI3.ff". Max 31 chars. Two devices with the same name are treated as one. Typically built as snprintf(buf, sizeof(buf), "%s.%02x", deviceType, netId). NET_ID 0x00 is reserved as "disabled" — do not call begin() when NET_ID is 0. |
Processes all incoming packets, sends periodic keepalive, and retransmits unACKed CON messages.
Must be called every iteration of Arduino loop() without blocking delays.
Blocking for more than ~2 seconds risks missing discovery probes and CON retransmit windows.
Registers a callback for incoming messages on path.
typedef void (*TrxNetCallback)(const char* from, const uint8_t* data, size_t len);| Parameter | Description |
|---|---|
path |
Topic path, e.g. "/freq". Max 31 chars including leading /. |
cb |
Function called when a message arrives on this path. |
- Registering the same path twice replaces the callback.
- Up to
TRXNET_MAX_SUBSsubscriptions — 16 on ESP32/ESP8266, 8 on AVR (see Configuration). A path that does not fit is dropped silently: the callback simply never fires, with no error anywhere. Count the paths a device registers before assuming they all took. - The callback is called synchronously inside
loop()— keep it short. Nodelay(), no blocking I/O.
Removes the subscription for path. Does nothing if the path was not subscribed.
Sends data to all currently known peers on path.
| Parameter | Description |
|---|---|
path |
Topic path, e.g. "/freq". |
data |
Pointer to payload bytes. The library does not interpret them. |
len |
Payload length. Max TRXNET_MAX_PAYLOAD (default 64) bytes — excess is silently truncated. |
type |
TRX_NON (default) — fire-and-forget. TRX_CON — retransmits until ACKed. |
TRX_NON vs TRX_CON:
| TRX_NON | TRX_CON | |
|---|---|---|
| Delivery | Best effort | Retransmits up to TRXNET_CON_MAX_RETRIES times |
| Use for | Telemetry (freq, mode, flags) — next update replaces lost one | Text messages (CW, RTTY) — must not be lost |
| Overhead | None | ACK round-trip per peer per message |
- If no peers are known,
publish()does nothing. TRX_CONimproves reliability by retransmitting until ACK, but it is not a hard delivery guarantee. Delivery can still fail if the peer is offline, the pending queue is full, or the receiver'sloop()is blocked too long.- TRX_CON delivers at-least-once. The receiver deduplicates retransmits via a ring buffer of
TRXNET_MAX_SEEN(per-board default, see Configuration) recent(src, msgId)pairs — the callback fires once per message as long as the buffer is not exhausted. IfTRXNET_MAX_SEENis set too low for the actual traffic, duplicate callbacks are possible. - CON messages use a shared pending queue of
TRXNET_MAX_PENDINGtotal slots across all peers (per-board default, see Configuration). A singlepublish(..., TRX_CON)to many peers can consume the entire queue immediately. If the queue is full, additional CON sends are silently dropped. For multi-peer deployments, sizeTRXNET_MAX_PENDINGfor peak fan-out, not average traffic. - Invalid usage is rejected silently: topic paths longer than
TRXNET_MAX_TOPIC_LENare ignored, oversized payloads are truncated to fit, and excess CON messages are dropped when the queue is full. If your application needs diagnostics, add checks in application code before callingpublish()orsubscribe().
Returns the number of currently active peers (devices that have been discovered and have not timed out).
Returns a read-only pointer to peer at index (0-based). Returns NULL if index >= peerCount().
struct TrxPeer {
char name[TRXNET_MAX_DEVICE_NAME]; // device name string
IPAddress ip;
uint16_t port;
uint32_t lastSeen; // millis() of last discovery packet
bool active;
};Register a callback fired once per newly discovered peer — useful for sending
the current state snapshot to a peer that just joined (frequency, mode, etc.) so
it does not have to wait for the next change. Passing NULL clears the callback.
Only one slot — registering again replaces the previous callback.
typedef void (*TrxPeerCallback)(const TrxPeer* peer);The callback is not fired for known peers that simply refresh their lastSeen
via a repeat announce. It fires again only if a peer was removed by
TRXNET_PEER_TIMEOUT_MS and later rejoins.
Re-entrancy warning. The callback runs from inside net.loop() during UDP
packet handling. Calling publish() or publishTo() directly from the callback
is technically possible on W5500 + Ethernet stacks, but is brittle and may break
on other UDP backends. Defer the actual send to the main loop.
// Pending list of peers waiting for the initial state snapshot.
// Filled by onPeerAdded callback, drained by loop().
char pendingGreet[TRXNET_MAX_PEERS][TRXNET_MAX_DEVICE_NAME];
uint8_t pendingGreetCount = 0;
void onPeerJoined(const TrxPeer* peer) {
if (pendingGreetCount >= TRXNET_MAX_PEERS) return;
strncpy(pendingGreet[pendingGreetCount], peer->name, TRXNET_MAX_DEVICE_NAME - 1);
pendingGreet[pendingGreetCount][TRXNET_MAX_DEVICE_NAME - 1] = '\0';
pendingGreetCount++;
}
// Send all topics this device publishes — keep in sync with regular publishes.
void republishState(const char* peerName) {
uint32_t f = currentFreq;
uint8_t m = currentMode;
net.publishTo(peerName, "/hz", (uint8_t*)&f, sizeof(f), TRX_CON);
net.publishTo(peerName, "/mode", &m, sizeof(m), TRX_CON);
}
void setup() {
// ... Ethernet.begin etc ...
net.onPeerAdded(onPeerJoined);
net.begin(deviceName);
}
void loop() {
net.loop();
// Drain ONE peer per iteration — keeps _pending bounded to one peer's
// worth of CON slots at a time. Draining all in a tight while-loop would
// queue N_peers × N_topics slots at once and require TRXNET_MAX_PENDING
// to scale with peer count. Per-iteration drain lets _pending stay at
// its default size regardless of how many peers join simultaneously.
if (pendingGreetCount > 0) {
pendingGreetCount--;
republishState(pendingGreet[pendingGreetCount]);
}
}Use TRX_CON for the snapshot to guarantee delivery — UDP drop at the moment
of join would otherwise leave the new peer with stale state until the next
change. With the per-iteration drain pattern shown above, TRXNET_MAX_PENDING
only needs to cover one peer's snapshot (N_topics + 2 for retry headroom), not
the whole peer count — the per-board defaults (8 on ATmega2560, 24 on ESP32)
comfortably cover the 2-topic OI3 snapshot. If you publish many more state topics
per peer, raise TRXNET_MAX_PENDING accordingly.
To change buffer sizes with Arduino IDE, edit TrxNet.h directly. The
#ifndef override pattern does not work for sketch-level #define because
Arduino IDE compiles library .cpp files in a separate translation unit that
does not see the sketch's macros — the resulting class-size mismatch between
sketch and library produces a silent C++ ODR violation (or, with stricter
compilers, a build failure). PlatformIO build_flags are the only portable
way to override these values from outside the library.
Protects high-value devices when the peer table (TRXNET_MAX_PEERS) is full —
so a RAM-constrained node (ATmega2560 / 328) keeps the devices that matter and
sheds the rest, while a large-RAM node (ESP32) with room for everyone is
unaffected.
prefixes is a caller-owned array of count name-prefix strings, matched
with strncmp — so "IC-705" matches "IC-705.01". When a peer whose name
matches a prefix announces and the table is full, the stalest non-matching
peer is evicted to make room. Peers already in the table are never evicted for
a non-priority newcomer, and if every slot already holds a priority peer the
newcomer is simply dropped (no thrashing).
static const char* const prio[] = { "IC-705", "AntHub", "PA." };
void setup() {
// ... Ethernet/WiFi up ...
net.setPriorityPrefixes(prio, 3); // array must stay valid for the object's lifetime
net.begin(deviceName);
}Pass NULL, 0 to disable. The feature has no effect until the table actually
fills — below TRXNET_MAX_PEERS peers, everyone is admitted normally.
What "dropping a peer" actually costs. The peer table is "who this node
sends to". publish() unicasts to each known peer (it is not a UDP broadcast),
so a peer not in the table will not receive this node's publish()/publishTo()
and cannot be addressed by name. Incoming messages from that peer are still
received and dispatched to subscriptions regardless — only the sender's friendly
name is left blank. So prioritisation governs outbound reach, not reception.
NET_ID = 0x00 is reserved across all TrxNet devices as a "disabled" sentinel.
A device configured with NET_ID = 0x00 must not call begin() and must not
participate in the network. This lets firmware disable TrxNet via a single config
byte without a separate enable flag.
uint8_t NET_ID = EEPROM.read(NET_ID_ADDR);
if (NET_ID != 0x00) {
snprintf(deviceName, sizeof(deviceName), "705.%02x", NET_ID);
net.begin(deviceName);
}| Type prefix | Example name | Description |
|---|---|---|
705 |
705.01 |
IC-705 Interface — publishes /freq, /mode, /flags; subscribes /s-hz, /s-mode |
OI3 |
OI3.ff |
k3ng CW keyer — publishes /cw; subscribes /s-cw |
ROT |
ROT.01 |
IP-rotator — publishes /azimuth, /elevation; subscribes /s-azimuth, /s-elevation |
DIN |
DIN.01 |
ETH DIN rail dev kit — subscribes /s-gpio (set 8 outputs); publishes /gpio (current output state) |
WX |
WX.01 |
ESP32-POE weather station — publishes /temp, /hum, /press, /rain, /winddir, /windavg, /windmax (publish-only) |
PA |
PA.01 |
EXPERT 1K-FA linear amplifier — publishes /pa-flags, /fwd, /ref, /swr, /band; subscribes /hz, /s-on, /s-operate, /s-full, /s-tune. A Python peer, not firmware: the amplifier's serial protocol is spoken by a daemon on a Linux box |
Device type prefixes are arbitrary strings — the library does not interpret them. The table above documents the convention used across the remoteQTH device family.
The /azimuth and /elevation topics carry a raw uint16_t, 2 bytes, little-endian value in degrees.
// Publish current azimuth (180°)
uint16_t az = 180;
net.publish("/azimuth", (const uint8_t*)&az, sizeof(az), TRX_NON);
// Receive azimuth on another device
void onAzimuth(const char* from, const uint8_t* data, size_t len) {
if (len < sizeof(uint16_t)) return;
uint16_t az;
memcpy(&az, data, sizeof(az));
}
net.subscribe("/azimuth", onAzimuth);/s-azimuth and /s-elevation follow the same encoding and are used to command a rotation target from a peer device.
The /mode topic carries an ICOM CI-V mode byte (uint8_t). Using CI-V bytes
as the shared format means devices with direct CI-V access (e.g. IC-705 Interface)
can pass the byte through without conversion. Devices without CI-V (e.g. OI3 keyer)
map their internal mode to the nearest CI-V equivalent.
| CI-V byte | Mode |
|---|---|
0x00 |
LSB |
0x01 |
USB |
0x02 |
AM |
0x03 |
CW |
0x04 |
RTTY / FSK |
0x05 |
FM |
0x06 |
WFM |
0x07 |
CW-R |
0x08 |
RTTY-R |
0x17 |
DV (D-STAR) |
The PA device publishes /pa-flags as a bit map. The low byte is the
amplifier's own FLAGS byte, passed through unchanged, so a reading can be
checked against the SPE protocol document directly; the high byte carries what
the amplifier itself cannot know.
It is deliberately not called /flags: that topic already carries the CI-V
transceiver bitfield (PTT, SPLIT, RIT…), and one name with two bit maps means a
consumer that meets both reads one as the other. A separate name makes an
unknown publisher show up as undecoded rather than as plausible nonsense.
| bit | meaning | bit | meaning | |
|---|---|---|---|---|
| 0 | TUNE — tuning right now | 7 | always 0 | |
| 1 | OPERATE (0 = STANDBY) | 8 | ON — powered and running | |
| 2 | TX | 9 | LINK — telemetry is arriving | |
| 3 | ALARM | 10 | REV2 — protocol revision | |
| 4 | FULL (0 = half power) | 11–15 | reserved, zero | |
| 5 | CONTEST | |||
| 6 | BEEP |
Bit 7 is forced to zero on purpose: the amplifier uses it for PA_PROT in
protocol Rev. 1.0 and for the temperature scale in Rev. 2.0, so it would mean
two different things on the wire.
// Is the amplifier transmitting?
void onPaFlags(const char* from, const uint8_t* data, size_t len) {
if (len < sizeof(uint16_t)) return;
uint16_t f;
memcpy(&f, data, sizeof(f));
bool tx = f & (1 << 2), alarm = f & (1 << 3);
}| topic | type | unit × scale | example wire value |
|---|---|---|---|
/fwd |
uint16_t |
W × 10, forward | 850.0 W → 8500 |
/ref |
uint16_t |
W × 10, reflected | 12.5 W → 125 |
/swr |
uint16_t |
SWR × 100 | 1.35 → 135 |
/band |
uint8_t |
band in metres | 20 m → 20 |
Both power topics carry the instantaneous reading, not a peak: they are
published with every telemetry packet while transmitting (5–8 per second) and on
change otherwise. /swr uses 0 for "no answer" — below a few watts the ratio
is noise — and 65535 for infinite.
The /s-gpio and /gpio topics carry a single uint8_t whose 8 bits map to 8
GPIO pins. /s-gpio commands the outputs; /gpio reports the current state back
(same encoding). Bit i drives pin map[i], bit 0 = LSB.
Pin map used by the DIN (ETH DIN rail dev kit) device:
| bit | GPIO |
|---|---|
| 0 | 0 |
| 1 | 2 |
| 2 | 4 |
| 3 | 12 |
| 4 | 13 |
| 5 | 14 |
| 6 | 32 |
| 7 | 33 |
// Command outputs: turn on bit0 (GPIO0) and bit3 (GPIO12)
uint8_t out = (1 << 0) | (1 << 3);
net.publish("/s-gpio", &out, sizeof(out), TRX_NON);
// Receive /s-gpio (or /gpio state report) on the device
const int pinMap[8] = {0, 2, 4, 12, 13, 14, 32, 33};
void onSGpio(const char* from, const uint8_t* data, size_t len) {
if (len < 1) return;
uint8_t b = data[0];
for (int i = 0; i < 8; i++) digitalWrite(pinMap[i], (b >> i) & 1);
}
net.subscribe("/s-gpio", onSGpio);The device replies on /gpio with the applied byte on every change (TRX_NON)
and sends a current-state snapshot to each newly joined peer (TRX_CON).
The WX device publishes 7 telemetry topics as raw little-endian scaled
integers. All are uint16_t except /temp, which is int16_t to allow
sub-zero readings. Periodic publish (every 5 min) is TRX_NON; the snapshot
sent to a newly joined peer via onPeerAdded is TRX_CON.
| topic | type | unit × scale | example wire value |
|---|---|---|---|
/temp |
int16_t |
°C × 100 | 21.35 °C → 2135 |
/hum |
uint16_t |
% × 100 | 67.50 % → 6750 |
/press |
uint16_t |
hPa × 10 | 1013.2 hPa → 10132 |
/rain |
uint16_t |
mm × 100 (daily total) | 3.80 mm → 380 |
/winddir |
uint16_t |
° (0–359) | 180° → 180 |
/windavg |
uint16_t |
m/s × 100 | 4.20 m/s → 420 |
/windmax |
uint16_t |
m/s × 100 | 9.80 m/s → 980 |
// Publish temperature 21.35 °C
int16_t t = (int16_t)lround(TemperatureCelsius * 100.0);
net.publish("/temp", (const uint8_t*)&t, sizeof(t), TRX_NON);The library transfers raw bytes. Serialize/deserialize in your code:
// uint32_t (frequency, Hz)
uint32_t freq = 14250000UL;
net.publish("/freq", (uint8_t*)&freq, sizeof(freq));
void onFreq(const char* from, const uint8_t* data, size_t len) {
if (len < sizeof(uint32_t)) return;
uint32_t freq;
memcpy(&freq, data, sizeof(freq));
}
// uint8_t (mode enum)
uint8_t mode = MODE_USB;
net.publish("/mode", &mode, sizeof(mode));
void onMode(const char* from, const uint8_t* data, size_t len) {
if (len < sizeof(uint8_t)) return;
uint8_t mode = data[0];
}
// uint16_t (bitfield flags)
uint16_t flags = FLAG_PTT | FLAG_SPLIT;
net.publish("/flags", (uint8_t*)&flags, sizeof(flags));
void onPaFlags(const char* from, const uint8_t* data, size_t len) {
if (len < sizeof(uint16_t)) return;
uint16_t flags;
memcpy(&flags, data, sizeof(flags));
}
// char[] (CW/RTTY text, max 20 chars, CON for reliable delivery)
const char* msg = "CQ CQ DE OK1HRA";
net.publish("/cw", (const uint8_t*)msg, strlen(msg), TRX_CON);
void onCW(const char* from, const uint8_t* data, size_t len) {
char msg[21] = {};
memcpy(msg, data, (len < 20) ? len : 20);
}Byte order: memcpy preserves the CPU's native byte order. All devices in your network run the same architecture (little-endian for AVR and ESP32), so this is safe. Do not use pointer casts (*(uint32_t*)data) — they risk unaligned reads on some platforms.
The four RAM-scaling limits default per board by SRAM class, so the same
library sees the whole network on a big MCU and only what fits on a small one.
Each board flashes its own binary, so this compile-time selection is exactly a
"limit per processor" — no runtime cost. Defaults (auto-selected in TrxNet.h):
| Limit | ESP32 / ESP8266 | ATmega2560 (8 KB) | 328 / other AVR (2 KB) |
|---|---|---|---|
TRXNET_MAX_PEERS |
24 | 8 | 4 |
TRXNET_MAX_PENDING |
24 | 8 | 4 |
TRXNET_MAX_SEEN |
48 | 16 | 8 |
TRXNET_MAX_SUBS |
16 | 8 | 8 |
TRXNET_MAX_SUBS is the only one of the four that costs nothing on the wire —
subscribe() is a local callback table, not a protocol message — so it is sized
by what a device actually listens to. An ESP32 interface can carry a radio's
topics and a linear amplifier's six at once; an AVR keyer needs three. The AVR
figure is deliberately not lowered below 8 as the other three are: an
over-full table drops subscriptions silently, so shrinking it would break
existing sketches with no diagnostic at all.
TRXNET_MAX_PEERS is the "how much of the network do I see" knob. A peer that
does not fit is dropped — unless it matches a prefix registered with
setPriorityPrefixes(),
which evicts the stalest non-priority peer instead. So a strong node sees
everyone and a weak node keeps the essentials.
RAM cost per extra slot on AVR: TrxPeer ~43 B, Pending ~130 B (the expensive
one), SeenMsg ~6 B, Sub ~35 B (TRXNET_MAX_TOPIC_LEN + a pointer + a flag). TRXNET_MAX_PENDING need not scale with peer count — size
it to the app's worst-case CON burst (see onPeerAdded greet pattern above),
not blindly to TRXNET_MAX_PEERS.
⚠️ Never#definethese in your.ino/.cppbefore includingTrxNet.h. The macros marked (sizes class) below changesizeof(TrxNet).TrxNet.cppis a separate translation unit that does not see your sketch's#defines, so it keeps the defaults — the sketch and library then disagree on the object layout (a C++ ODR violation). The library constructor initializes more array slots than the sketch allocated, corrupting adjacent global memory. The symptom is bizarre and far away (e.g. aLoadProhibitedcrash in the firstnvs_open()/EEPROM.begin()). Since v1.05begin()catches this — you get a link error "undefined reference totrxnet_detail::abi_tag<...>" pointing at yourbegin()call, not silent corruption.
The two safe ways to override (both apply to every translation unit):
- PlatformIO —
build_flags = -DTRXNET_MAX_PENDING=12inplatformio.ini. - Arduino IDE — edit the value in
TrxNet.hdirectly (the per-board#ifndefblocks). The IDE has no per-sketch global-define mechanism.
Values you can set (defaults are per-board — see the header):
TRXNET_MAX_PEERS 12 // (sizes class) peer/discovery table
TRXNET_MAX_SUBS 16 // (sizes class) max subscriptions
TRXNET_MAX_DEVICE_NAME 32 // (sizes class) device name buffer (incl. null)
TRXNET_MAX_TOPIC_LEN 32 // (sizes class) topic path buffer (incl. null)
TRXNET_MAX_PAYLOAD 64 // (sizes class) max payload bytes per message
TRXNET_MAX_PENDING 12 // (sizes class) shared CON retransmit queue (peak fan-out)
TRXNET_MAX_SEEN 24 // (sizes class) incoming CON dedup ring buffer
TRXNET_ANNOUNCE_MS 30000 // behavior only — keepalive broadcast interval
TRXNET_PEER_TIMEOUT_MS 95000 // behavior only — peer removed after this silence
TRXNET_CON_TIMEOUT_MS 2000 // behavior only — CON retransmit interval
TRXNET_CON_MAX_RETRIES 3 // behavior only — CON attempts before giving up
The (sizes class) macros carry the ODR hazard above. The behavior only macros
are used solely inside TrxNet.cpp, so a sketch-side #define of them does not
corrupt anything — it is simply silently ignored (the .cpp never sees it), which
is its own footgun; set them via build_flags or in the header too.
On ATMEGA2560 (8 KB RAM) review RAM usage before raising these — TRXNET_MAX_PENDING dominates. Lower them if RAM is tight; increase them only if CON reliability on a busy network matters more than memory. On ESP32 the defaults are already generous and can be raised freely.
On begin() the device broadcasts a PROBE packet to 255.255.255.255 on the configured port. Every device that receives a PROBE replies with a unicast ANNOUNCE.
Every TRXNET_ANNOUNCE_MS (30 s) each device broadcasts an ANNOUNCE as a keepalive. A peer is removed from the table if no ANNOUNCE is received within TRXNET_PEER_TIMEOUT_MS (95 s, ~3 missed keepalives).
Discovery packets share the same UDP port as CoAP. They are distinguished by the first byte: 0xAA for discovery (invalid CoAP version bits), 0x4x/0x5x for CoAP.
Discovery depends on local broadcast delivery. All devices must be on the same Layer-2 broadcast domain. Discovery does not cross routers or subnet boundaries.
Some WiFi networks can silently break discovery even when normal IP connectivity works: guest networks, mesh systems, VLANs, and AP client isolation settings all block or filter broadcasts between clients. Wired Ethernet (ATMEGA2560 + Ethernet shield) is not affected by AP isolation.
Always validate discovery on the exact target network before deployment. A working bench test does not guarantee the same behaviour on a different WiFi infrastructure.
1. Do not block in loop() or in callbacks.
void loop() {
net.loop();
delay(500); // BAD — CON retransmit fires every 2 s; delay breaks timing
}
void onCW(...) {
delay(100); // BAD — blocks further packet processing
sendToSerial(...); // OK only if it returns quickly
}2. Do not send more simultaneous CON messages than TRXNET_MAX_PENDING.
Each call to publish(..., TRX_CON) occupies one pending slot per peer. If more slots are needed than TRXNET_MAX_PENDING (per-board default: 8 on ATmega2560, 24 on ESP32), the excess sends are silently dropped. Increase TRXNET_MAX_PENDING or send CON messages one at a time (per-iteration drain).
3. Do not call begin() before the network is up.
void setup() {
net.begin(); // BAD — UDP socket fails silently if WiFi not connected yet
WiFi.begin(...);
}4. Do not use String class in callbacks on ATMEGA2560.
String allocates on the heap. Heap fragmentation over hours of runtime causes random crashes on AVR. Use char[] buffers instead.
5. Do not expect delivery to peers discovered after publish() is called.
publish() sends to the peer list at the moment of the call. A device that connects one millisecond later will not receive that message.
6. Do not use payloads larger than TRXNET_MAX_PAYLOAD.
Excess bytes are silently truncated by the sender. The receiver sees a shorter-than-expected payload — guard with if (len < sizeof(T)) return; in every callback.
7. Do not mix ports between devices.
All devices must pass the same port to the constructor. Devices on different ports cannot discover each other.
8. Do not rely on message ordering.
UDP does not guarantee order. Two publish("/freq", ...) calls may arrive in reverse order or the first may be lost entirely (TRX_NON). Design for the latest value winning.
9. Do not assume every device fits in every peer table.
On a network larger than a small MCU's TRXNET_MAX_PEERS, that node holds only the first peers to announce and silently drops the rest — it then cannot publish/publishTo the dropped ones. Either raise TRXNET_MAX_PEERS (RAM permitting) or register the must-reach devices with setPriorityPrefixes() so they evict low-value peers instead of being dropped. Count the other devices: a node needs TRXNET_MAX_PEERS ≥ number of peers it must actually address (the passive Python monitor does not consume a slot).
A passive UDP sniffer and real-time web dashboard for TrxNet networks. See MONITOR.md for full documentation.
