Batteries-included JSON messaging for ESP32. WiFi and user configuration, WebSocket, MQTT, UDP multicast, self-healing reconnection — all handled.
Important
Courier is under active development. See docs/changelog.md for changes on each release.
To pin to the previous stable version use lib_deps = https://github.com/inanimate-tech/courier.git#v0.3.2 in your platformio.ini.
When you're ready to migrate, change your lib_deps to use the latest version from GitHub and point your coding agent at docs/migration-0.3-to-0.4.md.
Motivation: When you make something neat on your M5Stick you want the quickest path to messaging the back-end, and you want to carry it to places to show people and configure the Wi-Fi from your phone. Courier is how you do that.
Courier expects JSON messages with a "type" field. Messages are parsed with ArduinoJson and the type string is passed to the onMessage callback alongside the parsed document. Use the per-transport receive hooks (WebSocketTransport::onText, MqttTransport::onMessage, etc.) for non-JSON or topic-routed payloads.
- Bring up your hardware as normal with Arduino or ESP-IDF.
- Install Courier (see below; we recommend managing your libraries with PlatformIO).
- Initialize Courier with a config struct, set up your callbacks, and call
setup()andloop().
#include <Courier.h>
Courier::Config makeConfig() {
Courier::Config cfg;
cfg.host = "api.example.com";
cfg.port = 443;
cfg.path = "/ws";
cfg.defaultTransport = "ws"; // enable courier.send(doc)
return cfg;
}
Courier::Client courier(makeConfig());
void setup() {
courier.onConnected([]() {
JsonDocument doc;
doc["type"] = "hello";
courier.send(doc);
});
courier.onMessage([](const char* transportName, const char* type, JsonDocument& doc) {
Serial.printf("Got: %s (via %s)\n", type, transportName);
});
courier.setup();
}
void loop() { courier.loop(); }HttpTransport gives you a JS-shaped fetch() alongside WiFi and time sync — useful on its own (no WS/MQTT) or as an extra transport. Set defaultTransport = "https" to skip auto-registering "ws":
#include <Courier.h>
#include <HttpTransport.h>
Courier::Config makeConfig() {
Courier::Config cfg;
cfg.host = "httpbin.org";
cfg.port = 443;
cfg.path = "/anything"; // send() POSTs here
cfg.defaultTransport = "https"; // no built-in "ws"
return cfg;
}
Courier::Client courier(makeConfig());
void setup() {
auto& http = courier.addTransport<Courier::HttpTransport>("https");
courier.onConnected([]() {
auto& http = courier.transport<Courier::HttpTransport>("https");
Courier::Response r = http.get("https://httpbin.org/get");
Serial.printf("GET -> %d\n", r.status);
});
courier.setup();
}
void loop() { courier.loop(); }See examples/https-only for the full sketch.
- WiFi — captive portal config via WiFiManager, auto-reconnection
- WebSocket — built-in transport with TLS, ping/pong heartbeat, self-healing auto-reconnect
- MQTT — opt-in transport with subscribe/unsubscribe, topic-addressed publishing (text or NUL-safe binary), self-healing auto-reconnect
- UDP multicast — opt-in transport for local network discovery and messaging
- HTTPS — opt-in transport with a JS-shaped
fetch()(buffered or streaming), plussend()/onMessagefor the messaging idiom - Self-healing — transports auto-reconnect independently; if all persistent transports fail after 60s, Courier escalates to full WiFi reconnection
- Reconnection — exponential backoff (5s-60s), health monitoring, automatic recovery
- Time sync — NTP primary (continuous drift correction) + HTTP Date header fallback
- JSON routing — messages parsed and dispatched by
typefield - Transport map — named transports, per-transport hooks for direct access
Courier bundles a number of other great libraries:
- WebSocket — esp_websocket_client Documentation
- MQTT — esp_mqtt_client Documentation
- WiFi config — WiFiManager GitHub
- JSON — ArduinoJson Documentation
- Time — ezTime GitHub
Use onConfigure hooks to access the full configuration surface of each bundled library.
Courier itself is MIT. Its bundled dependencies carry their own (permissive) licenses:
- WiFiManager, ArduinoJson, ezTime — MIT
- esp_websocket_client, esp_mqtt_client, ESP-IDF — Apache 2.0
- arduino-esp32 — LGPL 2.1+
When you ship firmware built with Courier, those libraries ship with it. Follow each library's notice/attribution requirements as applicable — in particular arduino-esp32's LGPL terms around relinking if you statically link it into a closed-source binary.
From GitHub (recommended while in active development):
lib_deps = https://github.com/inanimate-tech/courier.gitOr to pin a version: https://github.com/inanimate-tech/courier.git#v0.3.2
From the PlatformIO registry (for stable versions):
lib_deps = inanimate/courier@^0.3.2From GitHub (recommended while in active development):
dependencies:
inanimate-tech/courier:
git: https://github.com/inanimate-tech/courier.gitOr to pin a version, add version: v0.3.2
From the ESP Component Registry (for stable versions):
dependencies:
inanimate-tech/courier:
version: "^0.3.2"See docs/api.md for the full API reference. Migrating from 0.3.x? See docs/migration-0.3-to-0.4.md.
Quick overview:
// State
courier.isConnected();
courier.getState(); // Courier::State
// Sending via Client (routes to defaultTransport)
JsonDocument doc;
doc["type"] = "hello";
courier.send(doc); // WS default
Courier::SendOptions opts;
opts.topic = "sensors/me";
courier.send(doc, opts); // MQTT with per-call topic
// Explicit transport access — for raw frames or multi-transport setups
courier.transport<Courier::WebSocketTransport>("ws").sendText(payload);
courier.transport<Courier::WebSocketTransport>("ws").sendBinary(data, len);
courier.transport<Courier::MqttTransport>("mqtt").publish("topic", payload);
courier.transport<Courier::MqttTransport>("mqtt").publishBinary("topic", data, len);
// Transports — Client constructs and owns
auto& mqtt = courier.addTransport<Courier::MqttTransport>("mqtt", mqttCfg);
courier.suspend(); // free SRAM for OTA
courier.resume();
// Callbacks (single-slot, last registration wins)
courier.onMessage([](const char* transportName, const char* type, JsonDocument& doc) { });
courier.onConnected([]() { });
courier.onDisconnected([]() { });
courier.onError([](const char* category, const char* msg) { });
// Per-transport hooks (raw / topic-aware / binary)
auto& ws = courier.transport<Courier::WebSocketTransport>("ws");
ws.onText ([](const char* p, size_t l) { });
ws.onBinary([](const uint8_t* d, size_t l) { });
mqtt.onMessage([](const char* topic, const char* p, size_t l) { });
mqtt.subscribeBinary("topic/audio"); // declares the lane: bytes, never JSON
mqtt.onBinary([](const char* topic, const uint8_t* d, size_t l) { });
mqtt.onError([](const Courier::MqttTransport::ErrorInfo& e) { }); // CONNACK / TLS detail
// Raw ESP-IDF config access
ws.onConfigure ([](esp_websocket_client_config_t& cfg) { });
mqtt.onConfigure([](esp_mqtt_client_config_t& cfg) { });
courier.onConfigureWiFi([](WiFiManager& wm) { });Booting -> WifiConnecting -> WifiConnected -> NetworkReady -> TransportsConnecting -> Connected
^ ^ |
| +-------- enterNetworkReady() --------+
| |
Reconnecting <-----------------------------------------------+
|
ConnectionFailed
onConnectionChange fires at each state transition. onError fires alongside transitions caused by failures, providing a category and reason (e.g. "WIFI", "connection lost").
NetworkReady is the point where WiFi is up and time sync has been attempted (so TLS can validate certificates when it succeeded) but no persistent transport is running. onNetworkReady runs there on every entry, blocking, before onTransportsWillConnect. enterNetworkReady() returns to it from Connected or TransportsConnecting, tearing the transports down and keeping WiFi — for work that needs the network but not the transports, such as a large HTTPS download that cannot share RAM with a second TLS session.
- Single instance — WiFiManager requires a static callback, so only one
Courier::Clientinstance per process - Single-slot callbacks — each
on*method is a setter (last registration wins). Application frameworks take the slot and expose virtual methods for subclasses - Bounded SPSC FIFO per transport — small bounded queue (depth 8) absorbs bursts; sustained overload drops
- Arduino + ESP-IDF — depends on Arduino framework for WiFiManager, ArduinoJson, ezTime
MIT