Getting Started
From zero to two Hanasu nodes chatting on a private channel: build, flash, open the host link, and run the first commands.
This walkthrough takes you from an empty bench to two nodes exchanging encrypted chat on a channel of your own. Two ESP32-C6 boards are the shortest path. An MG24 can stand in for either one. Paths are relative to the Hanasu reference design, reference-designs/MagNET_Thread_COaP_hanasu_esp32c6/, unless noted otherwise.
Prerequisites
- Two boards from Boards & Builds: XIAO ESP32C6, M5NanoC6, or XIAO MG24, in any mix.
- The whole
reference-designs/tree, not just the Hanasu folder. The C6 build reads../../MagNET_M5DialFiddlerCrab/magnet-common.iniand pulls theforthcomponent from../../MagNET_M5DialFiddlerCrab/ESPIDFORTH/components. The role-bundle engine also comes fromMagNET_M5DialFiddlerCrab/components. - For ESP32-C6: PlatformIO. It installs the pinned toolchain itself (
espressif32@6.9.0, IDF 5.3.1). - For XIAO MG24: a Zephyr workspace set up as in
firmware-zephyr/README.md(west, Zephyr SDK 1.0.1,hal_silabsblobs), plus the Silicon Labs Arduino core’s OpenOCD for flashing. - Host tools: Python 3 with
pyserial.tools/ble_hcp.pyalso needsbleak, andtools/mnpkg.pyneedscryptography.
1. Know which port is which
-
List the attached devices with their hardware IDs:
pio device listEach entry’s
Hardware IDline includesSER=<serial>. Write down which serial number belongs to which board, and map it to the current port path before each session. -
If you use
tools/hcp.py, itslistandallcommands skip ports whose path contains a substring fromMAGNET_HCP_SKIP(comma-separated). Set it to keep the tools off boards that are not Hanasu nodes.
2. Build and flash an ESP32-C6
-
Go to the IDF tree:
cd firmware-idf -
Pick the environment for your board and build-and-flash it to the port you identified in step 1:
# XIAO ESP32C6: always a *_xiao env (it enables the RF switch) pio run -e esp32c6_xiao -t upload --upload-port /dev/cu.usbmodemXXXX # M5NanoC6 / DevKit: the plain env pio run -e esp32c6 -t upload --upload-port /dev/cu.usbmodemYYYY # Want to provision from a phone instead of USB? Use a BLE env: pio run -e esp32c6_ble -t upload --upload-port /dev/cu.usbmodemYYYY -
On first boot, a node with no peers self-promotes to Thread leader. The README’s single-node bring-up saw this about 33 s after boot, followed by
!ROLE leaderand then!STATE READY. A second node attaches to it as a child and is then promoted to router.
sdkconfig.defaults* file, delete the cached sdkconfig.<env> before rebuilding, or PlatformIO silently keeps the old configuration.3. Build and flash a XIAO MG24 (optional)
-
Activate the Zephyr workspace and build. The Thread-only variant is shown here. Add
-- -DEXTRA_CONF_FILE=ble.conffor BLE provisioning.cd ~/zephyrproject && . .venv/bin/activate && export ZEPHYR_SDK_INSTALL_DIR=~/zephyr-sdk-1.0.1 APP=<path to>/firmware-zephyr west build --sysbuild -b xiao_mg24 $APP -d build/mnota -
Flash MCUboot and the app. Stock OpenOCD has no MG24 support, so point west at the OpenOCD bundled with the Silicon Labs Arduino core:
OO=~/Library/Arduino15/packages/SiliconLabs/tools/openocd/0.12.0-arduino1-static west flash -d build/mnota -r openocd --openocd $OO/bin/openocd --openocd-search $OO/share/openocd/scripts
Build variants and the memory budget are on Boards & Builds. The full MG24 story is on MG24 / Zephyr.
4. Open the host link
The host link is a plain line protocol at 115200 baud. Each command is one line, and each command gets exactly one +OK… or -ERR… reply.
- ESP32-C6. The C6’s USB-serial-JTAG bridge is on the chip itself. The host must assert DTR. pyserial does this by default. A bare
open(2)does not, and you then receive nothing (tools/uartpipe.pyexists to work around this for non-Python hosts). Opening the port usually resets the node, and the peer table lives only in RAM, soPEERSreads empty right after you connect until traffic arrives. - XIAO MG24. HCP runs on USART0, bridged to USB by a SAMD11. The bridge forwards only while DTR is asserted, in both directions. A DTR-low open looks exactly like a dead board. The bridge injects one
0x00on open, which the firmware drops. It buffers nothing while the port is closed. Pace long writes (32 B every 5 ms), never callflush(), and keep one line in flight: wait for each reply before sending the next. Opening this port does not reset the node.
Any of these works:
pio device monitor -b 115200 -p /dev/cu.usbmodemXXXX # interactive
python3 tools/hcp.py /dev/cu.usbmodemXXXX STATUS # one line, print the reply
python3 tools/hcp.py /dev/cu.usbmodemXXXX --watch 20 # stream the console for 20 s
python3 host-sdk/python/magnet_cli.py /dev/cu.usbmodemXXXX # terminal chat client
HCP mode does not echo what you type, so the reply line is the only feedback.
5. First commands
Run these on each node, one line at a time:
CAPS → +OK proto=2.1 fw=0.7.0-eh maxline=512 … verbs=STATUS,CAPS,HELP,… mode=HCP
STATUS → +OK state=READY role=leader channel=magnet peers=1 name=- id=1fa5d82e ble=off
WHOAMI → +OK id=1fa5d82e name=- fw=0.7.0-eh
NAME alice → +OK
PEERS → # peer 7c0e19a4 bob fd…:… last-seen=4s ago
+OK
MESH → # mesh role=leader partition=0x… rloc16=0x… chan=24 pan=0x4d4e
# mesh ml-eid=fd…:… mcast=ff05::…
# mesh neighbor rloc16=0x… rssi=… lqi=… age=…s …
+OK
CHAT hello → +OK
The IDs, addresses and names above are illustrative. NAME takes 1–16 characters with no spaces and is announced on the mesh, so peers show alice instead of a hex ID. On the other node the chat arrives as an unsolicited event:
!CHAT magnet 1fa5d82e alice hello
CHAT is accepted only in READY (or queued with +QUEUED n in DEGRADED). Otherwise it answers -ERR E_BAD_STATE <state>. For a direct message, take the peer’s IPv6 address from PEERS and send DM <ipv6> <text>. The full verb list is on the HCP page.
6. Move to a private channel
A fresh node sits on the well-known magnet channel. Its traffic is readable by anyone nearby, and the node says so with !WARN default-channel-insecure. Give both nodes the same credential:
CHANNEL SET correct-horse-battery-staple
# deriving channel key (Path B takes ~seconds)...
+OK correct-horse-ba selector=XXXX path=B
(The channel name is the first 16 characters of a passphrase, the first word of a seed phrase, or qr-channel for a qr: secret.)
The node detects the credential type automatically:
| Credential | Path | Cost |
|---|---|---|
qr:<base64 of 32 random bytes> |
A | no stretch. Preferred on MG24 |
| a phrase of 12 or more words | C | HKDF only |
| anything else (a passphrase) | B | PBKDF2, 100,000 iterations: ≈7.9 s on the C6, 10.6 s on the MG24 |
Run the same CHANNEL SET on the second node and confirm that both report the same selector with CHANNEL SHOW. The reply never echoes the secret. The channel persists across reboots. CHANNEL SET is a privileged verb: over USB, physical access is the trust boundary, and over BLE it needs a bonded link. How the keys are derived is covered under Security.
If a node ends up on the wrong channel, FACTORY RESET CONFIRM erases every provisioned setting (channel, name, admin keys, script, identity key, BLE bonds) and reboots it as a fresh node.
7. Provision over BLE instead (optional)
A BLE build (esp32c6_ble, the _ble_led variants, or MG24 with ble.conf) advertises the HCP GATT service only while the node is still on the default channel. It prints # ble: provisioning window open at boot. The service UUID is 6d61676e-2d68-6370-0001-000000000000: commands go to characteristic …0001, replies and events come back as notifications on …0002, and reading …0003 (encryption required) is what makes a phone pair.
- Read-only verbs (
STATUS,WHOAMI,CAPS,CHANNEL SHOW) work before pairing. Privileged verbs answer-ERR E_NOT_BONDED pair with this node first. - Pair (Just Works). Only the operating system’s pairing prompt can complete a bond. On Android it often shows up as a notification (“Tap to pair with MagNET-xxxx”) rather than a dialog.
- Send
NAMEandCHANNEL SETover the bonded link. On a provisioning-only build, BLE then shuts down and the node leaves BLE on purpose.
From a laptop, tools/ble_hcp.py drives the same binding:
python3 tools/ble_hcp.py --id 1fa5d82e --pair "WHOAMI" "NAME alice"
--id (the 8-hex device ID from WHOAMI) is required. Several bench nodes advertise the same service, and a host already bonded to one of them gets an encrypted link silently. Without --id, a privileged verb lands on whichever node answers first. Select nodes by service UUID or address, never by the MagNET-xxxx name: macOS and iOS cache the GAP name.
The Flutter companion app (magnet_app) does this flow on a phone: scan, connect, read STATUS/WHOAMI/CHANNEL SHOW, set a name, and provision with a passphrase, seed phrase or qr: secret. The pitfalls (stale bonds after a factory reset, OEM-specific prompts, untested iOS) are written up in docs/BLE-PAIRING.md and summarized under Security.
8. Host tools
| Tool | What it does |
|---|---|
tools/hcp.py |
Talks to a node over USB serial: list, one-shot commands, --reboot (RTS pulse, waits for READY), --watch SECS, --time (time to response), all <cmd> across the bench, and synctime (seeds the mesh clock with TIME SET). Exits 1 on -ERR. |
host-sdk/python/magnet_hcp.py |
The Python SDK. MagnetNode matches replies by @tag, raises HCPError with the E_* code, and routes ! events to node.on(...) callbacks. Needs only pyserial. |
host-sdk/python/magnet_cli.py |
A small terminal chat client on the SDK: /status, /peers, /join <cred>, /dm, /raw, /quit. |
tools/ble_hcp.py |
HCP over BLE-GATT with bleak. --id is required. --pair forces pairing. |
tools/xfer.py |
Reference driver for Type 6 extended transfer: send, recv, loopback. |
tools/chat-bench/ |
bridge.py plus a browser page with one chat pane per attached node, per-message latency, and photo transfer. |
tools/bundle_push.py |
Installs a signed role bundle over HCP (BUNDLE BEGIN/ADD/COMMIT). |
tools/mnpkg.py / tools/ota_push.py |
Build and verify .mnpkg OTA packages, then send one across the mesh. See OTA Operations. |
tools/soak.py |
Long-running whole-bench soak: holds every port open, drips chat, samples STATS/SYSINFO. |
tools/uartpipe.py |
Exposes one node’s serial port as a FIFO pair for non-Python hosts. |
tools/hcp.py opens and closes the port for each call, which resets a C6 every time. For anything long-running, hold one connection open with the SDK, soak.py or uartpipe.py.
Where to next
- Host Control Protocol: every verb, event and error code.
- Mesh Time: a Thread-only mesh has no NTP, so run
python3 tools/hcp.py synctimeonce. - Forth & Bots:
FORTHdrops the link into the Forth REPL, and.hcpreturns.
Last updated 12 Oct 2026, 20:51 -0700 .