From 17cfd3e35c6bf9dca8959b118dca6b873d9f194a Mon Sep 17 00:00:00 2001 From: alkonosst Date: Wed, 24 Jun 2026 15:03:16 -0400 Subject: [PATCH 1/4] feat: Add support for native builds --- CMakeLists.txt | 14 ++ README.md | 23 ++- examples/BasicNative/BasicNative.cpp | 81 +++++++++ platformio.ini | 111 ++++++++---- scripts/coverage.py | 38 +++++ scripts/require-example.py | 36 ++++ test/test_byteframe.cpp | 243 ++++++++++++++------------- 7 files changed, 395 insertions(+), 151 deletions(-) create mode 100644 CMakeLists.txt create mode 100644 examples/BasicNative/BasicNative.cpp create mode 100644 scripts/coverage.py create mode 100644 scripts/require-example.py diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..e81fb69 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,14 @@ +cmake_minimum_required(VERSION 3.14) +project(ByteFrame VERSION 0.1.0 LANGUAGES CXX) + +# Create an INTERFACE library. +add_library(ByteFrame INTERFACE) + +# Alias with author prefix. Consumers link "alkonosst::ByteFrame". +add_library(alkonosst::ByteFrame ALIAS ByteFrame) + +# Specify the include directories for the library. +target_include_directories(ByteFrame INTERFACE ${CMAKE_CURRENT_SOURCE_DIR}/src) + +# Set a C++ standard requirement for the library. This will also propagate to consumers of the library. +target_compile_features(ByteFrame INTERFACE cxx_std_17) \ No newline at end of file diff --git a/README.md b/README.md index f0d9d01..607303d 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,9 @@ PlatformIO Registry

