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
`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
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.
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).
transport. The same core drops into Linux, ESP-IDF, bare STM32/nRF52, or any
host bridge unchanged — see the example for each platform under `examples-*/`,
plus the host unit test in `test/` that runs with no hardware.
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
@@ -45,11 +49,18 @@ meshcore_c/
├── examples/
│ └── SensorChannelBridge/ # Arduino sketch (.ino)
├── 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_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
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
@@ -84,6 +95,26 @@ cc -std=c99 -Wall -Wextra -Isrc \
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
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);
}
}
}
}