12 KiB
MeshCoreCompanion
A client library for the MeshCore Companion Radio serial protocol, for when the radio lives on one MCU and your application (display, sensor hub, gateway) lives on another. Talk to a separate companion radio — e.g. a Seeed XIAO + Wio-SX1262 — over a UART/USB serial link and exchange channel messages, set channel PSKs, and read link metadata.
This is the inverse of the phone/web app: your host MCU plays the client, the companion radio plays the server.
Design: portable C core + Arduino wrapper
Two layers, so the protocol is reusable and testable far beyond Arduino:
meshcore_companion.h/.c— a portable C99 core. No I/O, nomalloc, no Arduino. It assembles inbound frames from a byte stream, builds outbound command frames into buffers you own, and parses payloads into plain structs. It compiles and runs on a desktop for unit testing (seetest/), and drops into ESP-IDF, bare nRF52/STM32, or a host bridge unchanged.MeshCoreCompanion.h/.cpp— a thin C++ wrapper. Inject anyStream(Serial1on a Grove UART, USB CDC,SoftwareSerial), callloop()often, register lambda callbacks. It also auto-drains the radio's queue: on theMsgWaitingpush it loopsSyncNextMessageuntil empty, so you just receiveonChannelMessage(...).
Transport is injected; the core never touches a port. "Physical serial only" is
simply which Stream you hand the wrapper.
Why talk to a companion radio?
The alternative is to compile the full MeshCore stack into your own MCU and drive an SX-series LoRa modem (e.g. an SX1262) directly over SPI. That works, but it is heavy on flash and RAM, and it needs several free GPIOs for the SPI bus plus the radio's control/IRQ lines — pins many boards (or your own design) simply can't spare once a display, sensors and other peripherals are wired up.
Running the radio as a separate companion module keeps all of that — the LoRa modem, mesh routing and crypto — on the radio, and lets your application talk to it over a single UART/USB serial link. Your host stays light, and because the core is plain C99 with no dependencies it runs equally on a small microcontroller or on a low-level embedded Linux SBC/SoC where C is the natural language.
Wire protocol
frame = [type:u8][len:u16 LE][payload:len bytes], type 0x3C for app→radio,
0x3E for radio→app. payload[0] is the command/response/push code. Derived
from the MeshCore companion protocol and the meshcore.js reference client.
Install (PlatformIO)
Drop this folder into your project's lib/, or add to platformio.ini:
lib_deps = symlink:///path/to/MeshCoreCompanion
The companion radio must be flashed with the serial companion variant
(companion_radio_usb) and have its serial interface bound to the UART you wire
to — not BLE, and not the WiFi/TCP variant. Companions compile in one interface
at a time. If you are using an OS (e.g. Linux) you can use USB variant as the drivers will present a /dev/ttyX interface that looks same as serial. If wanting to use bare metal evices such as AVR/ESP32/nRF52/STM serial, then see notes below about changes to radio.
Quick start
#include "MeshCoreCompanion.h"
MeshCoreCompanion mc(Serial1);
void setup() {
Serial1.begin(115200, SERIAL_8N1, /*RX*/16, /*TX*/17);
mc.onDeviceInfo([](const mc_device_info_t& d){
mc.setChannelHexSecret(2, "sensors", "000102...0f"); // 32 hex chars
mc.getDeviceTime();
});
mc.onChannelMessage([](const mc_channel_msg_t& m){
// m.text is "SenderName: body"
});
mc.onChannelData([](const mc_channel_data_t& d){
// d.data / d.data_len, plus MC_SNR_DB(d.snr_q4), d.path_len
});
mc.begin(); // AppStart + DeviceQuery
}
void loop() {
mc.loop(); // pump + auto-drain
// mc.sendChannelText(2, "hello");
}
Linux / host build (CMake)
The portable C core needs no Arduino and no hardware — it builds and runs on any
desktop. A CMakeLists.txt at the repo root builds the core, the host unit test,
and the Linux examples (the Arduino C++ wrapper is skipped here — it needs
<Arduino.h>):
cmake -B build && cmake --build build
ctest --test-dir build --output-on-failure # run the codec unit test
Linux examples (examples-linux/)
Both talk to a companion radio over any tty — a USB-CDC device
(/dev/ttyACM0), a USB-serial adapter (/dev/ttyUSB0), or a raw UART
(/dev/serial0):
tty_bridge— live: receive/echo channel traffic and send messages../build/meshcore_tty /dev/ttyACM0info— one-shot: dump everything the radio reports (model, firmware, radio params, device time, channels + PSKs, stats), then exit../build/meshcore_info /dev/ttyACM0
No CMake? Compile a single example directly:
cc -std=c99 -Wall -Wextra -Isrc \
examples-linux/info/meshcore_info.c src/meshcore_companion.c -o meshcore_info
The same C core also ships starter examples for other toolchains in
examples-esp-idf/ (ESP-IDF UART driver) and examples-stm32/ (STM32 HAL).
What's covered
| Need | API |
|---|---|
| Handshake / identity | begin(), appStart(), deviceQuery() → onDeviceInfo, onSelfInfo |
| Receive channel text | onChannelMessage (auto-drained) |
| Receive channel data + metadata | onChannelData (SNR, path length, data type) |
| Send on a channel | sendChannelText(idx, text) |
| Set / read channel PSK | setChannel, setChannelHexSecret, getChannel → onChannelInfo |
| Device time | getDeviceTime, setDeviceTime, deviceEpochNow() |
| Adverts | sendSelfAdvert(flood) |
| Radio params / stats | getStats, plus mc_cmd_set_radio_params in the core |
The C core also exposes raw command builders and mc_parse for codes the
wrapper doesn't surface yet (contacts, battery, send-confirmed, etc.) — adding a
new command is one builder plus one case.
Testing the core (no hardware)
Run everything at once with ./testall.sh — it runs the CMake/ctest, PlatformIO
native, and Arduino-compile suites, and auto-skips whichever tools aren't
installed (so it works on a cmake-only or PlatformIO-only box):
./testall.sh
Or run a suite individually:
ctest --test-dir build --output-on-failure # via CMake (see above)
pio test -e native # via PlatformIO's native env
cd test && cc -std=c99 -Wall -Wextra -I../src test_codec.c ../src/meshcore_companion.c -o t && ./t
The unit test exercises command encoding, frame reassembly across split reads, every parser, and resync past line garbage.
Connecting to the radio: USB vs TTL serial
On a desktop or SBC (Linux, Windows, macOS) the companion's USB driver exposes a
serial port you open straight from C — /dev/ttyACM0 and friends. From a
bare-metal / FreeRTOS host there's no USB stack, so you wire the companion's
TTL UART to a hardware serial port on your host. That's straightforward on
most MeshCore companion hardware, but it can't be done from the online flasher —
you have to build the firmware yourself.
Build a serial companion (XIAO ESP32S3 + SX1262)
The companion firmware is built with PlatformIO, so
you need its source. Clone the MeshCore firmware repo (VS Code + PlatformIO, or
the pio CLI):
git clone https://github.com/ripplebiz/MeshCore
cd MeshCore
A companion compiles in one host interface at a time, and the XIAO + Wio-SX1262 variant ships a ready-made environment for each — pick the serial one (no source edit needed):
| PlatformIO environment | Host link |
|---|---|
Xiao_S3_WIO_companion_radio_usb |
native USB-CDC |
Xiao_S3_WIO_companion_radio_ble |
BLE |
Xiao_S3_WIO_companion_radio_wifi |
TCP / WiFi |
Xiao_S3_WIO_companion_radio_serial |
hardware UART ← use this |
The …_serial env already routes the command interface to Serial1: it sets
-D SERIAL_TX=D6 -D SERIAL_RX=D7 in variants/xiao_s3_wio/platformio.ini. Build
and flash it over USB:
pio run -e Xiao_S3_WIO_companion_radio_serial -t upload
(If the upload can't find the board, put the XIAO in bootloader mode: hold B, tap R, release B.)
Then wire the radio to your host — companion D6 (TX, GPIO43) → host RX,
companion D7 (RX, GPIO44) ← host TX, GND↔GND. Those host pins are exactly
what UART_RX_PIN / UART_TX_PIN select in the AutoProvision example. The
Wio-SX1262 hat uses the SPI bus (D8–D10) plus GPIO38–42 for the radio, so D6/D7
(and D0–D5) stay free; to use a different pair just override SERIAL_TX /
SERIAL_RX in that env's build_flags.
XIAO nRF52840 + SX1262
The xiao_nrf52 variant is also a XIAO + SX1262, but it only ships
companion_radio_usb and companion_radio_ble — so how you connect decides how
much work it is:
-
Host over USB → already works. Flash
Xiao_nrf52_companion_radio_usb:pio run -e Xiao_nrf52_companion_radio_usb -t uploadThe nRF52840's native USB-CDC enumerates as
/dev/ttyACM*, which is a serial port like any other — the Linux examples and AutoProvision work unchanged. No firmware edit needed. -
Host needs raw TTL UART → small firmware edit. Unlike the ESP32-S3/RP2040, the nRF52 path in
examples/companion_radio/main.cpponly doesserial_interface.begin(Serial)(USB-CDC); theSERIAL_RX/SERIAL_TXHardwareSerial route is guarded to ESP32/RP2040 only, so adding those flags on nRF52 does nothing. To expose a TTL UART, bindSerial1in that#elsebranch:Serial1.setPins(/*rx*/D7, /*tx*/D6); Serial1.begin(115200); serial_interface.begin(Serial1); // instead of begin(Serial)(On the XIAO nRF52840
Serial1defaults to D6/D7 under the Adafruit core, butsetPinsmakes it explicit.) It's a few lines, mirroring the ESP32 path.
Either way the host-side code here is identical — a USB-CDC companion and a TTL-UART companion look the same to this library.
Zero-config provisioning from the host
examples/AutoProvision/ is a plug-and-go host (basic ESP32-S3 dev board): on
boot it programs the companion the way it wants — node name, the AU narrow
band region, and a channel — then loops watching that channel and replies to
any hello with a response. Wire it to the companion's UART, flash, and the
radio comes up configured with no manual steps:
cd examples/AutoProvision
pio run -t upload && pio device monitor
It's a self-contained PlatformIO project (platformio.ini + src/main.cpp). The
region is set the same way you'd compile MeshCore firmware — LORA_FREQ /
LORA_BW / LORA_SF / LORA_CR / LORA_TX_POWER in platformio.ini's
build_flags (916.575 MHz / 62.5 kHz / SF7 / CR4/8 / 20 dBm). The code is plain
Arduino C++, so you can rename src/main.cpp to AutoProvision.ino for the
Arduino IDE instead.
Notes
- Timestamps: channel messages carry an epoch-seconds sender timestamp.
Call
getDeviceTime()(orsetDeviceTime()) once sosendChannelTextcan stamp outgoing messages; otherwise it sends0. - PSK derivation:
setChanneltakes a raw 16-byte secret. If you need to derive one from a passphrase, do it host-side (e.g. SHA-256 prefix, asmeshcore.js's transport-key util shows) — kept out of the core to stay dependency-free. - Buffer sizes: override
MC_MAX_PAYLOAD,MC_MAX_TEXT,MC_MAX_DATAbefore including the header if your traffic needs more.
License
MIT. Protocol derived from MeshCore and meshcore.js (MIT, © Liam Cottle).