+ + Coverage + License @@ -35,6 +38,7 @@ - [Installation](#installation) - [PlatformIO](#platformio) - [Arduino IDE](#arduino-ide) + - [CMake](#cmake) - [Usage](#usage) - [Including the library](#including-the-library) - [Namespace](#namespace) @@ -54,7 +58,7 @@ # Description -**ByteFrame** is a header-only C++11 Arduino library that delimits packets over raw byte streams (UART, RS-485, radios, raw TCP...). It answers the question a stream cannot: _where does each packet start and end, and did it arrive intact?_ +**ByteFrame** is a header-only C++11 embedded/native library that delimits packets over raw byte streams (UART, RS-485, radios, raw TCP...). It answers the question a stream cannot: _where does each packet start and end, and did it arrive intact?_ Each frame is the payload plus a selectable CRC, encoded with [COBS](https://en.wikipedia.org/wiki/Consistent_Overhead_Byte_Stuffing) and terminated by a single `0x00` delimiter. COBS guarantees the delimiter never appears inside a frame, so the decoder can always resynchronize after corruption or after joining a stream mid-frame. The payload is just bytes: ByteFrame does not care if it is a [BytePack](https://github.com/alkonosst/BytePack) message, a protobuf, or a raw struct. @@ -135,6 +139,23 @@ lib_deps = 3. Search for **"ByteFrame"**. 4. Click **Install**. +## CMake + +For desktop C++ projects, pull the library with `FetchContent` and link the `alkonosst::ByteFrame` +target: + +```cmake +include(FetchContent) +FetchContent_Declare( + ByteFrame + GIT_REPOSITORY https://github.com/alkonosst/ByteFrame.git + GIT_TAG vx.y.z # pin a release tag (recommended), or a branch/commit +) +FetchContent_MakeAvailable(ByteFrame) + +target_link_libraries(your_app PRIVATE alkonosst::ByteFrame) +``` + # Usage ## Including the library diff --git a/examples/BasicNative/BasicNative.cpp b/examples/BasicNative/BasicNative.cpp new file mode 100644 index 0000000..de57baa --- /dev/null +++ b/examples/BasicNative/BasicNative.cpp @@ -0,0 +1,81 @@ +/** + * SPDX-FileCopyrightText: 2026 Maximiliano Ramirez + * + * SPDX-License-Identifier: MIT + */ + +/** + * Basic Native Example Overview: + * - Mirrors the Basic Arduino example, swapping Serial for printf. Useful for native builds. + * - Build and run locally: + * PowerShell: $env:EXAMPLE="examples/BasicNative"; pio run -e native-example -t exec + * bash/WSL : export EXAMPLE="examples/BasicNative"; pio run -e native-example -t exec + */ + +#include + +#include + +// Maximum payload this link accepts; both the TX buffer and the Decoder derive from it +constexpr size_t MAX_PAYLOAD = 32; + +ByteFrame::Decoder decoder; + +static void printHex(const uint8_t* data, const size_t len) { + for (size_t i = 0; i < len; ++i) + printf("%02X ", data[i]); + printf("\n"); +} + +int main() { + printf("--------------------------------\n"); + printf("ByteFrame - Basic Native Example\n"); + printf("--------------------------------\n"); + + // Any bytes work as payload: a BytePack message, a string, a raw struct... + const uint8_t payload[] = {'H', 'i', ' ', 0x00, 0x42, 0xFF}; // zeros are fine: COBS handles them + + // TX buffer sized at compile time for the worst case (CRC + COBS overhead + delimiter) + uint8_t frame[ByteFrame::getMaxEncodedSize(MAX_PAYLOAD)] = {}; + + const size_t frame_size = ByteFrame::encode(payload, sizeof(payload), frame, sizeof(frame)); + if (frame_size == 0) { + printf("Encoding failed: frame buffer too small\n"); + return 1; + } + + printf("Payload (%zu bytes):\n", sizeof(payload)); + printHex(payload, sizeof(payload)); + printf("\n"); + + printf("Encoded frame (%zu bytes, ends with the 0x00 delimiter):\n", frame_size); + printHex(frame, frame_size); + printf("\n"); + + // Feed the frame byte by byte, as it would arrive from a UART + for (size_t i = 0; i < frame_size; i++) { + if (decoder.feed(frame[i])) { + printf("Decoded payload (%zu bytes):\n", decoder.getPayloadSize()); + printHex(decoder.getPayload(), decoder.getPayloadSize()); + printf("\n"); + } + } + + // Corrupt one byte: the CRC rejects the frame and the counter registers it + frame[2] ^= 0x55; + + for (size_t i = 0; i < frame_size; i++) { + decoder.feed(frame[i]); + } + + const auto& stats = decoder.getStats(); + + printf("After feeding a corrupted copy of the frame:\n"); + printf("- Frame available: %s\n", decoder.isFrameAvailable() ? "yes" : "no"); + printf("- Frames OK: %u\n", stats.frames_ok); + printf("- CRC errors: %u\n", stats.crc_errors); + printf("- Malformed errors: %u\n", stats.malformed); + printf("- Overflow errors: %u\n", stats.overflows); + + return 0; +} diff --git a/platformio.ini b/platformio.ini index 0ffded2..4155d46 100644 --- a/platformio.ini +++ b/platformio.ini @@ -8,41 +8,35 @@ ; Please visit documentation for the other options and examples ; https://docs.platformio.org/page/projectconf.html -[platformio] -lib_dir = . -src_dir = examples/Basic -; src_dir = examples/StreamingEncoder -; src_dir = examples/CrcSelection -; src_dir = examples/ChunkedStream -; src_dir = examples/OneShotDecode +; ------------------------------------------------------------------------------------------------ ; +; Common configurations ; +; ------------------------------------------------------------------------------------------------ ; +; Common configuration for all environments. [common] -; Tests -; Ignore Unity library to avoid scanning .pio/libdeps/Unity/examples/ as part of lib_dir = . -; Add Unity src path manually so unity.h is still found during test compilation -lib_ignore = Unity +build_src_flags = -Wall -Wextra -Werror -; Flags -build_flags = - ; All warning as errors - -Wall - -Wextra - -Werror - -[env:uno_wifi_rev2] +; Common configuration for compiling examples. +; It's necessary to set the EXAMPLE env variable when running. Example: +; - Windows: $env:EXAMPLE="examples/basic"; pio run -e native-example +; - Linux : export EXAMPLE="examples/basic"; pio run -e native-example +[example] extends = common -platform = atmelmegaavr@1.9.0 -framework = arduino -board = uno_wifi_rev2 +build_src_filter = +<*> +<../${sysenv.EXAMPLE}> +extra_scripts = pre:scripts/require-example.py -[env:stm32f103c8] +; Common configuration for compiling tests. +[test] extends = common -platform = ststm32@19.6.0 -framework = arduino -board = bluepill_f103c8 +test_build_src = yes +build_flags = -DUNITY_INCLUDE_DOUBLE -[env:esp32-s3] -extends = common +; Common configuration for native builds. +[native] +platform = native + +; Common configuration for esp32-s3. +[esp32-s3] platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.37/platform-espressif32.zip board = esp32-s3-devkitc-1 framework = arduino @@ -61,23 +55,68 @@ upload_speed = 921600 monitor_echo = true monitor_filters = esp32_exception_decoder - send_on_enter log2file -; Flags build_flags = - ${common.build_flags} - - ; Unity include path (lib_ignore = Unity prevents it from being auto-added) - -I.pio/libdeps/esp32-s3/Unity/src - ; Enable debug (ESP-IDF logs) ; -DUSE_ESP_IDF_LOG ; -DCORE_DEBUG_LEVEL=5 ; -DCONFIG_LOG_COLORS ; Enable PSRAM - -DBOARD_HAS_PSRAM + ; -DBOARD_HAS_PSRAM ; Enable USB CDC on boot -DARDUINO_USB_CDC_ON_BOOT=1 + +; ------------------------------------------------------------------------------------------------ ; +; Environments ; +; ------------------------------------------------------------------------------------------------ ; + +; Native example compilation. +; Usage: export EXAMPLE=examples/; pio run -e native-example -t exec +[env:native-example] +extends = native, example + +; Native tests without sanitizers or code coverage for quick tests. +; Usage: pio test -e native-test +[env:native-test] +extends = native, test + +; Native tests with sanitizers (ASan + UBSan). +; Usage: pio test -e native-san-test +[env:native-san-test] +extends = native, test +build_type = debug +build_flags = + ${test.build_flags} + -fsanitize=address,undefined + -fno-sanitize-recover=all + -fno-omit-frame-pointer + +; Native tests with code coverage (gcov). +; Usage: pio test -e native-cov-test +; - coverage.py passes --coverage to the linker (SCons does not forward it) and adds a +; custom "coverage" target that runs the tests and builds the gcovr report +; (pio run -e native-cov-test -t coverage). +[env:native-cov-test] +extends = native, test +build_type = debug +build_flags = + ${test.build_flags} + -fno-inline + --coverage +extra_scripts = pre:scripts/coverage.py + +; ESP32-S3 example compilation. +; Usage: export EXAMPLE=examples/; pio run -e esp32-s3-example -t upload -t monitor +[env:esp32-s3-example] +extends = esp32-s3, example + +; ESP32-S3 tests. +; Usage: pio test -e esp32-s3-test +[env:esp32-s3-test] +extends = esp32-s3, test +build_flags = + ${esp32-s3.build_flags} + ${test.build_flags} diff --git a/scripts/coverage.py b/scripts/coverage.py new file mode 100644 index 0000000..a197f3a --- /dev/null +++ b/scripts/coverage.py @@ -0,0 +1,38 @@ +# SPDX-FileCopyrightText: 2026 Maximiliano Ramirez +# SPDX-License-Identifier: MIT + +# Coverage setup for the native-cov-test environment. Does two things: +# 1) Pass --coverage to the linker. PlatformIO/SCons (4.8.1) forwards -fsanitize to the linker +# automatically, but NOT --coverage (it has no special case in SCons ParseFlags, so it only reaches +# the compiler). Without this, the gcov runtime symbols (__gcov_*) are undefined at link time. +# 2) Add a custom "coverage" target that runs the tests and builds the report with gcovr. +# Use: pio run -e native-cov-test -t coverage (output in coverage/ as XML and HTML). +import platform + +Import("env") + +env.Append(LINKFLAGS=["--coverage"]) + +env_name = env["PIOENV"] + +coverage_dir_cmd = "" +platform_name = platform.system() +if platform_name == "Windows": + coverage_dir_cmd = "if not exist coverage mkdir coverage" +else: + coverage_dir_cmd = "mkdir -p coverage" + +env.AddCustomTarget( + name="coverage", + dependencies=None, + actions=[ + "pio test -e " + env_name, + coverage_dir_cmd, + "gcovr --root . --filter src/ .pio/build/" + + env_name + + " --print-summary" + + " --xml coverage/coverage.xml --html-details coverage/index.html", + ], + title="Local coverage report", + description="Unit tests + gcovr report (XML/HTML) in coverage/", +) diff --git a/scripts/require-example.py b/scripts/require-example.py new file mode 100644 index 0000000..0b7c109 --- /dev/null +++ b/scripts/require-example.py @@ -0,0 +1,36 @@ +# SPDX-FileCopyrightText: 2026 Maximiliano Ramirez +# SPDX-License-Identifier: MIT + +# Check that the EXAMPLE environment variable is defined and points to an existing folder. This +# variable is used to compile an example from the examples/ directory, and it needs to know which +# one to compile. If the variable is not defined or points to a non-existing folder, an error +# message is printed, and the build process is terminated. +import os +import sys + +Import("env") + +example = os.environ.get("EXAMPLE") + +if not example: + sys.stderr.write( + "\n" + "==================================================================================\n" + " ERROR: the EXAMPLE environment variable is not defined.\n" + " This env compiles an example from examples/ and needs to know which one.\n" + " Define EXAMPLE before 'pio run', for example:\n" + ' PowerShell: $env:EXAMPLE="examples/"\n' + ' bash/WSL: export EXAMPLE="examples/"\n' + "==================================================================================\n" + ) + env.Exit(1) +elif not os.path.isdir(os.path.join(env.subst("$PROJECT_DIR"), example)): + sys.stderr.write( + "\n" + "==================================================================================\n" + " ERROR: the example folder does not exist: EXAMPLE=" + example + "\n" + " Check the value (relative path to the project root, e.g., examples/)\n" + " and make sure the folder exists.\n" + "==================================================================================\n" + ) + env.Exit(1) diff --git a/test/test_byteframe.cpp b/test/test_byteframe.cpp index 22404e9..09fdc9c 100644 --- a/test/test_byteframe.cpp +++ b/test/test_byteframe.cpp @@ -28,10 +28,14 @@ * garbage input (no crash, invariants hold), single-byte corruption of valid frames. */ -#include +#ifdef ARDUINO +# include +#endif + #include #include +using namespace ByteFrame; /* ---------------------------------------------------------------------------------------------- */ /* Helpers */ @@ -40,7 +44,7 @@ // Feeds a whole buffer byte by byte and returns true if exactly one valid frame came out, // completed by the very last byte template -bool feedAll(ByteFrame::Decoder& decoder, const uint8_t* data, const size_t size) { +bool feedAll(Decoder& decoder, const uint8_t* data, const size_t size) { for (size_t i = 0; i < size; i++) { const bool frame = decoder.feed(data[i]); if (frame) return i == size - 1; @@ -63,11 +67,11 @@ typename Crc::value_type crcOf(const uint8_t* data, const size_t size) { void test_get_max_encoded_size() { // data = payload + 2 (CRC); frame = data + ceil(data / 254) (COBS) + 1 (delimiter) - constexpr size_t size_1 = ByteFrame::getMaxEncodedSize(1); // 3 + 1 + 1 - constexpr size_t size_10 = ByteFrame::getMaxEncodedSize(10); // 12 + 1 + 1 - constexpr size_t size_252 = ByteFrame::getMaxEncodedSize(252); // 254 + 1 + 1 - constexpr size_t size_253 = ByteFrame::getMaxEncodedSize(253); // 255 + 2 + 1 - constexpr size_t size_300 = ByteFrame::getMaxEncodedSize(300); // 302 + 2 + 1 + constexpr size_t size_1 = getMaxEncodedSize(1); // 3 + 1 + 1 + constexpr size_t size_10 = getMaxEncodedSize(10); // 12 + 1 + 1 + constexpr size_t size_252 = getMaxEncodedSize(252); // 254 + 1 + 1 + constexpr size_t size_253 = getMaxEncodedSize(253); // 255 + 2 + 1 + constexpr size_t size_300 = getMaxEncodedSize(300); // 302 + 2 + 1 TEST_ASSERT_EQUAL(5, size_1); TEST_ASSERT_EQUAL(14, size_10); @@ -87,8 +91,8 @@ void test_encode_known_vector() { const uint8_t expected[13] = {0x0C, '1', '2', '3', '4', '5', '6', '7', '8', '9', 0xB1, 0x29, 0x00}; - uint8_t frame[ByteFrame::getMaxEncodedSize(sizeof(payload))] = {}; - const size_t written = ByteFrame::encode(payload, sizeof(payload), frame, sizeof(frame)); + uint8_t frame[getMaxEncodedSize(sizeof(payload))] = {}; + const size_t written = encode(payload, sizeof(payload), frame, sizeof(frame)); TEST_ASSERT_EQUAL(sizeof(expected), written); TEST_ASSERT_EQUAL_HEX8_ARRAY(expected, frame, sizeof(expected)); @@ -97,7 +101,7 @@ void test_encode_known_vector() { void test_encode_payload_with_zeros() { // data = [0x11, 0x00, 0x22, crc_lo, crc_hi]: the zero splits the data in two COBS blocks const uint8_t payload[3] = {0x11, 0x00, 0x22}; - const uint16_t crc = crcOf(payload, sizeof(payload)); + const uint16_t crc = crcOf(payload, sizeof(payload)); const uint8_t crc_lo = uint8_t(crc & 0xFF); const uint8_t crc_hi = uint8_t(crc >> 8); @@ -107,8 +111,8 @@ void test_encode_payload_with_zeros() { const uint8_t expected[7] = {0x02, 0x11, 0x04, 0x22, crc_lo, crc_hi, 0x00}; - uint8_t frame[ByteFrame::getMaxEncodedSize(sizeof(payload))] = {}; - const size_t written = ByteFrame::encode(payload, sizeof(payload), frame, sizeof(frame)); + uint8_t frame[getMaxEncodedSize(sizeof(payload))] = {}; + const size_t written = encode(payload, sizeof(payload), frame, sizeof(frame)); TEST_ASSERT_EQUAL(7, written); TEST_ASSERT_EQUAL_HEX8_ARRAY(expected, frame, 7); @@ -117,7 +121,7 @@ void test_encode_payload_with_zeros() { void test_encode_leading_zero() { // data = [0x00, crc_lo, crc_hi]: a leading zero produces an empty first block (code 0x01) const uint8_t payload[1] = {0x00}; - const uint16_t crc = crcOf(payload, sizeof(payload)); + const uint16_t crc = crcOf(payload, sizeof(payload)); const uint8_t crc_lo = uint8_t(crc & 0xFF); const uint8_t crc_hi = uint8_t(crc >> 8); @@ -126,8 +130,8 @@ void test_encode_leading_zero() { const uint8_t expected[5] = {0x01, 0x03, crc_lo, crc_hi, 0x00}; - uint8_t frame[ByteFrame::getMaxEncodedSize(sizeof(payload))] = {}; - const size_t written = ByteFrame::encode(payload, sizeof(payload), frame, sizeof(frame)); + uint8_t frame[getMaxEncodedSize(sizeof(payload))] = {}; + const size_t written = encode(payload, sizeof(payload), frame, sizeof(frame)); TEST_ASSERT_EQUAL(5, written); TEST_ASSERT_EQUAL_HEX8_ARRAY(expected, frame, 5); @@ -141,7 +145,7 @@ void test_encode_block_boundary() { payload[i] = uint8_t(1 + (i % 255)); // never zero } - const uint16_t crc = crcOf(payload, sizeof(payload)); + const uint16_t crc = crcOf(payload, sizeof(payload)); const uint8_t crc_lo = uint8_t(crc & 0xFF); const uint8_t crc_hi = uint8_t(crc >> 8); @@ -149,8 +153,8 @@ void test_encode_block_boundary() { TEST_ASSERT_NOT_EQUAL(0x00, crc_lo); TEST_ASSERT_NOT_EQUAL(0x00, crc_hi); - uint8_t frame[ByteFrame::getMaxEncodedSize(sizeof(payload))] = {}; - const size_t written = ByteFrame::encode(payload, sizeof(payload), frame, sizeof(frame)); + uint8_t frame[getMaxEncodedSize(sizeof(payload))] = {}; + const size_t written = encode(payload, sizeof(payload), frame, sizeof(frame)); TEST_ASSERT_EQUAL(256, written); // 1 (code) + 254 (data) + 1 (delimiter) TEST_ASSERT_EQUAL_HEX8(0xFF, frame[0]); @@ -165,11 +169,11 @@ void test_encode_block_boundary() { payload2[i] = uint8_t(1 + (i % 255)); } - uint8_t frame2[ByteFrame::getMaxEncodedSize(sizeof(payload2))] = {}; - const size_t written2 = ByteFrame::encode(payload2, sizeof(payload2), frame2, sizeof(frame2)); + uint8_t frame2[getMaxEncodedSize(sizeof(payload2))] = {}; + const size_t written2 = encode(payload2, sizeof(payload2), frame2, sizeof(frame2)); TEST_ASSERT_TRUE(written2 > 0); - TEST_ASSERT_TRUE(written2 <= ByteFrame::getMaxEncodedSize(sizeof(payload2))); + TEST_ASSERT_TRUE(written2 <= getMaxEncodedSize(sizeof(payload2))); TEST_ASSERT_EQUAL_HEX8(0xFF, frame2[0]); } @@ -177,11 +181,11 @@ void test_encode_structural_properties() { // Whatever the payload, a frame ends with exactly one delimiter and contains no other zero const uint8_t payload[6] = {0x00, 0xFF, 0x00, 0x00, 0x01, 0x00}; - uint8_t frame[ByteFrame::getMaxEncodedSize(sizeof(payload))] = {}; - const size_t written = ByteFrame::encode(payload, sizeof(payload), frame, sizeof(frame)); + uint8_t frame[getMaxEncodedSize(sizeof(payload))] = {}; + const size_t written = encode(payload, sizeof(payload), frame, sizeof(frame)); TEST_ASSERT_TRUE(written > 0); - TEST_ASSERT_TRUE(written <= ByteFrame::getMaxEncodedSize(sizeof(payload))); + TEST_ASSERT_TRUE(written <= getMaxEncodedSize(sizeof(payload))); TEST_ASSERT_EQUAL_HEX8(0x00, frame[written - 1]); for (size_t i = 0; i < written - 1; i++) { TEST_ASSERT_NOT_EQUAL(0x00, frame[i]); @@ -193,14 +197,14 @@ void test_encode_rejects_invalid_input() { const uint8_t payload[4] = {1, 2, 3, 4}; // Empty payload - TEST_ASSERT_EQUAL(0, ByteFrame::encode(payload, 0, frame, sizeof(frame))); + TEST_ASSERT_EQUAL(0, encode(payload, 0, frame, sizeof(frame))); // Exact fit works, one byte less does not (and returns 0) - const size_t written = ByteFrame::encode(payload, sizeof(payload), frame, sizeof(frame)); + const size_t written = encode(payload, sizeof(payload), frame, sizeof(frame)); TEST_ASSERT_TRUE(written > 0); - TEST_ASSERT_EQUAL(written, ByteFrame::encode(payload, sizeof(payload), frame, written)); - TEST_ASSERT_EQUAL(0, ByteFrame::encode(payload, sizeof(payload), frame, written - 1)); - TEST_ASSERT_EQUAL(0, ByteFrame::encode(payload, sizeof(payload), frame, 0)); + TEST_ASSERT_EQUAL(written, encode(payload, sizeof(payload), frame, written)); + TEST_ASSERT_EQUAL(0, encode(payload, sizeof(payload), frame, written - 1)); + TEST_ASSERT_EQUAL(0, encode(payload, sizeof(payload), frame, 0)); } /* ---------------------------------------------------------------------------------------------- */ @@ -212,13 +216,12 @@ void test_encoder_streaming_matches_one_shot() { // bytes as the one-shot encode() and decode back to the original const uint8_t payload[9] = {0x11, 0x00, 0x22, 0x00, 0xAB, 0xCD, 0x00, 0xEE, 0xFF}; - uint8_t one_shot[ByteFrame::getMaxEncodedSize(sizeof(payload))] = {}; - const size_t one_shot_size = - ByteFrame::encode(payload, sizeof(payload), one_shot, sizeof(one_shot)); + uint8_t one_shot[getMaxEncodedSize(sizeof(payload))] = {}; + const size_t one_shot_size = encode(payload, sizeof(payload), one_shot, sizeof(one_shot)); TEST_ASSERT_TRUE(one_shot_size > 0); - uint8_t streamed[ByteFrame::getMaxEncodedSize(sizeof(payload))] = {}; - ByteFrame::Encoder<> encoder(streamed, sizeof(streamed)); + uint8_t streamed[getMaxEncodedSize(sizeof(payload))] = {}; + Encoder<> encoder(streamed, sizeof(streamed)); encoder.feed(payload, 3); // first chunk encoder.feed(payload + 3, 4); // second chunk encoder.feed(payload + 7, 2); // third chunk @@ -227,7 +230,7 @@ void test_encoder_streaming_matches_one_shot() { TEST_ASSERT_EQUAL(one_shot_size, streamed_size); TEST_ASSERT_EQUAL_HEX8_ARRAY(one_shot, streamed, one_shot_size); - ByteFrame::Decoder<32> decoder; + Decoder<32> decoder; TEST_ASSERT_TRUE(feedAll(decoder, streamed, streamed_size)); TEST_ASSERT_EQUAL(sizeof(payload), decoder.getPayloadSize()); TEST_ASSERT_EQUAL_HEX8_ARRAY(payload, decoder.getPayload(), sizeof(payload)); @@ -241,12 +244,11 @@ void test_encoder_streaming_block_boundary() { payload[i] = uint8_t(1 + (i % 255)); // never zero } - uint8_t one_shot[ByteFrame::getMaxEncodedSize(sizeof(payload))] = {}; - const size_t one_shot_size = - ByteFrame::encode(payload, sizeof(payload), one_shot, sizeof(one_shot)); + uint8_t one_shot[getMaxEncodedSize(sizeof(payload))] = {}; + const size_t one_shot_size = encode(payload, sizeof(payload), one_shot, sizeof(one_shot)); - uint8_t streamed[ByteFrame::getMaxEncodedSize(sizeof(payload))] = {}; - ByteFrame::Encoder<> encoder(streamed, sizeof(streamed)); + uint8_t streamed[getMaxEncodedSize(sizeof(payload))] = {}; + Encoder<> encoder(streamed, sizeof(streamed)); encoder.feed(payload, 100); encoder.feed(payload + 100, sizeof(payload) - 100); const size_t streamed_size = encoder.finalize(); @@ -258,8 +260,8 @@ void test_encoder_streaming_block_boundary() { void test_encoder_reuse_and_limits() { const uint8_t payload[4] = {0x01, 0x02, 0x03, 0x04}; - uint8_t frame[ByteFrame::getMaxEncodedSize(sizeof(payload))] = {}; - ByteFrame::Encoder<> encoder(frame, sizeof(frame)); + uint8_t frame[getMaxEncodedSize(sizeof(payload))] = {}; + Encoder<> encoder(frame, sizeof(frame)); // finalize() with nothing fed yields no frame TEST_ASSERT_EQUAL(0, encoder.finalize()); @@ -277,8 +279,8 @@ void test_encoder_reuse_and_limits() { TEST_ASSERT_EQUAL(size1, size2); // A capacity one byte too small overflows: finalize() returns 0 and isOk() turns false - uint8_t tight[ByteFrame::getMaxEncodedSize(sizeof(payload))] = {}; - ByteFrame::Encoder<> small(tight, size1 - 1); + uint8_t tight[getMaxEncodedSize(sizeof(payload))] = {}; + Encoder<> small(tight, size1 - 1); small.feed(payload, sizeof(payload)); TEST_ASSERT_EQUAL(0, small.finalize()); TEST_ASSERT_FALSE(small.isOk()); @@ -289,7 +291,7 @@ void test_encoder_reuse_and_limits() { /* ---------------------------------------------------------------------------------------------- */ void test_decoder_initial_state() { - ByteFrame::Decoder<32> decoder; + Decoder<32> decoder; TEST_ASSERT_FALSE(decoder.isFrameAvailable()); TEST_ASSERT_EQUAL(0, decoder.getPayloadSize()); @@ -303,11 +305,11 @@ void test_decoder_initial_state() { void test_decoder_byte_feed_round_trip() { const uint8_t payload[5] = {0xDE, 0xAD, 0xBE, 0xEF, 0x42}; - uint8_t frame[ByteFrame::getMaxEncodedSize(sizeof(payload))] = {}; - const size_t written = ByteFrame::encode(payload, sizeof(payload), frame, sizeof(frame)); + uint8_t frame[getMaxEncodedSize(sizeof(payload))] = {}; + const size_t written = encode(payload, sizeof(payload), frame, sizeof(frame)); TEST_ASSERT_TRUE(written > 0); - ByteFrame::Decoder<32> decoder; + Decoder<32> decoder; // The frame becomes available exactly at the delimiter, not before for (size_t i = 0; i < written - 1; i++) { @@ -335,17 +337,15 @@ void test_decoder_chunk_feed_multiple_frames() { const uint8_t payload2[4] = {0xAA, 0x00, 0xBB, 0x00}; // Two frames back to back in a single buffer, as they would arrive from a stream - uint8_t stream[ByteFrame::getMaxEncodedSize(sizeof(payload1)) + - ByteFrame::getMaxEncodedSize(sizeof(payload2))] = {}; + uint8_t stream[getMaxEncodedSize(sizeof(payload1)) + getMaxEncodedSize(sizeof(payload2))] = {}; - const size_t size1 = ByteFrame::encode(payload1, sizeof(payload1), stream, sizeof(stream)); + const size_t size1 = encode(payload1, sizeof(payload1), stream, sizeof(stream)); TEST_ASSERT_TRUE(size1 > 0); - const size_t size2 = - ByteFrame::encode(payload2, sizeof(payload2), stream + size1, sizeof(stream) - size1); + const size_t size2 = encode(payload2, sizeof(payload2), stream + size1, sizeof(stream) - size1); TEST_ASSERT_TRUE(size2 > 0); const size_t total = size1 + size2; - ByteFrame::Decoder<32> decoder; + Decoder<32> decoder; // First call consumes up to and including the delimiter of the first frame size_t consumed = decoder.feed(stream, total); @@ -363,7 +363,7 @@ void test_decoder_chunk_feed_multiple_frames() { } void test_decoder_max_payload() { - ByteFrame::Decoder<16> decoder; + Decoder<16> decoder; // A payload of exactly MaxPayload bytes is accepted uint8_t payload[17]; @@ -371,21 +371,21 @@ void test_decoder_max_payload() { payload[i] = uint8_t(i + 1); } - uint8_t frame[ByteFrame::getMaxEncodedSize(sizeof(payload))] = {}; - size_t written = ByteFrame::encode(payload, 16, frame, sizeof(frame)); + uint8_t frame[getMaxEncodedSize(sizeof(payload))] = {}; + size_t written = encode(payload, 16, frame, sizeof(frame)); TEST_ASSERT_TRUE(feedAll(decoder, frame, written)); TEST_ASSERT_EQUAL(16, decoder.getPayloadSize()); TEST_ASSERT_EQUAL_HEX8_ARRAY(payload, decoder.getPayload(), 16); TEST_ASSERT_EQUAL(0, decoder.getStats().overflows); // One byte more overflows: no frame, counted once, parser recovers afterwards - written = ByteFrame::encode(payload, 17, frame, sizeof(frame)); + written = encode(payload, 17, frame, sizeof(frame)); TEST_ASSERT_FALSE(feedAll(decoder, frame, written)); TEST_ASSERT_FALSE(decoder.isFrameAvailable()); TEST_ASSERT_EQUAL(1, decoder.getStats().overflows); // A valid frame right after the oversized one decodes normally - written = ByteFrame::encode(payload, 16, frame, sizeof(frame)); + written = encode(payload, 16, frame, sizeof(frame)); TEST_ASSERT_TRUE(feedAll(decoder, frame, written)); TEST_ASSERT_EQUAL(16, decoder.getPayloadSize()); TEST_ASSERT_EQUAL(1, decoder.getStats().overflows); @@ -394,28 +394,28 @@ void test_decoder_max_payload() { void test_decoder_crc_error() { const uint8_t payload[4] = {0x10, 0x20, 0x30, 0x40}; - uint8_t frame[ByteFrame::getMaxEncodedSize(sizeof(payload))] = {}; - const size_t written = ByteFrame::encode(payload, sizeof(payload), frame, sizeof(frame)); + uint8_t frame[getMaxEncodedSize(sizeof(payload))] = {}; + const size_t written = encode(payload, sizeof(payload), frame, sizeof(frame)); TEST_ASSERT_TRUE(written > 0); // Corrupt one payload byte inside the frame, keeping it non-zero so the COBS structure (and // therefore the frame boundary) is preserved: the CRC must catch it frame[2] = (frame[2] == 0x7F) ? 0x7E : 0x7F; - ByteFrame::Decoder<32> decoder; + Decoder<32> decoder; TEST_ASSERT_FALSE(feedAll(decoder, frame, written)); TEST_ASSERT_FALSE(decoder.isFrameAvailable()); TEST_ASSERT_EQUAL(1, decoder.getStats().crc_errors); TEST_ASSERT_EQUAL(0, decoder.getStats().malformed); // The decoder keeps working after the error - const size_t written2 = ByteFrame::encode(payload, sizeof(payload), frame, sizeof(frame)); + const size_t written2 = encode(payload, sizeof(payload), frame, sizeof(frame)); TEST_ASSERT_TRUE(feedAll(decoder, frame, written2)); TEST_ASSERT_EQUAL_HEX8_ARRAY(payload, decoder.getPayload(), sizeof(payload)); } void test_decoder_malformed_frames() { - ByteFrame::Decoder<32> decoder; + Decoder<32> decoder; // Code byte announces 4 data bytes but the delimiter arrives mid-block TEST_ASSERT_FALSE(decoder.feed(uint8_t(0x05))); @@ -435,11 +435,11 @@ void test_decoder_malformed_frames() { } void test_decoder_idle_delimiters_ignored() { - ByteFrame::Decoder<32> decoder; + Decoder<32> decoder; // Idle delimiters (empty frames) are not data and not errors for (int i = 0; i < 10; i++) { - TEST_ASSERT_FALSE(decoder.feed(ByteFrame::DELIMITER)); + TEST_ASSERT_FALSE(decoder.feed(DELIMITER)); } TEST_ASSERT_EQUAL(0, decoder.getStats().crc_errors); @@ -450,11 +450,11 @@ void test_decoder_idle_delimiters_ignored() { void test_decoder_resync_mid_stream() { const uint8_t payload[6] = {0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F}; - uint8_t frame[ByteFrame::getMaxEncodedSize(sizeof(payload))] = {}; - const size_t written = ByteFrame::encode(payload, sizeof(payload), frame, sizeof(frame)); + uint8_t frame[getMaxEncodedSize(sizeof(payload))] = {}; + const size_t written = encode(payload, sizeof(payload), frame, sizeof(frame)); TEST_ASSERT_TRUE(written > 0); - ByteFrame::Decoder<32> decoder; + Decoder<32> decoder; // Join the stream in the middle of a frame: the partial frame is dropped (one error of some // kind), and the next complete frame decodes normally @@ -471,11 +471,11 @@ void test_decoder_resync_mid_stream() { void test_decoder_reset() { const uint8_t payload[4] = {0x11, 0x22, 0x33, 0x44}; - uint8_t frame[ByteFrame::getMaxEncodedSize(sizeof(payload))] = {}; - const size_t written = ByteFrame::encode(payload, sizeof(payload), frame, sizeof(frame)); + uint8_t frame[getMaxEncodedSize(sizeof(payload))] = {}; + const size_t written = encode(payload, sizeof(payload), frame, sizeof(frame)); TEST_ASSERT_TRUE(written > 0); - ByteFrame::Decoder<32> decoder; + Decoder<32> decoder; // Feed half a frame, reset, then feed a complete frame: it must decode cleanly with no errors for (size_t i = 0; i < written / 2; i++) { @@ -496,10 +496,10 @@ void test_decoder_reset() { void test_round_trip_all_sizes() { constexpr size_t MAX_PAYLOAD = 64; - ByteFrame::Decoder decoder; + Decoder decoder; uint8_t payload[MAX_PAYLOAD]; - uint8_t frame[ByteFrame::getMaxEncodedSize(MAX_PAYLOAD)] = {}; + uint8_t frame[getMaxEncodedSize(MAX_PAYLOAD)] = {}; for (size_t size = 1; size <= MAX_PAYLOAD; size++) { // Pattern with zeros sprinkled in to exercise the COBS paths @@ -507,9 +507,9 @@ void test_round_trip_all_sizes() { payload[i] = (i % 3 == 0) ? 0x00 : uint8_t(i); } - const size_t written = ByteFrame::encode(payload, size, frame, sizeof(frame)); + const size_t written = encode(payload, size, frame, sizeof(frame)); TEST_ASSERT_TRUE(written > 0); - TEST_ASSERT_TRUE(written <= ByteFrame::getMaxEncodedSize(size)); + TEST_ASSERT_TRUE(written <= getMaxEncodedSize(size)); TEST_ASSERT_TRUE(feedAll(decoder, frame, written)); TEST_ASSERT_EQUAL(size, decoder.getPayloadSize()); @@ -529,33 +529,33 @@ void test_round_trip_all_sizes() { void test_crc_policies_check_values() { // Standard CRC check values over the ASCII string "123456789" const uint8_t data[9] = {'1', '2', '3', '4', '5', '6', '7', '8', '9'}; - TEST_ASSERT_EQUAL_HEX8(0xF4, crcOf(data, sizeof(data))); - TEST_ASSERT_EQUAL_HEX16(0x29B1, crcOf(data, sizeof(data))); - TEST_ASSERT_EQUAL_HEX32(0xCBF43926, crcOf(data, sizeof(data))); + TEST_ASSERT_EQUAL_HEX8(0xF4, crcOf(data, sizeof(data))); + TEST_ASSERT_EQUAL_HEX16(0x29B1, crcOf(data, sizeof(data))); + TEST_ASSERT_EQUAL_HEX32(0xCBF43926, crcOf(data, sizeof(data))); } void test_decode_one_shot() { const uint8_t payload[7] = {0x11, 0x00, 0x22, 0xFF, 0x00, 0xAB, 0x00}; - uint8_t frame[ByteFrame::getMaxEncodedSize(sizeof(payload))] = {}; - const size_t written = ByteFrame::encode(payload, sizeof(payload), frame, sizeof(frame)); + uint8_t frame[getMaxEncodedSize(sizeof(payload))] = {}; + const size_t written = encode(payload, sizeof(payload), frame, sizeof(frame)); TEST_ASSERT_TRUE(written > 0); // Round-trips: only the payload is written out, so the buffer is just the payload size uint8_t out[sizeof(payload)] = {}; - TEST_ASSERT_EQUAL(sizeof(payload), ByteFrame::decode(frame, written, out, sizeof(out))); + TEST_ASSERT_EQUAL(sizeof(payload), decode(frame, written, out, sizeof(out))); TEST_ASSERT_EQUAL_HEX8_ARRAY(payload, out, sizeof(payload)); // The trailing delimiter is optional: decoding the frame without it yields the same payload - TEST_ASSERT_EQUAL(sizeof(payload), ByteFrame::decode(frame, written - 1, out, sizeof(out))); + TEST_ASSERT_EQUAL(sizeof(payload), decode(frame, written - 1, out, sizeof(out))); TEST_ASSERT_EQUAL_HEX8_ARRAY(payload, out, sizeof(payload)); // A buffer too small for the payload is rejected - TEST_ASSERT_EQUAL(0, ByteFrame::decode(frame, written, out, sizeof(out) - 1)); + TEST_ASSERT_EQUAL(0, decode(frame, written, out, sizeof(out) - 1)); // Corrupting a payload byte (kept non-zero) is caught by the CRC frame[1] = (frame[1] == 0x7F) ? 0x7E : 0x7F; - TEST_ASSERT_EQUAL(0, ByteFrame::decode(frame, written, out, sizeof(out))); + TEST_ASSERT_EQUAL(0, decode(frame, written, out, sizeof(out))); } // Encodes a fixed payload and decodes it back through both decode paths with a given CRC policy @@ -563,33 +563,33 @@ template void roundTripWithCrc() { const uint8_t payload[7] = {0x11, 0x00, 0x22, 0xFF, 0x00, 0xAB, 0x00}; - uint8_t frame[ByteFrame::getMaxEncodedSize(sizeof(payload))] = {}; - const size_t written = ByteFrame::encode(payload, sizeof(payload), frame, sizeof(frame)); + uint8_t frame[getMaxEncodedSize(sizeof(payload))] = {}; + const size_t written = encode(payload, sizeof(payload), frame, sizeof(frame)); TEST_ASSERT_TRUE(written > 0); // One-shot decode uint8_t out[sizeof(payload)] = {}; - TEST_ASSERT_EQUAL(sizeof(payload), ByteFrame::decode(frame, written, out, sizeof(out))); + TEST_ASSERT_EQUAL(sizeof(payload), decode(frame, written, out, sizeof(out))); TEST_ASSERT_EQUAL_HEX8_ARRAY(payload, out, sizeof(payload)); // Streaming decoder with the same policy - ByteFrame::Decoder<32, Crc> decoder; + Decoder<32, Crc> decoder; TEST_ASSERT_TRUE(feedAll(decoder, frame, written)); TEST_ASSERT_EQUAL(sizeof(payload), decoder.getPayloadSize()); TEST_ASSERT_EQUAL_HEX8_ARRAY(payload, decoder.getPayload(), sizeof(payload)); } void test_crc_variants_round_trip() { - roundTripWithCrc(); - roundTripWithCrc(); - roundTripWithCrc(); - roundTripWithCrc(); + roundTripWithCrc(); + roundTripWithCrc(); + roundTripWithCrc(); + roundTripWithCrc(); // The CRC width drives the encoded size: a wider CRC yields a larger frame - const size_t no_crc = ByteFrame::getMaxEncodedSize(8); - const size_t crc8 = ByteFrame::getMaxEncodedSize(8); - const size_t crc16 = ByteFrame::getMaxEncodedSize(8); - const size_t crc32 = ByteFrame::getMaxEncodedSize(8); + const size_t no_crc = getMaxEncodedSize(8); + const size_t crc8 = getMaxEncodedSize(8); + const size_t crc16 = getMaxEncodedSize(8); + const size_t crc32 = getMaxEncodedSize(8); TEST_ASSERT_TRUE(no_crc < crc8); TEST_ASSERT_TRUE(crc8 < crc16); TEST_ASSERT_TRUE(crc16 < crc32); @@ -615,10 +615,10 @@ void test_stress_round_trip_random_chunks() { stress_state = 0xC0FFEE11; constexpr size_t MAX_PAYLOAD = 64; - ByteFrame::Decoder decoder; + Decoder decoder; uint8_t payload[MAX_PAYLOAD]; - uint8_t frame[ByteFrame::getMaxEncodedSize(MAX_PAYLOAD)] = {}; + uint8_t frame[getMaxEncodedSize(MAX_PAYLOAD)] = {}; for (uint32_t i = 0; i < STRESS_ITERATIONS; i++) { const size_t size = 1 + (nextRandom() % MAX_PAYLOAD); @@ -626,7 +626,7 @@ void test_stress_round_trip_random_chunks() { payload[j] = uint8_t(nextRandom()); } - const size_t written = ByteFrame::encode(payload, size, frame, sizeof(frame)); + const size_t written = encode(payload, size, frame, sizeof(frame)); TEST_ASSERT_TRUE(written > 0); // Feed the frame in random-sized chunks, as a stream would deliver it @@ -651,7 +651,7 @@ void test_stress_round_trip_random_chunks() { void test_stress_garbage_input() { stress_state = 0xA5A5A5A5; - ByteFrame::Decoder<32> decoder; + Decoder<32> decoder; for (uint32_t i = 0; i < STRESS_ITERATIONS; i++) { // Random bytes, delimiters included. Random garbage virtually never carries a valid CRC; the @@ -663,11 +663,11 @@ void test_stress_garbage_input() { } // After the garbage, a valid frame still decodes - const uint8_t payload[3] = {0x01, 0x02, 0x03}; - uint8_t frame[ByteFrame::getMaxEncodedSize(sizeof(payload))] = {}; - const size_t written = ByteFrame::encode(payload, sizeof(payload), frame, sizeof(frame)); + const uint8_t payload[3] = {0x01, 0x02, 0x03}; + uint8_t frame[getMaxEncodedSize(sizeof(payload))] = {}; + const size_t written = encode(payload, sizeof(payload), frame, sizeof(frame)); - decoder.feed(ByteFrame::DELIMITER); // close any partial garbage frame first + decoder.feed(DELIMITER); // close any partial garbage frame first TEST_ASSERT_TRUE(feedAll(decoder, frame, written)); TEST_ASSERT_EQUAL_HEX8_ARRAY(payload, decoder.getPayload(), sizeof(payload)); } @@ -676,10 +676,10 @@ void test_stress_corruption() { stress_state = 0xDEADBEEF; constexpr size_t MAX_PAYLOAD = 32; - ByteFrame::Decoder decoder; + Decoder decoder; uint8_t payload[MAX_PAYLOAD]; - uint8_t frame[ByteFrame::getMaxEncodedSize(MAX_PAYLOAD)] = {}; + uint8_t frame[getMaxEncodedSize(MAX_PAYLOAD)] = {}; for (uint32_t i = 0; i < STRESS_ITERATIONS; i++) { const size_t size = 1 + (nextRandom() % MAX_PAYLOAD); @@ -687,7 +687,7 @@ void test_stress_corruption() { payload[j] = uint8_t(nextRandom()); } - const size_t written = ByteFrame::encode(payload, size, frame, sizeof(frame)); + const size_t written = encode(payload, size, frame, sizeof(frame)); TEST_ASSERT_TRUE(written > 0); // Corrupt one random byte of the encoded frame (delimiter excluded), forcing a real change. @@ -713,18 +713,23 @@ void test_stress_corruption() { // Restore the frame and close any partial state so iterations stay independent frame[corrupt_pos] = original; - decoder.feed(ByteFrame::DELIMITER); + decoder.feed(DELIMITER); } } /* ---------------------------------------------------------------------------------------------- */ -/* setup / loop */ +/* Runners */ /* ---------------------------------------------------------------------------------------------- */ -void setup() { - Serial.begin(115200); - delay(2000); +void setUp(void) { + // set stuff up here +} + +void tearDown(void) { + // clean stuff up here +} +int runUnityTests(void) { UNITY_BEGIN(); // Size helper @@ -767,7 +772,17 @@ void setup() { RUN_TEST(test_stress_garbage_input); RUN_TEST(test_stress_corruption); - UNITY_END(); + return UNITY_END(); } +// For native +int main(void) { return runUnityTests(); } + +// For Arduino framework +#ifdef ARDUINO +void setup() { + delay(2000); + runUnityTests(); +} void loop() {} +#endif From 3593b2c3101ca387f164174396f3c43d36c9d7e2 Mon Sep 17 00:00:00 2001 From: alkonosst Date: Wed, 24 Jun 2026 17:19:03 -0400 Subject: [PATCH 2/4] test: Improve coverage by testing all possible CRC branches --- src/ByteFrame.h | 21 ++-- test/test_byteframe.cpp | 226 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 241 insertions(+), 6 deletions(-) diff --git a/src/ByteFrame.h b/src/ByteFrame.h index bee1f79..3e0aabb 100644 --- a/src/ByteFrame.h +++ b/src/ByteFrame.h @@ -458,9 +458,12 @@ class Decoder { for (size_t i = 0; i < payload_size; i++) crc = Crc::update(crc, _buffer[i]); - if (Crc::finalize(crc) != received) { - _stats.crc_errors++; - return; + // With NoCrc (Crc::SIZE == 0) both received and Crc::finalize() are always 0, so this check can + // never fail: the branch and its body are unreachable for that policy and excluded from + // coverage + if (Crc::finalize(crc) != received) { // GCOVR_EXCL_BR_LINE + _stats.crc_errors++; // GCOVR_EXCL_LINE + return; // GCOVR_EXCL_LINE } _payload_size = payload_size; @@ -596,18 +599,24 @@ size_t decode(const uint8_t* frame, const size_t frame_size, uint8_t* payload, // literal block bytes for (size_t i = append_zero ? 0 : 1; i <= block; i++) { const uint8_t byte = (i == 0) ? uint8_t(0x00) : frame[in++]; - if (produced < payload_size) { + + // With NoCrc (Crc::SIZE == 0) payload_size == total, so produced never reaches it: the else + // (rebuilding the received CRC) is unreachable for that policy and excluded from coverage + if (produced < payload_size) { // GCOVR_EXCL_BR_LINE payload[produced] = byte; crc = Crc::update(crc, byte); } else { - received = crc_type(received | (crc_type(byte) << (8 * (produced - payload_size)))); + // clang-format off + received = crc_type( received | (crc_type(byte) << (8 * (produced - payload_size)))); // GCOVR_EXCL_LINE + // clang-format on } produced++; } append_zero = (code != 0xFF); } - if (Crc::finalize(crc) != received) return 0; + // Unreachable with NoCrc (Crc::SIZE == 0): received and Crc::finalize() are both 0 (see above) + if (Crc::finalize(crc) != received) return 0; // GCOVR_EXCL_BR_LINE return payload_size; } diff --git a/test/test_byteframe.cpp b/test/test_byteframe.cpp index 09fdc9c..faaabdc 100644 --- a/test/test_byteframe.cpp +++ b/test/test_byteframe.cpp @@ -595,6 +595,226 @@ void test_crc_variants_round_trip() { TEST_ASSERT_TRUE(crc16 < crc32); } +/* ---------------------------------------------------------------------------------------------- */ +/* All paths across CRC policies */ +/* ---------------------------------------------------------------------------------------------- */ + +/** + * Encoder, Decoder, encode(), decode() and getMaxEncodedSize() are templates: gcov tracks every CRC + * instantiation separately, so a branch exercised only for the default Crc16CcittFalse still counts + * as uncovered for NoCrc, Crc8Smbus and Crc32IsoHdlc. The templated helpers below drive every error + * and edge path and are instantiated once per policy so each instantiation reaches full coverage. + */ + +// Encode a payload that crosses a full COBS block and contains zeros into every output-buffer +// capacity from 0 to the worst case. The full capacity exercises the happy path; each smaller +// capacity trips a different overflow branch (literal byte, encoded zero, full-block successor, CRC +// bytes, trailing delimiter). Every frame that is produced must round-trip back to the payload. +template +void exerciseEncoderOverflow() { + constexpr size_t N = 300; // > 254 so the payload crosses a full COBS block boundary + uint8_t payload[N]; + for (size_t i = 0; i < N; i++) { + payload[i] = uint8_t(1 + (i % 254)); // never zero + } + payload[260] = 0x00; // zeros past the first block boundary (encoded-zero overflow paths) + payload[280] = 0x00; + + uint8_t frame[getMaxEncodedSize(N)] = {}; + uint8_t out[N] = {}; + bool produced = false; + bool overflowed = false; + + for (size_t cap = 0; cap <= sizeof(frame); cap++) { + const size_t written = encode(payload, N, frame, cap); + if (written == 0) { + overflowed = true; + continue; + } + produced = true; + TEST_ASSERT_TRUE(written <= getMaxEncodedSize(N)); + TEST_ASSERT_EQUAL(N, decode(frame, written, out, sizeof(out))); + TEST_ASSERT_EQUAL_HEX8_ARRAY(payload, out, N); + } + + TEST_ASSERT_TRUE(produced); + TEST_ASSERT_TRUE(overflowed); + + // finalize() on a fresh encoder with nothing fed yields no frame (the "empty" guard) + Encoder empty(frame, sizeof(frame)); + TEST_ASSERT_EQUAL(0, empty.finalize()); +} + +// A payload whose data (payload + CRC) is exactly 254 bytes encodes to a single full COBS block +// (code 0xFF) with no trailing code byte: this exercises the block-boundary close at finalize(). +template +void exerciseEncoderFullBlock() { + constexpr size_t payload_size = 254 - Crc::SIZE; // data == 254 == one block + uint8_t payload[payload_size] = {}; + uint8_t frame[getMaxEncodedSize(payload_size)] = {}; + + size_t written = 0; + for (unsigned seed = 0; seed < 256; seed++) { + for (size_t i = 0; i < payload_size; i++) { + payload[i] = uint8_t(1 + ((i + seed) % 254)); + } + written = encode(payload, payload_size, frame, sizeof(frame)); + // 1 code byte + 254 data bytes + delimiter == 256: reached only when the last data byte (the + // top CRC byte) is non-zero, so the block closes "full" instead of via an encoded zero. + if (written == 256 && frame[0] == 0xFF) break; + } + + TEST_ASSERT_EQUAL(256, written); + TEST_ASSERT_EQUAL_HEX8(0xFF, frame[0]); + TEST_ASSERT_EQUAL_HEX8(DELIMITER, frame[written - 1]); + + uint8_t out[payload_size] = {}; + TEST_ASSERT_EQUAL(payload_size, decode(frame, written, out, sizeof(out))); + TEST_ASSERT_EQUAL_HEX8_ARRAY(payload, out, payload_size); +} + +// Drive every Decoder path: idle delimiter, happy frame, mid-block and too-short malformed frames, +// overflow on a literal byte and on an implicit inter-block zero, recovery, and a CRC mismatch. +// Templated on MaxPayload as well, because each Decoder is a distinct gcov +// instantiation that must be exercised in full. +template +void exerciseDecoderPaths() { + Decoder decoder; + + // getPayloadSize() before any frame is available -> 0 + TEST_ASSERT_EQUAL(0, decoder.getPayloadSize()); + + // Idle delimiter: an empty frame is ignored, not an error + TEST_ASSERT_FALSE(decoder.feed(DELIMITER)); + + // Happy path + const uint8_t payload[8] = {0x11, 0x00, 0x22, 0xFF, 0x00, 0xAB, 0xCD, 0x00}; + uint8_t frame[getMaxEncodedSize(sizeof(payload))] = {}; + size_t written = encode(payload, sizeof(payload), frame, sizeof(frame)); + TEST_ASSERT_TRUE(feedAll(decoder, frame, written)); + TEST_ASSERT_EQUAL(sizeof(payload), decoder.getPayloadSize()); + TEST_ASSERT_EQUAL_HEX8_ARRAY(payload, decoder.getPayload(), sizeof(payload)); + TEST_ASSERT_EQUAL(1, decoder.getStats().frames_ok); + + // Malformed: the delimiter arrives in the middle of a block + decoder.feed(uint8_t(0x05)); // announces 4 data bytes + decoder.feed(uint8_t(0x11)); + decoder.feed(uint8_t(0x22)); + TEST_ASSERT_FALSE(decoder.feed(DELIMITER)); + TEST_ASSERT_EQUAL(1, decoder.getStats().malformed); + + // Malformed: a frame decoding to fewer than 1 + Crc::SIZE bytes is too short (only meaningful + // when the policy carries a CRC; with NoCrc a single decoded byte is already a valid frame) + if (Crc::SIZE > 0) { + decoder.feed(uint8_t(0x02)); // announces 1 data byte + decoder.feed(uint8_t(0x41)); + TEST_ASSERT_FALSE(decoder.feed(DELIMITER)); + TEST_ASSERT_EQUAL(2, decoder.getStats().malformed); + } + + // Overflow on a literal byte: a run of non-zero bytes longer than the buffer + uint8_t big[MaxPayload + 8]; + for (size_t i = 0; i < sizeof(big); i++) { + big[i] = uint8_t(1 + (i % 254)); + } + uint8_t bigframe[getMaxEncodedSize(sizeof(big))] = {}; + written = encode(big, sizeof(big), bigframe, sizeof(bigframe)); + TEST_ASSERT_FALSE(feedAll(decoder, bigframe, written)); + TEST_ASSERT_EQUAL(1, decoder.getStats().overflows); + + // The decoder recovers after the overflow + written = encode(payload, sizeof(payload), frame, sizeof(frame)); + TEST_ASSERT_TRUE(feedAll(decoder, frame, written)); + + // Overflow exactly on an implicit inter-block zero: a zero placed right at the buffer limit, so + // the buffer fills on the literal run and the next push is the implied zero between blocks + uint8_t big_zero[MaxPayload + 8]; + for (size_t i = 0; i < sizeof(big_zero); i++) { + big_zero[i] = uint8_t(1 + (i % 254)); + } + big_zero[MaxPayload + Crc::SIZE] = 0x00; + uint8_t bigzframe[getMaxEncodedSize(sizeof(big_zero))] = {}; + written = encode(big_zero, sizeof(big_zero), bigzframe, sizeof(bigzframe)); + const uint32_t before = decoder.getStats().overflows; + TEST_ASSERT_FALSE(feedAll(decoder, bigzframe, written)); + TEST_ASSERT_EQUAL(before + 1, decoder.getStats().overflows); + + // CRC mismatch: corrupt a payload byte, keeping it non-zero so the COBS structure survives (only + // a policy that carries a CRC can detect this) + if (Crc::SIZE > 0) { + written = encode(payload, sizeof(payload), frame, sizeof(frame)); + frame[1] = (frame[1] == 0x7F) ? 0x7E : 0x7F; + const uint32_t crc_before = decoder.getStats().crc_errors; + TEST_ASSERT_FALSE(feedAll(decoder, frame, written)); + TEST_ASSERT_EQUAL(crc_before + 1, decoder.getStats().crc_errors); + } +} + +// Drive every one-shot decode() path: happy round-trip, optional trailing delimiter, capacity +// rejection, truncated COBS, interior zero, too-short frame and CRC mismatch. +template +void exerciseDecodePaths() { + const uint8_t payload[8] = {0x11, 0x00, 0x22, 0xFF, 0x00, 0xAB, 0xCD, 0x00}; + uint8_t frame[getMaxEncodedSize(sizeof(payload))] = {}; + const size_t written = encode(payload, sizeof(payload), frame, sizeof(frame)); + uint8_t out[sizeof(payload)] = {}; + + // Happy path + TEST_ASSERT_EQUAL(sizeof(payload), decode(frame, written, out, sizeof(out))); + TEST_ASSERT_EQUAL_HEX8_ARRAY(payload, out, sizeof(payload)); + + // The trailing delimiter is optional: without it the first/second pass loops exit on frame_size + TEST_ASSERT_EQUAL(sizeof(payload), decode(frame, written - 1, out, sizeof(out))); + + // Output buffer too small for the payload + TEST_ASSERT_EQUAL(0, decode(frame, written, out, sizeof(out) - 1)); + + // Truncated COBS block: the code byte announces more bytes than the frame provides + const uint8_t truncated[3] = {0x05, 0x11, 0x22}; + TEST_ASSERT_EQUAL(0, decode(truncated, sizeof(truncated), out, sizeof(out))); + + // Interior zero: a zero may not appear inside a COBS block + const uint8_t interior_zero[3] = {0x03, 0x11, 0x00}; + TEST_ASSERT_EQUAL(0, decode(interior_zero, sizeof(interior_zero), out, sizeof(out))); + + // Too short: not even one payload byte plus the CRC. With a CRC, a 1-byte decode is too short; + // with NoCrc only an empty frame (zero decoded bytes) is too short. + if (Crc::SIZE > 0) { + const uint8_t too_short[3] = {0x02, 0x41, 0x00}; // decodes to 1 byte < 1 + Crc::SIZE + TEST_ASSERT_EQUAL(0, decode(too_short, sizeof(too_short), out, sizeof(out))); + } else { + const uint8_t empty_frame[1] = {0x00}; // decodes to 0 bytes < 1 + TEST_ASSERT_EQUAL(0, decode(empty_frame, sizeof(empty_frame), out, sizeof(out))); + } + + // CRC mismatch (only a policy that carries a CRC can detect this) + if (Crc::SIZE > 0) { + uint8_t corrupted[getMaxEncodedSize(sizeof(payload))] = {}; + for (size_t i = 0; i < written; i++) { + corrupted[i] = frame[i]; + } + corrupted[1] = (corrupted[1] == 0x7F) ? 0x7E : 0x7F; + TEST_ASSERT_EQUAL(0, decode(corrupted, written, out, sizeof(out))); + } +} + +// Run every path helper for one CRC policy. The Decoder paths run for each MaxPayload the suite +// instantiates (16, 32, 64), since gcov tracks every Decoder separately. +template +void exerciseAllPaths() { + exerciseEncoderOverflow(); + exerciseEncoderFullBlock(); + exerciseDecodePaths(); + exerciseDecoderPaths<16, Crc>(); + exerciseDecoderPaths<32, Crc>(); + exerciseDecoderPaths<64, Crc>(); +} + +void test_all_paths_no_crc() { exerciseAllPaths(); } +void test_all_paths_crc8() { exerciseAllPaths(); } +void test_all_paths_crc16() { exerciseAllPaths(); } +void test_all_paths_crc32() { exerciseAllPaths(); } + /* ---------------------------------------------------------------------------------------------- */ /* Stress */ /* ---------------------------------------------------------------------------------------------- */ @@ -767,6 +987,12 @@ int runUnityTests(void) { RUN_TEST(test_decode_one_shot); RUN_TEST(test_crc_variants_round_trip); + // All paths across CRC policies + RUN_TEST(test_all_paths_no_crc); + RUN_TEST(test_all_paths_crc8); + RUN_TEST(test_all_paths_crc16); + RUN_TEST(test_all_paths_crc32); + // Stress RUN_TEST(test_stress_round_trip_random_chunks); RUN_TEST(test_stress_garbage_input); From 45d8bb8715b9c0884b1cd2a1bd7de92eed65c61d Mon Sep 17 00:00:00 2001 From: alkonosst Date: Wed, 24 Jun 2026 17:20:35 -0400 Subject: [PATCH 3/4] chore: Bump version and update metadata --- CMakeLists.txt | 4 ++-- library.json | 4 ++-- library.properties | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index e81fb69..66d5e4e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.14) -project(ByteFrame VERSION 0.1.0 LANGUAGES CXX) +project(ByteFrame VERSION 0.2.0 LANGUAGES CXX) # Create an INTERFACE library. add_library(ByteFrame INTERFACE) @@ -11,4 +11,4 @@ add_library(alkonosst::ByteFrame ALIAS ByteFrame) target_include_directories(ByteFrame INTERFACE ${CMAKE_CURRENT_SOURCE_DIR}/src) # Set a C++ standard requirement for the library. This will also propagate to consumers of the library. -target_compile_features(ByteFrame INTERFACE cxx_std_17) \ No newline at end of file +target_compile_features(ByteFrame INTERFACE cxx_std_11) \ No newline at end of file diff --git a/library.json b/library.json index 70cb8ba..571cd1c 100644 --- a/library.json +++ b/library.json @@ -1,6 +1,6 @@ { "name": "ByteFrame", - "version": "0.1.0", + "version": "0.2.0", "authors": { "name": "Maximiliano Ramirez", "email": "maximiliano.ramirezbravo@gmail.com" @@ -10,7 +10,7 @@ "url": "https://github.com/alkonosst/ByteFrame.git" }, "description": "Header-only C++11 framing library with zero dynamic allocation. Delimits packets on raw byte streams with COBS, a selectable CRC (none/CRC8/CRC16/CRC32) and a 0x00 delimiter. Incremental fixed-buffer decoder. Pairs with BytePack for type-safe payloads.", - "keywords": "framing, frame, cobs, crc, crc8, crc16, crc32, delimiter, packet, stream, serial, uart, rs485, protocol, header-only, embedded, arduino, esp32, static", + "keywords": "framing, frame, cobs, crc, crc8, crc16, crc32, delimiter, packet, stream, serial, uart, rs485, protocol, header-only, embedded, native, arduino, esp32, static", "license": "MIT", "frameworks": "*", "headers": ["ByteFrame.h"] diff --git a/library.properties b/library.properties index bd76193..311efef 100644 --- a/library.properties +++ b/library.properties @@ -1,5 +1,5 @@ name=ByteFrame -version=0.1.0 +version=0.2.0 author=Maximiliano Ramirez maintainer=Maximiliano Ramirez sentence=Header-only frame delimiting library with zero dynamic memory allocation. From c913f27203ce17ef7c78f7053ad8fc1cfbb9a139 Mon Sep 17 00:00:00 2001 From: alkonosst Date: Wed, 24 Jun 2026 17:43:16 -0400 Subject: [PATCH 4/4] fix(CrcSelection): Put template declaration on same function line to avoid compilation error --- examples/CrcSelection/CrcSelection.ino | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/examples/CrcSelection/CrcSelection.ino b/examples/CrcSelection/CrcSelection.ino index 8900928..d27f36f 100644 --- a/examples/CrcSelection/CrcSelection.ino +++ b/examples/CrcSelection/CrcSelection.ino @@ -22,8 +22,9 @@ constexpr size_t MAX_PAYLOAD = 16; // Encode the payload with policy Crc, decode it back with the same policy, and report the result. // The buffer size adapts to the policy through getMaxEncodedSize(). -template -void showPolicy(const char* name, const uint8_t* payload, const size_t payload_size) { +// clang-format off +template void showPolicy(const char* name, const uint8_t* payload, const size_t payload_size) { + // clang-format on uint8_t frame[ByteFrame::getMaxEncodedSize(MAX_PAYLOAD)] = {}; const size_t frame_size = ByteFrame::encode(payload, payload_size, frame, sizeof(frame));