commit cdfceba34d6486ec2f0673c72329a357bec1f6e3 Author: Scott Penrose Date: Mon Jun 8 02:06:32 2026 +1000 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) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..943861c --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..fc93db8 --- /dev/null +++ b/CMakeLists.txt @@ -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 +# 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) diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7a91d2c --- /dev/null +++ b/LICENSE @@ -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). diff --git a/README.md b/README.md new file mode 100644 index 0000000..8259c05 --- /dev/null +++ b/README.md @@ -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 ``) 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). diff --git a/examples-linux/tty_bridge/meshcore_tty.c b/examples-linux/tty_bridge/meshcore_tty.c new file mode 100644 index 0000000..2e8b1d1 --- /dev/null +++ b/examples-linux/tty_bridge/meshcore_tty.c @@ -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 +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#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 [channel_idx psk_hex [name]]\n" + " 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; +} diff --git a/examples/SensorChannelBridge/SensorChannelBridge.ino b/examples/SensorChannelBridge/SensorChannelBridge.ino new file mode 100644 index 0000000..d52c805 --- /dev/null +++ b/examples/SensorChannelBridge/SensorChannelBridge.ino @@ -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 +#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"); + } +} diff --git a/library.json b/library.json new file mode 100644 index 0000000..192489f --- /dev/null +++ b/library.json @@ -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"] } + ] +} diff --git a/library.properties b/library.properties new file mode 100644 index 0000000..f401948 --- /dev/null +++ b/library.properties @@ -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 diff --git a/src/MeshCoreCompanion.cpp b/src/MeshCoreCompanion.cpp new file mode 100644 index 0000000..69c0fe9 --- /dev/null +++ b/src/MeshCoreCompanion.cpp @@ -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; + } +} diff --git a/src/MeshCoreCompanion.h b/src/MeshCoreCompanion.h new file mode 100644 index 0000000..c085fea --- /dev/null +++ b/src/MeshCoreCompanion.h @@ -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 +#include +#include "meshcore_companion.h" + +class MeshCoreCompanion { +public: + using TextMsgCb = std::function; + using DataMsgCb = std::function; + using ContactMsgCb = std::function; + using DeviceInfoCb = std::function; + using ChannelInfoCb = std::function; + using SelfInfoCb = std::function; + using EventCb = std::function; + + 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 */ diff --git a/src/meshcore_companion.c b/src/meshcore_companion.c new file mode 100644 index 0000000..41fe518 --- /dev/null +++ b/src/meshcore_companion.c @@ -0,0 +1,313 @@ +/* + * meshcore_companion.c -- portable C99 core, no I/O, no malloc. + * SPDX-License-Identifier: MIT + */ +#include "meshcore_companion.h" +#include + +/* ---- 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 */ + } +} diff --git a/src/meshcore_companion.h b/src/meshcore_companion.h new file mode 100644 index 0000000..d860d0a --- /dev/null +++ b/src/meshcore_companion.h @@ -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 +#include + +#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 */ diff --git a/test/test_codec.c b/test/test_codec.c new file mode 100644 index 0000000..643f199 --- /dev/null +++ b/test/test_codec.c @@ -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 +#include +#include + +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; +}