282 lines
12 KiB
Markdown
282 lines
12 KiB
Markdown
# 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, no `malloc`,
|
||
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 (see `test/`), and drops
|
||
into ESP-IDF, bare nRF52/STM32, or a host bridge unchanged.
|
||
- **`MeshCoreCompanion.h/.cpp`** — a thin C++ wrapper. Inject any `Stream`
|
||
(`Serial1` on a Grove UART, USB CDC, `SoftwareSerial`), call `loop()` often,
|
||
register lambda callbacks. It also **auto-drains** the radio's queue: on the
|
||
`MsgWaiting` push it loops `SyncNextMessage` until empty, so you just receive
|
||
`onChannelMessage(...)`.
|
||
|
||
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`:
|
||
|
||
```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
|
||
|
||
```cpp
|
||
#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>`):
|
||
|
||
```sh
|
||
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.
|
||
```sh
|
||
./build/meshcore_tty /dev/ttyACM0
|
||
```
|
||
- **`info`** — one-shot: dump everything the radio reports (model, firmware,
|
||
radio params, device time, channels + PSKs, stats), then exit.
|
||
```sh
|
||
./build/meshcore_info /dev/ttyACM0
|
||
```
|
||
|
||
No CMake? Compile a single example directly:
|
||
|
||
```sh
|
||
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):
|
||
|
||
```sh
|
||
./testall.sh
|
||
```
|
||
|
||
Or run a suite individually:
|
||
|
||
```sh
|
||
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](https://platformio.org/), so
|
||
you need its source. Clone the MeshCore firmware repo (VS Code + PlatformIO, or
|
||
the `pio` CLI):
|
||
|
||
```sh
|
||
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:
|
||
|
||
```sh
|
||
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`:
|
||
```sh
|
||
pio run -e Xiao_nrf52_companion_radio_usb -t upload
|
||
```
|
||
The 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.cpp` only does
|
||
`serial_interface.begin(Serial)` (USB-CDC); the `SERIAL_RX`/`SERIAL_TX`
|
||
HardwareSerial route is guarded to ESP32/RP2040 only, so adding those flags on
|
||
nRF52 does nothing. To expose a TTL UART, bind `Serial1` in that `#else` branch:
|
||
```cpp
|
||
Serial1.setPins(/*rx*/D7, /*tx*/D6);
|
||
Serial1.begin(115200);
|
||
serial_interface.begin(Serial1); // instead of begin(Serial)
|
||
```
|
||
(On the XIAO nRF52840 `Serial1` defaults to D6/D7 under the Adafruit core, but
|
||
`setPins` makes 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:
|
||
|
||
```sh
|
||
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()` (or `setDeviceTime()`) once so `sendChannelText` can
|
||
stamp outgoing messages; otherwise it sends `0`.
|
||
- **PSK derivation:** `setChannel` takes a raw 16-byte secret. If you need to
|
||
derive one from a passphrase, do it host-side (e.g. SHA-256 prefix, as
|
||
`meshcore.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_DATA`
|
||
before including the header if your traffic needs more.
|
||
|
||
## License
|
||
|
||
MIT. Protocol derived from MeshCore and `meshcore.js` (MIT, © Liam Cottle).
|