A lightweight NetworkTables 4 client for the Teensy 4.1, built on QNEthernet. Designed for FRC applications where NT4 connectivity is an enhancement — not a dependency — and the main loop must never be blocked by network activity.
- Teensy 4.1 with onboard Ethernet populated (RJ45 magnetics + PHY)
- Standard Ethernet cable
| Library | Purpose |
|---|---|
| QNEthernet | lwIP-based TCP stack for Teensy 4.1 built-in PHY |
| ArduinoJson v7 | NT4 JSON control message parsing |
Both are declared in platformio.ini and will be fetched automatically by PlatformIO.
┌─────────────────────────────────────┐
│ Your Application (main.cpp) │
├─────────────────────────────────────┤
│ NT4Client │ topic registry, typed callbacks, value cache
├─────────────────────────────────────┤
│ NTWebSocket │ HTTP upgrade, RFC 6455 frame encode/decode
├─────────────────────────────────────┤
│ QNEthernet / lwIP │ TCP, DHCP
└─────────────────────────────────────┘
The main loop never blocks on network activity. The only call that can block is nt.beginConnect(), which is bounded to NTWebSocket::kTcpConnectTimeoutMs (150ms, configurable). It only fires when explicitly requested — never automatically.
On a live local network, beginConnect() typically completes in under 10ms.
#include "NT4Client.h"
NT4Client nt;
void setup() {
Ethernet.begin(); // DHCP, async — does not block
nt.subscribe("/ferraui/batteryVoltage", [](double v, uint32_t ts) {
Serial.printf("Battery: %.2fV\n", v);
});
nt.onConnect([]() { Serial.println("NT4 ready"); });
nt.beginConnect(IPAddress(10, 65, 74, 2), 5810, "my-device");
}
void loop() {
nt.loop(); // non-blocking — call every iteration
}// TCP connect (bounded blocking) + send HTTP upgrade request.
// Handshake completes non-blockingly via loop().
bool beginConnect(const IPAddress& host, uint16_t port, const char* identity);
void disconnect();
// Drive the state machine — call every loop() iteration
void loop();
bool isConnected();
bool isHandshaking();Register a typed callback for a specific topic. Registrations persist across reconnects. The callback type is inferred from the function pointer signature.
nt.subscribe("/ferraui/batteryVoltage", [](double v, uint32_t ts){ });
nt.subscribe("/ferraui/enabled", [](bool v, uint32_t ts){ });
nt.subscribe("/ferraui/matchTime", [](float v, uint32_t ts){ });
nt.subscribe("/ferraui/alliance", [](const char* v, uint32_t ts){ });
nt.subscribe("/ferraui/lapCount", [](int32_t v, uint32_t ts){ });
nt.unsubscribe("/ferraui/batteryVoltage");timestamp is the robot-side microsecond timestamp from the roboRIO clock.
Fire for any topic that does not have a per-topic subscription.
nt.onDouble([](const char* name, uint32_t ts, double v) { });
nt.onFloat ([](const char* name, uint32_t ts, float v) { });
nt.onInt ([](const char* name, uint32_t ts, int32_t v) { });
nt.onBool ([](const char* name, uint32_t ts, bool v) { });
nt.onString([](const char* name, uint32_t ts, const char* v) { });Fire a subscription's callback with the last received value, optionally fetching from the server if no value has arrived yet.
// Fire immediately from cache (if available), fetch from server if not
nt.poll("/ferraui/batteryVoltage"); // fetchIfMissing = true (default)
// Fire from cache only — don't contact the server
nt.poll("/ferraui/batteryVoltage", false);
// Returns true if a cached value was available and the callback fired.
// Returns false if fetching was initiated or no subscription exists.
bool result = nt.poll("/ferraui/batteryVoltage");nt.onConnect ([]() { /* WebSocket open, topics incoming */ });
nt.onDisconnect([]() { /* fired on TCP drop or handshake timeout */ });nt.printTopics(); // prints announced topic table (* = has subscription)
nt.printSubscriptions(); // prints subscription table with types
nt.topicCount(); // number of currently announced topics
nt.subscriptionCount(); // number of active subscriptionsThere are no automatic reconnects. Reconnection is always explicit — typically from a button press or the link-up detection in loop().
// Queue a reconnect attempt — safe to call from anywhere including ISRs
void requestReconnect() { _reconnectRequested = true; }
// In loop():
if (_reconnectRequested) {
_reconnectRequested = false;
if (ethernetReady() && !nt.isConnected() && !nt.isHandshaking()) {
nt.beginConnect(kNTServerIP, kNTPort, kIdentity);
}
}This ensures a cable pull during a match never causes beginConnect()'s TCP timeout to stall the main loop unexpectedly.
Edit the constants at the top of main.cpp:
static const IPAddress kNTServerIP(10, 65, 74, 2); // 10.TE.AM.2 for FRC robots
static const uint16_t kNTPort = 5810;
static const char* kIdentity = "teensy-nt4"; // shown in Driver Station
static constexpr bool kUseStaticIP = false; // true to skip DHCPTable sizes and buffer lengths are constants in NT4Client.h:
static constexpr uint8_t kMaxTopics = 48; // max announced topics
static constexpr uint8_t kMaxSubscriptions = 32; // max per-topic registrations
static constexpr uint8_t kMaxNameLen = 96; // max topic name length
static constexpr uint16_t kMaxStringLen = 256; // receive buffer for string values
static constexpr uint8_t kCachedStringLen = 64; // per-subscription string cacheIncreasing these costs only static RAM, of which the Teensy 4.1 has 1MB.
- Port: 5810 (plain WebSocket, not WSS)
- Subprotocol:
networktables.first.wpi.edu— required in the HTTP upgrade header; WPILib 2023+ will reject connections without it - Text frames: JSON arrays of control messages (
announce,unannounce,properties) - Binary frames: Packed MessagePack arrays of
[uid, timestamp, typeId, value]— multiple updates can be packed into a single frame - Timestamps: Robot-side microseconds; overflows
uint32_tafter ~71 minutes of uptime — acceptable for match use
platformio.ini
src/
main.cpp — application entry point, Ethernet init, reconnect logic
NTWebSocket.h/.cpp — WebSocket transport (HTTP upgrade + RFC 6455 framing)
NT4Client.h/.cpp — NT4 protocol layer (topic registry, MsgPack decode, callbacks)