Skip to content
greghulettePublic

About

No description, website, or topics provided.

Resources

Stars

3 stars

Watchers

0 watching

Forks

Latest commit

 

History

60 Commits

Folders and files

Repository files navigation

WCB_Client

An Arduino library for ESP32 devices that lets them join a Wireless Communication Board (WCB) ESP-NOW network as a first-class peer — sending commands, receiving commands, forwarding raw bytes to Pololu Maestro servo controllers, and participating in the ETM heartbeat system.


What is a WCB?

WCB (Wireless Communication Board) is an ESP32-based wireless networking system designed for prop and animatronic control. Multiple WCB boards form a mesh over ESP-NOW, each with a unique ID. They exchange text commands (to trigger actions, run scripts, control servos) and raw serial data (to drive Pololu Maestro servo controllers wirelessly).

This library lets any ESP32 — a Kyber controller, a custom prop brain, a stage cue device — join that network without any hardware modification to the WCB boards.


Requirements

  • Hardware: Any ESP32 or ESP32-S3 board
  • Arduino IDE: 2.x recommended
  • Board package: esp32 by Espressif Systems v3.x
  • WCB firmware: 6.0 or later on all WCB boards in the network

Installation

  1. Download or clone this repository
  2. In Arduino IDE: Sketch → Include Library → Add .ZIP Library and select the folder, or copy the folder into your Arduino libraries/ directory
  3. Restart Arduino IDE

Dependencies

WCB_Client itself has no external dependencies — it uses only built-in ESP32 Arduino libraries (WiFi.h, esp_now.h).

If you are using WCBStream for Maestro control, you also need:

  • PololuMaestro by Pololu — install via Sketch → Include Library → Manage Libraries..., search PololuMaestro

Limitations & Coexistence (WiFi / other ESP-NOW)

This library takes over the ESP32's radio to join the WCB ESP-NOW network. Read this before combining it with WiFi or another ESP-NOW stack.

It takes over WiFi (STA) — but a SoftAP can coexist

begin() puts WiFi into station mode and calls WiFi.disconnect(). It will drop any existing STA connection to a WiFi router, and ESP-NOW timing assumes no STA association. You generally cannot associate this ESP32 with an external WiFi router while using this library — connecting to someone else's AP hands channel control to that AP, and ESP-NOW only works if every WCB happens to already be on that channel too (see below).

Hosting your own SoftAP is a different story and is supported. If you call WiFi.softAP(ssid, password, channel) before begin(), the library detects it and switches to WIFI_AP_STA instead of WIFI_STA — your AP (and any web server built on it) stays up, and ESP-NOW simply rides the AP's radio channel (AP and STA share a single channel on the ESP32, so there's no separate negotiation). Calling softAP() after begin() also works, since WiFi.mode() ORs in WIFI_AP without dropping STA. Either way, all WCBs and clients still need to end up on the AP's channel — pick it deliberately rather than leaving it to default to channel 1.

If you need to associate with an external router (not host your own AP) alongside WCB traffic, that's still unsupported without keeping everything on a single fixed WiFi channel (see below) and accepting the MAC override — in practice it's far simpler to dedicate this ESP32 to the WCB network and use a second board for that WiFi workload.

Shared radio channel

ESP-NOW operates on whatever channel the WiFi radio is currently on. Every WCB and every client must be on the same channel. If something in your sketch associates with an access point, the channel is locked to that AP's channel and ESP-NOW only works if all peers happen to be on it too. Mismatched channels = packets silently dropped, no error.

Since 1.19.0, channel sync keeps the mesh together when it moves: the device that leads announces the new channel and everyone switches with it, and a group leader that misses a board looks for it on the other channels. Your sketch's part is to start on the channel it was last moved to and to save a move when one happens — see Channel sync.

The WiFi MAC is overwritten

begin() calls esp_wifi_set_mac() to force the station MAC to the WCB scheme 02:oct2:oct3:00:00:<deviceID> so the WCBs recognize this device as a peer. Consequences:

  • Anything that depends on the factory/default MAC (licensing, MAC-based device identity, router MAC filtering, DHCP reservations) will not see the real MAC.
  • Two devices with the same deviceID + oct2/oct3 get an identical MAC. Never reuse a device ID on the same network — use the special slot (20) for an extra out-of-band device.

