Restructure into dual-purpose meshcore_c library

Remove stale byte-identical root duplicates and promote the canonical
library to the repo root: one source of truth (src/meshcore_companion.{c,h})
serving both a portable C library and a publishable C++ Arduino/PlatformIO
library.

- Portable C99 core + C++ Arduino wrapper in src/
- Arduino sketch in examples/, new Linux tty example in examples-linux/
- CMakeLists.txt for the Linux/native host build (core + example + test)
- Host codec unit test in test/
- README rewritten around the two purposes

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Scott Penrose
2026-06-08 02:06:32 +10:00
commit cdfceba34d
13 changed files with 1539 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
# CMake / native build output
/build/
*.o
*.a
# Host test/example binaries
/test/t
/meshcore_tty
# PlatformIO
.pio/
# Local design scratch (chat transcript, not a repo artifact)
/STRUCTURE.md
# Editor / OS noise
.DS_Store
.vscode/
+34
View File
@@ -0,0 +1,34 @@
# CMakeLists.txt -- Linux / native (host) build for the portable C core.
#
# This builds the dependency-free C99 core (src/meshcore_companion.c), the host
# unit test, and the Linux tty example. It deliberately does NOT build the C++
# Arduino wrapper (src/MeshCoreCompanion.{h,cpp}) -- that requires <Arduino.h>
# and is compiled by the Arduino IDE / PlatformIO instead. The Arduino manifests
# (library.properties, library.json) at the repo root are simply ignored here.
#
# cmake -B build && cmake --build build
# ctest --test-dir build --output-on-failure
#
# SPDX-License-Identifier: MIT
cmake_minimum_required(VERSION 3.13)
project(meshcore_c LANGUAGES C)
set(CMAKE_C_STANDARD 99)
set(CMAKE_C_STANDARD_REQUIRED ON)
add_compile_options(-Wall -Wextra)
# Portable protocol core, reusable on any platform with a byte transport.
add_library(meshcore_companion STATIC src/meshcore_companion.c)
target_include_directories(meshcore_companion PUBLIC src)
# Linux example: drives a companion radio over any tty (USB-CDC or raw UART).
add_executable(meshcore_tty examples-linux/tty_bridge/meshcore_tty.c)
target_link_libraries(meshcore_tty PRIVATE meshcore_companion)
# Host unit test for the codec (no hardware required).
enable_testing()
add_executable(test_codec test/test_codec.c)
target_link_libraries(test_codec PRIVATE meshcore_companion)
add_test(NAME codec COMMAND test_codec)
+27
View File
@@ -0,0 +1,27 @@
MIT License
Copyright (c) 2026 Scott Penrose / Digital Dimensions
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
---
The companion protocol implemented here was derived from the MeshCore Companion
Radio protocol as documented in the MeshCore project and the reference client
meshcore.js (MIT, Copyright (c) Liam Cottle, https://github.com/meshcore-dev/meshcore.js).
+149
View File
@@ -0,0 +1,149 @@
# meshcore_c
A client for the **MeshCore Companion Radio** serial protocol, for when the radio
lives on one device and your application (display, sensor hub, gateway, desktop
tool) 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. Your host plays the *client*, the companion
radio plays the *server* — the inverse of the phone/web app.
Sibling of [`meshcore.js`](https://github.com/meshcore-dev/meshcore.js) (JS) and
`meshcore_py` (Python): this is the **C / C++** one.
## Two ways to use this repo
The protocol logic is a single portable **C99 core** with no I/O, no `malloc`, and
no Arduino dependency. Everything else is a thin layer over it, so one repo serves
two audiences from **one source of truth** (`src/meshcore_companion.{c,h}`):
1. **Portable C library** — drop `src/meshcore_companion.{c,h}` into any project.
It assembles inbound frames from a byte stream, builds outbound command frames
into buffers you own, and parses payloads into plain structs. You supply the
transport. A complete Linux example (POSIX `termios`) lives in
`examples-linux/`, and the host unit test in `test/` runs with no hardware.
The same core drops into ESP-IDF, bare nRF52/STM32, or any host bridge
unchanged (those examples are planned).
2. **C++ Arduino library**`src/MeshCoreCompanion.{h,cpp}` wrap the core in an
Arduino-friendly class: inject any `Stream` (`Serial1` on a Grove UART, USB
CDC, `SoftwareSerial`), call `loop()` often, register lambda callbacks. It
**auto-drains** the radio's queue: on the `MsgWaiting` push it loops
`SyncNextMessage` until empty, so you just receive `onChannelMessage(...)`.
The repo root is a publishable Arduino / PlatformIO library
(`library.properties` + `library.json`).
## Layout
```
meshcore_c/
├── library.properties # Arduino IDE / Library Manager metadata
├── library.json # PlatformIO metadata
├── CMakeLists.txt # Linux / native (host) build of the C core + examples + test
├── src/
│ ├── meshcore_companion.h/.c # portable C99 core — single source of truth
│ └── MeshCoreCompanion.h/.cpp # C++ Arduino wrapper (built by Arduino/PlatformIO)
├── examples/
│ └── SensorChannelBridge/ # Arduino sketch (.ino)
├── examples-linux/
│ └── tty_bridge/ # portable C example: any Linux tty (USB or raw UART)
└── test/
└── test_codec.c # host unit test (no hardware)
```
The Arduino/PlatformIO build only compiles `src/` (the manifests' `srcFilter` is
scoped there), so the Linux example and host test never enter a firmware build.
The CMake build only compiles the pure-C parts and ignores the Arduino wrapper
(which needs `<Arduino.h>`) and the manifests.
## 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.
The companion radio must be flashed with the **serial** companion variant
(`companion_radio_usb`) and have its serial interface bound to the port you wire
to — not BLE, and not the WiFi/TCP variant. Companions compile in one interface at
a time.
## Build & run the C library (Linux / host)
```sh
cmake -B build && cmake --build build
ctest --test-dir build --output-on-failure # run the codec unit test
./build/meshcore_tty /dev/ttyACM0 # USB-CDC companion
./build/meshcore_tty /dev/ttyUSB0 2 000102030405060708090a0b0c0d0e0f sensors
# ^tty ^ch ^16-byte PSK (32 hex chars) ^name
```
Or compile the example directly, no CMake:
```sh
cc -std=c99 -Wall -Wextra -Isrc \
examples-linux/tty_bridge/meshcore_tty.c src/meshcore_companion.c -o meshcore_tty
```
## Use the Arduino library
Install via PlatformIO (`lib_deps = symlink:///path/to/meshcore_c`), the Arduino
Library Manager (once published), or by copying the repo into your `libraries/`
folder. Then:
```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");
}
```
See `examples/SensorChannelBridge/` for a complete sketch.
## What's covered
| Need | C core API | Arduino wrapper |
|------|------------|-----------------|
| Handshake / identity | `mc_cmd_app_start`, `mc_cmd_device_query` | `begin()`, `appStart()`, `deviceQuery()``onDeviceInfo`/`onSelfInfo` |
| Receive channel text | `mc_parse``MC_RESP_CHANNEL_MSG_RECV` | `onChannelMessage` (auto-drained) |
| Receive channel data + metadata | `mc_parse``MC_RESP_CHANNEL_DATA_RECV` | `onChannelData` (SNR, path, type) |
| Send on a channel | `mc_cmd_send_channel_text` | `sendChannelText(idx, text)` |
| Set / read channel PSK | `mc_cmd_set_channel`, `mc_cmd_get_channel` | `setChannel`, `setChannelHexSecret`, `getChannel``onChannelInfo` |
| Device time | `mc_cmd_get/set_device_time` | `getDeviceTime`, `setDeviceTime`, `deviceEpochNow()` |
| Adverts | `mc_cmd_send_self_advert` | `sendSelfAdvert(flood)` |
| Radio params / stats | `mc_cmd_set_radio_params`, `mc_cmd_get_stats` | `getStats` |
Adding a command the wrapper doesn't surface yet is one builder plus one `case`.
## Notes
- **Timestamps:** channel messages carry an epoch-seconds sender timestamp. Call
`getDeviceTime()` once so outgoing text can be stamped; otherwise it sends `0`.
- **PSK derivation:** `set_channel` takes a raw 16-byte secret. Derive one from a
passphrase 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).
+274
View File
@@ -0,0 +1,274 @@
/*
* meshcore_tty.c
*
* Portable-C example: talk to a MeshCore Companion Radio over any Linux tty
* (USB-CDC like /dev/ttyACM0, a USB-serial adapter like /dev/ttyUSB0, or a raw
* UART like /dev/ttyAMA0 / /dev/serial0). It demonstrates that the protocol core
* in src/meshcore_companion.{c,h} needs nothing but a byte transport: here that
* transport is POSIX termios + select(), entirely owned by this file.
*
* build: see CMakeLists.txt at the repo root, or:
* cc -std=c99 -Wall -Wextra -I../../src \
* meshcore_tty.c ../../src/meshcore_companion.c -o meshcore_tty
*
* run: ./meshcore_tty /dev/ttyACM0
* ./meshcore_tty /dev/ttyUSB0 2 000102030405060708090a0b0c0d0e0f sensors
* ^tty ^ch ^16-byte PSK as 32 hex chars ^name
*
* The companion radio must run the serial companion firmware (companion_radio_usb)
* with its interface bound to this port (not BLE, not the WiFi/TCP variant).
*
* SPDX-License-Identifier: MIT
* Author: Scott Penrose / Digital Dimensions.
*/
#define _DEFAULT_SOURCE /* cfmakeraw, B115200 on glibc under -std=c99 */
#include <errno.h>
#include <fcntl.h>
#include <signal.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/select.h>
#include <termios.h>
#include <unistd.h>
#include "meshcore_companion.h"
/* ------------------------------------------------------------------ tty I/O */
/* Open `path` and put it in raw 8N1 mode at 115200 baud. Returns fd or -1. */
static int tty_open(const char *path)
{
int fd = open(path, O_RDWR | O_NOCTTY | O_NONBLOCK);
if (fd < 0) {
fprintf(stderr, "open %s: %s\n", path, strerror(errno));
return -1;
}
struct termios tio;
if (tcgetattr(fd, &tio) != 0) {
fprintf(stderr, "tcgetattr %s: %s\n", path, strerror(errno));
close(fd);
return -1;
}
cfmakeraw(&tio); /* no echo, no canonical, 8-bit clean */
cfsetispeed(&tio, B115200);
cfsetospeed(&tio, B115200);
tio.c_cflag |= (CLOCAL | CREAD);
tio.c_cc[VMIN] = 0; /* non-blocking read: return whatever is there */
tio.c_cc[VTIME] = 0;
if (tcsetattr(fd, TCSANOW, &tio) != 0) {
fprintf(stderr, "tcsetattr %s: %s\n", path, strerror(errno));
close(fd);
return -1;
}
tcflush(fd, TCIOFLUSH);
return fd;
}
/* Frame a command payload and write the whole thing to the radio. */
static int send_payload(int fd, const uint8_t *payload, size_t len)
{
uint8_t frame[MC_RX_BUFSZ];
size_t flen = mc_frame_encode(payload, len, frame, sizeof frame);
if (flen == 0) return -1;
size_t off = 0;
while (off < flen) {
ssize_t w = write(fd, frame + off, flen - off);
if (w < 0) {
if (errno == EAGAIN || errno == EINTR) continue;
fprintf(stderr, "write: %s\n", strerror(errno));
return -1;
}
off += (size_t)w;
}
return 0;
}
/* --------------------------------------------------------------- arg helpers */
static int hex_nibble(char c)
{
if (c >= '0' && c <= '9') return c - '0';
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
return -1;
}
/* Parse 32 hex chars into a 16-byte PSK. Returns 0 on success. */
static int parse_psk(const char *hex, uint8_t out[MC_SECRET_LEN])
{
if (strlen(hex) != (size_t)MC_SECRET_LEN * 2) return -1;
for (int i = 0; i < MC_SECRET_LEN; i++) {
int hi = hex_nibble(hex[i * 2]);
int lo = hex_nibble(hex[i * 2 + 1]);
if (hi < 0 || lo < 0) return -1;
out[i] = (uint8_t)((hi << 4) | lo);
}
return 0;
}
/* ----------------------------------------------------------- event handling */
static void on_event(const mc_event_t *ev)
{
switch (ev->code) {
case MC_RESP_DEVICE_INFO: {
const mc_device_info_t *d = &ev->u.device_info;
printf("[radio] model=%s fw=%d channels=%u build=%s\n",
d->model, d->fw_ver, d->max_channels, d->build_date);
break;
}
case MC_RESP_SELF_INFO:
printf("[radio] name=%s freq=%u bw=%u sf=%u cr=%u\n",
ev->u.self_info.name, ev->u.self_info.radio_freq,
ev->u.self_info.radio_bw, ev->u.self_info.radio_sf,
ev->u.self_info.radio_cr);
break;
case MC_RESP_CHANNEL_INFO: {
const mc_channel_info_t *c = &ev->u.channel_info;
printf("[chan %u] name=%s secret=%s\n",
c->channel_idx, c->name, c->have_secret ? "set" : "(none)");
break;
}
case MC_RESP_CHANNEL_MSG_RECV: /* text body is "SenderName: message" */
printf("[ch %d] %s\n", ev->u.channel_msg.channel_idx,
ev->u.channel_msg.text);
break;
case MC_RESP_CHANNEL_DATA_RECV: {
const mc_channel_data_t *d = &ev->u.channel_data;
printf("[ch %d] %u bytes type=0x%04X snr=%.1f dB %s\n",
d->channel_idx, d->data_len, d->data_type,
(double)MC_SNR_DB(d->snr_q4),
d->path_len == MC_PATH_DIRECT ? "direct" : "flood");
break;
}
case MC_RESP_CURR_TIME:
printf("[radio] device time = %u (epoch secs)\n", ev->u.curr_time);
break;
case MC_RESP_OK:
break; /* command acknowledged */
case MC_RESP_ERR:
printf("[radio] error response (code=%d)\n", ev->u.err_code);
break;
case MC_PUSH_MSG_WAITING:
/* The radio has queued messages; caller drains via SyncNextMessage. */
break;
default:
printf("[radio] event code 0x%02X\n", ev->code);
break;
}
}
/* -------------------------------------------------------------------- main */
static volatile sig_atomic_t g_stop = 0;
static void on_sigint(int sig) { (void)sig; g_stop = 1; }
static void usage(const char *argv0)
{
fprintf(stderr,
"usage: %s <tty> [channel_idx psk_hex [name]]\n"
" <tty> e.g. /dev/ttyACM0, /dev/ttyUSB0, /dev/serial0\n"
" channel_idx optional channel to program (0-255)\n"
" psk_hex optional 16-byte PSK as 32 hex chars\n"
" name optional channel name (default \"channel\")\n",
argv0);
}
int main(int argc, char **argv)
{
if (argc < 2) { usage(argv[0]); return 2; }
/* Optional channel programming. */
int set_channel = 0;
uint8_t channel_idx = 0;
uint8_t psk[MC_SECRET_LEN];
const char *channel_name = "channel";
if (argc >= 4) {
channel_idx = (uint8_t)strtoul(argv[2], NULL, 0);
if (parse_psk(argv[3], psk) != 0) {
fprintf(stderr, "psk_hex must be exactly 32 hex chars\n");
return 2;
}
if (argc >= 5) channel_name = argv[4];
set_channel = 1;
}
int fd = tty_open(argv[1]);
if (fd < 0) return 1;
signal(SIGINT, on_sigint);
/* Handshake: AppStart triggers SelfInfo, DeviceQuery triggers DeviceInfo. */
uint8_t cmd[MC_MAX_PAYLOAD];
size_t n;
n = mc_cmd_app_start(cmd, sizeof cmd, "meshcore_tty");
if (n) send_payload(fd, cmd, n);
n = mc_cmd_device_query(cmd, sizeof cmd, 1);
if (n) send_payload(fd, cmd, n);
if (set_channel) {
n = mc_cmd_set_channel(cmd, sizeof cmd, channel_idx, channel_name, psk);
if (n) send_payload(fd, cmd, n);
}
n = mc_cmd_get_device_time(cmd, sizeof cmd);
if (n) send_payload(fd, cmd, n);
printf("listening on %s (Ctrl-C to quit)\n", argv[1]);
mc_rx_t rx;
mc_rx_init(&rx);
while (!g_stop) {
fd_set rfds;
FD_ZERO(&rfds);
FD_SET(fd, &rfds);
struct timeval tv = { 1, 0 }; /* 1 s tick so Ctrl-C is responsive */
int r = select(fd + 1, &rfds, NULL, NULL, &tv);
if (r < 0) {
if (errno == EINTR) continue;
fprintf(stderr, "select: %s\n", strerror(errno));
break;
}
if (r == 0 || !FD_ISSET(fd, &rfds)) continue;
uint8_t in[256];
ssize_t got = read(fd, in, sizeof in);
if (got < 0) {
if (errno == EAGAIN || errno == EINTR) continue;
fprintf(stderr, "read: %s\n", strerror(errno));
break;
}
if (got == 0) continue;
mc_rx_feed(&rx, in, (size_t)got);
uint8_t payload[MC_MAX_PAYLOAD];
size_t plen;
while (mc_rx_poll(&rx, payload, sizeof payload, &plen)) {
mc_event_t ev;
if (mc_parse(payload, plen, &ev)) {
on_event(&ev);
/* Drain the radio's queue when it says messages are waiting. */
if (ev.code == MC_PUSH_MSG_WAITING) {
n = mc_cmd_sync_next_message(cmd, sizeof cmd);
if (n) send_payload(fd, cmd, n);
} else if (ev.code == MC_RESP_CHANNEL_MSG_RECV ||
ev.code == MC_RESP_CHANNEL_DATA_RECV ||
ev.code == MC_RESP_CONTACT_MSG_RECV) {
/* keep draining until NO_MORE_MESSAGES */
n = mc_cmd_sync_next_message(cmd, sizeof cmd);
if (n) send_payload(fd, cmd, n);
}
}
}
}
printf("\nbye\n");
close(fd);
return 0;
}
@@ -0,0 +1,69 @@
/*
* SensorChannelBridge.ino
*
* Talks to a MeshCore Companion Radio (e.g. a Seeed XIAO + Wio-SX1262 running
* the companion_radio_usb firmware, with its serial interface routed to a
* hardware UART) over a Grove connector. Demonstrates the minimum a display
* board needs: handshake, set the sensors channel PSK, receive the channel
* message stream, and send on the channel.
*
* Wire the Grove connector: VCC, GND, companion-TX -> host-RX, host-TX -> companion-RX.
* Adjust UART_RX_PIN / UART_TX_PIN to the GPIOs your Grove port exposes.
*
* SPDX-License-Identifier: MIT
*/
#include <Arduino.h>
#include "MeshCoreCompanion.h"
// --- Grove UART pins (CHANGE to match your board's free Grove port) ---
static const int UART_RX_PIN = 16; // host RX <- companion TX
static const int UART_TX_PIN = 17; // host TX -> companion RX
static const uint32_t UART_BAUD = 115200;
// Sensors channel: index + name + 16-byte PSK (here as 32 hex chars).
static const uint8_t SENSORS_CHANNEL_IDX = 2;
static const char* SENSORS_CHANNEL_NAME = "sensors";
static const char* SENSORS_CHANNEL_PSK_HEX = "000102030405060708090a0b0c0d0e0f"; // replace
MeshCoreCompanion mc(Serial1);
void setup() {
Serial.begin(115200);
Serial1.begin(UART_BAUD, SERIAL_8N1, UART_RX_PIN, UART_TX_PIN);
// Print the radio identity once it answers the handshake.
mc.onDeviceInfo([](const mc_device_info_t& d) {
Serial.printf("[radio] %s fw=%d channels=%u build=%s\n",
d.model, d.fw_ver, d.max_channels, d.build_date);
// Now that we know it's alive, program the sensors channel PSK.
mc.setChannelHexSecret(SENSORS_CHANNEL_IDX, SENSORS_CHANNEL_NAME, SENSORS_CHANNEL_PSK_HEX);
mc.getDeviceTime(); // so outgoing messages get a sensible timestamp
});
// Incoming channel TEXT messages. Body is "SenderName: text".
mc.onChannelMessage([](const mc_channel_msg_t& m) {
Serial.printf("[ch %d] %s\n", m.channel_idx, m.text);
// -> push m.text to your display here
});
// Incoming channel DATA (binary) messages, with SNR / path metadata.
mc.onChannelData([](const mc_channel_data_t& d) {
Serial.printf("[ch %d] %u bytes, type=0x%04X, snr=%.1f dB, %s\n",
d.channel_idx, d.data_len, d.data_type,
MC_SNR_DB(d.snr_q4),
d.path_len == MC_PATH_DIRECT ? "direct" : "flood");
});
mc.begin(); // resets RX, sends AppStart + DeviceQuery
}
void loop() {
mc.loop(); // pump serial; auto-drains the queue on MsgWaiting
// Example: send a heartbeat on the sensors channel every 60 s.
static uint32_t last = 0;
if (millis() - last > 60000) {
last = millis();
mc.sendChannelText(SENSORS_CHANNEL_IDX, "display online");
}
}
+19
View File
@@ -0,0 +1,19 @@
{
"name": "MeshCoreCompanion",
"version": "0.1.0",
"description": "Client library for the MeshCore Companion Radio serial protocol. Portable C99 core (no I/O, no malloc, host-testable) with a thin Arduino C++ wrapper that connects a host MCU to a separate MeshCore companion radio over UART/USB serial. Receive and send on channels, set channel PSKs, read SNR/path metadata.",
"keywords": ["meshcore", "lora", "companion", "serial", "mesh", "sx1262", "esp32", "nrf52"],
"authors": [
{ "name": "Scott Penrose", "maintainer": true }
],
"license": "MIT",
"frameworks": ["arduino"],
"platforms": ["espressif32", "nordicnrf52", "raspberrypi", "ststm32"],
"headers": ["MeshCoreCompanion.h", "meshcore_companion.h"],
"build": {
"srcFilter": ["+<*.c>", "+<*.cpp>"]
},
"examples": [
{ "name": "SensorChannelBridge", "base": "examples/SensorChannelBridge", "files": ["SensorChannelBridge.ino"] }
]
}
+9
View File
@@ -0,0 +1,9 @@
name=MeshCoreCompanion
version=0.1.0
author=Scott Penrose
maintainer=Scott Penrose
sentence=Client for the MeshCore Companion Radio serial protocol.
paragraph=Connect a host MCU (e.g. an ESP32 display board) to a separate MeshCore companion radio over UART/USB serial. Portable C99 protocol core plus a thin Arduino wrapper. Send/receive on channels, set channel PSKs, read SNR/path metadata. Auto-drains the radio message queue.
category=Communication
url=https://github.com/digitaldimensions/MeshCoreCompanion
architectures=esp32,nrf52,stm32,rp2040
+155
View File
@@ -0,0 +1,155 @@
/*
* MeshCoreCompanion.cpp
* SPDX-License-Identifier: MIT
*/
#include "MeshCoreCompanion.h"
void MeshCoreCompanion::begin(bool sendHandshake) {
mc_rx_init(&_rx);
_draining = false;
if (sendHandshake) {
appStart(); /* triggers SelfInfo */
deviceQuery(); /* triggers DeviceInfo */
}
}
void MeshCoreCompanion::sendPayload(const uint8_t *payload, size_t len) {
if (len == 0) return;
size_t flen = mc_frame_encode(payload, len, _frame, sizeof(_frame));
if (flen) _io.write(_frame, flen);
}
void MeshCoreCompanion::loop() {
uint8_t tmp[64];
int avail;
while ((avail = _io.available()) > 0) {
size_t want = (avail < (int)sizeof(tmp)) ? (size_t)avail : sizeof(tmp);
size_t n = _io.readBytes(tmp, want);
if (n == 0) break;
mc_rx_feed(&_rx, tmp, n);
}
size_t olen;
while (mc_rx_poll(&_rx, _scratch, sizeof(_scratch), &olen)) {
mc_event_t ev;
if (mc_parse(_scratch, olen, &ev)) dispatch(ev);
}
}
uint32_t MeshCoreCompanion::deviceEpochNow() const {
if (!_haveTime) return 0;
return _epochBase + (uint32_t)((millis() - _millisBase) / 1000UL);
}
/* ---- commands ---- */
void MeshCoreCompanion::appStart(const char *name) {
uint8_t p[1 + 1 + 6 + 32];
size_t n = mc_cmd_app_start(p, sizeof(p), name);
sendPayload(p, n);
}
void MeshCoreCompanion::deviceQuery(uint8_t appTargetVer) {
uint8_t p[2]; sendPayload(p, mc_cmd_device_query(p, sizeof(p), appTargetVer));
}
void MeshCoreCompanion::getDeviceTime() {
uint8_t p[1]; sendPayload(p, mc_cmd_get_device_time(p, sizeof(p)));
}
void MeshCoreCompanion::setDeviceTime(uint32_t epochSecs) {
uint8_t p[5]; sendPayload(p, mc_cmd_set_device_time(p, sizeof(p), epochSecs));
/* optimistically track it locally too */
_epochBase = epochSecs; _millisBase = millis(); _haveTime = true;
}
void MeshCoreCompanion::sendSelfAdvert(bool flood) {
uint8_t p[2];
sendPayload(p, mc_cmd_send_self_advert(p, sizeof(p), flood ? MC_ADVERT_FLOOD : MC_ADVERT_ZERO_HOP));
}
void MeshCoreCompanion::getChannel(uint8_t idx) {
uint8_t p[2]; sendPayload(p, mc_cmd_get_channel(p, sizeof(p), idx));
}
void MeshCoreCompanion::setChannel(uint8_t idx, const char *name, const uint8_t secret[MC_SECRET_LEN]) {
uint8_t p[2 + MC_NAME_LEN + MC_SECRET_LEN];
sendPayload(p, mc_cmd_set_channel(p, sizeof(p), idx, name, secret));
}
bool MeshCoreCompanion::setChannelHexSecret(uint8_t idx, const char *name, const char *hex32) {
if (!hex32) return false;
uint8_t secret[MC_SECRET_LEN];
for (int i = 0; i < MC_SECRET_LEN; i++) {
char hi = hex32[i * 2], lo = hex32[i * 2 + 1];
if (!hi || !lo) return false;
auto nib = [](char c) -> int {
if (c >= '0' && c <= '9') return c - '0';
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
return -1;
};
int h = nib(hi), l = nib(lo);
if (h < 0 || l < 0) return false;
secret[i] = (uint8_t)((h << 4) | l);
}
setChannel(idx, name, secret);
return true;
}
void MeshCoreCompanion::sendChannelText(uint8_t idx, const char *text, uint32_t senderTs) {
if (senderTs == 0) senderTs = deviceEpochNow(); /* 0 if unknown */
uint8_t p[1 + 1 + 1 + 4 + MC_MAX_TEXT];
size_t n = mc_cmd_send_channel_text(p, sizeof(p), MC_TXT_PLAIN, idx, senderTs, text);
sendPayload(p, n);
}
void MeshCoreCompanion::syncNextMessage() {
uint8_t p[1]; sendPayload(p, mc_cmd_sync_next_message(p, sizeof(p)));
}
void MeshCoreCompanion::drainMessages() {
if (!_draining) { _draining = true; syncNextMessage(); }
}
void MeshCoreCompanion::getStats(uint8_t statsType) {
uint8_t p[2]; sendPayload(p, mc_cmd_get_stats(p, sizeof(p), statsType));
}
/* ---- dispatch ---- */
void MeshCoreCompanion::dispatch(const mc_event_t &ev) {
if (_onEvent) _onEvent(ev);
switch (ev.code) {
case MC_RESP_CURR_TIME:
_epochBase = ev.u.curr_time; _millisBase = millis(); _haveTime = true;
break;
case MC_RESP_DEVICE_INFO:
if (_onDevInfo) _onDevInfo(ev.u.device_info);
break;
case MC_RESP_SELF_INFO:
if (_onSelfInfo) _onSelfInfo(ev.u.self_info);
break;
case MC_RESP_CHANNEL_INFO:
if (_onChanInfo) _onChanInfo(ev.u.channel_info);
break;
case MC_PUSH_MSG_WAITING:
if (_autoSync && !_draining) { _draining = true; syncNextMessage(); }
break;
case MC_RESP_CHANNEL_MSG_RECV:
if (_onText) _onText(ev.u.channel_msg);
if (_draining) syncNextMessage();
break;
case MC_RESP_CHANNEL_DATA_RECV:
if (_onData) _onData(ev.u.channel_data);
if (_draining) syncNextMessage();
break;
case MC_RESP_CONTACT_MSG_RECV:
if (_onContact) _onContact(ev.u.contact_msg);
if (_draining) syncNextMessage();
break;
case MC_RESP_NO_MORE_MESSAGES:
_draining = false;
break;
default:
break;
}
}
+99
View File
@@ -0,0 +1,99 @@
/*
* MeshCoreCompanion.h
*
* Arduino/C++ convenience wrapper over the portable meshcore_companion C core.
* Inject any Stream (Serial1 on a Grove UART, USB CDC, SoftwareSerial, ...),
* call loop() often, and register lambda callbacks.
*
* The wrapper auto-drains the radio's message queue: when the radio sends the
* MsgWaiting push it transparently issues SyncNextMessage repeatedly until the
* queue is empty, delivering each message through onChannelMessage / onChannelData.
*
* SPDX-License-Identifier: MIT
* Author: Scott Penrose / Digital Dimensions.
*/
#ifndef MESHCORE_COMPANION_HPP
#define MESHCORE_COMPANION_HPP
#include <Arduino.h>
#include <functional>
#include "meshcore_companion.h"
class MeshCoreCompanion {
public:
using TextMsgCb = std::function<void(const mc_channel_msg_t&)>;
using DataMsgCb = std::function<void(const mc_channel_data_t&)>;
using ContactMsgCb = std::function<void(const mc_contact_msg_t&)>;
using DeviceInfoCb = std::function<void(const mc_device_info_t&)>;
using ChannelInfoCb = std::function<void(const mc_channel_info_t&)>;
using SelfInfoCb = std::function<void(const mc_self_info_t&)>;
using EventCb = std::function<void(const mc_event_t&)>;
explicit MeshCoreCompanion(Stream &io) : _io(io) {}
/* Reset the receiver and (by default) send AppStart + DeviceQuery so the
* radio reports SelfInfo and DeviceInfo. */
void begin(bool sendHandshake = true);
/* Pump: read available serial bytes, decode and dispatch frames.
* Call this every loop iteration. Non-blocking. */
void loop();
/* ---- commands (fire-and-forget; replies arrive via callbacks) ---- */
void appStart(const char *name = "esp32");
void deviceQuery(uint8_t appTargetVer = 1);
void getDeviceTime();
void setDeviceTime(uint32_t epochSecs);
void sendSelfAdvert(bool flood = true);
void getChannel(uint8_t idx);
void setChannel(uint8_t idx, const char *name, const uint8_t secret[MC_SECRET_LEN]);
/* Set a channel using a 32-hex-char PSK string (-> 16 bytes). false if malformed. */
bool setChannelHexSecret(uint8_t idx, const char *name, const char *hex32);
/* senderTs == 0 uses the tracked device time if known, else 0. */
void sendChannelText(uint8_t idx, const char *text, uint32_t senderTs = 0);
void syncNextMessage();
void drainMessages(); /* start a manual sync-drain loop */
void getStats(uint8_t statsType);
/* ---- behaviour ---- */
void setAutoSync(bool on) { _autoSync = on; }
/* ---- device time (valid after a CurrTime response) ---- */
bool haveDeviceTime() const { return _haveTime; }
uint32_t deviceEpochNow() const;
/* ---- callbacks ---- */
void onChannelMessage(TextMsgCb cb) { _onText = cb; }
void onChannelData(DataMsgCb cb) { _onData = cb; }
void onContactMessage(ContactMsgCb cb){ _onContact = cb; }
void onDeviceInfo(DeviceInfoCb cb) { _onDevInfo = cb; }
void onChannelInfo(ChannelInfoCb cb) { _onChanInfo = cb; }
void onSelfInfo(SelfInfoCb cb) { _onSelfInfo = cb; }
void onEvent(EventCb cb) { _onEvent = cb; } /* every parsed frame */
private:
void sendPayload(const uint8_t *payload, size_t len);
void dispatch(const mc_event_t &ev);
Stream &_io;
mc_rx_t _rx;
uint8_t _scratch[MC_RX_BUFSZ];
uint8_t _frame[MC_RX_BUFSZ + 3];
bool _autoSync = true;
bool _draining = false;
bool _haveTime = false;
uint32_t _epochBase = 0;
uint32_t _millisBase = 0;
TextMsgCb _onText;
DataMsgCb _onData;
ContactMsgCb _onContact;
DeviceInfoCb _onDevInfo;
ChannelInfoCb _onChanInfo;
SelfInfoCb _onSelfInfo;
EventCb _onEvent;
};
#endif /* MESHCORE_COMPANION_HPP */
+313
View File
@@ -0,0 +1,313 @@
/*
* meshcore_companion.c -- portable C99 core, no I/O, no malloc.
* SPDX-License-Identifier: MIT
*/
#include "meshcore_companion.h"
#include <string.h>
/* ---- little-endian helpers ---- */
static void put_u16(uint8_t *p, uint16_t v) { p[0] = (uint8_t)v; p[1] = (uint8_t)(v >> 8); }
static void put_u32(uint8_t *p, uint32_t v) {
p[0] = (uint8_t)v; p[1] = (uint8_t)(v >> 8);
p[2] = (uint8_t)(v >> 16); p[3] = (uint8_t)(v >> 24);
}
static uint16_t get_u16(const uint8_t *p) { return (uint16_t)(p[0] | (p[1] << 8)); }
static uint32_t get_u32(const uint8_t *p) {
return (uint32_t)p[0] | ((uint32_t)p[1] << 8) | ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24);
}
/* Copy a fixed-width NUL-padded cstring field of `field_len` into dst (cap incl
* terminator). Advances nothing; returns field_len consumed by caller. */
static void copy_cstring(char *dst, size_t dst_cap, const uint8_t *src, size_t field_len) {
size_t i = 0;
for (; i < field_len && i + 1 < dst_cap; i++) {
if (src[i] == 0) break;
dst[i] = (char)src[i];
}
dst[i] = 0;
}
/* Copy remaining bytes [off..len) as a NUL-terminated string. */
static void copy_rest_string(char *dst, size_t dst_cap, const uint8_t *src, size_t off, size_t len) {
size_t n = (off < len) ? (len - off) : 0, i = 0;
for (; i < n && i + 1 < dst_cap; i++) dst[i] = (char)src[off + i];
dst[i] = 0;
}
/* ======================================================================== */
void mc_rx_init(mc_rx_t *rx) { rx->len = 0; }
size_t mc_rx_feed(mc_rx_t *rx, const uint8_t *data, size_t n) {
size_t space = sizeof(rx->buf) - rx->len, take = (n < space) ? n : space;
memcpy(rx->buf + rx->len, data, take);
rx->len += take;
return take;
}
static void rx_drop_front(mc_rx_t *rx, size_t k) {
if (k >= rx->len) { rx->len = 0; return; }
memmove(rx->buf, rx->buf + k, rx->len - k);
rx->len -= k;
}
int mc_rx_poll(mc_rx_t *rx, uint8_t *out, size_t out_cap, size_t *out_len) {
while (rx->len >= 3) {
uint8_t type = rx->buf[0];
if (type != MC_FRAME_RADIO_TO_APP && type != MC_FRAME_APP_TO_RADIO) {
rx_drop_front(rx, 1); /* not a frame lead, resync */
continue;
}
uint16_t flen = get_u16(rx->buf + 1);
if (flen == 0) { rx_drop_front(rx, 1); continue; }
if (flen > MC_MAX_PAYLOAD) { /* cannot hold it; skip lead byte */
rx_drop_front(rx, 1);
continue;
}
size_t need = (size_t)3 + flen;
if (rx->len < need) return 0; /* wait for the rest */
size_t copy = (flen < out_cap) ? flen : out_cap;
memcpy(out, rx->buf + 3, copy);
*out_len = copy;
rx_drop_front(rx, need);
return 1;
}
return 0;
}
/* ======================================================================== */
size_t mc_frame_encode(const uint8_t *payload, size_t payload_len,
uint8_t *out, size_t out_cap) {
if (payload_len > 0xFFFF || out_cap < payload_len + 3) return 0;
out[0] = MC_FRAME_APP_TO_RADIO;
put_u16(out + 1, (uint16_t)payload_len);
memcpy(out + 3, payload, payload_len);
return payload_len + 3;
}
/* ---- command builders ---- */
size_t mc_cmd_app_start(uint8_t *out, size_t cap, const char *app_name) {
size_t nlen = app_name ? strlen(app_name) : 0;
size_t total = 1 + 1 + 6 + nlen;
if (cap < total) return 0;
size_t i = 0;
out[i++] = MC_CMD_APP_START;
out[i++] = 1; /* app version */
memset(out + i, 0, 6); i += 6; /* reserved */
memcpy(out + i, app_name, nlen); i += nlen;
return i;
}
size_t mc_cmd_device_query(uint8_t *out, size_t cap, uint8_t app_target_ver) {
if (cap < 2) return 0;
out[0] = MC_CMD_DEVICE_QUERY; out[1] = app_target_ver; return 2;
}
size_t mc_cmd_get_device_time(uint8_t *out, size_t cap) {
if (cap < 1) return 0;
out[0] = MC_CMD_GET_DEVICE_TIME;
return 1;
}
size_t mc_cmd_set_device_time(uint8_t *out, size_t cap, uint32_t epoch_secs) {
if (cap < 5) return 0;
out[0] = MC_CMD_SET_DEVICE_TIME; put_u32(out + 1, epoch_secs); return 5;
}
size_t mc_cmd_sync_next_message(uint8_t *out, size_t cap) {
if (cap < 1) return 0;
out[0] = MC_CMD_SYNC_NEXT_MESSAGE;
return 1;
}
size_t mc_cmd_send_self_advert(uint8_t *out, size_t cap, uint8_t advert_type) {
if (cap < 2) return 0;
out[0] = MC_CMD_SEND_SELF_ADVERT; out[1] = advert_type; return 2;
}
size_t mc_cmd_get_channel(uint8_t *out, size_t cap, uint8_t channel_idx) {
if (cap < 2) return 0;
out[0] = MC_CMD_GET_CHANNEL; out[1] = channel_idx; return 2;
}
size_t mc_cmd_set_channel(uint8_t *out, size_t cap, uint8_t channel_idx,
const char *name, const uint8_t secret[MC_SECRET_LEN]) {
size_t total = 1 + 1 + MC_NAME_LEN + MC_SECRET_LEN;
if (cap < total) return 0;
size_t i = 0;
out[i++] = MC_CMD_SET_CHANNEL;
out[i++] = channel_idx;
/* 32-byte NUL-padded name, last byte forced NUL (matches meshcore.js) */
memset(out + i, 0, MC_NAME_LEN);
if (name) {
size_t nlen = strlen(name);
if (nlen > MC_NAME_LEN - 1) nlen = MC_NAME_LEN - 1;
memcpy(out + i, name, nlen);
}
i += MC_NAME_LEN;
memcpy(out + i, secret, MC_SECRET_LEN); i += MC_SECRET_LEN;
return i;
}
size_t mc_cmd_send_channel_text(uint8_t *out, size_t cap, uint8_t txt_type,
uint8_t channel_idx, uint32_t sender_ts,
const char *text) {
size_t tlen = text ? strlen(text) : 0;
size_t total = 1 + 1 + 1 + 4 + tlen;
if (cap < total) return 0;
size_t i = 0;
out[i++] = MC_CMD_SEND_CHANNEL_TXT_MSG;
out[i++] = txt_type;
out[i++] = channel_idx;
put_u32(out + i, sender_ts); i += 4;
memcpy(out + i, text, tlen); i += tlen;
return i;
}
size_t mc_cmd_set_radio_params(uint8_t *out, size_t cap, uint32_t freq_hz_x1000,
uint32_t bw, uint8_t sf, uint8_t cr) {
if (cap < 11) return 0;
size_t i = 0;
out[i++] = MC_CMD_SET_RADIO_PARAMS;
put_u32(out + i, freq_hz_x1000); i += 4;
put_u32(out + i, bw); i += 4;
out[i++] = sf; out[i++] = cr;
return i;
}
size_t mc_cmd_get_stats(uint8_t *out, size_t cap, uint8_t stats_type) {
if (cap < 2) return 0;
out[0] = MC_CMD_GET_STATS; out[1] = stats_type; return 2;
}
/* ======================================================================== */
int mc_parse(const uint8_t *p, size_t len, mc_event_t *ev) {
if (len < 1) return 0;
memset(ev, 0, sizeof(*ev));
ev->code = p[0];
const uint8_t *b = p + 1; /* body after code byte */
size_t n = len - 1;
switch (ev->code) {
case MC_RESP_OK:
case MC_RESP_DISABLED:
case MC_RESP_NO_MORE_MESSAGES:
case MC_PUSH_MSG_WAITING:
return 1;
case MC_RESP_ERR:
ev->u.err_code = (n >= 1) ? (int8_t)b[0] : (int8_t)-1;
return 1;
case MC_RESP_CURR_TIME:
if (n < 4) return 0;
ev->u.curr_time = get_u32(b);
return 1;
case MC_RESP_BATTERY_VOLTAGE:
if (n < 2) return 0;
ev->u.battery_mv = get_u16(b);
return 1;
case MC_RESP_DEVICE_INFO: {
/* [fw_ver:i8][max_contacts/2:u8][max_channels:u8][ble_pin:u32]
[build_date:cstr12][model:rest] */
if (n < 1) return 0;
mc_device_info_t *d = &ev->u.device_info;
d->fw_ver = (int8_t)b[0];
size_t off = 1;
if (n >= off + 6) {
d->max_contacts = (uint16_t)(b[off] * 2); off += 1;
d->max_channels = b[off]; off += 1;
d->ble_pin = get_u32(b + off); off += 4;
}
if (n >= off + 12) { copy_cstring(d->build_date, sizeof(d->build_date), b + off, 12); off += 12; }
copy_rest_string(d->model, sizeof(d->model), b, off, n);
return 1;
}
case MC_RESP_CHANNEL_MSG_RECV: {
if (n < 7) return 0;
mc_channel_msg_t *m = &ev->u.channel_msg;
m->channel_idx = (int8_t)b[0];
m->path_len = b[1];
m->txt_type = b[2];
m->sender_ts = get_u32(b + 3);
copy_rest_string(m->text, sizeof(m->text), b, 7, n);
return 1;
}
case MC_RESP_CONTACT_MSG_RECV: {
if (n < 12) return 0;
mc_contact_msg_t *m = &ev->u.contact_msg;
memcpy(m->pubkey_prefix, b, 6);
m->path_len = b[6];
m->txt_type = b[7];
m->sender_ts = get_u32(b + 8);
copy_rest_string(m->text, sizeof(m->text), b, 12, n);
return 1;
}
case MC_RESP_CHANNEL_DATA_RECV: {
if (n < 8) return 0;
mc_channel_data_t *d = &ev->u.channel_data;
d->snr_q4 = (int8_t)b[0];
/* b[1], b[2] reserved */
d->channel_idx = (int8_t)b[3];
d->path_len = b[4];
d->data_type = get_u16(b + 5);
d->data_len = b[7];
size_t avail = n - 8;
size_t dl = d->data_len;
if (dl > avail) dl = avail;
if (dl > sizeof(d->data)) dl = sizeof(d->data);
memcpy(d->data, b + 8, dl);
d->data_len = (uint8_t)dl;
return 1;
}
case MC_RESP_CHANNEL_INFO: {
if (n < 1 + MC_NAME_LEN) return 0;
mc_channel_info_t *c = &ev->u.channel_info;
c->channel_idx = b[0];
copy_cstring(c->name, sizeof(c->name), b + 1, MC_NAME_LEN);
size_t off = 1 + MC_NAME_LEN;
if (n - off >= MC_SECRET_LEN) {
memcpy(c->secret, b + off, MC_SECRET_LEN);
c->have_secret = 1;
}
return 1;
}
case MC_RESP_SELF_INFO: {
if (n < 55) return 0;
mc_self_info_t *s = &ev->u.self_info;
s->type = b[0]; s->tx_power = b[1]; s->max_tx_power = b[2];
memcpy(s->public_key, b + 3, 32);
s->adv_lat = (int32_t)get_u32(b + 35);
s->adv_lon = (int32_t)get_u32(b + 39);
/* b[43..45] reserved */
s->manual_add_contacts = b[46];
s->radio_freq = get_u32(b + 47);
s->radio_bw = get_u32(b + 51);
s->radio_sf = (n > 55) ? b[55] : 0;
s->radio_cr = (n > 56) ? b[56] : 0;
copy_rest_string(s->name, sizeof(s->name), b, 57, n);
return 1;
}
case MC_PUSH_ADVERT:
case MC_PUSH_PATH_UPDATED:
if (n < 32) return 0;
memcpy(ev->u.pubkey32, b, 32);
return 1;
case MC_PUSH_SEND_CONFIRMED:
if (n < 8) return 0;
ev->u.send_confirmed.ack_code = get_u32(b);
ev->u.send_confirmed.round_trip = get_u32(b + 4);
return 1;
default:
return 0; /* recognised code byte set in ev->code, body not parsed */
}
}
+247
View File
@@ -0,0 +1,247 @@
/*
* meshcore_companion.h
*
* Portable C99 client for the MeshCore Companion Radio serial protocol.
*
* This core has NO I/O, NO dynamic allocation and NO Arduino dependency.
* You feed it bytes received from the radio and it hands you decoded frames;
* you ask it to build command frames and it writes them into a buffer you own.
* The transport (UART, USB-CDC, TCP, a unit-test harness) is entirely yours.
*
* Wire format (verified against meshcore.js, MIT, (c) Liam Cottle):
* frame = [type:u8][len:u16 LE][payload:len bytes]
* type 0x3C ('<') app -> radio (commands we send)
* type 0x3E ('>') radio -> app (responses / push notifications we receive)
* payload[0] is the command code (outbound) or response/push code (inbound).
*
* SPDX-License-Identifier: MIT
* Author: Scott Penrose / Digital Dimensions.
* Protocol reference: https://github.com/meshcore-dev/meshcore.js
*/
#ifndef MESHCORE_COMPANION_H
#define MESHCORE_COMPANION_H
#include <stdint.h>
#include <stddef.h>
#ifdef __cplusplus
extern "C" {
#endif
/* ---- Compile-time sizing (override before including if you need more) ---- */
#ifndef MC_MAX_PAYLOAD
#define MC_MAX_PAYLOAD 255 /* largest companion payload we will buffer */
#endif
#ifndef MC_MAX_TEXT
#define MC_MAX_TEXT 184 /* max message text we keep (incl. NUL) */
#endif
#ifndef MC_MAX_DATA
#define MC_MAX_DATA 184 /* max channel-data payload we keep */
#endif
#ifndef MC_MAX_MODEL
#define MC_MAX_MODEL 48 /* DeviceInfo model/manufacturer string */
#endif
#define MC_NAME_LEN 32 /* channel / advert name field width (cstring) */
#define MC_SECRET_LEN 16 /* 128-bit channel secret (PSK) */
#define MC_RX_BUFSZ (MC_MAX_PAYLOAD + 8)
/* ---- Frame lead bytes ---- */
#define MC_FRAME_APP_TO_RADIO 0x3C
#define MC_FRAME_RADIO_TO_APP 0x3E
/* ---- Command codes (app -> radio) ---- */
enum {
MC_CMD_APP_START = 1,
MC_CMD_SEND_TXT_MSG = 2,
MC_CMD_SEND_CHANNEL_TXT_MSG = 3,
MC_CMD_GET_CONTACTS = 4,
MC_CMD_GET_DEVICE_TIME = 5,
MC_CMD_SET_DEVICE_TIME = 6,
MC_CMD_SEND_SELF_ADVERT = 7,
MC_CMD_SET_ADVERT_NAME = 8,
MC_CMD_SYNC_NEXT_MESSAGE = 10,
MC_CMD_SET_RADIO_PARAMS = 11,
MC_CMD_SET_TX_POWER = 12,
MC_CMD_DEVICE_QUERY = 22,
MC_CMD_GET_CHANNEL = 31,
MC_CMD_SET_CHANNEL = 32,
MC_CMD_GET_STATS = 56
};
/* ---- Response codes (radio -> app) ---- */
enum {
MC_RESP_OK = 0,
MC_RESP_ERR = 1,
MC_RESP_CONTACTS_START = 2,
MC_RESP_CONTACT = 3,
MC_RESP_END_OF_CONTACTS = 4,
MC_RESP_SELF_INFO = 5,
MC_RESP_SENT = 6,
MC_RESP_CONTACT_MSG_RECV = 7,
MC_RESP_CHANNEL_MSG_RECV = 8,
MC_RESP_CURR_TIME = 9,
MC_RESP_NO_MORE_MESSAGES = 10,
MC_RESP_EXPORT_CONTACT = 11,
MC_RESP_BATTERY_VOLTAGE = 12,
MC_RESP_DEVICE_INFO = 13,
MC_RESP_PRIVATE_KEY = 14,
MC_RESP_DISABLED = 15,
MC_RESP_CHANNEL_INFO = 18,
MC_RESP_STATS = 24,
MC_RESP_CHANNEL_DATA_RECV= 27
};
/* ---- Push codes (unsolicited, radio -> app) ---- */
enum {
MC_PUSH_ADVERT = 0x80,
MC_PUSH_PATH_UPDATED = 0x81,
MC_PUSH_SEND_CONFIRMED= 0x82,
MC_PUSH_MSG_WAITING = 0x83, /* "drain me": loop SYNC_NEXT_MESSAGE */
MC_PUSH_RAW_DATA = 0x84,
MC_PUSH_LOGIN_SUCCESS = 0x85,
MC_PUSH_LOGIN_FAIL = 0x86,
MC_PUSH_STATUS_RESP = 0x87,
MC_PUSH_LOG_RX_DATA = 0x88,
MC_PUSH_TRACE_DATA = 0x89,
MC_PUSH_NEW_ADVERT = 0x8A,
MC_PUSH_TELEMETRY = 0x8B,
MC_PUSH_BINARY_RESP = 0x8C
};
/* ---- Text message subtypes ---- */
enum { MC_TXT_PLAIN = 0, MC_TXT_CLI_DATA = 1, MC_TXT_SIGNED_PLAIN = 2 };
/* ---- Self-advert flood mode ---- */
enum { MC_ADVERT_ZERO_HOP = 0, MC_ADVERT_FLOOD = 1 };
#define MC_PATH_DIRECT 0xFF /* path_len value meaning "received direct" */
/* SNR is transmitted as a signed int8 scaled x4. Recover dB with this. */
#define MC_SNR_DB(q4) ((float)(q4) / 4.0f)
/* ======================================================================== *
* Receive side: streaming frame assembler
* ======================================================================== */
typedef struct {
uint8_t buf[MC_RX_BUFSZ];
size_t len;
} mc_rx_t;
void mc_rx_init(mc_rx_t *rx);
/* Append received bytes. Returns the number of bytes accepted (bytes beyond
* the buffer capacity are dropped; this only happens if a peer floods us with
* a frame larger than MC_MAX_PAYLOAD, which the poller will resync past). */
size_t mc_rx_feed(mc_rx_t *rx, const uint8_t *data, size_t n);
/* Pull the next complete payload (the bytes after the 3-byte header) into
* out[]. Returns 1 and sets *out_len if a frame is ready, 0 if more bytes are
* needed. Call repeatedly until it returns 0. Garbage / oversized frames are
* resynced automatically by skipping one byte at a time. */
int mc_rx_poll(mc_rx_t *rx, uint8_t *out, size_t out_cap, size_t *out_len);
/* ======================================================================== *
* Transmit side: wrap a payload into an on-wire app->radio frame
* ======================================================================== */
/* Writes [0x3C][len LE][payload] into out[]. Returns total bytes, or 0 on
* overflow / oversize. */
size_t mc_frame_encode(const uint8_t *payload, size_t payload_len,
uint8_t *out, size_t out_cap);
/* ======================================================================== *
* Command payload builders (write payload only; wrap with mc_frame_encode)
* Each returns the payload length, or 0 if it would overflow `cap`.
* ======================================================================== */
size_t mc_cmd_app_start (uint8_t *out, size_t cap, const char *app_name);
size_t mc_cmd_device_query (uint8_t *out, size_t cap, uint8_t app_target_ver);
size_t mc_cmd_get_device_time (uint8_t *out, size_t cap);
size_t mc_cmd_set_device_time (uint8_t *out, size_t cap, uint32_t epoch_secs);
size_t mc_cmd_sync_next_message(uint8_t *out, size_t cap);
size_t mc_cmd_send_self_advert (uint8_t *out, size_t cap, uint8_t advert_type);
size_t mc_cmd_get_channel (uint8_t *out, size_t cap, uint8_t channel_idx);
size_t mc_cmd_set_channel (uint8_t *out, size_t cap, uint8_t channel_idx,
const char *name, const uint8_t secret[MC_SECRET_LEN]);
size_t mc_cmd_send_channel_text(uint8_t *out, size_t cap, uint8_t txt_type,
uint8_t channel_idx, uint32_t sender_ts,
const char *text);
size_t mc_cmd_set_radio_params (uint8_t *out, size_t cap, uint32_t freq_hz_x1000,
uint32_t bw, uint8_t sf, uint8_t cr);
size_t mc_cmd_get_stats (uint8_t *out, size_t cap, uint8_t stats_type);
/* ======================================================================== *
* Parsed events
* ======================================================================== */
typedef struct {
int8_t fw_ver;
uint16_t max_contacts; /* already x2 from the wire field */
uint8_t max_channels;
uint32_t ble_pin;
char build_date[16];
char model[MC_MAX_MODEL];
} mc_device_info_t;
typedef struct {
int8_t channel_idx;
uint8_t path_len; /* MC_PATH_DIRECT or flood hop count */
uint8_t txt_type;
uint32_t sender_ts;
char text[MC_MAX_TEXT]; /* for channel msgs this is "Name: body" */
} mc_channel_msg_t;
typedef struct {
int8_t snr_q4; /* divide by 4 for dB; see MC_SNR_DB() */
int8_t channel_idx;
uint8_t path_len;
uint16_t data_type;
uint8_t data_len;
uint8_t data[MC_MAX_DATA];
} mc_channel_data_t;
typedef struct {
uint8_t pubkey_prefix[6];
uint8_t path_len;
uint8_t txt_type;
uint32_t sender_ts;
char text[MC_MAX_TEXT];
} mc_contact_msg_t;
typedef struct {
uint8_t channel_idx;
char name[MC_NAME_LEN + 1];
uint8_t secret[MC_SECRET_LEN];
int have_secret;
} mc_channel_info_t;
typedef struct {
uint8_t type, tx_power, max_tx_power;
uint8_t public_key[32];
int32_t adv_lat, adv_lon;
uint8_t manual_add_contacts;
uint32_t radio_freq, radio_bw;
uint8_t radio_sf, radio_cr;
char name[MC_MAX_TEXT];
} mc_self_info_t;
typedef struct {
uint8_t code; /* response or push code (first payload byte) */
union {
mc_device_info_t device_info;
mc_channel_msg_t channel_msg;
mc_channel_data_t channel_data;
mc_contact_msg_t contact_msg;
mc_channel_info_t channel_info;
mc_self_info_t self_info;
uint32_t curr_time; /* epoch secs */
uint16_t battery_mv;
int8_t err_code; /* MC_RESP_ERR (-1 if absent) */
uint8_t pubkey32[32]; /* advert / path-updated pushes */
struct { uint32_t ack_code, round_trip; } send_confirmed;
} u;
} mc_event_t;
/* Decode one received payload. Returns 1 if recognised (ev->code set and the
* matching union member filled), 0 if the code is unknown to this build. */
int mc_parse(const uint8_t *payload, size_t len, mc_event_t *ev);
#ifdef __cplusplus
} /* extern "C" */
#endif
#endif /* MESHCORE_COMPANION_H */
+126
View File
@@ -0,0 +1,126 @@
/*
* test_codec.c -- host-side unit test for the portable core.
* Build & run: cc -std=c99 -Wall -Wextra -I../src test_codec.c ../src/meshcore_companion.c -o t && ./t
* SPDX-License-Identifier: MIT
*/
#include "meshcore_companion.h"
#include <stdio.h>
#include <string.h>
#include <assert.h>
static int fails = 0;
#define CHECK(cond, msg) do { \
if (cond) { printf(" ok %s\n", msg); } \
else { printf(" FAIL %s\n", msg); fails++; } } while (0)
/* Build a radio->app frame (0x3E + len + payload) for feeding the assembler. */
static size_t make_inbound(uint8_t *out, const uint8_t *payload, size_t plen) {
out[0] = MC_FRAME_RADIO_TO_APP;
out[1] = (uint8_t)plen; out[2] = (uint8_t)(plen >> 8);
memcpy(out + 3, payload, plen);
return plen + 3;
}
int main(void) {
uint8_t scratch[512], frame[512], payload[300];
size_t plen, flen, olen;
printf("== command builders ==\n");
plen = mc_cmd_device_query(scratch, sizeof scratch, 1);
CHECK(plen == 2 && scratch[0] == MC_CMD_DEVICE_QUERY && scratch[1] == 1, "device_query payload");
flen = mc_frame_encode(scratch, plen, frame, sizeof frame);
CHECK(flen == 5 && frame[0] == MC_FRAME_APP_TO_RADIO && frame[1] == 2 && frame[2] == 0,
"frame_encode wraps with 0x3C + len16");
/* set_channel: 1 + 1 + 32 + 16 = 50 bytes */
uint8_t secret[16];
for (int i = 0; i < 16; i++) secret[i] = (uint8_t)(0xA0 + i);
plen = mc_cmd_set_channel(scratch, sizeof scratch, 2, "sensors", secret);
CHECK(plen == 50 && scratch[0] == MC_CMD_SET_CHANNEL && scratch[1] == 2, "set_channel length & header");
CHECK(memcmp(scratch + 2, "sensors", 7) == 0 && scratch[2 + 7] == 0, "set_channel name NUL-padded");
CHECK(memcmp(scratch + 2 + 32, secret, 16) == 0, "set_channel secret tail");
plen = mc_cmd_send_channel_text(scratch, sizeof scratch, MC_TXT_PLAIN, 2, 0x11223344, "tank=87%");
CHECK(scratch[0] == MC_CMD_SEND_CHANNEL_TXT_MSG && scratch[1] == MC_TXT_PLAIN && scratch[2] == 2,
"send_channel_text header");
CHECK(scratch[3] == 0x44 && scratch[6] == 0x11, "send_channel_text timestamp LE");
CHECK(memcmp(scratch + 7, "tank=87%", 8) == 0, "send_channel_text body");
printf("== rx assembler + parse: DeviceInfo ==\n");
mc_rx_t rx; mc_rx_init(&rx);
/* fw_ver=8, maxc/2=50, maxch=8, blepin=123456, build="7 Jun 2026", model="XIAO-S3" */
size_t k = 0;
payload[k++] = MC_RESP_DEVICE_INFO;
payload[k++] = 8;
payload[k++] = 50;
payload[k++] = 8;
payload[k++] = 0x40; payload[k++] = 0xE2; payload[k++] = 0x01; payload[k++] = 0x00; /* 123456 LE */
memcpy(payload + k, "7 Jun 2026\0", 12); k += 12; /* 12-byte cstring field */
memcpy(payload + k, "XIAO-S3", 7); k += 7;
flen = make_inbound(frame, payload, k);
/* feed in two awkward chunks to exercise reassembly */
mc_rx_feed(&rx, frame, 4);
mc_rx_feed(&rx, frame + 4, flen - 4);
mc_event_t ev;
int got = mc_rx_poll(&rx, scratch, sizeof scratch, &olen);
CHECK(got == 1, "poll produced a frame from split feed");
CHECK(mc_parse(scratch, olen, &ev) == 1 && ev.code == MC_RESP_DEVICE_INFO, "parse device_info");
CHECK(ev.u.device_info.fw_ver == 8 && ev.u.device_info.max_contacts == 100 &&
ev.u.device_info.max_channels == 8 && ev.u.device_info.ble_pin == 123456,
"device_info numeric fields");
CHECK(strcmp(ev.u.device_info.build_date, "7 Jun 2026") == 0, "device_info build date");
CHECK(strcmp(ev.u.device_info.model, "XIAO-S3") == 0, "device_info model");
printf("== parse: ChannelMsgRecv (text) ==\n");
k = 0;
payload[k++] = MC_RESP_CHANNEL_MSG_RECV;
payload[k++] = 2; /* channel idx */
payload[k++] = MC_PATH_DIRECT; /* path len */
payload[k++] = MC_TXT_PLAIN;
payload[k++]=0x44;payload[k++]=0x33;payload[k++]=0x22;payload[k++]=0x11; /* ts */
memcpy(payload + k, "node3: tank=87%", 15); k += 15;
flen = make_inbound(frame, payload, k);
mc_rx_feed(&rx, frame, flen);
got = mc_rx_poll(&rx, scratch, sizeof scratch, &olen);
CHECK(got == 1 && mc_parse(scratch, olen, &ev) == 1 && ev.code == MC_RESP_CHANNEL_MSG_RECV,
"parse channel_msg");
CHECK(ev.u.channel_msg.channel_idx == 2 && ev.u.channel_msg.path_len == MC_PATH_DIRECT &&
ev.u.channel_msg.sender_ts == 0x11223344, "channel_msg fields");
CHECK(strcmp(ev.u.channel_msg.text, "node3: tank=87%") == 0, "channel_msg text (Name: body)");
printf("== parse: ChannelDataRecv (metadata) ==\n");
k = 0;
payload[k++] = MC_RESP_CHANNEL_DATA_RECV;
payload[k++] = (uint8_t)40; /* snr q4 = 40 -> 10.0 dB */
payload[k++] = 0; payload[k++] = 0;
payload[k++] = 2; /* channel idx */
payload[k++] = 3; /* path len (flood, 3 hops) */
payload[k++] = 0xFF; payload[k++] = 0xFF; /* data type 0xFFFF (Dev) */
payload[k++] = 4; /* data len */
payload[k++]=0xDE;payload[k++]=0xAD;payload[k++]=0xBE;payload[k++]=0xEF;
flen = make_inbound(frame, payload, k);
mc_rx_feed(&rx, frame, flen);
got = mc_rx_poll(&rx, scratch, sizeof scratch, &olen);
CHECK(got == 1 && mc_parse(scratch, olen, &ev) == 1 && ev.code == MC_RESP_CHANNEL_DATA_RECV,
"parse channel_data");
CHECK(ev.u.channel_data.snr_q4 == 40 && MC_SNR_DB(ev.u.channel_data.snr_q4) == 10.0f,
"channel_data SNR x4 decode");
CHECK(ev.u.channel_data.path_len == 3 && ev.u.channel_data.data_type == 0xFFFF &&
ev.u.channel_data.data_len == 4 && ev.u.channel_data.data[0] == 0xDE &&
ev.u.channel_data.data[3] == 0xEF, "channel_data payload");
printf("== resync: garbage before a valid frame ==\n");
uint8_t junk[3] = { 0x00, 0x99, 0x01 };
mc_rx_feed(&rx, junk, 3);
uint8_t okp[1] = { MC_RESP_OK };
flen = make_inbound(frame, okp, 1);
mc_rx_feed(&rx, frame, flen);
got = mc_rx_poll(&rx, scratch, sizeof scratch, &olen);
CHECK(got == 1 && mc_parse(scratch, olen, &ev) == 1 && ev.code == MC_RESP_OK,
"resync past junk to find OK frame");
printf("\n%s (%d failure%s)\n", fails ? "TESTS FAILED" : "ALL TESTS PASSED",
fails, fails == 1 ? "" : "s");
return fails ? 1 : 0;
}