CHIRP-PROV v1.6
Provisioning over USB
Four lines of text over the serial port your firmware already prints to. Implement them and the Warblet portal can write Wi-Fi credentials and a claim code straight into your board from a browser tab — no toolchain, no flashing, no serial monitor, nothing installed on the machine doing the provisioning.
This is the protocol Set up a device speaks over the cable. There is no mode to choose: a board that answers CHIRP? keeps the firmware it is running and setup finishes over the same wire. It is published, versioned, and yours to implement — Warblet firmware has no privileged path, and anything that answers CHIRP? is provisionable.
The wire
The device’s primary UART or USB-CDC console, 115200 8N1, UTF-8 lines terminated with \n. Both directions tolerate \r\n on receive, soSerial.println() and MicroPython’s print() are both correct as they are.
Lines that do not begin with CHIRP are normal console output. Both sides ignore what they do not recognise, which is what lets the protocol share a port with boot chatter, log prints, and stack traces — you do not need a quiet console.
The four exchanges
- 1Probe (portal → device):
CHIRP?— repeated every 500 ms for up to 15 s, starting the moment the port opens. Opening the port may reset the device on boards where control-line transitions pulse EN, so firmware should listen from boot (always-listening is simplest; a boot-window listener is a valid hardening choice). - 2Announce (device → portal):
CHIRP! v=1 hw=<hwId>—hw=optional (omit if the device doesn’t know its id yet).v=is the responder’s capability level, not the spec version:1for Wi-Fi,2once you also store a Thread dataset. The portal treats it as informational and never refuses a version.
Optional.radios=wifi,thread— a comma list of what this board can actually join, e.g.CHIRP! v=2 hw=sunny-otter-7c2f radios=wifi,thread. This pair is read, not ignored: holding the announce before it writes, the portal aborts a push those radios can’t use withradios-mismatch, so a board with no Wi-Fi can never be handed Wi-Fi credentials and answerokto them. Ifradios=is absent, the portal skips this check.
Optional.sig=1— this firmware signs its payloads and stores a pushedkey, e.g.CHIRP! v=2 hw=sunny-otter-7c2f radios=wifi sig=1. Read, not ignored, for the same reasonradios=is: it tells the portal which elevation branch this board is in, so it can say “this firmware signs” instead of guessing. Onlysig=1is a yes; saying nothing means didn’t say, not doesn’t sign, and the portal words it that way.
Optional.t=<slug>— the template slug of the demo this firmware runs. Use 3–40 lowercase letters, digits or hyphens, with a letter or digit at each end. The portal treats a malformed value as absent. It reads this hint withhw=and shows the matching demo before anything is written; the hint alone does not select a spec. Any other space-separatedk=vpair is permitted and ignored. - 3Config push (portal → device):
CHIRP+ <json>— a single-line JSON object, keys below. JSON string escaping guarantees the line contains no raw newline regardless of credential content. Unknown keys must be ignored. - 4Result (device → portal, within 5 s):
CHIRP= okorCHIRP= err <reason>, reason ∈bad-json | missing-field | store-failed | unsupported | apply-failed.apply-failedmeans the config was well-formed and stored but the device’s own network stack refused it — for Thread, typically a dataset whose TLVs don’t decode.
portal → device CHIRP?
device → portal CHIRP! v=1 hw=sunny-otter-7c2f
portal → device CHIRP+ {"ssid":"workshop","pass":"hunter2","aps":[{"ssid":"workshop","pass":"hunter2"},{"ssid":"phone-hotspot","pass":"roadtrip"}],"claim":"CHIRP-7K2QF4XN","hwid":"sunny-otter-7c2f","level":1}
device → portal CHIRP= okConfig keys
All values are strings unless noted. Absent keys are absent, not empty — the portal strips what it doesn’t have rather than sending blanks.
| Key | Meaning |
|---|---|
ssid, pass | Wi-Fi credentials. pass may be "" for an open network — treat empty as a value, not as missing. |
aps | Optional. An ordered array of {"ssid","pass"} objects — every network this device should be willing to join, best first, at most eight. When it is present its first entry is also sent as the plain ssid/pass pair above. Store and use the whole list. See More than one network. |
thread | Optional. 802.15.4 network credentials for Thread-class radios, which have no Wi-Fi to speak of: either {"name","key"} (network name ≤16 chars, key as 32 hex chars) with optional panid (4 hex), xpanid (16 hex) and channel (11–26) — or a whole {"dataset"}: the Active Operational Dataset as hex TLVs, ≤508 hex chars, which wins when both forms appear. The merged config must contain at least one of ssid or thread; responders that understand it announce v=2. |
host | Override for self-hosted cores only. Absent in a normal push: your firmware carries the endpoint for its own transport (see Endpoints below) — the sender never guesses which radio you’ll use. |
claim or token | At most one is present: a claim code (CHIRP-…) for a device that has never reported in, or a device token. A push carrying neither means “keep the credential you already store” — pushes are merges, which is what makes re-provisioning a claimed board a networks-only operation. |
key | Optional. The device’s HMAC signing key, base64 — what Signed signs each payload with. Present only when the portal is holding one for this device (it is issued once, so that is the moment it just minted or re-minted one); absent means “keep whatever you store”, exactly like the credential above. The trust argument is the protocol’s own: this wire is a USB cable on your desk, the same one the token already travels down. If your firmware signs payloads, store it and announce sig=1. |
hwid | The hardware id the portal expects this device to transmit as. Present when the portal generated or knows one — adopt it, or your first packet lands under a different name than the dashboard is waiting on. |
level | Number 0–2, informational: the spec’s security level. |
After ok the device persists the config, then reboots or reconnects and sends its first packet carrying the claim code. That first packet is what binds the device to the account — the claim code becomes its provisional token, so it keeps working with the credential it already has. From that moment it is the device’s token, not a claim code — rotate it from the portal once the board is settled.
More than one network
A device that moves — bench to workshop, workshop to a customer’s site, anything with a phone hotspot as its backup — wants a list, not a network. That is what aps is: up to eight {ssid, pass} pairs in the order you would try them yourself. The flasher’s credentials form collects them with an Add another network button, and the ordering you type is the ordering that ships.
The first list entry is repeated as ssid/pass. Without aps, use that pair as a one-network list. A push carrying ssid without aps replaces the stored list with that one network.
What your firmware does with the list is your call, but this is the shape we recommend, and the one the flasher’s own MicroPython client implements:
- 1Scan once, then sort — put the configured networks the radio can actually hear first, strongest first, and leave the rest behind them in configured order. Never drop a network for not appearing in the scan: hidden SSIDs exist, and a scan is a snapshot.
- 2Give each one about 12 seconds to associate, then disconnect and move to the next. Long enough for a real join, short enough that a list of absent networks comes back around fast.
- 3Run the whole ladder again when the link drops — not just the network you were on. A device that walks out of range of the workshop should find the hotspot without being power-cycled; that is the entire point of the list.
Worth knowing before you go wide: a board whose networks are all out of range spends roughly 12 seconds per entry discovering that. Eight networks is a minute and a half of silence before the first retry, which is fine for a sensor and irritating on a bench — list the ones you actually use.
Endpoints — where your firmware sends
One rule covers all of it: the endpoint is named for the transport — <transport>.warbletiot.com plus the transport’s well-known port. Bake the one your firmware speaks into the firmware as its default; the config host key exists only to point at a self-hosted core. Don’t pick a host from what radios a board has — a dual-radio device chooses its uplink at runtime, and whichever transport it picks, the endpoint follows the same rule.
https.warbletiot.com HTTPS POST /ingest/{hwid} TLS Open–Signed, can be Encrypted
http.warbletiot.com:80 plain-HTTP POST, no TLS stack readable on path, up to Signed
mqtt.warbletiot.com:1883 MQTT publish chirp/{hwid}/up readable on path, up to Signed
mqtt.warbletiot.com:8883 the same publish, TLS TLS Open–Signed, can be Encrypted
tcp.warbletiot.com:7700 CHIRP1 hello line + payload lines readable on path, up to Signed
udp.warbletiot.com:7701 "CHIRP1 {hwid} {token} " + payload readable on path, up to SignedThe name is also honest signage: what goes to http., tcp., udp. or mqtt. on 1883 can be read by an on-path observer. Those first three can carry any level but can never be Encrypted — there is no TLS listener on them to enforce. mqtt. uses port 8883 for the same broker with TLS terminated on the relay, so an MQTT device satisfies the flag by changing a port and nothing else. One deliberate quirk worth knowing for Thread and other IPv6-only devices: udp.warbletiot.com has an A record only, so a NAT64 border router (an eero, a HomePod) synthesizes a reachable address for it — your firmware can just resolve the name and send.
What the portal does, so you can plan around it
- It probes from the instant the port opens — every 500 ms, for 15 s total. A device that only listens for the first two seconds after boot still gets caught, because…
- after four silent probes it closes and reopens the port. That is the browser’s only reliable reset: Web Serial’s
open()does not assert DTR/RTS, and explicit signal pulses do nothing at all on some USB-serial bridges. Dropping the port drops the control lines, which is what the auto-reset circuit is actually watching. Expect one reboot mid-handshake and make it harmless. - It ignores everything that isn’t a
CHIRPline — and you should too. Neither side gets a clean channel. - It answers slowly if the tab is in the background — browsers clamp timers to about a second in hidden tabs, which stretches the probe cadence and can miss a boot window. The flasher says so on screen; if you are debugging a device that only answers sometimes, check that first.
Reference implementation: MicroPython
Non-blocking, always listening, roughly 50 lines. It polls sys.stdin, so it costs nothing when the console is quiet and needs no extra thread.
# chirp_prov.py — a CHIRP-PROV responder for MicroPython.
# It announces v=1: the wire's capability level, not the spec version.
# v=1 is Wi-Fi credentials; answer v=2 only once you also store a Thread dataset.
# Import it and call poll() from your main loop. Nothing here blocks.
import sys
import json
import select
CONFIG_PATH = "chirp.json"
HW_ID = "sunny-otter-7c2f" # whatever this device transmits as
_poll = select.poll()
_poll.register(sys.stdin, select.POLLIN)
_buf = ""
def poll():
"""Drain whatever the console has for us. Returns immediately."""
global _buf
while _poll.poll(0):
ch = sys.stdin.read(1)
if not ch:
return
if ch == "\n":
line, _buf = _buf, ""
_handle(line.rstrip("\r")) # the portal may send \r\n
else:
_buf += ch
if len(_buf) > 1024: # a runaway line is not ours
_buf = ""
def _handle(line):
if line == "CHIRP?":
print("CHIRP! v=1 hw=%s" % HW_ID)
elif line.startswith("CHIRP+ "):
_apply(line[7:])
# anything else is normal console traffic — ignore it
def _apply(raw):
try:
pushed = json.loads(raw)
except Exception:
print("CHIRP= err bad-json")
return
# A push is a merge. Keys it doesn't carry keep their stored value,
# which is what lets the portal re-provision networks without re-sending
# the credential the board already holds.
try:
with open(CONFIG_PATH) as f:
cfg = json.load(f)
except Exception:
cfg = {}
cfg.update(pushed)
if "ssid" in pushed and "aps" not in pushed:
cfg.pop("aps", None) # one network named means that network
# The MERGED result must be complete -- a never-provisioned board still
# needs a credential in its first push.
if not cfg.get("ssid") or not (cfg.get("claim") or cfg.get("token")):
print("CHIRP= err missing-field")
return
try:
with open(CONFIG_PATH, "w") as f:
json.dump(cfg, f) # keep unknown keys, don't drop them
except Exception:
print("CHIRP= err store-failed")
return
print("CHIRP= ok")
# Persist first, answer second, reboot last. If you want the credentials
# live immediately: import machine; machine.reset()
def networks(cfg):
"""Every Wi-Fi network to try, best-first, as (ssid, password).
"aps" is the ordered network list. The plain ssid/pass pair names the
same network as its first entry. Without "aps", use the plain pair.
"""
nets = []
for ap in cfg.get("aps") or []:
if ap.get("ssid"):
nets.append((ap["ssid"], ap.get("pass", "")))
primary = (cfg.get("ssid"), cfg.get("pass", ""))
if primary[0] and primary not in nets:
nets.insert(0, primary)
return nets
# --- in your main.py -------------------------------------------------------
# import chirp_prov
# while True:
# chirp_prov.poll()
# ...your application work...
#
# and when the link is down, walk the list rather than retrying one network:
# for ssid, password in chirp_prov.networks(cfg):
# wlan.connect(ssid, password) # give each about 12 s to associate
# ...if wlan.isconnected(): breakReference implementation: Arduino (ESP32)
Same shape in C++, storing into NVS via Preferences. The JSON is scanned by hand so the sketch has no dependencies; swap in ArduinoJson if you already use it.
// A CHIRP-PROV responder — ESP32 Arduino core, no JSON library required.
// It announces v=1: the wire's capability level, not the spec version.
// v=1 is Wi-Fi credentials; answer v=2 only once you also store a Thread dataset.
#include <Preferences.h>
static const char *HW_ID = "sunny-otter-7c2f";
static Preferences prefs;
static String line;
// Pulls one string value from the JSON. (ArduinoJson is an alternative:
// deserializeJson(doc, json) and read doc["ssid"].)
static String jsonString(const String &src, const char *key) {
String needle = String("\"") + key + "\"";
int k = src.indexOf(needle);
if (k < 0) return String();
int c = src.indexOf(':', k + needle.length());
if (c < 0) return String();
int i = c + 1;
while (i < (int)src.length() && isspace((unsigned char)src[i])) i++;
if (i >= (int)src.length() || src[i] != '"') return String();
String out;
for (i++; i < (int)src.length(); i++) {
char ch = src[i];
if (ch == '"') break;
if (ch != '\\') { out += ch; continue; }
char esc = src[++i];
switch (esc) {
case 'n': out += '\n'; break;
case 'r': out += '\r'; break;
case 't': out += '\t'; break;
case 'b': out += '\b'; break;
case 'f': out += '\f'; break;
case 'u': { // \uXXXX, ASCII range
long cp = strtol(src.substring(i + 1, i + 5).c_str(), NULL, 16);
out += (cp > 0 && cp < 0x80) ? (char)cp : '?';
i += 4;
break;
}
default: out += esc; // \" \\ \/
}
}
return out;
}
// Returns the text between the brackets of an array value, or "". String-aware
// so an SSID containing a bracket doesn't end the array early.
static String jsonArray(const String &src, const char *key) {
String needle = String("\"") + key + "\"";
int k = src.indexOf(needle);
if (k < 0) return String();
int open = src.indexOf('[', k + needle.length());
if (open < 0) return String();
bool inStr = false;
int depth = 0;
for (int i = open; i < (int)src.length(); i++) {
char ch = src[i];
if (inStr) {
if (ch == '\\') i++; // skip the escaped character
else if (ch == '"') inStr = false;
continue;
}
if (ch == '"') inStr = true;
else if (ch == '[') depth++;
else if (ch == ']' && --depth == 0) return src.substring(open + 1, i);
}
return String();
}
static void handleLine(const String &in) {
if (in == "CHIRP?") {
Serial.printf("CHIRP! v=1 hw=%s\n", HW_ID);
return;
}
if (!in.startsWith("CHIRP+ ")) return; // console traffic, not ours
String json = in.substring(7);
if (!json.startsWith("{")) { Serial.println("CHIRP= err bad-json"); return; }
String ssid = jsonString(json, "ssid");
String pass = jsonString(json, "pass"); // "" is legal: open network
String host = jsonString(json, "host");
String claim = jsonString(json, "claim");
String token = jsonString(json, "token");
String hwid = jsonString(json, "hwid");
bool networksPushed = ssid.length() > 0 || json.indexOf("\"aps\"") >= 0;
if (!prefs.begin("chirp", false)) {
Serial.println("CHIRP= err store-failed");
return;
}
// A push is a merge — keys it doesn't carry keep their stored value.
// That is what lets the portal change networks without re-sending the
// credential this board already holds. The merged result must be complete.
if (ssid.length() == 0) ssid = prefs.getString("ssid", "");
if (json.indexOf("\"pass\"") < 0) pass = prefs.getString("pass", "");
if (host.length() == 0) host = prefs.getString("host", "https.warbletiot.com");
if (hwid.length() == 0) hwid = prefs.getString("hwid", HW_ID);
if (claim.length() == 0) claim = prefs.getString("claim", "");
if (token.length() == 0) token = prefs.getString("token", "");
if (ssid.length() == 0 || (claim.length() == 0 && token.length() == 0)) {
Serial.println("CHIRP= err missing-field");
prefs.end();
return;
}
prefs.putString("ssid", ssid);
prefs.putString("pass", pass);
// "aps" is the optional ordered list of networks to try. Its first entry
// matches the ssid/pass pair above. Store all entries and try them when
// connecting. Stored flat, ap0s/ap0p, ap1s/ap1p…
String aps = jsonArray(json, "aps");
int count = 0;
bool inStr = false;
int depth = 0, start = -1;
for (int i = 0; i < (int)aps.length() && count < 8; i++) {
char ch = aps[i];
if (inStr) {
if (ch == '\\') i++;
else if (ch == '"') inStr = false;
continue;
}
if (ch == '"') { inStr = true; continue; }
if (ch == '{') { if (depth++ == 0) start = i; continue; }
if (ch == '}' && --depth == 0 && start >= 0) {
String ap = aps.substring(start, i + 1);
prefs.putString(("ap" + String(count) + "s").c_str(), jsonString(ap, "ssid"));
prefs.putString(("ap" + String(count) + "p").c_str(), jsonString(ap, "pass"));
count++;
}
}
if (networksPushed) prefs.putInt("apcount", count); // 0 = just use ssid/pass
prefs.putString("host", host);
prefs.putString("claim", claim);
prefs.putString("token", token);
prefs.putString("hwid", hwid);
prefs.end();
Serial.println("CHIRP= ok"); // println sends \r\n: fine
delay(50); // let the line drain
// ESP.restart(); // come up on the new config
}
void setup() {
Serial.begin(115200);
}
void loop() {
while (Serial.available()) {
char c = Serial.read();
if (c == '\n') { handleLine(line); line = ""; }
else if (c != '\r') {
line += c;
if (line.length() > 1024) line = ""; // runaway line, not ours
}
}
// ...the rest of your loop
}Testing it without the portal
Everything here is typeable. Open any serial monitor at 115200 with line ending \n (or \r\n), then send:
CHIRP?
CHIRP+ {"ssid":"workshop","pass":"hunter2","aps":[{"ssid":"workshop","pass":"hunter2"},{"ssid":"phone-hotspot","pass":"roadtrip"}],"claim":"CHIRP-TESTTEST"}You should get an announce and then CHIRP= ok. If the monitor is holding the port, the portal can’t have it — close one before opening the other.
What this protects, and what it doesn’t
CHIRP-PROV is one-shot and unencrypted, deliberately. The wire is a USB cable on your desk: anyone who can send bytes down it can already reflash the board outright, so encrypting the handshake would buy nothing while costing every firmware author a crypto stack. Be clear-eyed about what follows from that:
- The credentials are in the clear on that cable — Wi-Fi password included. Provision from a computer you trust: software running on it can read what goes over the cable.
- The device does not authenticate the portal — anything on the port can push config. That is a physical-access problem, and physical access to a microcontroller was already game over.
- Where they end up is usually readable — NVS and a JSON file on flash both give up their contents to anyone holding the board. If that matters for your deployment, use the platform’s encrypted storage and flash encryption; the protocol doesn’t change.
- A claim code claims once, and expires unused after 72 hours — a second device can never claim with it. On the device it did claim, it lives on as that device’s token until you rotate it from the portal.