Add ESP-IDF and STM32 C examples; document platform examples

Both compile the same portable core (src/meshcore_companion.c) and differ only
in the UART transport:
- examples-esp-idf/tty_bridge: full ESP-IDF project (UART driver), core
  compiled directly via the component CMakeLists (no copy)
- examples-stm32/uart_bridge: HAL drop-in (meshcore_setup/meshcore_poll) for a
  CubeMX/CubeIDE project, with integration README

README updated: new examples in layout + an 'Other platform examples' section.
Verified host build/test still pass and both new examples pass -Wall -Wextra
syntax checks against stubbed platform headers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Scott Penrose
2026-06-08 02:21:22 +10:00
parent cdfceba34d
commit b54e1c22e7
7 changed files with 376 additions and 5 deletions
+36 -5
View File
@@ -10,6 +10,11 @@ radio plays the *server* — the inverse of the phone/web app.
Sibling of [`meshcore.js`](https://github.com/meshcore-dev/meshcore.js) (JS) and Sibling of [`meshcore.js`](https://github.com/meshcore-dev/meshcore.js) (JS) and
`meshcore_py` (Python): this is the **C / C++** one. `meshcore_py` (Python): this is the **C / C++** one.
SEE ALSO:
* RUST - meshcore-rs (aka meschore_rs) - https://github.com/andrewdavidmackenzie/meshcore-rs.git
* Javascript - meshcore.js - https://github.com/meshcore-dev/meshcore.js
* Python - meshcore_py - https://github.com/meshcore-dev/meshcore_py
## Two ways to use this repo ## Two ways to use this repo
The protocol logic is a single portable **C99 core** with no I/O, no `malloc`, and The protocol logic is a single portable **C99 core** with no I/O, no `malloc`, and
@@ -19,10 +24,9 @@ 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. 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 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 into buffers you own, and parses payloads into plain structs. You supply the
transport. A complete Linux example (POSIX `termios`) lives in transport. The same core drops into Linux, ESP-IDF, bare STM32/nRF52, or any
`examples-linux/`, and the host unit test in `test/` runs with no hardware. host bridge unchanged — see the example for each platform under `examples-*/`,
The same core drops into ESP-IDF, bare nRF52/STM32, or any host bridge plus the host unit test in `test/` that runs with no hardware.
unchanged (those examples are planned).
2. **C++ Arduino library**`src/MeshCoreCompanion.{h,cpp}` wrap the core in an 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 Arduino-friendly class: inject any `Stream` (`Serial1` on a Grove UART, USB
@@ -45,11 +49,18 @@ meshcore_c/
├── examples/ ├── examples/
│ └── SensorChannelBridge/ # Arduino sketch (.ino) │ └── SensorChannelBridge/ # Arduino sketch (.ino)
├── examples-linux/ ├── examples-linux/
│ └── tty_bridge/ # portable C example: any Linux tty (USB or raw UART) │ └── tty_bridge/ # portable C: any Linux tty (USB or raw UART)
├── examples-esp-idf/
│ └── tty_bridge/ # portable C: ESP-IDF UART driver (esp32/s3/c3)
├── examples-stm32/
│ └── uart_bridge/ # portable C: STM32 HAL UART drop-in
└── test/ └── test/
└── test_codec.c # host unit test (no hardware) └── test_codec.c # host unit test (no hardware)
``` ```
The portable-C examples all compile the *same* `src/meshcore_companion.c`; only
the byte transport differs per platform.
The Arduino/PlatformIO build only compiles `src/` (the manifests' `srcFilter` is 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. 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 The CMake build only compiles the pure-C parts and ignores the Arduino wrapper
@@ -84,6 +95,26 @@ cc -std=c99 -Wall -Wextra -Isrc \
examples-linux/tty_bridge/meshcore_tty.c src/meshcore_companion.c -o meshcore_tty examples-linux/tty_bridge/meshcore_tty.c src/meshcore_companion.c -o meshcore_tty
``` ```
## Other platform examples (same C core)
Each example compiles `src/meshcore_companion.c` directly and supplies only a
platform-specific UART transport — no copy of the core is kept anywhere.
- **ESP-IDF** — `examples-esp-idf/tty_bridge/`. A full IDF project using the UART
driver:
```sh
cd examples-esp-idf/tty_bridge
idf.py set-target esp32s3 && idf.py build flash monitor
```
- **STM32** — `examples-stm32/uart_bridge/`. A HAL drop-in (STM32 builds are
board/toolchain specific): add `meshcore_stm32.c` + `src/` to a CubeMX/CubeIDE
project and call `meshcore_setup()` / `meshcore_poll()` from `main()`. See its
README.
The transport is just two operations — *write bytes* and *read available bytes* —
so porting to bare-metal STM32, nRF52 (nRF5 SDK or Zephyr), or any other MCU only
changes those calls; the protocol code is identical.
## Use the Arduino library ## Use the Arduino library
Install via PlatformIO (`lib_deps = symlink:///path/to/meshcore_c`), the Arduino Install via PlatformIO (`lib_deps = symlink:///path/to/meshcore_c`), the Arduino
@@ -0,0 +1,5 @@
# Project-level CMake for the ESP-IDF build system.
# Build with: idf.py set-target esp32s3 && idf.py build flash monitor
cmake_minimum_required(VERSION 3.16)
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
project(meshcore_tty_bridge)
+30
View File
@@ -0,0 +1,30 @@
# ESP-IDF example: tty_bridge
Drives a MeshCore Companion Radio over a hardware UART using the ESP-IDF UART
driver. The protocol logic is the repo's portable C core
(`src/meshcore_companion.c`) — this example only supplies the transport and an
`app_main()` loop.
## Wiring
| Companion radio | ESP32 (default pins) |
|-----------------|----------------------|
| TX → host RX | GPIO16 (`MC_UART_RX_PIN`) |
| RX ← host TX | GPIO17 (`MC_UART_TX_PIN`) |
| GND | GND |
Change the pins/UART at the top of `main/main.c`. The radio must run the serial
companion firmware (`companion_radio_usb`) bound to this UART.
## Build
Requires an installed [ESP-IDF](https://docs.espressif.com/projects/esp-idf/)
(v5.x). From this directory:
```sh
idf.py set-target esp32s3 # or esp32, esp32c3, ...
idf.py build flash monitor
```
`main/CMakeLists.txt` compiles `../../../src/meshcore_companion.c` directly, so
there is no copy of the core to keep in sync.
@@ -0,0 +1,8 @@
# Compile the portable C core straight from the repo's src/ -- no copy, no
# duplication. The path is relative to this component directory:
# examples-esp-idf/tty_bridge/main -> ../../../src
set(MC_CORE_DIR "${CMAKE_CURRENT_LIST_DIR}/../../../src")
idf_component_register(
SRCS "main.c" "${MC_CORE_DIR}/meshcore_companion.c"
INCLUDE_DIRS "." "${MC_CORE_DIR}")
+130
View File
@@ -0,0 +1,130 @@
/*
* main.c -- ESP-IDF example: drive a MeshCore Companion Radio over a UART.
*
* Same portable C99 core as every other example (src/meshcore_companion.{c,h}),
* here with the ESP-IDF UART driver as the byte transport. Wire the companion
* radio's serial lines to MC_UART_TX_PIN / MC_UART_RX_PIN and flash this onto an
* ESP32 / ESP32-S3 / ESP32-C3 etc.
*
* idf.py set-target esp32s3
* idf.py build flash monitor
*
* The companion radio must run the serial companion firmware (companion_radio_usb)
* with its interface bound to this UART.
*
* SPDX-License-Identifier: MIT
* Author: Scott Penrose / Digital Dimensions.
*/
#include <stdlib.h>
#include <string.h>
#include "driver/uart.h"
#include "esp_log.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "meshcore_companion.h"
/* --- UART wiring: change to free pins on your board --- */
#define MC_UART_NUM UART_NUM_1
#define MC_UART_TX_PIN 17 /* host TX -> companion RX */
#define MC_UART_RX_PIN 16 /* host RX <- companion TX */
#define MC_UART_BAUD 115200
#define MC_UART_BUFSZ 1024
static const char *TAG = "meshcore";
static void send_payload(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) uart_write_bytes(MC_UART_NUM, (const char *)frame, flen);
}
static void on_event(const mc_event_t *ev)
{
switch (ev->code) {
case MC_RESP_DEVICE_INFO:
ESP_LOGI(TAG, "radio model=%s fw=%d channels=%u build=%s",
ev->u.device_info.model, ev->u.device_info.fw_ver,
(unsigned)ev->u.device_info.max_channels,
ev->u.device_info.build_date);
break;
case MC_RESP_CHANNEL_MSG_RECV: /* body is "SenderName: message" */
ESP_LOGI(TAG, "[ch %d] %s", ev->u.channel_msg.channel_idx,
ev->u.channel_msg.text);
break;
case MC_RESP_CHANNEL_DATA_RECV: {
/* SNR is q4 (x4). Print exact dB without floating-point printf. */
int centi = ev->u.channel_data.snr_q4 * 25; /* /4 then *100 */
const char *sign = centi < 0 ? "-" : "";
ESP_LOGI(TAG, "[ch %d] %u bytes type=0x%04X snr=%s%d.%02d dB %s",
ev->u.channel_data.channel_idx,
(unsigned)ev->u.channel_data.data_len,
(unsigned)ev->u.channel_data.data_type,
sign, abs(centi) / 100, abs(centi) % 100,
ev->u.channel_data.path_len == MC_PATH_DIRECT ? "direct" : "flood");
break;
}
case MC_RESP_CURR_TIME:
ESP_LOGI(TAG, "device time = %u (epoch secs)", (unsigned)ev->u.curr_time);
break;
case MC_RESP_ERR:
ESP_LOGW(TAG, "radio error response (code=%d)", ev->u.err_code);
break;
default:
break;
}
}
void app_main(void)
{
const uart_config_t cfg = {
.baud_rate = MC_UART_BAUD,
.data_bits = UART_DATA_8_BITS,
.parity = UART_PARITY_DISABLE,
.stop_bits = UART_STOP_BITS_1,
.flow_ctrl = UART_HW_FLOWCTRL_DISABLE,
.source_clk = UART_SCLK_DEFAULT,
};
ESP_ERROR_CHECK(uart_driver_install(MC_UART_NUM, MC_UART_BUFSZ, 0, 0, NULL, 0));
ESP_ERROR_CHECK(uart_param_config(MC_UART_NUM, &cfg));
ESP_ERROR_CHECK(uart_set_pin(MC_UART_NUM, MC_UART_TX_PIN, MC_UART_RX_PIN,
UART_PIN_NO_CHANGE, UART_PIN_NO_CHANGE));
/* Handshake: AppStart -> SelfInfo, DeviceQuery -> DeviceInfo. */
uint8_t cmd[MC_MAX_PAYLOAD];
size_t n;
n = mc_cmd_app_start(cmd, sizeof cmd, "esp-idf"); if (n) send_payload(cmd, n);
n = mc_cmd_device_query(cmd, sizeof cmd, 1); if (n) send_payload(cmd, n);
n = mc_cmd_get_device_time(cmd, sizeof cmd); if (n) send_payload(cmd, n);
mc_rx_t rx;
mc_rx_init(&rx);
ESP_LOGI(TAG, "listening on UART%d (rx=%d tx=%d)",
MC_UART_NUM, MC_UART_RX_PIN, MC_UART_TX_PIN);
for (;;) {
uint8_t in[128];
int got = uart_read_bytes(MC_UART_NUM, in, sizeof in, pdMS_TO_TICKS(100));
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)) continue;
on_event(&ev);
/* Drain the radio's queue when it signals waiting messages. */
if (ev.code == MC_PUSH_MSG_WAITING ||
ev.code == MC_RESP_CHANNEL_MSG_RECV ||
ev.code == MC_RESP_CHANNEL_DATA_RECV ||
ev.code == MC_RESP_CONTACT_MSG_RECV) {
n = mc_cmd_sync_next_message(cmd, sizeof cmd);
if (n) send_payload(cmd, n);
}
}
}
}
+44
View File
@@ -0,0 +1,44 @@
# STM32 example: uart_bridge
Integration example showing how to drive a MeshCore Companion Radio from an STM32
using the CubeMX-generated HAL. The protocol logic is the repo's portable C core
(`src/meshcore_companion.c`); this file supplies only the UART transport and two
entry points you call from your generated `main()`.
Because an STM32 firmware build is tied to your specific MCU, clocks, pins and
linker script (all produced by STM32CubeIDE / CubeMX), this is **not** a
standalone buildable project — it is a drop-in.
## Steps (STM32CubeIDE / CubeMX)
1. Generate a project with one USART enabled at **115200 8N1** (e.g. `USART1`).
Wire it to the companion radio: host TX → radio RX, host RX ← radio TX, GND↔GND.
The radio must run the serial companion firmware (`companion_radio_usb`).
2. Add `src/meshcore_companion.c` and `src/meshcore_companion.h` from this repo to
your project (or add this repo's `src/` to the include paths).
3. Add `meshcore_stm32.c` to your project. If your USART handle isn't `huart1`,
change the `extern UART_HandleTypeDef huart1;` line near the top.
4. In the generated `main.c`:
```c
/* USER CODE BEGIN 2 */
meshcore_setup();
/* USER CODE END 2 */
while (1) {
/* USER CODE BEGIN 3 */
meshcore_poll();
}
```
## Notes
- `meshcore_poll()` polls the UART one byte at a time, which is fine for the
companion's low data rate. For high throughput, switch the RX side to
interrupt/DMA into a ring buffer and feed that to `mc_rx_feed()` — the core
code is unchanged.
- Logging uses `printf()`. Retarget it to a **separate** debug UART or SWO/ITM
(commonly USART2 = the ST-Link VCP) by implementing `_write()`; do not point it
at the companion UART.
- The same two-function transport pattern (`send bytes` / `read available bytes`)
ports directly to bare-metal STM32, nRF52 (nRF5 SDK or Zephyr), or any other
MCU — only the HAL calls change.
+123
View File
@@ -0,0 +1,123 @@
/*
* meshcore_stm32.c -- STM32 HAL integration example.
*
* Unlike the Linux and ESP-IDF examples, STM32 builds are board- and
* toolchain-specific (the CubeMX-generated HAL, startup code and linker script
* belong to your project), so this is an *integration example* rather than a
* standalone build. It shows the only two things the portable core needs from a
* platform: write some bytes, and read whatever bytes have arrived.
*
* How to use (STM32CubeIDE / CubeMX):
* 1. Generate a project with one USART enabled at 115200 8N1 (e.g. USART1).
* Wire it to the companion radio (host TX -> radio RX, host RX <- radio TX).
* 2. Add src/meshcore_companion.c and src/meshcore_companion.h to the project
* (Core/Src and Core/Inc, or add this repo's src/ to the include paths).
* 3. Add this file to the project.
* 4. In the generated main(): call meshcore_setup() once after MX_USARTx_UART_Init(),
* then call meshcore_poll() every iteration of the main while(1) loop.
*
* Byte-at-a-time polling is fine for the companion's low data rate; for high
* throughput switch the transport to interrupt/DMA RX into a ring buffer and
* feed that buffer to mc_rx_feed() — the core code does not change.
*
* Logging here uses printf(); retarget it to a *separate* debug UART or SWO/ITM
* (do not point it at the companion UART). On many CubeIDE projects that means
* implementing _write() to HAL_UART_Transmit on USART2 (the ST-Link VCP).
*
* SPDX-License-Identifier: MIT
* Author: Scott Penrose / Digital Dimensions.
*/
#include "main.h" /* CubeMX-generated: pulls in stm32xxxx_hal.h + handles */
#include <stdio.h>
#include <stdlib.h>
#include "meshcore_companion.h"
/* The USART you enabled in CubeMX and wired to the companion radio. */
extern UART_HandleTypeDef huart1;
#define MC_UART (&huart1)
static mc_rx_t s_rx;
static void send_payload(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) HAL_UART_Transmit(MC_UART, frame, (uint16_t)flen, HAL_MAX_DELAY);
}
static void on_event(const mc_event_t *ev)
{
switch (ev->code) {
case MC_RESP_DEVICE_INFO:
printf("radio model=%s fw=%d channels=%u build=%s\r\n",
ev->u.device_info.model, ev->u.device_info.fw_ver,
(unsigned)ev->u.device_info.max_channels,
ev->u.device_info.build_date);
break;
case MC_RESP_CHANNEL_MSG_RECV: /* body is "SenderName: message" */
printf("[ch %d] %s\r\n", ev->u.channel_msg.channel_idx,
ev->u.channel_msg.text);
break;
case MC_RESP_CHANNEL_DATA_RECV: {
int centi = ev->u.channel_data.snr_q4 * 25; /* q4 -> /4 then *100 */
const char *sign = centi < 0 ? "-" : "";
printf("[ch %d] %u bytes type=0x%04X snr=%s%d.%02d dB %s\r\n",
ev->u.channel_data.channel_idx,
(unsigned)ev->u.channel_data.data_len,
(unsigned)ev->u.channel_data.data_type,
sign, abs(centi) / 100, abs(centi) % 100,
ev->u.channel_data.path_len == MC_PATH_DIRECT ? "direct" : "flood");
break;
}
case MC_RESP_CURR_TIME:
printf("device time = %u (epoch secs)\r\n", (unsigned)ev->u.curr_time);
break;
case MC_RESP_ERR:
printf("radio error response (code=%d)\r\n", ev->u.err_code);
break;
default:
break;
}
}
/* Call once after MX_USARTx_UART_Init(). */
void meshcore_setup(void)
{
mc_rx_init(&s_rx);
uint8_t cmd[MC_MAX_PAYLOAD];
size_t n;
n = mc_cmd_app_start(cmd, sizeof cmd, "stm32"); if (n) send_payload(cmd, n);
n = mc_cmd_device_query(cmd, sizeof cmd, 1); if (n) send_payload(cmd, n);
n = mc_cmd_get_device_time(cmd, sizeof cmd); if (n) send_payload(cmd, n);
}
/* Call from your main while(1) loop. Non-blocking. */
void meshcore_poll(void)
{
uint8_t cmd[MC_MAX_PAYLOAD];
size_t n;
uint8_t b;
/* Drain every byte currently available (timeout 0 = return immediately). */
while (HAL_UART_Receive(MC_UART, &b, 1, 0) == HAL_OK) {
mc_rx_feed(&s_rx, &b, 1);
uint8_t payload[MC_MAX_PAYLOAD];
size_t plen;
while (mc_rx_poll(&s_rx, payload, sizeof payload, &plen)) {
mc_event_t ev;
if (!mc_parse(payload, plen, &ev)) continue;
on_event(&ev);
if (ev.code == MC_PUSH_MSG_WAITING ||
ev.code == MC_RESP_CHANNEL_MSG_RECV ||
ev.code == MC_RESP_CHANNEL_DATA_RECV ||
ev.code == MC_RESP_CONTACT_MSG_RECV) {
n = mc_cmd_sync_next_message(cmd, sizeof cmd);
if (n) send_payload(cmd, n);
}
}
}
}