CHIRP-PROV v1.5
Provisioning over USB
Four lines of text over the serial port your firmware already prints to. Implement them and the Warble 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 the portal’s browser flasher speaks in its provision mode. It is published, versioned, and yours to implement — Warble 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 10 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, v1.4: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. Unlike every other extra pair this one 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. Announcing radios is optional and a responder that stays quiet gets exactly the old behaviour.
Optional, v1.5: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. Any other space-separatedk=vpair is permitted and ignored (forward compatibility). - 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-failed(v1.2) means 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, v1.1. 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, so firmware that has never heard of aps still gets a network it understands. See More than one network. |
thread | Optional, v1.2. 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. Since v1.2 a push carries 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 (v1.3): 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, v1.5. The device’s HMAC signing key, base64 — what L2 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. Store it and announce sig=1; a responder that ignores unknown keys drops it and is no worse off than before. |
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.
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 addition is deliberately backwards-compatible in both directions. v1 said unknown keys must be ignored, so a responder written before this existed reads the config, skips aps, and provisions the primary network exactly as it always did — which is why the first list entry is repeated as ssid/pass rather than replaced by the list. And a v1.1 responder handed a v1 config simply finds no aps and has a one-network list.
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>.chirpiot.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.chirpiot.com HTTPS POST /ingest/{hwid} TLS L0–L2, can Enforce TLS
http.chirpiot.com:80 plain-HTTP POST, no TLS stack readable on path, ≤L2
mqtt.chirpiot.com:1883 MQTT publish chirp/{hwid}/up readable on path, ≤L2
mqtt.chirpiot.com:8883 MQTTS planned — no listener yet
tcp.chirpiot.com:7700 CHIRP1 hello line + payload lines readable on path, ≤L2
udp.chirpiot.com:7701 "CHIRP1 {hwid} {token} " + payload readable on path, ≤L2The name is also honest signage: what goes to http., tcp. or udp. can be read by an on-path observer, so those endpoints can carry any level but can never Enforce TLS — there is no TLS listener on them to enforce. One deliberate quirk worth knowing for Thread and other IPv6-only devices: udp.chirpiot.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 10 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
# v1.3: 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 v1.1 list. The plain ssid/pass pair is always sent as well
and always names the same network as the first list entry, so storing the
whole config -- as _apply does above -- is all a v1 responder needed to do
to become a v1.1 one.
"""
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 out of a flat JSON object — which is all CHIRP-PROV
// ever sends. (ArduinoJson is a fine alternative if you already have it:
// 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");
if (!prefs.begin("chirp", false)) {
Serial.println("CHIRP= err store-failed");
return;
}
// v1.3: 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 (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);
// v1.1: "aps" is the ordered list of networks to try. It is optional, and
// its first entry is always the ssid/pass pair above — so ignoring it costs
// you nothing but the fallback networks. 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++;
}
}
prefs.putInt("apcount", count); // 0 = just use ssid/pass
prefs.putString("host", host.length() ? host : String("https.chirpiot.com"));
prefs.putString("claim", claim);
prefs.putString("token", token);
prefs.putString("hwid", hwid.length() ? hwid : String(HW_ID));
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","claim":"CHIRP-TESTTEST"}
# and the v1.1 form, to check your list handling:
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 on a machine you trust, not on a shared bench with a logic analyser on the header.
- 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 is single-use and expires in 72 hours — a code sniffed off the cable after the device has used it is worth nothing. Once bound, rotate the device token from the portal whenever you want.