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:
+18
@@ -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/
|
||||||
@@ -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)
|
||||||
@@ -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).
|
||||||
@@ -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).
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"] }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 */
|
||||||
@@ -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 */
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 */
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user