Lightweight, robust & user-friendly messaging library for secure structured communication between microcontrollers
- Introduction
- EZLink Minimal Example
- Design Goals & Motivations
- Key Features
- Quick Start Guide
- Architecture & Design Overview
- Practical Considerations & Best Practices
- Testing & Validation
- Examples
- License
- Final Notes
EZLink is a C++ library designed for robust & low-overhead communication between microcontrollers (typically via UART, but suitable to any transport layer carrying binary data). Its primary goal is to simplifythe process of exchanging structured binary frames, without requiring you to define your own protocol from scratch or adopt complex serialization frameworks.
The library is built around message structs that serve as message prototypes. You simply declare these structs in a declarative way using native C++ types, and the library handles all the framing, encoding, decoding and validation automatically. Think of it as a very lightweight alternative to Protobuf, combined with a robust transport layer that handles message framing and validation. All of this using simple C++ structs that double as both message definitions and instances, making the protocol self-documenting and easy to maintain.
Key design points:
- Minimal Flash/RAM footprint (~1KB code size + ~250B/message): suitable for the most constrained microcontrollers such as
STM32F03x
series. - Simple API with a strong focus on reliability and explicit error reporting.
- User-friendly, declarative approach to define message prototypes.
- Built-in support for messages (one-way), acknowledged messages, and request/response flows.
- Extendable with your own structured types (PODs).
- No code generation toolchain required (unlike Protobuf/Cap’nProto).
- Works seamlessly on Arduino platforms or via custom TX/RX callbacks on bare-metal/RTOS-based firmware as long as your target supports C++11.
Whether you are building a Master/Slave setup over UART or need robust bidirectional communications, EZLink aims to keep things KISS (Keep It Simple, Stupid) while maximizing runtime safety (CRC checks, well-defined message boundaries, error codes, etc.).
#include "EZLink.h"
using MsgType = EZLink::MsgType;
// Simple control message with acknowledgment
struct ControlMsg {
static constexpr MsgType type = MsgType::MESSAGE_ACK;
static constexpr uint8_t id = 1;
uint8_t channel; // Which channel to control
uint16_t value; // Control value
uint8_t flags; // Control flags
} __attribute__((packed));
#include "EZLink.h"
#include "Prototypes.h"
EZLink master(&UART);
void setup() {
UART.begin(115200);
master.begin();
master.registerRequest<ControlMsg>();
}
void loop() {
// Send control message
ControlMsg msg{
.channel = 1,
.value = 1000,
.flags = 0x01
};
auto result = master.sendMsgAck(msg);
if (result != EZLink::SUCCESS) {
// handle error
}
delay(1000);
}
#include "EZLink.h"
#include "Prototypes.h"
EZLink slave(&UART);
// Function to process control messages
void onControlMsg(const ControlMsg& msg) {
processControl(msg.channel, msg.value, msg.flags); // Process received message (business logic)
}
void setup() {
UART.begin(115200);
slave.begin();
// Register message and handler
slave.registerRequest<ControlMsg>();
slave.onReceive<ControlMsg>(processControl);
// Acknowledgment is automatically sent back to the master after processing
}
void loop() {
// Process incoming messages
slave.poll();
}
That's it! A complete bidirectional communication system in ~50 lines of code.
- Primary Use Case: Secure, structured, but resource-constrained communication between an ESP32 and an STM32 over UART, with the latter having only 16 KB of Flash.
- Requirement: A robust framing and validation system that does not blow up code size or add heavy toolchain dependencies.
- Protobuf: Great for structured data but generally overkill for small microcontrollers due to runtime overhead, code size, and compiler plugin complexities.
- SerialCommands: Simple, but often text-based or lacks robust binary framing (no built-in CRC or typed messages).
- TinyFrame: A similar concept, but still can be less direct if you want strongly typed C++ messages and integrated request/response patterns.
EZLink stands out by allowing you to:
- Declare message structures in pure C++ with minimal boilerplate.
- Rely on compile-time checks (via templates &
static_assert
s) for correctness. - Have built-in request/response, acknowledgment flows, and detailed error codes.
- Keep code size minimal.
-
Simplicity:
- Minimal user API (just register your prototypes, send, receive, done).
- No manual parsing logic; a message is always read/written as a strongly typed C++ struct.
-
Lightweight & fast:
- Fits into tight STM32 flash constraints (on the order of 2KB compiled w/ 4 message structs).
- Ultra-low latency communications.
-
Full Safety by Default:
- CRC16 for integrity checking on all frames.
- Acknowledgment or request/response flows if you want guaranteed delivery.
- Automatic error detection and cleanup on malformed frames.
-
Flexible Usage:
- Synchronous approach by default (for acknowledgment or request/response).
- Asynchronous usage based on unidirectional messages for non-blocking communications (see the appropriate section for details).
-
Transport Agnostic:
- Built-in support for Arduino
HardwareSerial
. - Alternative approach: provide your own TX/RX callbacks (interrupt, DMA, RTOS queues, etc.).
- Built-in support for Arduino
A minimal project layout example is shown below:
├── lib/
│ └── EZLink/ <- Library files
│ └── src/
│ ├── ScrollBuffer.h
│ └── EZLink.h
├── src/
│ └── main.cpp <- Your application code
└── include/
└── Prototypes.h <- Your message prototypes
(shared between all targets)
- Arduino/PlatformIO: Copy or clone the
lib/EZLink/
folder into your project’slib/
. - Bare-metal: Include the
.h
files in your build system. Ensure you compileEZLink.h
andScrollBuffer.h
.
This can be done directly in your source code, but the best practice is to do this in a separate header file that is shared between all targets. This will ensure that your message definitions are consistent across emitters and receivers.
MESSAGE
: A one-way message. No confirmation is expected.MESSAGE_ACK
: A one-way message where the receiver automatically echoes the same message back (flipping ID bit 7).REQUEST
: A message that requires a specific typedRESPONSE
.RESPONSE
: A message that answers a specificREQUEST
.
-
Each proto struct declares:
- Message type:
static constexpr MsgType type;
(see above) - Message ID:
static constexpr uint8_t id;
(must be in 1..127).
- Message type:
-
Basic rules:
id=0
is invalid.- When you define a
RESPONSE
, it must have the sameid
as its request. The library handles linking them together.
-
Under the hood:
- The library automatically uses
id | 0x80
for responses (IDs 128..255). - It automatically links requests and responses together.
- It can recognize the type of message as well as the request/response relationship.
- It reject frames with unrecognized IDs, inconsistent request/response types, as well as responses that don't match any request.
- The library automatically uses
Basically, each message received is sure to be of the type expected by its listener (message/request when polling, ACK/response after sending a request).
#include "EZLink.h"
using MsgType = EZLink::MsgType;
// Motor control message (one-way command)
struct SetMotorMsg {
static constexpr MsgType type = MsgType::MESSAGE;
static constexpr uint8_t id = 1;
uint8_t motor_id; // Which motor (1-4)
int16_t speed; // -1000 to +1000
uint8_t acceleration; // 0-255
uint8_t mode; // 0=normal, 1=smooth, 2=precise
} __attribute__((packed));
// PID configuration (requires acknowledgment)
struct ConfigPidMsg {
static constexpr MsgType type = MsgType::MESSAGE_ACK;
static constexpr uint8_t id = 2;
uint8_t channel; // Which control loop
float kp; // Proportional gain
float ki; // Integral gain
float kd; // Derivative gain
} __attribute__((packed));
// Sensor data response
struct SensorDataResponse {
static constexpr MsgType type = MsgType::RESPONSE;
static constexpr uint8_t id = 3;
int16_t temperature; // Celsius x100 (-4000 to +15000)
uint16_t humidity; // RH x100 (0 to 10000)
uint32_t pressure; // Pascal
uint8_t status; // Bit flags for sensor status
} __attribute__((packed));
// Request sensor data
struct GetSensorDataMsg {
static constexpr MsgType type = MsgType::REQUEST;
static constexpr uint8_t id = 3;
uint8_t sensorId; // Target sensor ID
using ResponseType = SensorDataResponse;
} __attribute__((packed));
Instantiate the EZLink
object with a reference to the HardwareSerial
port you want to use for communication.
// Basic initialization
EZLink comm(&Serial1);
// With custom response timeout (default: 500ms)
EZLink comm(&Serial1);
comm.setResponseTimeout(1000); // Set to 1 second
#ifdef EZLINK_DEBUG
// With debug output
EZLink comm(&Serial1, // Communication serial port
&Serial, // Debug serial port
"DBG_MASTER"); // Optional prefix for debug messages
#endif
For non-Arduino environments, provide your own TX/RX callbacks, with the following signatures:
std::function<size_t(const uint8_t*, size_t)>
for TXstd::function<size_t(uint8_t*, size_t)>
for RX
// Define callbacks
EZLink::TxCallback txCb = [](const uint8_t* data, size_t len) {
return yourCustomUartWrite(data, len);
};
EZLink::RxCallback rxCb = [](uint8_t* data, size_t maxLen) {
return yourCustomUartRead(data, maxLen);
};
// Basic initialization
EZLink comm(txCb, rxCb);
// Custom response timeout can also be set
// Debug is also available but it outputs to a Stream which needs to be implemented
Make sure to call begin()
after construction, for example in the setup()
function if you're using the Arduino framework:
void setup() {
Serial1.begin(115200); // Initialize hardware first
comm.begin(); // Then call begin()
}
This method is here only to clear the RX buffer since garbage can be present right after UART initialization. Consider adding a small delay before calling begin()
if you're still catching garbage. Anyway, it shouldn't cause issues:
- In emitter mode, the RX buffer will be cleaned up upon the next message sent.
- In receiver mode, the first call to
poll()
might throw anERR_RCV_INVALID_SOF
error, but it will be automatically recovered on the next legit bytes received.
For the communication to work properly, you must:
- Register all messages you want to send with
registerRequest<T>()
- Register all responses you expect to receive with
registerResponse<T>()
This must be done after instantiating the EZLink
object.
The order of registration matters:
// Correct order:
comm.registerRequest<GetStatusMsg>(); // Register the request first
comm.registerResponse<StatusResponseMsg>(); // Then its response
// Incorrect - will return ERR_REG_INVALID_ID:
comm.registerResponse<StatusResponseMsg>(); // Can't register response first
comm.registerRequest<GetStatusMsg>(); // Request must be registered before
Each message type has specific registration requirements:
Message Type | Register With | Notes |
---|---|---|
MESSAGE | registerRequest | One-way messages |
MESSAGE_ACK | registerRequest | Messages expecting echo |
REQUEST | registerRequest | Messages expecting specific response |
RESPONSE | registerResponse | Must register after its request |
After registering your messages and responses, you can attach callbacks that will trigger an action upon reception of a message or request (for example in the setup()
function if you're using the Arduino framework).
Internally, the library uses C-style function pointers for maximum efficiency. Handlers are registered with a strong-typed façade avoiding the need to cast the data at all, making the usage safer and more intuitive.
N.B.: The registration is limited to one handler per message prototype. If you register another handler for the same message struct, it will replace the previous one.
-
For
MESSAGE
orMESSAGE_ACK
:void handleLedMessage(const SetLedMsg& msg) { digitalWrite(LED_BUILTIN, msg.state); // Utilize message data } // Register prototype and handler comm.registerRequest<SetLedMsg>(); comm.onReceive<SetLedMsg>(handleLedMessage);
-
For
REQUEST/RESPONSE
pairs:void handleStatusRequest(const GetStatusMsg& req, StatusResponseMsg& resp) { resp.uptime = millis(); resp.state = getSystemState(); // Response is sent automatically by the library after the callback returns } // Register prototypes and handler comm.registerRequest<GetStatusMsg>(); comm.registerResponse<StatusResponseMsg>(); comm.onRequest<GetStatusMsg>(handleStatusRequest);
Callbacks will be automatically called by the library when a matching message is received.
SetLedMsg msg{.state = 1};
auto result = comm.sendMsg(msg);
if (result != EZLink::SUCCESS) {
// handle error
}
- No response is expected;
poll()
can still be used to receive inbound messages from the other side if needed.
SetPwmMsg pwmMsg{.pin = 5,
.freq = 1000};
auto result = comm.sendMsgAck(pwmMsg);
if (result != EZLink::SUCCESS) {
// handle error, e.g., ERR_RCV_TIMEOUT
}
- Under the hood, the library clears the RX buffer, sends
SetPwmMsg
, and waits for an exact echo (flipped ID). - If no echo arrives (or it mismatches the data), you get an error.
- The approach is deliberately synchronous (i.e. blocking) to guarantee message delivery and avoid issues with sending the same message multiple times.
- Echo is sent after executing the receiver's
onReceive
callback : when an echo is received, the sender is sure that the receiver is ready to process the next incoming message.
GetStatusMsg req;
StatusResponseMsg resp;
auto result = comm.sendRequest(req, resp);
if (result == EZLink::SUCCESS) {
// use resp.state, resp.uptime, ...
}
else {
// error handling
}
- Like
MESSAGE_ACK
, it blocks waiting for the correctRESPONSE
. - On the receiver side:
void handleStatusRequest(const GetStatusMsg& req, StatusResponseMsg& resp){
resp.state = 1;
resp.uptime = millis();
}
comm.onRequest<GetStatusMsg>(handleStatusRequest);
- Reponse is sent after executing the receiver's
onRequest
callback : when a response is received, the sender is sure that the receiver is ready to process the next incoming request.
EZLink implements a simple frame-based protocol over a raw byte stream:
- Each frame starts with a Start of Frame (SOF) byte
0xAA
. - Followed by length, message identifier (ID), the payload, and CRC16.
Frame Format (total size = PAYLOAD_SIZE + 5 bytes overhead)
+-------+-------+--------+----- - - - - ------+---------+
| SOF | LEN | ID | PAYLOAD | CRC16 |
+-------+-------+--------+----- - - - - ------+---------+
0xAA N+5 1-127 N bytes 2 bytes
Notes:
- Default PAYLOAD: Maximum 27 bytes (MAX_FRAME_SIZE[32] - FRAME_OVERHEAD[5])
- LEN includes all fields (SOF + LEN + ID + PAYLOAD + CRC16)
- ID: 1-127 for requests, 128-255 for responses (request_id | 0x80)
- CRC16: Calculated over all preceding bytes (SOF to end of PAYLOAD)
Internally, the library:
- Buffers incoming bytes in a small ring buffer (
ScrollBuffer
). - Scans for the next valid SOF.
- Verifies length, ID, and CRC to confirm a valid message frame.
- Calls the corresponding handler if registered.
- By default ("Arduino mode"), you provide a
HardwareSerial*
. The library willwrite()
frames out andread()
bytes in automatically. - Otherwise, you can supply custom callbacks (
std::function
handlers):TxCallback
for TXRxCallback
for RX
- TX/RX callbacks will be called automatically by the library when needed to send and receive bytes to the hardware interface. This allows integration with any hardware driver, buffer, or OS primitives.
- This callback-based approach allows EZLink to send and receive even during synchronous operations: even if the main thread is blocked, the library still has access to the hardware interface.
- It also reduces memory footprint by avoiding the need for dedicated buffers where the user would push/pull incoming and outgoing data: existing comm buffers are generally sufficient, especially with short message size like the default 32B - but you can still implement your own if needed.
- The transport layer supports response timeout and transmission errors handling. However, retries are not handled by the library, you need to implement the logic yourself if required.
- Synchronous: For
MESSAGE_ACK
orREQUEST/RESPONSE
, EZLink blocks internally waiting for the correct acknowledgment or response. It also cleans the receive buffer to avoid stale data. - Asynchronous: For
MESSAGE
type, no response is expected, so the user can simply send and forget.
If you want purely asynchronous behavior:
- Use only
MESSAGE
types or treat each exchange as unidirectional. - You can call
comm.poll()
periodically or within a specific task/thread to process inbound frames. - For concurrency, note that EZLink is not inherently thread-safe. If multiple threads call
sendMsg()
concurrently, you must protect them externally.
- By default,
MAX_FRAME_SIZE
is set to32
. This limits the maximum payload. You can adjust it inEZLinkDfs
if needed. - Important: When modifying
MAX_FRAME_SIZE
, ensure that:- All nodes (master & slaves) use the same value to maintain protocol compatibility
- Your messages respect the size limit via compile-time checks (
static_assert
) - Consider RAM usage impact as the ScrollBuffer size scales with MAX_FRAME_SIZE
- The "scroll buffer" approach implemented in the library ensures partial frames or garbage are eventually discarded without losing any valid subsequent frames.
- Several error cases have been thought of and handled, such as truncated frame, invalid length, invalid SOF, SOF present in data, etc. See unit tests for more details.
- TLDR: as long as you continuously call
poll()
on the receiver side, the message processing pipeline will jump from one frame to the next and end up synchronizing with the next valid frame even if there's garbage in-between.
- Every frame includes a 2-byte CRC16.
- The library discards frames with invalid CRC, tries to find the next valid SOF in the buffer, and continues.
- If you have extremely noisy lines, consider adding re-transmissions or switching to
MESSAGE_ACK
orREQUEST/RESPONSE
flows for guaranteed data integrity.
EZLink provides detailed error reporting through its Status
enum and Result
structure. Each operation returns a Result
containing both a status
code and the relevant message id
.
struct Result {
Status status; // Status or error code
uint8_t id; // Related message ID (0 if not applicable)
};
The ==
and !=
operators are overloaded for Result
so you can easily check the status without having to extract it from the struct.
Code | Name | Description | Common Causes | Solution |
---|---|---|---|---|
0 | SUCCESS |
Operation completed successfully | N/A | N/A |
1 | NOTHING_TO_DO |
No data to process | Empty RX buffer, no messages to handle | Normal condition during polling |
Code | Name | Description | Common Causes | Solution |
---|---|---|---|---|
10 | ERR_ID_ALREADY_REGISTERED |
Message ID already in use | Duplicate ID in message definitions | Ensure unique IDs across all messages |
11 | ERR_TOO_MANY_PROTOS |
Maximum number of prototypes exceeded | Too many registered messages | Increase MAX_PROTOS or reduce message types |
12 | ERR_REG_INVALID_ID |
Invalid message ID used | ID=0 or ID≥128 for requests | Use IDs between 1-127 for requests |
13 | ERR_REG_PROTO_MISMATCH |
Protocol type mismatch | Wrong message type registration method | Use correct register method for message type |
Code | Name | Description | Common Causes | Solution |
---|---|---|---|---|
20 | ERR_BUSY_RECEIVING |
Frame capture in progress | Attempting to send while receiving | Wait and retry, or call cleanupRxBuffer() |
Code | Name | Description | Common Causes | Solution |
---|---|---|---|---|
31 | ERR_RCV_INVALID_ID |
Received unknown message ID | Message not registered, corrupted frame | Register message, check connection |
32 | ERR_RCV_INVALID_SOF |
Invalid start of frame | Noise, desynchronization | Will auto-recover on next valid frame |
33 | ERR_RCV_INVALID_LEN |
Invalid frame length | Corrupted frame, noise | Will auto-recover on next valid frame |
34 | ERR_RCV_PROTO_MISMATCH |
Message type mismatch | Wrong message type received | Check message definitions match |
35 | ERR_RCV_ACK_MISMATCH |
Wrong acknowledgment | Corrupted response, wrong echo | Check connection, retry if needed |
36 | ERR_RCV_RESP_MISMATCH |
Wrong response type | Response size mismatch | Check message definitions match |
37 | ERR_RCV_CRC |
CRC check failed | Corrupted frame, noise | Will auto-recover on next valid frame |
38 | ERR_RCV_UNEXPECTED_RESPONSE |
Unsolicited response | Late response, protocol error | Check timing, clean buffers |
39 | ERR_RCV_TIMEOUT |
Response timeout | No response received | Check connection, increase timeout |
Code | Name | Description | Common Causes | Solution |
---|---|---|---|---|
41 | ERR_SND_INVALID_ID |
Invalid message ID | Message not registered | Register message before sending |
42 | ERR_SND_EMPTY_DATA |
No data to send | Null pointer, zero length | Check message content |
43 | ERR_SND_PROTO_MISMATCH |
Protocol type mismatch | Wrong message type for operation | Use correct send method |
Code | Name | Description | Common Causes | Solution |
---|---|---|---|---|
50 | ERR_HW_FLOOD |
Hardware buffer overflow | Too much incoming data | Check for emitter flooding the RX buffer |
51 | ERR_HW_TX_FAILED |
TX hardware failure | Buffer full, hardware error | Check hardware, retry |
auto result = comm.sendMsg(msg);
if (result != EZLink::SUCCESS) {
// Handle error
handleError(result.status, result.id);
}
Enable debug output to get detailed error information with the EZLINK_DEBUG
flag
#define EZLINK_DEBUG
EZLink comm(&Serial1, // Serial port to use for communication
&Serial, // Stream for debug output (Serial=USB on ESP32)
"DBG_MASTER"); // Debug tag (prefix to log messages)
The debug output to a Stream
will provide:
- Hex dumps of frames with errors
- Error descriptions and message IDs
- Frame analysis (SOF, LEN, ID values)
- State transitions
This makes debugging very easy on the Arduino framework, where you can easily attach a custom logger or Serial port to the debug stream.
Here is an example of debug output with the parameters above:
// On MASTER side
[DBG_MASTER] RX buffer cleaned
[DBG_MASTER] TX frame (10B) SOF=0xAA LEN=10 ID=0x01 => AA 0A 01 02 00 00 00 00 D4 C6
[DBG_MASTER] RX valid frame (10B) SOF=0xAA LEN=10 ID=0x81 => AA 0A 81 02 00 00 00 00 00 E6
// On SLAVE side
[DBG_SLAVE] RX valid frame (10B) SOF=0xAA LEN=10 ID=0x01 => AA 0A 01 01 00 00 00 00 3A 14
[DBG_SLAVE] TX frame (10B) SOF=0xAA LEN=10 ID=0x81 => AA 0A 81 01 00 00 00 00 EE 34
The library follows a header-only approach where most code is contained in the headers:
- Easy to include in projects (no separate .cpp files needed)
- Simple dependency management
- Direct compiler optimization possibilities
- Template usage is moderate to avoid code bloat while offering an intuitive API with strong typing.
- Most logic is factored into non-template code
- Compilation units generate minimal duplicate code
This design choice balances ease of use with resource efficiency, making it particularly suitable for embedded projects.
- Always register the same prototypes on both ends: matching types, IDs, and data structures. Make sure to use a common
Prototypes.h
(or similar) file. - Never forget to use the
__attribute__((packed))
keyword on your message structs to avoid padding issues. - In your
Prototypes.h
file, forREQUEST/RESPONSE
pairs, ensure the Response struct is declared before the Request struct or use a forward declaration for the Response struct. Otherwise the compiler might throw an error, as when it's parsing the Request struct, he yet doesn't know theResponseType
declared inside. - The library accepts a single handler per message prototype. If you register another handler for the same message struct, it will replace the previous one.
- Synchronous patterns (like
sendMsgAck
orsendRequest
) block until a response arrives or times out. In a busy system, call them from a context where blocking the current thread is acceptable. - On the receiver side, ensure the
poll()
method is called regularly during execution of your program, or better, run it in a dedicated thread/task (e.g. FreeRTOS task on ESP32) to keep your main loop clean. - Keep processing loops short inside callbacks to avoid the sender waiting for a response. If you need to perform long operations, consider using an asynchronous pattern with a second message to indicate the outcome of the operation.
- If you need fully async interactions, do not rely on the built-in synchronous request/response calls. Instead, use your own logic with unidirectional messages.
- If you see errors like
ERR_BUSY_RECEIVING
, it means a partial frame capture is ongoing. Wait or poll more frequently to allow the library to finish capturing the current frame. - For debugging, enabling
EZLINK_DEBUG
can help identify framing or registration issues quickly.
- Alignment: While the library uses
__attribute__((packed))
to avoid padding, be aware that some architectures (especially certain ARM Cortex-M7) may not handle unaligned 32-bit accesses well:- Works fine on most common MCUs (ESP32, ESP8266, STM32F4, etc.)
- If targeting strict alignment architectures, consider using memcpy for 32-bit field access
Here's an example showing how to safely handle 32-bit fields if needed:
// Message definition (always packed for protocol consistency)
struct SensorDataMsg {
static constexpr MsgType type = MsgType::MESSAGE;
static constexpr uint8_t id = 1;
uint8_t sensorId; // offset 0
uint32_t timestamp; // offset 1 (unaligned!)
float value; // offset 5 (unaligned!)
} __attribute__((packed));
// Safe access pattern for strict architectures
void processSensorData(const SensorDataMsg& msg) {
// Direct access to 8-bit fields is always safe
uint8_t sensor = msg.sensorId;
// For 32-bit fields, use memcpy if needed
uint32_t timestamp;
float value;
memcpy(×tamp, &msg.timestamp, sizeof(uint32_t));
memcpy(&value, &msg.value, sizeof(float));
// Now use timestamp and value safely...
}
The test suite demonstrates robustness against various edge cases:
- CRC errors and frame corruption
- Truncated frames and partial reception
- Message collisions and timing issues
- Buffer overflows and memory constraints
- Multi-task compatibility (ESP32/FreeRTOS examples)
- Response timeout handling
On RTOS systems (like ESP32), the test framework shows proper operation across multiple tasks while maintaining frame integrity and proper request/response matching.
Under test/test_native
, there are UNITY
-based tests that run on a desktop environment with a mock of Arduino.h
. These tests cover:
- Registration edge cases (duplicate ID, etc.).
- Sending/receiving frames, partial frames, CRC errors.
- Request/response logic and timeouts.
To run them locally (PlatformIO example):
pio test -e native_test
Under test/test_hardware
, you’ll find tests that run on actual hardware, exchanging messages across real UART lines. This verifies timing, buffering, and physical transport behavior. The tests have been run on an ESP32S3 dev board in a "loopback" setup (2 UART peripherals chained together with RX/TX pins crossed).
pio test -e hardware_test
Inside examples/arduino/
:
loopback.cpp
: A simple loopback test on a single device with logging.master.cpp
&slave.cpp
: A typical Master/Slave scenario. The master sendsSetLedMsg
orGetStatusMsg
, and the slave toggles a pin or responds with uptime data.
Under examples/esp-idf/
:
- Illustrates using
EZLink
in a typical ESP-IDF project, with non-Arduino drivers.
This library is released under the MIT License. Feel free to use and modify it to suit your needs.
EZLink’s approach is intentionally minimalistic, but it offers enough structure to avoid “reinventing the wheel” each time you need robust UART-based message handling. With compile-time validation, CRC checks, and straightforward message definitions, you can focus on business logic rather than protocol plumbing.
If you encounter issues or have feature requests, please open an issue or PR on the repo! I'll be happy to get feedback and contributions to improve the library.
Happy hacking!