Only one ESP-NOW stack, one instance

  • ESP-NOW is global ESP-IDF state. begin() calls esp_now_init() and registers the single receive callback via esp_now_register_recv_cb(). You cannot run a second, independent ESP-NOW network/library alongside this one — whichever registers its receive callback last wins, and the other stack stops receiving.
  • Only one WCB_Client instance is supported per sketch (it's a singleton). Declare it once at global scope.

WiFi power save

If you do bring up WiFi for any reason, modem sleep can cause this device to miss ESP-NOW packets. Disable it with esp_wifi_set_ps(WIFI_PS_NONE) (or WiFi.setSleep(false)) after begin().

Quick reference

If you need… Result with this library
WCB ESP-NOW only ✅ Intended use — works out of the box
SoftAP (hosted web UI) + WCB on the same board ✅ Supported — begin() preserves an existing/later SoftAP as WIFI_AP_STA. The AP owns the single radio's channel, so bring it up on the mesh channel (WiFi.softAP(ssid, pass, 1)); the library warns (never force-moves it) if the radio ends up elsewhere. Match a non-default fleet channel with setMeshChannel(). When the mesh moves (channel sync), the library cannot move an AP live: register onChannelMove(), save the channel and restart.
WiFi STA (associating with an external router) + WCB on the same board ⚠️ Only if all peers share the router's fixed channel; begin() still disconnects STA
A second, separate ESP-NOW network ❌ Not supported — single global receive callback
Multiple WCB_Client objects ❌ Not supported — singleton
Factory MAC preserved ❌ MAC is overwritten by design

Network Credentials

All devices on the same WCB network must share four values. Find them by querying any WCB over serial or using the WCB Config Tool:

Parameter WCB Command Description
mac_oct2 ?WCBM MAC address octet 2 identifying the network
mac_oct3 ?WCBM MAC address octet 3 identifying the network
password ?WCBP ESP-NOW network password
wcb_quantity ?WCBQ Total number of WCB boards

Device ID Notes

  • IDs 1 through wcb_quantity are standard WCB slots. WCB boards pre-register these MACs so packets arrive correctly. Your device ID must be ≤ wcb_quantity.
  • ID 20 is the special out-of-band slot — it doesn't consume a WCB number and works even if wcb_quantity is less than 20, but requires ?SPECIAL,ON to be set on the WCB boards.

Quick Start

Send and receive text commands

#include <WCB_Client.h>

const uint8_t MAC_OCT2     = 0x5C;
const uint8_t MAC_OCT3     = 0x57;
const char*   PASSWORD     = "MyNetworkPassword";
const uint8_t WCB_QUANTITY = 3;
const uint8_t DEVICE_ID    = 20;

void onCommandReceived(uint8_t sender, const char* cmd) {
    Serial.printf("From WCB%d: %s\n", sender, cmd);
}

WCB_Client wcb(MAC_OCT2, MAC_OCT3, PASSWORD, WCB_QUANTITY, DEVICE_ID,
              onCommandReceived);

void setup() {
    Serial.begin(115200);
    wcb.begin();
}

void loop() {
    wcb.update();              // must call every loop — drives heartbeats
    wcb.broadcast(":PP100");   // send to all WCBs at once
    wcb.send(2, ":LEDON");     // send to WCB2 only
    delay(5000);
}

Forward Maestro servo commands wirelessly — unicast

Send to one specific WCB and serial port. The receiving WCB writes the bytes directly to the requested port — no ?KYBER,REMOTE configuration is required. The ?KYBER,REMOTE flag is only checked on the broadcast path; unicast bypasses it entirely.

#include <WCB_Client.h>
#include <WCBStream.h>
#include <PololuMaestro.h>

WCB_Client wcb(MAC_OCT2, MAC_OCT3, PASSWORD, WCB_QUANTITY, DEVICE_ID);

// WCB_Client must be declared before WCBStream
WCBStream maestroStream(2, 1);   // → WCB2, serial port 1
MiniMaestro maestro(maestroStream, Maestro::noResetPin, /*deviceID=*/1);

void setup() {
    Serial.begin(115200);
    wcb.begin();
}

void loop() {
    wcb.update();                    // also flushes WCBStream automatically
    maestro.setTarget(0, 6000);      // 1500µs — centre position
    delay(1000);
    maestro.setTarget(0, 4000);      // 1000µs — full left
    delay(1000);
}

Forward Maestro servo commands wirelessly — broadcast

Send to every WCB that has a Maestro configured. Requires ?KYBER,REMOTE on each receiving WCB.

#include <WCB_Client.h>
#include <WCBStream.h>
#include <PololuMaestro.h>

WCB_Client wcb(MAC_OCT2, MAC_OCT3, PASSWORD, WCB_QUANTITY, DEVICE_ID);

// broadcast — every WCB with ?KYBER,REMOTE configured will receive it
WCBStream maestroStream(broadcast);
MiniMaestro maestro(maestroStream, Maestro::noResetPin, /*deviceID=*/1);

void setup() {
    Serial.begin(115200);
    wcb.begin();
}

void loop() {
    wcb.update();
    maestro.setTarget(0, 6000);   // reaches all configured Maestros simultaneously
    delay(1000);
}

API Reference

WCB_Client

Method Description
WCB_Client(oct2, oct3, password, quantity, deviceID, [cmdCb], [statusCb]) Constructor. deviceID must match your WCB system (1–quantity for a standard slot, or 20 for the out-of-band special slot).
begin() Initialize WiFi and ESP-NOW, set custom MAC, register peers. Call once from setup().
update() Call every loop(). Drives heartbeats, offline detection, and WCBStream flushing.
send(wcbID, command, [ensured=true]) Unicast a text command to one WCB. Ensured by default (retransmits until ACK'd, like WCB-to-WCB ETM); pass false for fire-and-forget.
broadcast(command, [ensured=true]) Broadcast a text command to all WCBs simultaneously. Ensured by default (retransmits per-board until every online board ACKs); pass false for fire-and-forget telemetry.
sendRaw(wcbID, port, data, len) Unicast raw bytes to a specific WCB's serial port.
sendKyber(data, len) Broadcast raw bytes to all WCBs — any with ?KYBER,REMOTE will forward to their Maestro.
monitorRaw(serial, target, port, [gap_ms]) Watch a UART; flush buffered bytes via sendRaw() or sendKyber() (pass broadcast as target) when silent for gap_ms.
monitorSerial(serial, target, [terminator]) Watch a UART for newline-terminated text commands; forward each line via send() or broadcast().
isOnline(wcbID) Returns true if the specified WCB has sent a heartbeat recently.
onCommand(callback) Register or replace the received-command callback.
onStatusChange(callback) Register or replace the online/offline status callback.
setChecksum(bool) Enable/disable CRC32 checksums. Must match ?ETM,CHKSM setting on WCBs (default: on).
setMeshChannel(uint8_t) Set the ESP-NOW mesh channel (1–maxChannel(): 1-11 by default, 12-13 after setRegion()) this device expects. Best called before begin() with the channel you last saved from onChannelMove(), or the one the WCBs use (?WCBCH / the Wizard); calling it after begin() also re-pins the radio live when no SoftAP is active. One radio = one channel (default: 1).
setChanSyncRank(rank) / chanSyncRank() Channel sync leader rank (lower leads). Default 100 + device id; NaviCore uses 0; 255 never leads or searches but follows. Before begin().
setChanSyncEnabled(bool) / chanSyncEnabled() Take part in channel sync (default on; live). Off = never moves, searches or answers a search.
setRegion(cc) / region() Radio region before begin(): "01" (default, channels 1-11) or an IDF country code such as "DE" for 12-13.
maxChannel() The region's top channel (≤ 13).
currentChannel() The mesh channel this device is on now.
onChannelMove(cb) bool cb(uint8_t channel), on the loop task when a move reaches its switch time: persist channel; return true if the host applies it (a SoftAP restarts), false to let the library switch the radio live.
chanSyncSearchNow() One channel search now, if this device can hop and leads its group.
chanSyncMove(ch, [String* why]) Operator move of the whole mesh; false and a reason when refused.
printChanSyncStatus(Print&) Status, ending in the [CHSYNC:...] machine line.
getPeerStats(wcbID) Delivery counters for one peer, by value. Out-of-range returns a zeroed struct.
getAggregateStats() Totals across all peers, computed on demand.
getBroadcastSent() Broadcast COMMAND frames put on the air.
resetStats() Zero every counter. Call from loop(), not from a receive callback.

Delivery statistics

Per-peer counters for how ETM command traffic is actually landing — which links are healthy, which are retrying, which are failing. Nothing is computed for you: there are no floats and no ratios in the library, because the 0/0 case only the display layer can sensibly decide.

WCBPeerStats s = wcb.getPeerStats(2);
Serial.printf("WCB2: %lu sent, %lu ackd, %lu retries, %lu failed, %lu unguaranteed\n",
              s.sent, s.ackd, s.retries, s.failed, s.unguaranteed);
Counter Increments when
sent a command is first aimed at that peer — initial attempts only, never retries
ackd that peer's ACK arrives for a command we were waiting on — once per command, even though a peer re-ACKs every retransmit it hears
retries a resend fires to that peer; N retries on one command counts N
failed we stopped waiting for that peer's ACK without getting one — retries exhausted, peer dropped offline, slot evicted, or a best-effort unicast that timed out
unguaranteed a send asked for a pending slot and was denied one because all WCB_PENDING_MAX were busy: transmitted once, never tracked, so no ACK can match it and it can never be ackd or failed. Covers an ensured send losing its guarantee and a best-effort unicast losing its ACK tracking. Local backpressure, not a link problem

sent counts commands; retries counts extra attempts, so sent + retries is the delivery attempts aimed at that peer. They are kept apart on purpose — folding retries into sent would let a link that retries constantly show a healthy-looking ackd/sent while flooding the mesh.

That is attempts, not frames on the air. One ensured broadcast puts a single frame up but credits sent to every board it expects to ACK, so summing sent + retries across peers over-counts that frame by (N−1). Per-board retries are genuine separate unicast frames and do count once each. If you want airtime, use getBroadcastSent() alongside the unicast counts rather than this sum.

Invariant, per peer and in the aggregate: ackd + failed + unguaranteed <= sent, the difference being commands still in flight. resetStats() re-credits sent for whatever is in flight at the moment of the reset, so a reset means "start counting here" rather than leaving in-air commands to land as ackd against a zeroed sent.

Two deliberate consequences worth knowing:

  • A board that was offline when an ensured broadcast went out still hears it and ACKs. That ACK is discarded, not counted — it has no matching sent, and counting it would push ackd past sent for a peer we never aimed at. Reachability is what isOnline() and getNeighbor() are for; these counters answer "did what I sent arrive".
  • For an advert-only peer that never heartbeats, the final retry is transmitted in the same pass that settles the slot, so that attempt gets no ACK window and is recorded as failed even if it lands. This reports pre-existing retry behaviour rather than causing it; it cannot happen for a peer that is online.

What is counted

The ETM COMMAND layer only — send(), broadcast(), sendToSpecialPeer(). That is the only outbound traffic carrying a sequence number that can be ACK'd.

Not counted, deliberately: sendRaw() and sendKyber() — and therefore every WCBStream / Maestro byte, usually the highest-volume traffic on the mesh — plus sendRawPacket() (OTA), the MGMT chunks an oversized send() fragments into, heartbeats, WDP adverts, and outbound ACKs. None of these is acknowledged, so counting them as sent would leave ackd pinned at zero against a climbing sent — which reads as a dead link rather than as "not measured". Seeing your Maestro traffic move none of these counters is correct behaviour, not a bug.

An ensured broadcast credits sent to every board it expects to ACK, because it is a per-board guaranteed delivery — retried per board, acknowledged per board. getBroadcastSent() is a separate frame counter (broadcast frames on the air, ensured or not) and is not a subset of any peer's sent; one ensured broadcast increments both.

Counters are unsigned 32-bit and free-running. Without resetStats(), a board that had one bad hour looks bad forever and the ratios stop meaning anything.

See the DeliveryStats example for a working readout: a per-peer table with deltas between samples, a continuous invariant self-check, and a guided two-board bench procedure (g) that walks target-on → target-off-under-50 s → target-off-past-50 s. That last step is the one worth doing carefully — it is the only way to exercise the give-up path, and a counter that is wrong there would never show up in normal use. AllFeatures also has a compact dump on the t key.

WCBStream

A Stream adapter that intercepts writes from the Pololu Maestro library and forwards them wirelessly instead of to a physical serial port.

WCB_Client must be declared before WCBStream at global scope. WCBStream self-registers with WCB_Client on construction so wcb.update() flushes all streams automatically — no extra calls needed in loop().

WCBStream stream(broadcast);         // broadcast to all WCBs with Kyber_Remote
WCBStream stream(wcbID, port);       // unicast to a specific WCB:port
WCBStream stream(wcbID, port, 5);    // unicast with 5ms inter-frame gap

Pass the WCBStream to the Pololu library in place of a serial port:

MiniMaestro maestro(stream, Maestro::noResetPin, deviceNumber);

Constants

Constant Value Use
broadcast 0 Pass as target_wcb to use Kyber broadcast path
WCB_TARGET_BROADCAST 0 Target ID for text command broadcasts
WCB_TARGET_RAW_SERIAL 97 Target ID for unicast raw serial forwarding
WCB_TARGET_KYBER 98 Target ID for Kyber broadcast raw forwarding
WCB_SPECIAL_ID 20 Out-of-band device slot (no WCB slot consumed)

Device Identity (WDP)

void setIdentity(const char* type, const char* fw,
                 const char* hwRev = nullptr, const char* caps = nullptr);

Advertises this device on the WCB mesh so every board discovers it automatically — it shows up in ?WDP,LIST and the config tool by name and firmware version, with no manual port labeling. This is the mesh counterpart of the serial WDP-DA device-announce protocol: a device describes itself the same way whether it's wired to a WCB serial port or joined over ESP-NOW.

  • type — canonical device name (e.g. "NaviCore", "Flthy HP Controller"); use a name from the shared WCB device vocabulary. Required — a null/empty type leaves advertising off.
  • fw — this device's firmware version string.
  • hwRev — optional hardware revision ("revB"); omit with nullptr.
  • caps — optional space-separated capability tags ("hp.servo hp.led"); omit with nullptr.

Call it from setup() (before or after begin()); the advert goes out as a short boot burst and then periodically (~60 s, staggered) from update(). Requires ETM active on the WCBs (the factory default).

wcb.setIdentity("NaviCore", "v0.2.0");

Discovering neighbors (WDP consumer)

The same WDP adverts that setIdentity() broadcasts are also decoded by this library, so your device can learn every other board on the mesh — each WCB and each setIdentity()-enabled client — without any manual configuration.

void onNeighbor(WCBNeighborCallback callback);   // fires when an advert is learned or expires
const WCBNeighbor* getNeighbor(uint8_t wcbNumber);// latest record for a board, or nullptr
uint8_t neighborCount();                          // how many neighbors are currently known

A neighbor is described by a WCBNeighbor:

struct WCBNeighbor {
    bool          valid;          // false when the slot has aged out
    bool          isClient;       // true = a WCB_Client device, false = a WCB
    uint8_t       wcbNumber;      // 1..20
    char          name[25];       // WCB alias, or the client's device type
    char          fw[28];         // firmware version string
    uint8_t       hwVer;          // WCB numeric hardware version (0 for clients)
    char          hwRev[16];      // client hardware revision ("" for WCBs)
    uint16_t      capFlags;       // WCB capability bitmap — test with WCB_CAP_*
    char          capTags[49];    // client capability tags, space-separated
    uint8_t       ctrlId;         // controller (special-peer) id this board links to; 0 = none
    uint8_t       maestroIds[9];  // this board's local Maestro IDs
    uint8_t       maestroCount;
    char          portLabels[5][25]; // advertised serial-port labels ("" = unlabeled)
    uint32_t      seqHash;        // stored-sequence inventory fingerprint (TLV 0x13); 0 = not advertised
    bool          chanValid;      // channel sync (TLV 0x14 CHAN, 1.19.0): false = older firmware
    uint8_t       chanChannel;    //   the channel it says it is on
    uint8_t       chanRank;       //   its leader rank (lower leads; 255 never leads)
    uint8_t       chanFlags;      //   chansync::F_PINNED (joined to a network) / chansync::F_SYNC
    char          chanCc[3];      //   its region ("01", "DE", ...)
    unsigned long lastSeenMs;     // millis() of the last advert heard
};

onNeighbor fires on the ESP-NOW (WiFi) task — keep it minimal and poll getNeighbor() from loop() for heavier work. Neighbors age out after WCB_WDP_NEIGHBOR_TTL_MS (180 s, ~6 missed cycles) of silence; the callback fires once more with valid == false on expiry so you can drop stale entries.

wcb.onNeighbor([](const WCBNeighbor& nb) {
    if (!nb.valid) { Serial.printf("WCB%u went quiet\n", nb.wcbNumber); return; }
    Serial.printf("WCB%u = %s (%s)%s\n", nb.wcbNumber, nb.name, nb.fw,
                  nb.capFlags & WCB_CAP_MAESTRO_HOST ? "  [has Maestro]" : "");
});

Auto-join

By default the same adverts also drive auto-join: when this device hears a regular WCB it isn't already peered with (after two adverts), it registers that board as an ESP-NOW peer live. So you can leave wcb_quantity at whatever covers the boards you address directly and still reach boards discovered later — without pre-registering all 20 slots (the ESP-NOW peer table is small). Client devices and the special peer are never auto-joined.

A learned peer is permanent: it is saved to NVS, restored on every begin(), and from then on always expected to be on and ready. Heartbeats drive its online/offline state, but membership never expires on its own — a board that's powered down stays a member and is simply reported offline until it returns. Removing one is deliberately a user action:

void setAutoJoin(bool enabled);   // default true
bool autoJoinEnabled() const;
void forgetPeer(uint8_t id);      // drop one learned peer (deregisters + clears NVS)
void clearLearnedPeers();         // drop all learned peers

Turn auto-join off to pin the peer set to exactly 1..wcb_quantity (+ special peer):

wcb.setAutoJoin(false);

Stored-sequence inventory

Ask a WCB which stored sequences it has, by name, so a controller can build its command list from the live mesh instead of a hard-coded snapshot that goes stale the first time someone saves one from the Wizard.

// Read
void onSequenceNames(WCBSequenceNamesCallback callback);
bool requestSequenceNames(uint8_t wcbNumber);          // what exists
void onSequenceValue(WCBSequenceValueCallback callback);
bool requestSequence(uint8_t wcbNumber, const char* key);   // one sequence's contents
bool sequenceNamesPending() const;                     // gates BOTH reads

// Write
bool saveSequence(uint8_t wcbNumber, const char* key, const char* value);
bool deleteSequence(uint8_t wcbNumber, const char* key);
wcb.onSequenceNames([](uint8_t n, uint32_t hash, uint16_t count, const char* names) {
    // names = "wave,park,scream"  — comma-separated, "" when count == 0
    // Fires on the LOOP task, so allocation / flash I/O here is safe.
});
wcb.requestSequenceNames(3);

Don't poll this. Every WDP advert carries a 4-byte fingerprint of that board's inventory, so you can request only when it has actually changed:

const WCBNeighbor* nb = wcb.getNeighbor(3);
if (nb && nb->seqHash != 0 && nb->seqHash != myCachedHash)
    wcb.requestSequenceNames(3);

A board re-advertises within ~500 ms of a config change, so a newly saved sequence appears within a couple of seconds while a steady mesh costs no request traffic at all. The hash covers stored values as well as names, so an in-place edit moves it too.

Pulling contents, and pushing edits back

wcb.onSequenceValue([](uint8_t n, const char* key, WCBSeqStatus st, const char* value) {
    if (st == WCB_SEQ_OK) { /* value = ";M1,1^;S5,<CA1021>" */ }
    // else WCB_SEQ_NOTFOUND / WCB_SEQ_TOOBIG (only from a WCB older than values in parts) —
    // always check before trusting value
});

wcb.requestSequence(3, "wave");                          // one at a time
wcb.saveSequence(3, "wave", ";M1,1^;S5,<CA1021>");       // create or overwrite
wcb.deleteSequence(3, "oldname");

To mirror a whole board, pull the names and walk them one at a time — there is no "give me everything" call on purpose. A single value is bounded (3,999 characters, the store's limit; one longer than one reply comes in parts and is delivered whole, see below) but the total is not, so a bulk fetch would have no ceiling. Walking has no aggregate ceiling, lets you fetch lazily, and resumes cleanly if a board drops. At ~100 ms per round trip a 20-sequence board mirrors in 2–3 seconds.

Issuing the next request from inside a callback is fine — the library clears its in-flight state before firing you.

Writes are fire-and-forget: they ride the ordinary command path (auto-fragmented, unicast) and there is no reply packet. Confirm by watching seqHash change, or by reading the value back. Keys must be 1–15 chars with no comma — the firmware takes a stored key as everything before the first comma, so one would silently truncate it.

Notes:

  • seqHash == 0 means the board advertised no hash — firmware older than the feature. It does not mean "no sequences": an empty inventory hashes to 0x811C9DC5.
  • One request in flight at a time, shared between requestSequenceNames() and requestSequence(); a second call returns false. Poll sequenceNamesPending(), which gates either kind.
  • A request with no answer within ~4 s is abandoned silently — no callback fires, because a half-list would look like a real answer. Just call again.
  • A long list comes in pages, and the callback still fires once. One mesh reply holds 2912 characters, about 180 names, and a board's sequence store holds a few hundred. A list that does not fit comes a page per request (since 1.19.0; the WCB must be new enough too): the library asks for each page itself, about 0.8 s a page, asks a page again after 3 s without it (3 times), starts over if the list changes mid-pull (twice), and calls you once with the whole list. A pull that cannot finish is abandoned with a [WCB_Client] sequence names ... line on Serial and no callback. Use sequenceNamesPending() to tell "still working" from "lost", not a fixed timeout. A WCB whose firmware predates paged names never answers a list that long (its "Result too large" text is not taken for a list; 1.18.1 and earlier delivered it as an empty one), and a WCB with paged names does not answer a library older than 1.19.0 for one either, rather than send it something it would misread.
  • A long value comes in parts, and the callback still fires once (1.20.0). One reply holds 2912 characters with the value's <key>,OK, head, and a WCB stores values of up to 3,999 (an ESP32-S3 can save one that long). requestSequence() asks for part 0 with the 59-byte SEQVAL_PART_REQ (packet type 31) ahead of the old SEQVAL_REQ, then for each further part (type-34 replies), checks the joined reply against its CRC and calls onSequenceValue() once with WCB_SEQ_OK and the whole value. A part is asked again after 3 s without it (3 times), the pull starts over if the value is edited mid-pull (twice), and a pull that cannot finish is abandoned with a [WCB_Client] sequence '<key>' ... line and no callback. A WCB whose firmware predates values in parts answers WCB_SEQ_TOOBIG, as before. The split, the part header and the join are src/WCB_ResultWire.h, byte-identical to the WCB firmware's (host-tested there by tests/result_parts_test.cpp).
  • requestSequenceNames() returns false unless a callback is registered first.
  • Names only, never contents. The values live in the board's config pull, which fits one reply only up to 2912 characters for the entire board config; past that it takes a multi-part protocol and a requester that asks for parts (see Management relay). A bulk fetch here would have walked into the same ceiling.

Full example: examples/SequenceInventory.

Management relay (WCB_Mgmt.h)

WcbMgmt makes a WCB_Client host the WCB Wizard's doorway into the mesh, answering on whatever Print the host passes to begin(). The host wires it in with four calls (begin(), handleLine(), onRawPacket(), service(); see the 1.17.0 notes below). examples/MgmtRelay keeps its own copy of the same surface, with the same wire behaviour.

Line from the Wizard Sent on the mesh Printed back
?MGMT,PULL,<n> CONFIG_REQ (type 5) ×3 [MGMT:CONFIG,<n>]<config>, or [MGMT:CFGERR,<n>]<code>,<detail>
?MGMT,PULL,<n>,P CONFIG_REQ_PARTS (type 19) ×3, then CONFIG_REQ (5) ×3 as above, or one [MGMT:CFGPART,<n>]P<id>,<k>,<K>:<data>~ line per part
?MGMT,STATS,<n> RESULT_REQ (type 30) ×3, then STATS_REQ (7) [MGMT:STATS,<n>]<text>, whole, however long
?MGMT,ETM,CHAR,<n> RESULT_REQ (30) ×3, then ETM_REQ (8) [MGMT:ETM,<n>]<text>, whole, however long
?MGMT,FRAG,<n>,<sid>,<idx>,<total>,<payload> the chunk, forwarded a [relay] MGMT -> WCB<n> … note
  • A config of up to 2912 characters is one [MGMT:CONFIG,<n>] line, exactly as before. Above that, a WCB that supports parts sends the config in parts, but only to a requester that asked with ,P. The relay prints each part as it completes and never joins them; the requester collects the K parts of one id, joins them in order and checks the CRC (^?CHK) of the whole. An older WCB ignores type 19 and answers the type-5 copies as it always has — with nothing, for a config over the limit.
  • Parts and errors ride packet type 18, never type 6. Every older relay drops type 18 silently, and every older Wizard stores any non-empty [MGMT:CONFIG,<n>] body as the board's config without checking it, so a part must never be able to look like one.
  • [MGMT:CFGERR,<n>] is a code and a short ASCII detail, never config text: NOPARTS (over the limit, and the request did not ask for parts), TOOBIG (would need more than 64 parts, more than any board's config), and NOMEM and CHANGED, which are worth retrying.
  • A STATS or ETM result too long for one reply comes in parts (1.20.0; a full fleet's ?STATS is up to ~6.6 KB). WcbMgmt::ResultPuller asks for part 0 with RESULT_REQ (type 30, 59 B) three times and the old request behind it, then for each further part; the WCB holds the result it built and sends a part per request as type 32 / 33, split after a line. Each part is printed as it comes, in one write - part 0 behind its tag, the last with the line end - so what the relay prints is byte for byte the one reply an older WCB sends, and nothing is held. A lost part is asked again (3 times, 3 s apart); a pull that cannot finish prints [MGMT] Stats from WCB<n>: ... (or ETM characterization results) saying why. An older WCB answers the old request as it always did: whole, or the [ERROR] Result too large to relay text. examples/MgmtRelay runs the same ResultPuller.
  • One reassembly slot. A reply is keyed on (session id, packet type, source board), and a part never displaces a CONFIG/STATS/ETM reply that is still arriving (a frame within the last 200 ms): the part is dropped and recovered by the target's second pass or a retry. Send pulls through one relay one at a time.
  • Every line is printed from service(), on the loop task, with one printf, so nothing another task prints can land inside it. ?MGMT,PULL,<n>,P blocks the calling task for about 75 ms (six request frames, 15 ms apart; a plain pull is about 30 ms).
  • A pulled config holds the board's secrets (?EPASS, and a WiFi passphrase where one is set). Don't log these lines.

Transmit watchdog (1.18.0)

A device can go on hearing the mesh while nothing it sends arrives. NaviCore did, for 20 minutes, after ten minutes of heavy relayed traffic: every WCB listed it offline and dropped it from WDP while its own roster showed every board online, and only a reboot brought it back. Broadcasts are never acknowledged, so nothing on the device could see it.

wcb.onTxStall(onTxStall);        // optional: hear about it on the loop task
wcb.setTxWatchdog(true);         // off by default; probe every WCB_TXWD_PROBE_MS (30 s)

void onTxStall(uint8_t level, uint32_t stalledMs) {
  if (level == 2) ESP.restart(); // the library could not fix it - a reboot did, in the field
}
  • The probe is a WDP SOLICIT, the frame ?WDP,POLL sends. A WCB answers it with an advert within 0.6 s and prints nothing (unless ?DEBUG,MGMT is on). An advert after the probe, or an ACK to anything this device sent, proves the mesh hears it; an ACK also makes the next probe unnecessary. Cost while healthy: one ~10-byte broadcast per interval and one advert back from each board.
  • Only boards that can answer are asked: heard within WCB_TXWD_HEARD_MS (15 s) and known to advertise. A fleet with WDP off, or nobody around, is never judged — no false stall.
  • Two boards make an answer when two or more could give one: a solicit makes every board advertise, while one board's own 60 s advert landing in the window is a coincidence — one did, and 1.18.0 called a dead radio recovered (HIL run 20261014-173713). With a single board, its advert is enough.
  • WCB_TXWD_MISSES (3) unanswered probes in a row, WCB_TXWD_RETRY_MS (5 s) apart once one is missed, are a stall: level 1. A stall ends only after WCB_TXWD_RECOVER (2) answered probes in a row, and one that comes back within WCB_TXWD_RELAPSE_MS (3 min) of ending goes straight to level 2 — the ESP-NOW restart did not hold. The library puts the mesh MAC back on the STA interface if the radio lost it, restarts ESP-NOW and registers every peer again (floor, broadcast, special, learned — from RAM, no NVS write). As many more misses after that are level 2, reported again after each further restart the library tries; at level 2 it probes at the normal interval.
  • Every level change is printed with the radio's STA MAC (and whether it is still the mesh MAC), its channel against the mesh channel, esp_now_send() errors and the internal heap — the evidence a wedge leaves: [WCB_Client] mesh transmit stalled: 3 WDP solicit(s) in a row unanswered for 15 s while boards are heard (STA MAC 02:05:4B:00:00:14 as set, channel 1 of mesh 1, send errors 0, internal heap free … largest … min …) - restarting ESP-NOW, then … mesh transmit recovered: the boards answer again after N s unanswered.
  • onTxStall(level, stalledMs) fires on the loop task (from update()), so a callback may touch flash or restart the device. Level 0 means answered again.
  • getTxWatchStatus() returns enabled, level, misses, probes, restarts, send errors and the ms since the mesh last answered.
  • debugTxWedge(mode) is a test-only fault injector: every frame is reported as sent and never reaches the radio — until the watchdog's ESP-NOW restart (1) or until reboot (2). Never call it in a shipping sketch.

Channel sync (1.19.0)

The ESP32 has one radio, so every board, every client and any access point one of them hosts share one channel; a device on another channel hears nothing, and nobody hears it. Channel sync moves the mesh together and brings strays back. It is on by default and needs nothing from a sketch that never moves — but a sketch should start where the mesh last moved to and save a move when one happens. The design is the WCB repo's docs/CHANNEL_SYNC.md; the wire format and every decision are in src/WCB_ChanSyncWire.h, a byte-identical copy of the file the WCB firmware compiles.

wcb.setRegion("DE");                    // before begin(); default "01" (channels 1-11)
wcb.setMeshChannel(savedChannel);       // where the last move left the mesh
wcb.setChanSyncRank(0);                 // NaviCore only; default 100 + id, 255 = never lead
wcb.onChannelMove([](uint8_t ch) {      // loop task (inside update())
  saveChannel(ch);                      // persist it either way
  return requestRestart();              // true: the host applies it (a SoftAP restarts)
});                                     // false: the library switches the radio live
wcb.begin();
wcb.setIdentity("MyDevice", "1.0");     // the key travels in the WDP advert
  • Who leads. Every device has a key: id, rank, pinned. A device that can lead beats one that cannot (rank 255); a pinned device — a WCB joined to a WiFi network, whose access point owns its channel — beats an unpinned one; then the lower rank; then the lower id. WCB n has rank n, NaviCore 0, any other client 100 + id. A client is never pinned. Only a device that takes part leads (its key has SYNC): one with sync off, or firmware older than channel sync, cannot move or search, so it is heard but never anyone's leader — the WCB firmware's rule too. The key is advertised in WDP TLV 0x14 CHAN ([channel][rank][flags][cc0][cc1]) and decoded from every neighbor into WCBNeighbor::chan*; a neighbor without it gets a derived key (a WCB's number, a client's 100 + id, a temporary peer 255; SYNC off). One WDP SOLICIT goes out after the first boot advert (or 1.5 s after begin() with no identity), so the neighbors' keys arrive in seconds rather than at their next 60 s advert. Call setIdentity(): a device that sends no advert is ranked by the WCBs as if it were a WCB, and never leads.
  • MOVE (ETM packet type 25). The leader broadcasts the new channel 5 times, 300 ms apart, and everyone switches 1.5 s after the first frame; each frame carries the time left. A follower acts on the first frame of a move and refuses it when sync is off, the channel is outside its region, it is already there, or a device it hears outranks the sender. A MOVE heard at home while this device's own search is under way is held and judged once the search is over, with the time it has waited taken off its countdown.
  • Switching. At the switch time onChannelMove(channel) is called once, on the loop task. Return false and the library calls esp_wifi_set_channel() — only possible with no SoftAP and no STA association. Before it does, it waits (at most 30 s) for what a channel change would cut: a fragmented send, a bulk transfer, a sequence request, a sent command awaiting its ACK, raw packets still arriving (an OTA, a config pull). Return true and the library leaves the radio alone: a SoftAP (or a STA association) moves only by restarting, so save, then restart from your own loop(), never inside the callback. Until the restart the device counts as leaving: it starts no search, follows and announces no other move, refuses an operator move, and logs *** ... has not restarted ... OFF THE MESH *** every minute (WCB_CHSYNC_HOST_MOVE_MS) if the restart never comes. A host that applies the move live instead calls setMeshChannel(channel), which ends it. With a SoftAP up or a STA association and no callback (or false), the library says it cannot move, and stays. ESP-NOW peers are registered on channel 0 ("the current one"), so none needs registering again. After a live switch the device heartbeats and re-advertises at once, and the transmit watchdog waits WCB_TXWD_HEARD_MS before judging — a move is not a dead radio.
  • Search (PROBE 26 / REPLY 27). A device that leads its group and misses a board it knows (a floor peer, a learned peer or the special peer, offline by the heartbeat sweep) visits every other channel its region allows: switch, broadcast a PROBE, listen 150 ms. Then it takes its group to a better leader it found, or visits each worse group and sends it a MOVE home. A device ever seen advertising TEMPORARY or rank 255 is never missed — remembered in NVS (wcb_peers/csnever), because the record that says so ages out exactly when the device has gone, and is not there at all after a reboot. Not in the 40 s boot grace, only once the condition has held 10 s, then attempts at 0, 1, 2, 4, 8, 16 and 32 minutes, each the gap after the one before started (so attempts held up never run back to back); then none until a reboot, chanSyncSearchNow(), or the condition stays clear for 10 minutes (WCB_CHSYNC_TRIGGER_RESET_MS) — a shorter clear carries on the same schedule, so a member that flaps cannot restart the seven attempts each time. It waits until the device can hop (no station on its SoftAP, no STA association) and is quiet: no ensured send awaiting its ACK, no fragmented send, bulk transfer, sequence request or watchdog probe in flight, and no command or raw packet in or out for 3 s (WCB_CHSYNC_QUIET_MS; best-effort broadcasts such as telemetry do not count). While the radio is away (about 1.5-2 s) update() sends nothing else — no heartbeat, advert, retry or probe — every send the app makes returns false (an ensured one is retried once home), and every frame received except a REPLY is dropped.
  • Answering. Every device with sync on answers a PROBE after (id % 16) × 3 ms, naming the best device it hears, itself included. A searcher credits both the best a REPLY names and the responder itself — so a responder that still names the searcher (its offline sweep has not run yet) still tells it who is there.
  • Region. setRegion(cc) before begin(): "01" or an IDF country code (AT AU BE BG BR CA CH CN CY CZ DE DK EE ES FI FR GB GR HK HR HU IE IN IS IT JP KR LI LT LU LV MT MX NL NO NZ PL PT RO SE SI SK TW US). begin() applies it with esp_wifi_set_country_code(cc, false) after WiFi starts and before the channel is set; "01" makes no call, unless the WiFi driver restored another region from flash (the IDF keeps it there), which "01" then resets. If WiFi is already running when setRegion() is called it applies at once too, so a host that raises a SoftAP on 12-13 before begin() calls WiFi.mode(), then setRegion(), then softAP(). Every device on the mesh needs the same region; 12-13 are legal in Europe and most of the world, not in the US.
  • Operator move. chanSyncMove(ch, &why) sends a MOVE with the OPERATOR flag, which every unpinned follower obeys whoever sent it, and moves this device with it. It is refused when a pinned device is heard (it would stay, the mesh would go, and searching would bring everyone back), within 5 s of begin() (WCB_CHSYNC_VIEW_SETTLE_MS: a pinned device may not be known yet), when a SoftAP is up or the STA is associated and no onChannelMove() is registered, while a move or search runs, and for a bad channel. chanSyncSearchNow() is refused in the boot grace and when another device leads the group.
  • Status. printChanSyncStatus(Serial) ends in [CHSYNC:EN=1,CH=6,MAXCH=11,CC=01,RANK=105,PIN=0,LEAD=1,LRANK=1,LPIN=0,STATE=idle] (STATE is idle, search or move).
  • Threads. The ESP-NOW receive callback only copies these frames; every decision, send and print happens in update(). setChanSyncEnabled() and chanSyncSearchNow() only set a flag, so they are safe from a command callback; call chanSyncMove() and printChanSyncStatus() from loop().
  • Needs ETM on the WCBs (as WDP does). Firmware and libraries older than channel sync drop packet types 25-27 and are left behind when the mesh moves.

Callbacks

Both callbacks are optional. If you only need to send commands and never receive them, you can omit both entirely.

Command callback

Called whenever a text command arrives that is addressed to your device or broadcast to all devices.

void onCommandReceived(uint8_t senderID, const char* command) {
    // senderID : which WCB sent the command (1–20)
    // command  : the command string, clean — checksum already stripped
}

senderID is the WCB number of the sender. Use this to respond differently depending on who sent the command, or to log which WCB triggered an action.

command is a null-terminated C string containing the exact command text. If checksums are enabled, the CRC32 suffix has already been verified and removed before your callback is called — you always receive the clean command.

void onCommandReceived(uint8_t senderID, const char* command) {
    Serial.printf("From WCB%d: %s\n", senderID, command);

    if (strcmp(command, ":TRIGGER") == 0) {
        fireEffect();
    } else if (strncmp(command, ":VOL", 4) == 0) {
        int vol = atoi(command + 4);   // e.g. ":VOL75" → 75
        setVolume(vol);
    }
}

Status callback

Called whenever a WCB transitions between online and offline. A WCB is considered offline after approximately 30 seconds of missed heartbeats (3 × the 10-second heartbeat interval, matching WCB firmware defaults).

void onStatusChanged(uint8_t wcbID, bool online) {
    // wcbID  : which WCB changed state (1–WCB_QUANTITY)
    // online : true = just came online, false = just went offline
}

wcbID is the WCB number that changed state.

online is true when the WCB just sent its first heartbeat after being offline (or on startup), and false when heartbeats have stopped arriving for ~30 seconds.

void onStatusChanged(uint8_t wcbID, bool online) {
    if (online) {
        Serial.printf("WCB%d is online\n", wcbID);
        // Re-send initial state, sync positions, etc.
    } else {
        Serial.printf("WCB%d went offline\n", wcbID);
        // Stop sending commands to this WCB, trigger a warning, etc.
    }
}

Registering callbacks

In the constructor — both are optional positional arguments. Pass nullptr to skip one:

// Command callback only
WCB_Client wcb(MAC_OCT2, MAC_OCT3, PASSWORD, WCB_QUANTITY, DEVICE_ID,
              onCommandReceived);

// Status callback only
WCB_Client wcb(MAC_OCT2, MAC_OCT3, PASSWORD, WCB_QUANTITY, DEVICE_ID,
              nullptr, onStatusChanged);

// Both
WCB_Client wcb(MAC_OCT2, MAC_OCT3, PASSWORD, WCB_QUANTITY, DEVICE_ID,
              onCommandReceived, onStatusChanged);

After construction — use these methods any time, including after begin():

wcb.onCommand(onCommandReceived);
wcb.onStatusChange(onStatusChanged);

This is useful when your callback logic depends on objects that are not yet initialised at global-scope construction time.

Lambda callbacks

Both callbacks also accept lambdas for compact inline definitions:

WCB_Client wcb(MAC_OCT2, MAC_OCT3, PASSWORD, WCB_QUANTITY, DEVICE_ID,
    [](uint8_t sender, const char* cmd) {
        Serial.printf("From WCB%d: %s\n", sender, cmd);
    },
    [](uint8_t id, bool online) {
        Serial.printf("WCB%d %s\n", id, online ? "online" : "offline");
    }
);

WCBStream Modes

Broadcast mode — recommended for servo passthrough

WCBStream stream(broadcast);

Sends raw bytes to all WCBs simultaneously using a single ESP-NOW packet (sendKyber()). Each WCB that has ?KYBER,REMOTE configured on a serial port will forward the bytes to that port. WCBs without Kyber Remote configured ignore the packet.

Broadcast is the preferred approach for continuous servo control. Because only one packet is sent regardless of how many Maestros are in the system, the network stays uncongested and latency stays low even at high update rates. Use this when:

  • You need continuous position updates or high-rate servo streaming
  • You have Maestros on multiple WCBs and want to address all of them at once
  • You don't know (or don't care) which specific WCB has the Maestro
  • You want one command to ripple out to every servo controller simultaneously

Requires ?KYBER,REMOTE,S<port> configured on each WCB that has a Maestro wired to it.

Unicast mode

WCBStream stream(2, 1);   // → WCB2, serial port 1

Sends raw bytes directly to one specific WCB's serial port using sendRaw(). The receiving WCB writes the bytes straight to the requested port — ?KYBER,REMOTE is not required. The Kyber Remote flag is only checked on the broadcast path (target 98); the unicast path (target 97) ignores it entirely and writes unconditionally to whatever port number is in the packet.

Unicast is perfectly acceptable for low-frequency operations like triggering a RestartScript on a known target, but for continuous servo streaming broadcast will give noticeably better performance. Use unicast when:

  • You need to target one specific WCB:port and broadcast is not an option
  • You are sending infrequent commands (script triggers, one-shot moves) rather than a continuous stream

Maestro Device Number

The third argument to MiniMaestro corresponds to the Device Number in Maestro Control Center → Serial Settings. It enables the Pololu protocol so each packet includes the 0xAA start byte and device address.

// Compact protocol — one Maestro, no device addressing
MiniMaestro maestro(stream, Maestro::noResetPin, Maestro::deviceNumberDefault);

// Pololu protocol — device number 1
MiniMaestro maestro(stream, Maestro::noResetPin, 1);

Quarter-microsecond values for common servo positions:

µs Quarter-µs Position
1000 4000 Full left / min
1500 6000 Centre
2000 8000 Full right / max

Checksum Setting

The library appends a CRC32 checksum to every outgoing command and verifies checksums on incoming commands. This must match the ?ETM,CHKSM setting on all WCBs. The default on both the library and WCB firmware is ON.

wcb.setChecksum(true);    // Default — matches WCB factory default
wcb.setChecksum(false);   // Only if ?ETM,CHKSM,OFF on all WCBs

Examples

Example Description
BasicUsage Send and receive text commands
MaestroUnicast Forward Maestro commands to a specific WCB:port (unicast)
MaestroBroadcast Broadcast Maestro commands to all WCBs with Maestros configured
CombinedUsage Text commands and Maestro forwarding running simultaneously
SpecialPeer Talk to the out-of-band controller (e.g. NaviCore) at id 20
NeighborDiscovery Learn the mesh over WDP — who's out there and what they can do
DeliveryStats Per-peer delivery counters, with a guided two-board bench procedure that proves each one
MgmtRelay Turn any ESP32 into a management relay — drive the Config Tool (Via WCB) and manage every WCB + the NaviCore over one USB port, and optionally over a WebSocket from its own SoftAP or NaviCore's, with a login token (?AUTH,<token>). It follows the mesh when it moves (rank 255, never leads) and remembers where it went
TxWatchdog Turn on the transmit watchdog and reboot when the library cannot recover the mesh
ChannelSync Start on the channel the mesh last moved to, save a move when it happens, set the region; status, search and operator move from the Serial Monitor
AllFeatures Interactive "kitchen sink" — every public method in one sketch, including a t key that dumps per-peer delivery statistics and z to reset them

Changelog

1.20.0

  • Mesh results in parts (src/WCB_ResultWire.h, byte-identical to the WCB firmware's, host-tested there by tests/result_parts_test.cpp; design: WCB repo docs/MGMT_RELAY.md §10). requestSequence() reads a value too long for one 2912-character reply a part at a time (SEQVAL_PART_REQ 31 / SEQVAL_PART 34) and fires onSequenceValue() once with the whole value; WCB_SEQ_TOOBIG now comes only from a WCB that predates it. WcbMgmt (and examples/MgmtRelay, through the new WcbMgmt::ResultPuller) relays ?MGMT,STATS and ?MGMT,ETM,CHAR results too long for one reply whole, a part per request (RESULT_REQ 30, STATS_PART 32, ETM_PART 33), printed as they come - where it printed the WCB's [ERROR] Result too large to relay text. A STATS / ETM part frag yields the reassembly slot to a live CONFIG / STATS / ETM reply, as a config part does. examples/MgmtRelay is relay 1.4.
  • WCB_CLIENT_VERSION_NUM 12000.

1.19.0

  • Channel sync (setChanSyncRank, setChanSyncEnabled, setRegion, maxChannel, currentChannel, onChannelMove, chanSyncSearchNow, chanSyncMove, printChanSyncStatus; on by default). The mesh follows one channel: the device that leads announces a MOVE (ETM packet type 25) and everyone switches together, and a group leader that misses a known board searches the other channels (PROBE 26 / REPLY 27) and either takes its group to a better leader or pulls the other group home. Every device advertises its key in WDP TLV 0x14 CHAN and decodes its neighbors' into WCBNeighbor::chanValid/chanChannel/ chanRank/chanFlags/chanCc. The wire format and decisions are src/WCB_ChanSyncWire.h, byte-identical to the WCB firmware's, and the plumbing around them follows the firmware's (WCB_ChanSync.cpp): only a device with SYNC leads, one WDP SOLICIT after boot, attempts re-based on when each really started and a schedule kept across a clear shorter than 10 minutes, a MOVE heard during a search judged after it, a REPLY credited to its responder too, and a live switch that waits (≤ 30 s) for a session it would cut. A host that applies a move itself leaves the device "leaving" until it restarts (WCB_CHSYNC_HOST_MOVE_MS warnings); a STA association is treated like a SoftAP; devices seen as temporary or rank 255 are never missed by a search (NVS wcb_peers/csnever). Design: WCB repo docs/CHANNEL_SYNC.md. See Channel sync above.
  • Stored-sequence names in pages. requestSequenceNames() reads a list longer than one 2912-character mesh reply (about 180 names): it asks for page 0 with the new 59-byte SEQ_PAGE_REQ (packet type 28) ahead of the old SEQ_REQ, then for each further page, and joins the type-29 pages; onSequenceNames() still fires once, with the whole list. The split, the page header and the join are src/WCB_SeqNamesWire.h, byte-identical to the WCB firmware's (host-tested there by tests/seqnames_page_test.cpp). A one-reply answer is now checked whole (8 hex digits, the count equal to the names): the "[ERROR] Result too large to relay" text an older WCB sends is dropped, where it read as an empty list. Design: WCB repo docs/SEQUENCE_INVENTORY.md §3. See Stored-sequence inventory above.
  • setMeshChannel() accepts 12-13 once setRegion() allows them (it was 1-11 only), and begin() falls back to WCB_MESH_CHANNEL with a warning for a channel the region does not allow.
  • examples/ChannelSync is new. examples/MgmtRelay (relay 1.3) follows the mesh as rank 255, saves the channel it was moved to, takes ?CHSYNC, ?CHSYNC,SEARCH, ?CHSYNC,MOVE,<ch> and RELAY_REGION, prints [WDPCH:...] lines in ?WDP,DUMP, and its WebSocket takes a login token (?RELAY,WIFI,TOKEN,<token>|CLEAR, RELAY_WS_TOKEN): [AUTH:REQUIRED] on open, no output and no command until ?AUTH,<token>, [AUTH:FAIL] closes the session and locks every ?AUTH out for 1 s — the same protocol as a WCB's and NaviCore's WebSocket. A session is its serial, not its socket number: a queued command runs, and its session joins the console mirror, only while that same session is open and logged in (lwIP reuses a closed socket's number for the next connection). A session not logged in within 10 s is closed, and a full server evicts a signed-out session before a signed-in one. A deferred restart is capped at 20 s however busy the console is, so a client polling once a second cannot hold the relay on an old channel.
  • WCB_CLIENT_VERSION_NUM 11900.

1.18.1

  • The transmit watchdog no longer takes a coincidence for an answer. 1.18.0 counted any advert inside a probe's 2.5 s window as the answer; on a busy mesh one board's own periodic advert landed there while every frame this device sent was being dropped, and a dead radio was called recovered after one probe (HIL ncmesh.tx_watchdog_reboot, run 20261014-173713). Now two boards must answer when two or more can, a stall ends only after two answered probes in a row, and a stall within 3 minutes of the last one skips the ESP-NOW restart and reports level 2.
  • WCB_CLIENT_VERSION_NUM (11801): the library version as a number, kept equal to library.properties, so a consumer can refuse to build against an older one.

1.18.0

  • Transmit watchdog (setTxWatchdog, onTxStall, getTxWatchStatus, debugTxWedge; off by default). Proves every 30 s, with the WDP SOLICIT ?WDP,POLL uses, that boards this device hears still hear it; after 3 unanswered probes it puts the mesh MAC back, restarts ESP-NOW and re-registers every peer, and reports level 2 to the app if that does not help. Found on NaviCore: after ten minutes of relayed traffic it heard every WCB and none heard it, for 20 minutes, until a reboot (WCB repo HIL run 20261014-084137). See Transmit watchdog above.
  • Every frame the library sends now goes through one wrapper (_espSend), which counts esp_now_send() errors for the watchdog's report.

1.17.2

  • A config push of more than one chunk through a WcbMgmt host now reaches the board. ?MGMT,FRAG with a total above 1 forwarded each chunk as a config_frag, the 230-byte layout of a config reply. A WCB routes a raw frame by its size and reassembles a push only from its 226-byte push struct, so the chunks were dropped and the push never ran. They now go as wcb_packet_mgmt_t, the frame examples/MgmtRelay already sends. A single-chunk push was never affected. (NaviCore D-NC48.)

1.17.1

  • A rebooted WCB's commands are no longer dropped as duplicates. The inbound anti-replay window keeps the highest COMMAND sequence number seen from each board plus 32 older ones. A WCB restarts its sequence counter at 0 when it reboots, so a client that stayed up (NaviCore) kept the old window: until the WCB's counter climbed back past the old high, any command whose new number landed on a remembered one was ACKd and silently discarded — some ;W<id> commands ran and others did not. The client now handles the WCB boot announce (packet type 11, WCB_PACKET_ETM_BOOT) by forgetting that board's window, the same thing the WCB firmware does to its own duplicate ring, and counts the announce as presence like a heartbeat. The window is cleared once per boot: the three announces arrive about 1.2 s apart, and further announces from that board within WCB_BOOT_RESET_HOLD_MS (4 s) are presence only, so a command sent between them whose ACK was lost is not run a second time by its retry. The announce is an unacknowledged broadcast, so a window can still survive if all three are lost, and a board that reboots again within 4 s of its previous boot's first announce is not cleared. Found by the WCB hardware-in-the-loop bench.
  • The client announces its own boot. begin() now sends the same three boot announces a WCB does (about 1.2 s apart, from update(), so joining is never blocked). A WCB clears a sender's duplicate ring only on that sender's boot announce, and a client restarts its sequence counter at every begin() — so before this, a rebooted client's first commands could be ACKd and then dropped as duplicates. broadcast() also sends a {-prefixed (JSON) payload best-effort even with ensured true: a WCB never ACKs a target-0 JSON frame, so an ensured one could only time out and be re-sent as unicast, reaching every board twice.
  • A heartbeat or boot announce no longer marks an untracked sender online. Only a floor peer, a learned peer or the special peer is marked online by them, because those are the only boards update()'s offline check ever marks offline again. A temporary client above the floor (a management relay, a bench probe) heartbeats and now announces its boot too; marked online, it stayed online and "known" forever after it left. It is tracked through its WDP neighbour row instead, which ages out. Found by the WCB hardware-in-the-loop bench.
  • WcbMgmt and MgmtRelay: a config over 2912 characters can be pulled in parts, and a refusal arrives as a coded error. ?MGMT,PULL,<n>,P sends three CONFIG_REQ_PARTS requests (packet type 19) ahead of the usual three CONFIG_REQs. A WCB with parts support answers an over-limit config with type-18 messages, printed as [MGMT:CFGPART,<n>] lines, and a request it cannot serve with [MGMT:CFGERR,<n>]<code>,<detail>; an older WCB ignores type 19 and answers the CONFIG_REQs as before. They are new tags on a new packet type because every older relay prints a type-6 reply as [MGMT:CONFIG,<n>] and every older Wizard stores that as the board's config with no CRC check. Replies are now keyed on (session, type, source board) rather than on the session alone, so two boards' replies can no longer be spliced into one line; a part never displaces a CONFIG/STATS/ETM reply that is still arriving; and a reply is assembled in place instead of through String, whose += skipped a chunk silently when an allocation failed and could print a short but well-formed [MGMT:CONFIG] line. A plain ?MGMT,PULL,<n> sends what it always sent, and every existing reply line is byte for byte what it was. See Management relay.

1.17.0

WCB_Mgmt.h — the WCB Wizard's management surface, usable by any WCB_Client host. Previously it lived inside examples/MgmtRelay, so only that sketch could be the Wizard's doorway into the mesh. It is now a library module, and a host enables it with four calls:

WcbMgmt::begin(&wcb, &Serial, identity);   // once, in setup()
WcbMgmt::handleLine(line)                  // from your "?" dispatcher — true = consumed
WcbMgmt::onRawPacket(data, len)            // from your raw-packet hook — true = consumed
WcbMgmt::service();                        // from loop()

That covers WCB_WEBTOOL_CONFIG_PULL / ?backup, ?version, ?WDP,DUMP, and ?MGMT,FRAG|PULL|STATS|ETM,CHAR, plus reassembly of the replies into [MGMT:CONFIG|STATS| ETM,<src>] and [TERM:<src>] lines. Identity.advertiseRelay emits ?RELAY,1, which makes the Wizard file the device as a dedicated relay card rather than a configurable board.

Why a module and not a copy. This is a wire protocol with three parties — the Wizard, the relaying device, and the WCB firmware. Every struct is byte-matched to WCB.ino and every reply string to the Wizard's parser. A second copy in another firmware drifts the first time one side is fixed and the other is not, and drifts silently, because a stale copy still answers — just wrongly. One module compiled by both is the same answer WcbCmd gives to the same problem.

It registers nothing with WCB_Client. No onRawPacket, no onCommand, no channel handling — the host feeds it. That is required, not polite: onRawPacket() takes a single callback and a host may already own it (NaviCore registers its firmware-OTA hook there). Self-registering would silently replace that hook, with nothing visible until an update failed. Composing from the host's existing hook — which should already discriminate by packet length — is the only safe shape.

Concurrency. onRawPacket() runs on the WiFi/ESP-NOW task and only copies into a queue. Every decode, reassembly and print happens in service() on the loop task, so there is exactly one writer to the output Print. A multi-packet write from the receive callback races the loop task and corrupts any line longer than one USB packet.

examples/MgmtRelay keeps its own behaviour unchanged; the module is what it was built from.

1.16.1

MgmtRelay can now be reached over WiFi, so a phone or desktop app can manage the whole mesh with no USB cable. Library source is unchanged — this is the example plus a new mgmt_wsserver.h beside it. Off by default (RELAY_WIFI_MODE), and with it off the compiled binary is byte-identical to 1.16.0's.

RELAY_WIFI_AP hosts a SoftAP; RELAY_WIFI_JOIN joins an existing one (NaviCore's, typically) with automatic fallback to hosting its own. Either way a WebSocket at ws://<ip>/ws carries exactly the newline-delimited line protocol the USB port already speaks — ;w3,:PP100, bare text to broadcast — because both mouths feed the same relaySerialLine(). USB keeps working alongside it.

The mode is set from the console, not at compile time. It and the credentials live in NVS, so moving a relay between a bench AP and a droid's network no longer means an edit, a rebuild and a reflash of a board that is already installed:

?RELAY,WIFI                     report the mode, SSID and address
?RELAY,WIFI,OFF                 ESP-NOW only
?RELAY,WIFI,AP[,<pass>]         host our own AP (optionally set its password)
?RELAY,WIFI,JOIN,<ssid>,<pass>  join an existing AP
?RELAY,WIFI,DEFAULTS            forget NVS, return to the compiled-in values

RELAY_WIFI_MODE is now only the factory default, used when NVS holds nothing. RELAY_WIFI_BUILD (default 1) decides whether the WiFi code is compiled in at all — a separate switch, because every mode has to be present in the image for a console command to be able to select it.

Each of these saves and then reboots, once the console has been quiet for a moment. That is not laziness: a SoftAP must be up before begin() and a STA joined after it, so re-running that dance on a live radio would tear ESP-NOW down beneath a mesh that is mid-conversation. A reboot re-runs setup() in the one order known to work. It is deferred rather than immediate because a host tool sends commands as a stream, and restarting inside a handler discards everything queued behind it while the boot banner still satisfies the sender's "did it answer" test — the same trap as rule 11 in the WCB firmware.

Passwords can be set but never read back. This console is mirrored to every connected WebSocket client, so echoing one would send it over the network it exists to protect.

RELAY_STATIC_IP (on by default) pins the relay to 192.168.4.<DEVICE_ID> — 192.168.4.19 out of the box — in both modes, so a host app needs no discovery and does not care whether the relay ended up hosting the AP or joining NaviCore's. That address cannot collide with the AP's DHCP pool, which the core fixes at eleven leases starting one above the AP's own IP (NetworkInterface.cpp:451-453) — .2–.12 for a default 192.168.4.1 AP. Ids 2–12 land inside that pool and id 1 is the AP itself, so the build static_asserts DEVICE_ID >= 13 rather than letting it fail in the field.

This is deliberately the same transport NaviCore exposes (same URI, same framing, same console-mirror reply semantics), so a host app writes ONE client and points it at either box; only the payload grammar differs.

Five constraints are load-bearing and are recorded in the code rather than left to be rediscovered:

  • JOIN never blocks and keeps retrying. Powered from one switch, the relay is ready long before NaviCore raises its SoftAP — NaviCore mounts LittleFS, loads config and allocates a ~3.1 MB PSRAM record buffer first — so a one-shot wait in setup() loses that race on every cold boot and strands the app on a fallback AP with two APs up. The join is now serviced from loop(), retrying every 5 s for 90 s with the mesh fully alive throughout, and only hosts its own AP if the other never appears. Retries are affordable because the channel is pinned: the probe is confined to the one channel we are already on instead of sweeping the band.

  • The two modes have OPPOSITE ordering rules against begin(). A SoftAP must be raised before WCB_Client::begin(), because begin() inspects WiFi.getMode() and preserves an existing AP as WIFI_AP_STA instead of forcing WIFI_STA. A STA association must be made after, because that same begin() calls WiFi.disconnect() unconditionally and would silently undo it.

  • The channel is passed explicitly, everywhere. softAP()'s channel parameter defaults to 1, and once an AP owns the radio begin() stops calling esp_wifi_set_channel and only warns. A defaulted channel against a mesh on any other channel is a total, silent blackout. JOIN mode additionally verifies the channel after associating and hangs up if it landed off-mesh — update() notices the drift too, but it only warns, and a relay that cannot reach the mesh is useless.

  • JOIN never scans. The channel is pinned in WiFi.begin() and auto-reconnect is off. An unpinned association sweeps every channel looking for its SSID and goes deaf to the mesh for the length of each sweep; with the AP absent that repeats forever, presenting not as "WiFi failed" but as the relay randomly dropping traffic.

  • The AP fails closed without a password. softAP() with an empty passphrase creates an open network, and this WebSocket relays ;w<id>,<cmd> to every board with no credential of its own.

The httpd handler runs on Core 0 beside the ESP-NOW receive callback, so it only copies, enqueues and returns; commands run from loop() on Core 1 — the same split relayOutQueue already uses, and for the same reason. Unlike NaviCore's equivalent the queue is by value, not a PSRAM pointer: this relay's longest input is a ~283-char OTA DATA line, so it stays within WS_LINE_MAX (kept in step with the sketch's inBuf) and the sketch still runs on a plain ESP32 with no PSRAM.

Cost when enabled: +37 KB flash, +4.7 KB RAM (48% / 20% of a min_spiffs ESP32). Verified compiling on ESP32 and ESP32-S3 in all three modes.

1.16.0

Oversized commands now work in BOTH directions. send() has always auto-fragmented anything longer than one packet (it falls through to _sendFragmented), but nothing in this library could put one back together — only the WCB firmware could. So client → WCB worked, and client → client transmitted perfectly and then delivered nothing at all. Two WCB_Client hosts talking to each other — a NaviCore and a MgmtRelay, say — had a silent ceiling at _maxSingleCommandLen().

Inbound MGMT fragment sessions are now reassembled and handed to the ordinary command callback, so a consumer never learns the command was split. Nothing to enable and no API change: send() is simply symmetric now.

Details that matter if you are debugging it:

  • Fragments are broadcast with targetWCB addressing, so every host sees every session and filters on its own id. The sender is taken from mac[5], which is safe because the receive path has already proved the source MAC belongs to this network's octet scheme.
  • The sender re-broadcasts each chunk over 2–3 passes, so duplicates are normal. A received-mask dedups them, and a completed session is remembered so the following pass cannot deliver the same command twice.
  • Chunks may arrive out of order; a missing one simply withholds delivery until a later pass fills it.
  • totalChunks and chunkIdx are bounds-checked before being used for arithmetic, so a corrupt or hostile header cannot index past the buffer.
  • A half-received session is abandoned after WCB_MGMT_RX_TIMEOUT_MS (15 s), swept from update() rather than from the receive callback.
  • The reassembly buffer is static (~2.9 KB), not heap: fragments arrive on the ESP-NOW receive callback, and allocating there is exactly the work this library keeps off that path.
  • Ceiling is unchanged at WCB_MGMT_MAX_COMMAND_LEN (16 × 179 = 2864 bytes).
  • broadcast() still refuses oversized input rather than fragmenting — a broadcast has no addressed reassembly session, so it must fail loudly.

1.13.2

  • MgmtRelay: an oversized serial line no longer broadcasts its tail to the whole mesh. The overrun guard zeroed the buffer length mid-line, which did not drop the line — it restarted accumulation, so the tail arrived at the newline looking like a fresh command. With no ; or ? prefix it fell through to broadcast(), putting an arbitrary mid-line fragment on every board, moments after the operator was told the line was dropped. The overrun is now swallowed to end-of-line. Relay firmware string bumped to 1.1 so a fixed relay is identifiable in a WDP roster.
  • MgmtRelay: OTA relay failures are no longer silent. sendRawPacket() returns false when the target cannot be registered as an ESP-NOW peer — most often a full peer table (the cap is ~20, and auto-join makes learned peers permanent in NVS, so the set only grows). All three OTA paths discarded that, so a failed BEGIN/DATA/END vanished with nothing on the console and the host tool simply timed out with "no response via relay" / "target rejected BEGIN". Each now logs the target and the likely cause.
  • Docs: sent + retries is delivery attempts, not frames on the air. One ensured broadcast is a single frame but credits sent to every board it expects to ACK, so summing across peers over-counts it; getBroadcastSent() is the frame counter. No code change.

1.13.0

  • Per-peer delivery statistics. getPeerStats(), getAggregateStats(), getBroadcastSent() and resetStats() expose sent / ackd / retries / failed / unguaranteed counts for the ETM COMMAND layer, so a host can finally see which mesh links are healthy and which are retrying or failing. Read-only, free-running uint32_t, ~400 bytes. See API Reference → Delivery statistics for exactly what is and is not counted — raw and Maestro traffic is deliberately excluded, because it carries no delivery signal.
  • failed counts every way we stop waiting for a peer's ACK, not just retry exhaustion. The common case by far is a target that is simply powered off: the offline window is 50 s against a ~2 s retry budget, so those sends complete with zero retries and never touch the retry-exhausted path at all.
  • ackd counts the ACK transition, not the ACK packet. A peer re-ACKs every retransmit it hears, and an ensured broadcast slot stays open to collect ACKs from several boards, so one board can ACK the same command up to four times.

1.12.1

  • Sequence number 0 is no longer sent. The WCB firmware's per-sender duplicate ring is zero-initialised and treats 0 as "empty slot", so a COMMAND that landed on seq 0 — once every 65,536 tracked sends — was ACKd and then silently discarded as a duplicate by every board. The firmware ACKs before it de-dups, and the ensured retries reuse the same sequence number, so the pending slot completed on those ACKs and the sender reported a clean ensured delivery for a command that executed nowhere. _sendPacket() now skips 0 on the wrap. Inbound, this library exempts seq 0 from its own de-dup, so the mirror case would have double-fired the command instead.
  • _seqCounter is explicitly zero-initialised. A default-initialised std::atomic holds an indeterminate value, and a heap-allocated client (new WCB_Client(...), as NaviCore does) gets none of the static zeroing a global-scope instance gets. begin() already assigns 0 before anything can send, so this closes a window that was unreachable rather than a live bug — it is there to keep it unreachable.

Changelog entries for 1.10.0–1.12.0 are not recorded here; see the commit history.

1.9.7

  • Configurable ESP-NOW mesh channel. The mesh channel (1–11) is now an explicit, operator-selectable setting instead of an accident of "an unassociated STA lands on channel 1." On the WCBs it's set via ?WCBCH,<n> or the Wizard's Advanced → Mesh Channel field, persisted in NVS, and applied on the next reboot. On this library the default is 1; call setMeshChannel(ch) before begin() to match a fleet running elsewhere.
  • Channel awareness in begin() / update(). With no hosted SoftAP, begin() now pins the radio to the mesh channel deterministically. With a SoftAP active (which owns the single radio's channel), the library instead warns — rate-limited and re-checked on every heartbeat — when the radio isn't on the mesh channel, rather than silently failing to reach the mesh. It never force-moves a deliberately chosen SoftAP channel.

1.9.6

  • device_id may now sit above wcb_quantity. begin() no longer hard-fails when device_id > wcb_quantity (for ids 1–19) — it logs a non-fatal warning and joins the mesh anyway. wcb_quantity means "how many WCBs I pre-register," not a cap on this device's own id. A device above the floor is reachable inbound once the other boards auto-join it from its WDP advert, so call setIdentity() and keep auto-join on (the default). The normal case (device_id <= wcb_quantity) is byte-for-byte unchanged. This also un-breaks the AllFeatures example, whose device_id 19 / quantity 4 combo previously failed begin().
  • Reply to any authenticated sender. A board can now unicast an ACK/reply to an above-floor client that commands it before auto-join has registered it: the sender is flagged on the ESP-NOW receive callback and added as a transient ESP-NOW peer on the loop task (no NVS write, no ≥2-advert wait), matching the proven auto-join discipline. So e.g. the NaviCore answers a MgmtRelay at id 19 on the config tool's very next ping instead of never — which is what makes the above-floor device_id genuinely usable for a bridge/relay/monitor.
  • New example: MgmtRelay — turn any ESP32 into a USB-serial ↔ ESP-NOW management relay so a host tool (e.g. the NaviCore Config Tool in "Via WCB" mode) can drive every WCB and the NaviCore over one USB port.

1.9.5

  • forgetPeer() now also clears the peer's WDP neighbor record, so a forgotten peer drops off getNeighbor() immediately instead of lingering until its advert ages out (up to the neighbor TTL).

1.9.4

  • isLearnedPeer(uint8_t id) accessor. Query whether an id is currently an auto-joined (learned) peer — a node above the wcb_quantity floor that was heard over WDP and made a permanent peer. Membership persists across reboots and is independent of online/offline state, so a consumer can keep a learned peer listed (e.g. on a status panel) even while it's powered off — its WDP advert ages out and, for a client device, it never heartbeats.
  • New example: AllFeatures — an interactive, serial-menu "kitchen sink" sketch exercising every public method in one place.

1.9.3

  • Auto-join now learns CLIENT devices too (mesh monitors, other controllers, command-accepting clients), not just regular WCBs — anything heard on the mesh becomes a persistent, NVS-remembered ESP-NOW peer you can send() to.
  • Ensured send() to a never-heartbeating learned client no longer silently degrades to fire-and-forget. A learned client that only advertises (WDP) but never sends heartbeats is now treated as still-outstanding and retried up to ETM_MAX_RETRIES, while a board that was online and then dropped still gives up.
  • Cross-core safety on the pending table. _pending[] is mutated from both the loop task (_sendPacket claim, update() retry service) and the WiFi/RX task (ACK handler); all three now run under a portMUX critical section (with the blocking _transmit() done on snapshots after releasing the lock), closing a torn-slot / dropped-ensured-command race.
  • _findFreePending() no longer evicts an outstanding ensured delivery. When the table is saturated it returns "no slot" and the send reports non-guaranteed delivery (best-effort once) instead of silently dropping an in-flight guaranteed command.
  • CRC strip/verify is gated on _checksumEnabled and anchored to the tail. A legitimate payload that merely contains the literal |CRC (e.g. a command-library command) is no longer truncated/rejected; a real checksum must be |CRC + exactly 8 hex digits at the end.
  • Special-peer slot hardening. A client advertising at WCB_SPECIAL_ID (20) can no longer be learned/persisted and then have its ESP-NOW MAC deleted by clearLearnedPeers()/forgetPeer() (which would silently break sendToSpecialPeer()); forgetPeer() also resets the advert counter so a forgotten peer must be heard twice again before it can re-join.
  • WCBStream no longer truncates a large burst mid-frame. Added flushNow()/bytesFree(), and write() now returns 0 when the buffer is full, so a producer (e.g. a Maestro burst programming many channels) splits on frame boundaries into multiple packets instead of forwarding a corrupt byte-stream.

1.9.2

  • Boot-loop crash fix (begin() failure was unhandled). If begin() returned false — most commonly because esp_now_init() failed (WiFi driver not up) — the sketch would keep calling update(), whose first heartbeat called esp_now_send() on an uninitialised ESP-NOW driver, dereferencing a NULL driver global and panicking (Guru Meditation LoadProhibited, EXCVADDR 0x4c). On an ESP32-S3 this presented as a hard boot loop straight out of the ROM bootloader. Now:

    • update() and every send method (send/broadcast/sendRaw/sendKyber/ sendToSpecialPeer/sendRawPacket/_sendHeartbeat) no-op when ESP-NOW is not initialised (_started == false) instead of crashing.
    • begin() prints the exact esp_now_init() error name/code and warns if WiFi.mode(WIFI_STA) reports failure, so a failed init is obvious in the boot log rather than a bare panic.
    • The NeighborDiscovery example now checks begin()'s return value and halts with a clear message on failure (all examples should — ignoring it is what let the crash happen).

    Note: the 1.9.1 deferred-join change below is a genuine robustness fix but was not the cause of the S3 boot loop — this is.

1.9.1

  • Robustness (auto-join off the WiFi task). The auto-join added in 1.9.0 called esp_now_add_peer() and wrote NVS from inside the ESP-NOW receive callback (WiFi task), whose small stack a flash write can overflow. The receive path now only flags a join; the actual esp_now_add_peer + NVS write happen in update() on the loop task, matching how the WCB firmware defers all WDP flash work. (This hardens the callback path; the boot loop some users saw was the begin()-failure crash fixed in 1.9.2.)

1.9.0

  • Auto-join (on by default) — the device now registers a regular WCB it hears over WDP as an ESP-NOW peer live, once it has seen the board advertise at least twice. This means it discovers the fleet on its own: you no longer have to set wcb_quantity high enough to cover every board, and it doesn't burn peer-table slots (the ESP-NOW cap is ~20) on boards that may not exist. Client devices (other setIdentity() peers) and the special peer are never auto-joined. Learned peers are permanent: persisted to NVS, restored on every begin(), always expected on the mesh (offline detection covers them too) — membership never expires on its own. Manage it with setAutoJoin(bool) / autoJoinEnabled() / forgetPeer(id) / clearLearnedPeers().
  • Sender-MAC binding — inbound packets are now dropped when the claimed structSenderID disagrees with the source MAC's last octet (every board forces its derived MAC 02:oct2:oct3:00:00:id). Closes id-spoofing — a rogue in-group node can no longer claim another board's id to satisfy an ACK or trigger auto-join. Mirrors the same hardening added to the WCB firmware.

1.8.0

  • WDP consumer — the library now decodes the WDP adverts it already broadcasts, so a client can discover the whole mesh. onNeighbor() fires as boards are learned or age out, getNeighbor(n) returns the latest record for a board, and neighborCount() reports how many are currently known. Each WCBNeighbor carries alias/device-type, firmware, hardware version/revision, capability flags (WCB_CAP_*) and tags, the controller link id, local Maestro IDs and serial-port labels. Neighbors expire after 180 s of silence. Consuming is passive — no extra traffic, and it works whether or not you called setIdentity().

1.7.0

  • Device identity over WDP — setIdentity(type, fw, hwRev, caps) broadcasts this device's identity on the mesh via the Wireless Discovery Protocol, so every WCB discovers it by name and firmware version automatically (it appears in ?WDP,LIST and the config tool as a named device, not a bare ID). This is the mesh twin of the serial WDP-DA device-announce model. Advertising is a short boot burst plus a staggered ~60 s backstop driven from update(), and stays off until you call setIdentity(). Requires ETM active on the WCBs (the factory default).

1.3.1

Hardening release for the 1.3.0 fragmentation feature — fixes four review findings before wide adoption.

  • Fragmented sends are now NON-BLOCKING. 1.3.0 ran delay(10) loops inside send() (up to ~0.5 s), freezing animation loops and — if called from the command callback — stalling the WiFi task. Chunks are now queued and transmitted from update() (~10 ms pacing); send() returns immediately (true = queued) and the result is logged on completion. One fragmented send in flight at a time; a second call while busy returns false.
  • Unicast stays unicast. Fragmented commands are now sent with a dedicated packet type; on firmware 6.1.1+ the reassembled command keeps target-local semantics — previously, broadcastable tokens in a fragmented unicast would re-broadcast to every board on the network once the command crossed the length threshold. (Older firmware still executes the command; it just keeps the old broadcast behavior.)
  • Oversize guard fixed. The chunk count was bounds-checked after a uint8 cast, so absurdly long commands (~45 KB+) could wrap the counter and bypass the rejection — silently truncating. The length is now checked first.
  • Serial monitor lines are clamped, not dropped. A monitored text line longer than the single-packet limit (187 chars with checksum) is truncated and delivered; 1.3.0 either dropped it entirely (broadcast target) or pushed it through the fragmentation path inside update() (unicast target).
  • Success is now judged per chunk across passes (a chunk accepted in any pass counts), and the single-packet limit is derived from the packet struct instead of magic numbers.

1.3.0

Automatic fragmentation for long unicast commands — send() now transparently handles commands longer than a single ESP-NOW packet (199 chars, or 187 with checksum enabled). Previously they were silently truncated: a long ?SEQ,SAVE,... arrived cut short and a corrupt sequence got stored on the board (or, with checksum on, was rejected entirely after burning the retries).

  • send() auto-fragments oversized commands using the WCB firmware's MGMT fragmentation protocol (the same one the web config tool uses): the command is split into 179-char chunks, the target board reassembles them and executes the whole command through its normal parser. Works up to ~2.8 KB — comfortably covering the firmware's 1800-char stored-sequence limit.
  • Reliability: the MGMT layer has no per-chunk ACK, so the full chunk set is transmitted in multiple passes (3 when ensured, 2 otherwise). Duplicates are harmless — the board stores each chunk once and discards retransmits of completed sessions. A fragmented send blocks ~10 ms per chunk per pass (a maximal ensured send ≈ 0.5 s).
  • broadcast() does NOT fragment — fragmentation is unicast-only (the reassembly session on the board is per-target). An oversized broadcast now fails loudly (error log + return false) instead of silently truncating; send() the long command to each board individually.
  • No API changes — existing sketches gain this automatically.

Requires WCB firmware with the MGMT fragmentation handler (present in all 6.x firmware this library targets).

1.2.0

Adds ensured delivery, and makes it the default for text commands so the library is reliable by default — matching the WCB firmware, which transmits with ETM ON by default. (Earlier 1.x releases never retransmitted, so client→WCB text was always best-effort regardless of the WCBs' ETM setting.)

  • send() and broadcast() now default to ensured=true. A lost packet or ACK is retransmitted instead of silently missed, just like WCB-to-WCB ETM. Mirrors the firmware's retry engine: after the initial send, the packet is retried as a per-board unicast (reusing the original sequence number, so receivers dedup it) to each expected board that hasn't ACK'd — up to ETM_MAX_RETRIES per board. For a unicast the expected recipient is the target; for a broadcast it's every board that was online at send time. A board that drops offline mid-flight is dropped from the expected set rather than retried forever.
  • Opt out with ensured=false for high-rate telemetry / status spam where the next update supersedes a lost one — it avoids spending a pending slot and airtime per send. (For binary streaming use the raw helpers sendRaw() / sendKyber() / WCBStream, which are best-effort and unaffected by this default — exactly as the firmware keeps PWM/servo streaming out of ETM.)
  • Tuning via ETM_RETRY_INTERVAL_MS (default 500 ms) and ETM_MAX_RETRIES (default 3) — matched to the firmware's etmTimeoutMs and retry count.
  • The in-flight tracking table grew from 3 to 10 slots (WCB_PENDING_MAX), matching the firmware's ETM_PENDING_MAX. When full it now evicts the oldest entry rather than dropping the new send, so ensured commands always get tracked.

Upgrade note: if you have a sketch that calls broadcast() or send() at a high rate for telemetry, add , false to those calls to keep them best-effort. Discrete commands need no change — they just became reliable.

1.1.0

Reliability fixes for the ETM acknowledgement layer. No API changes — existing sketches compile and run unchanged.

  • Fixed: duplicate sequence numbers under concurrent sends. The per-command sequence counter is now atomic. It is incremented from both the main loop (send() / broadcast()) and the ESP-NOW receive callback (when a command callback replies with send()). A non-atomic increment could hand the same sequence number to two packets, causing the receiving WCB's duplicate detection to silently drop one of them.
  • Fixed: heavy broadcasting could starve unicast ACK tracking. Broadcasts are fire-and-forget and are no longer recorded in the small in-flight ACK table (WCB_PENDING_MAX = 3 slots). Previously, broadcasting filled all tracking slots within seconds and left no room to match unicast ACKs.
  • Improved: stale ACK-pending slots are reclaimed automatically. A unicast whose ACK never arrives no longer holds its slot indefinitely; slots older than 1 second are freed for reuse. (Reclaim only — at 1.1.0 the library does not retransmit at all; a lost packet is simply missed. Application-layer ETM retransmission arrives in 1.2.0.)
  • Fixed: pending-table command copy is now always null-terminated.

1.0.0

Initial release.

  • Join a WCB ESP-NOW network as a first-class peer
  • Unicast and broadcast text commands (send() / broadcast())
  • Raw byte forwarding to Pololu Maestro controllers via WCBStream (unicast and Kyber broadcast)
  • UART monitors (monitorRaw() / monitorSerial()) for transparent passthrough
  • ETM heartbeat tracking with online/offline status callbacks
  • Optional CRC32 checksums matching WCB firmware

License

MIT — see LICENSE

About

No description, website, or topics provided.

Resources

Stars

3 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages