commit cd7d411332cbcdb74adb43b3f62f0d7c8f0d3404 Author: portersky <24420859+portersky@users.noreply.github.com> Date: Sun Jun 14 19:48:37 2026 +0200 Inital commit diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..c9a7e1a --- /dev/null +++ b/.editorconfig @@ -0,0 +1,14 @@ +[*] +end_of_line = LF +charset = utf-8 +indent_style = space +indent_size = 4 +insert_final_newline = true + +[*.{yml,json,lua,md,html}] +charset = utf-8 +indent_style = space +indent_size = 2 + +[Makefile] +indent_style = tab diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e7ff504 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,60 @@ +name: CI + +on: + push: + branches: [trunk] + pull_request: + branches: [trunk] + +jobs: + macos: + name: macOS + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Ninja + run: brew install ninja + + - name: Install gcovr + run: pip install gcovr + + - name: Configure with coverage + run: cmake -S . -B build-cov -G Ninja -DENABLE_COVERAGE=ON + + - name: Build and generate coverage + run: ninja -C build-cov coverage + + - name: Upload coverage report + uses: actions/upload-artifact@v4 + with: + name: coverage-macos + path: build-cov/coverage/ + + windows-clang: + name: Windows / Clang + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - uses: ilammy/msvc-dev-cmd@v1 + + - name: Install gcovr + run: pip install gcovr + + - name: Configure with coverage + shell: bash + run: | + cmake -S . -B build-cov -G Ninja \ + -DENABLE_COVERAGE=ON \ + -DCMAKE_C_COMPILER="C:/Program Files/LLVM/bin/clang.exe" \ + -DCMAKE_CXX_COMPILER="C:/Program Files/LLVM/bin/clang++.exe" + + - name: Build and generate coverage + run: ninja -C build-cov coverage + + - name: Upload coverage report + uses: actions/upload-artifact@v4 + with: + name: coverage-windows-clang + path: build-cov/coverage/ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..07103c1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +# CMake +build +build-* +cmake-build-* +coverage + +# Editors +.vscode +.idea +.ccls +.ccls-cache +.cache +compile_commands.json + +# macOS +.DS_Store diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..0ce1e2b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,256 @@ +# AGENTS.md + +## Project Overview + +`celrs` is a C23 project for interfacing with ELRS TX modules via serial +USB using the CRSF (Crossfire Serial) protocol. Wired for test-driven +development using Unity and CMock. + +## Build System + +- **Generator:** Ninja +- **CMake minimum:** 3.21 +- **C standard:** C23 + +### Commands + +Configure (default): +```sh +cmake -S . -B build -G Ninja +``` + +Configure with coverage: +```sh +cmake -S . -B build-cov -G Ninja -DENABLE_COVERAGE=ON +``` + +Build: +```sh +ninja -C build +``` + +Run tests (full Unity output, colored): +```sh +ninja -C build check +``` + +Run tests (CTest summary only): +```sh +ninja -C build test +``` + +Run tests and generate coverage HTML: +```sh +ninja -C build-cov coverage +``` + +Run (Linux/macOS): +```sh +./build/main /dev/ttyUSB0 +``` + +Run (Windows): +```sh +./build/main.exe COM3 +``` + +### Dependencies + +Dependencies are managed via custom `Find*.cmake` scripts in `deps/`. +These scripts use `FetchContent` under the hood to download and build +libraries automatically. + +To add a new dependency: + +1. Add the corresponding `Find.cmake` to `deps/` +2. Add `find_package( REQUIRED)` to `CMakeLists.txt` +3. Link with `::` in `target_link_libraries()` + +### CMake Module Path + +`deps/` is added to `CMAKE_MODULE_PATH` so `find_package()` resolves +to the custom scripts instead of system-installed packages. + +## Coding Conventions + +- **Language:** C23 +- **Trailing return type** for function signatures (e.g. `auto fn() -> void`) +- **4-space indentation** +- `auto` for obvious types (e.g. `auto main(...) -> int`) +- **East const** (e.g. `char const*` not `const char*`) +- **Naming:** `snake_case` for variables, functions, and types +- **Naming:** `SCREAMING_SNAKE_CASE` only for macros and constants +- Include order: + 1. C standard library headers (``, ``, etc.) + 2. *(blank line)* + 3. OS-specific headers (Windows API, POSIX, etc.) + 4. *(blank line)* + 5. Local/project headers + +## Shell Scripts + +- Always use `#!/bin/sh` shebang for shell scripts +- Scripts must be POSIX compliant (no bashisms) +- When providing commands to users: + - Windows/PowerShell: use `` ` `` for line continuation + - Unix/Linux/macOS: use `\` for line continuation + +## Commit Messages + +- Follow the 50/72 rule: + - Subject line: max 50 characters + - Body lines: wrapped at 72 characters +- Use conventional commit prefixes (`feat:`, `fix:`, `docs:`, `chore:`, + etc.) +- Separate subject from body with a blank line +- Do **not** add yourself as a co-author (`Co-Authored-By:` trailers are + forbidden) + +Example: + +``` +feat: add CRSF CRC8 calculation + +Implement CRC8-CCITT (poly 0x07) for CRSF frame validation. +Added unit tests for empty, single-byte, and known-value cases. +``` + +## Documentation (Markdown) + +- Wrap normal text and lists at **max 80 columns** (for readability in + terminals and editors). +- **Exceptions**: Tables and code blocks (```` ``` ````) can exceed 80 + columns when formatting requires it (e.g. trees, alignment). +- Use standard Markdown: `**bold**`, `` `inline code` ``, `##` headings, + `-` or numbered lists, fenced code blocks with language hints + (```` ```c ````, ```` ```sh ````). +- Keep examples concise, up-to-date, and self-documenting. +- Do not use em dashes (`—`). Use a colon or rewrite the sentence. +- Each shell command gets its own fenced code block; do **not** combine + multiple commands into one block. Precede each block with a short + plain-text label describing what the command does: + + Setup build: + ```sh + cmake -S . -B build -G Ninja + ``` + +- This file (`AGENTS.md`) follows its own rules. + +## Source Layout + +```text +celrs/ + crsf.h / crsf.c CRSF protocol: CRC8, frame parse/build + serial.h / serial.c Serial port abstraction (Win/POSIX stubs) + logger.h / logger.c Level-filtering logger + log_write.h/.c stdout log sink +main.c Entry point — demo heartbeat + read +tests/ + test_crsf.c CRSF CRC, parse, build tests + test_serial.c Serial open/close/stub tests + test_logger.c Logger level-filtering tests +deps/ + FindUnity.cmake Fetches Unity v2.6.1 via ZIP + FindCMock.cmake Fetches CMock v2.6.0 via ZIP +``` + +## TDD Workflow + +This project follows Red-Green-Refactor. All changes to testable source +files under `celrs/` should be test-driven: write a failing test first, +then implement. + +### Adding a new module + +1. Create `celrs/.h` with the public prototype. +2. Create `tests/test_.c`. Set CMock expectations for any + dependency calls, then assert the result. +3. Register the test in `tests/CMakeLists.txt`: + +```cmake +add_executable(test_module test_module.c) +target_include_directories(test_module PRIVATE "${CMAKE_SOURCE_DIR}") +target_link_libraries(test_module PRIVATE celrs_module Unity::Unity CMock::CMock) +target_compile_features(test_module PRIVATE c_std_23) +cmock_generate_mock(test_module "${CMAKE_SOURCE_DIR}/celrs/dep.h") +add_test(NAME test_module COMMAND test_module) +list(APPEND TEST_TARGETS test_module) +``` + +4. Stub `celrs/.c` with a dummy return, confirm RED, implement, + confirm GREEN: + +```sh +ninja -C build check +``` + +### Mocking a dependency + +Use `cmock_generate_mock` in the test target to generate a mock from a +header. Include `Mock.h` in the test and use the generated API: + +```c +#include "Mockdep.h" + +void setUp(void) { Mockdep_Init(); } +void tearDown(void) { Mockdep_Verify(); Mockdep_Destroy(); } + +void test_something(void) { + dep_fn_ExpectAndReturn(arg, expected); + TEST_ASSERT_TRUE(module_do_thing()); +} +``` + +## Behavioral Guidelines + +Reduce common LLM coding mistakes. Bias toward caution over speed. +For trivial tasks, use judgment. + +### Think Before Coding + +- State assumptions explicitly. If uncertain, ask. +- If multiple interpretations exist, present them, don't pick silently. +- If something is unclear, stop. Name what's confusing. Ask. + +### Simplicity First + +Minimum code that solves the problem. Nothing speculative. + +- No features beyond what was asked. +- No abstractions for single-use code. +- If you write 200 lines and it could be 50, rewrite it. + +### Surgical Changes + +Touch only what you must. Clean up only your own mess. + +- Don't "improve" adjacent code, comments, or formatting. +- Don't refactor things that aren't broken. +- Match existing style, even if you'd do it differently. +- Remove imports/variables/functions that YOUR changes made unused. +- Don't remove pre-existing dead code unless asked. + +### Goal-Driven Execution + +Transform tasks into verifiable goals: + +- "Add validation" means: write tests for invalid inputs, then pass. +- "Fix the bug" means: write a test that reproduces it, then pass. +- "Refactor X" means: ensure tests pass before and after. + +## Platform Support + +The project supports Windows, Linux, macOS, Emscripten, and Android via +`Platform.cmake` and `Flags.cmake` in `deps/`. + +## CRSF Protocol Notes + +CRSF is the Crossfire Serial Protocol used by ELRS. Key points: + +- Frame header is always `0xC8` +- CRC8-CCITT with polynomial `0x07`, init `0x00` +- CRC is computed over: destination + source + type + size + payload +- Standard baud rate for ELRS CRSF is 400000 bps +- TX modules expose CRSF over USB serial (appears as COM port on Windows, + /dev/ttyUSB* on Linux) diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..d56e3c4 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,25 @@ +cmake_minimum_required(VERSION 3.21) +project(celrs VERSION 0.1.0) + +# CMake configuration +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) +list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/deps") + +# Platform flags +include(Platform) +include(Flags) + +include(Coverage) + +# Core Library +add_subdirectory(celrs) + +# Tools +add_subdirectory(tools) + +# Testing +enable_testing() +add_subdirectory(tests) + +# IDE configuration +include(IDE) diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4a61a86 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 PorterSky + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b98fc8c --- /dev/null +++ b/README.md @@ -0,0 +1,147 @@ +# celrs + +A C23 project for interfacing with ELRS TX modules (e.g., BAYCK Nano Dual +Band) via serial USB using the CRSF (Crossfire Serial) protocol. + +Built on the same TDD foundation as [ctdd](https://github.com/PorterSky/ctdd) +using [Unity](https://github.com/ThrowTheSwitch/Unity) and +[CMock](https://github.com/ThrowTheSwitch/CMock). + +All dependencies are fetched automatically via CMake `FetchContent` — no +manual installation required beyond the tools listed below. + +## Requirements + +| Tool | Purpose | +| ------------ | ----------------------------------------------------- | +| CMake ≥ 3.21 | Build system | +| Ninja | Build backend | +| C23 compiler | GCC 14+, Clang 18+ | +| Ruby ≥ 3.0 | CMock mock generation | +| gcovr ≥ 6.0 | Coverage reports — optional (`uv tool install gcovr`) | + +## Build + +Configure: +```sh +cmake -S . -B build -G Ninja +``` + +Build: +```sh +ninja -C build +``` + +## Test + +Full Unity output with colors: +```sh +ninja -C build check +``` + +CTest summary only: +```sh +ninja -C build test +``` + +`check` builds all suites and runs CTest with `--output-on-failure`, +so assertion-level detail appears on any failure without running +binaries by hand. + +## Coverage + +Configure with coverage instrumentation: +```sh +cmake -S . -B build-cov -G Ninja -DENABLE_COVERAGE=ON +``` + +Generate the HTML report: +```sh +ninja -C build-cov coverage +``` + +Open `build-cov/coverage/index.html` in a browser to view results. + +Only `celrs/` source files are measured. Unity, CMock, and generated +mock files are excluded. Requires GCC or Clang with gcov support, and +`gcovr` on `PATH`. + +> **Windows note:** requires GCC (e.g. `scoop install gcc`) or a Clang +> build that includes compiler-rt. A custom Clang without compiler-rt +> will fail at link time. + +## Run + +Connect your ELRS TX module via USB, then run: + +Windows: +```sh +./build/main.exe COM3 +``` + +Linux / macOS: +```sh +./build/main /dev/ttyUSB0 +``` + +## Architecture + +``` +celrs/ + crsf.h / crsf.c CRSF protocol: CRC8, frame parse/build + serial.h / serial.c Serial port abstraction (Win/POSIX) + logger.h / logger.c Level-filtering logger + log_write.h/.c stdout log sink +main.c Entry point — demo heartbeat + read +tests/ + test_crsf.c CRSF CRC, parse, build tests + test_serial.c Serial open/close/stub tests + test_logger.c Logger level-filtering tests +deps/ + FindUnity.cmake Fetches Unity v2.6.1 via ZIP + FindCMock.cmake Fetches CMock v2.6.0 via ZIP +``` + +## CRSF Protocol + +CRSF (Crossfire Serial Protocol) is the serial protocol used by ELRS for +communication between ground station and TX/RX modules. + +### Frame format + +``` ++------+------+------+------+-------+-------+ +| 0xC8 | dest | src | type | size | ... | CRC | ++------+------+------+------+-------+-------+ + 1 byte 1B 1B 1B 1B N B 1 byte +``` + +- **Header:** Always `0xC8` +- **Destination:** Target device address +- **Source:** Sender device address +- **Type:** Frame type (heartbeat, RC channels, telemetry, etc.) +- **Size:** Payload length in bytes +- **Payload:** Frame-specific data +- **CRC:** CRC8-CCITT over dest+src+type+size+payload + +### Common device addresses + +| Address | Device | +| ------- | ----------------------------- | +| 0x00 | FC Broadcast | +| 0x10 | Flight Controller | +| 0x80 | TBS Ground Station | +| 0xEA | Custom Module | +| 0xDD | RC Device | + +### Common frame types + +| Type | Name | +| ------ | --------------------------- | +| 0x01 | RC Channels Packed | +| 0x02 | Packet Link Telemetry | +| 0x03 | Heartbeat | +| 0x08 | Device Info | +| 0x09 | Parameter List | +| 0x17 | MSP Read | +| 0x18 | MSP Write | diff --git a/celrs/CMakeLists.txt b/celrs/CMakeLists.txt new file mode 100644 index 0000000..30397f1 --- /dev/null +++ b/celrs/CMakeLists.txt @@ -0,0 +1,17 @@ +add_library(celrs_crsf STATIC crsf.c) +target_include_directories(celrs_crsf PUBLIC "${CMAKE_SOURCE_DIR}") +target_compile_features(celrs_crsf PRIVATE c_std_23) + +add_library(celrs_serial STATIC serial.c) +target_include_directories(celrs_serial PUBLIC "${CMAKE_SOURCE_DIR}") +target_compile_features(celrs_serial PRIVATE c_std_23) + +# Level-filtering logger — calls log_write(); symbol resolved by the final binary +add_library(celrs_logger STATIC logger.c) +target_include_directories(celrs_logger PUBLIC "${CMAKE_SOURCE_DIR}") +target_compile_features(celrs_logger PRIVATE c_std_23) + +# Real log_write implementation — linked into production binaries only +add_library(celrs_log_write STATIC log_write.c) +target_include_directories(celrs_log_write PUBLIC "${CMAKE_SOURCE_DIR}") +target_compile_features(celrs_log_write PRIVATE c_std_23) diff --git a/celrs/crsf.c b/celrs/crsf.c new file mode 100644 index 0000000..fb60be4 --- /dev/null +++ b/celrs/crsf.c @@ -0,0 +1,74 @@ +#include "celrs/crsf.h" +#include + +/* CRC8-CCITT with polynomial 0x07, init 0x00 (used by CRSF/ELRS) */ +uint8_t cel_crsf_crc(uint8_t const* data, size_t len) { + uint8_t crc = 0x00; + for (size_t i = 0; i < len; i++) { + crc ^= data[i]; + for (int j = 0; j < 8; j++) { + crc = (crc & 0x80) ? (crc << 1) ^ 0x07 : (crc << 1); + } + } + return crc; +} + +int cel_crsf_frame_validate(cel_crsf_frame const* frame) { + /* Rebuild the data that was CRC'd: dest + src + type + size + payload */ + uint8_t data[260]; + size_t offset = 0; + data[offset++] = frame->destination; + data[offset++] = frame->source; + data[offset++] = frame->type; + data[offset++] = frame->size; + memcpy(data + offset, frame->payload, frame->size); + offset += frame->size; + + uint8_t calc_crc = cel_crsf_crc(data, offset); + return calc_crc == frame->crc ? 0 : -1; +} + +int cel_crsf_frame_parse(cel_crsf_frame* frame, uint8_t const* buf, size_t len) { + if (frame == NULL || buf == NULL) return -1; + /* Minimum: header(1) + dest(1) + src(1) + type(1) + size(1) = 5 bytes */ + if (len < 5) return -1; + + /* Verify header */ + if (buf[0] != CEL_CRSF_FRAME_HEADER) return -1; + + frame->destination = buf[1]; + frame->source = buf[2]; + frame->type = buf[3]; + frame->size = buf[4]; + + uint8_t size = buf[4]; + /* Total: header(1) + dest(1) + src(1) + type(1) + size(1) + payload(N) + crc(1) */ + size_t total = 6 + size; + if (len < total) return -1; + + memcpy(frame->payload, buf + 5, size); + frame->crc = buf[5 + size]; + + return cel_crsf_frame_validate(frame); +} + +size_t cel_crsf_frame_build(uint8_t* dst, uint8_t destination, uint8_t source, + uint8_t type, uint8_t const* payload, uint8_t size) { + if (dst == NULL) return 0; + + dst[0] = CEL_CRSF_FRAME_HEADER; + dst[1] = destination; + dst[2] = source; + dst[3] = type; + dst[4] = size; + + if (payload != NULL && size > 0) { + memcpy(dst + 5, payload, size); + } + + /* CRC over dest + src + type + size + payload */ + uint8_t crc = cel_crsf_crc(dst + 1, 3 + 1 + size); + dst[5 + size] = crc; + + return 1 + 3 + 1 + size + 1; /* header + 3 fields + size byte + payload + crc */ +} diff --git a/celrs/crsf.h b/celrs/crsf.h new file mode 100644 index 0000000..d0ba3e8 --- /dev/null +++ b/celrs/crsf.h @@ -0,0 +1,65 @@ +#pragma once +#include +#include + +/* CRSF frame header byte */ +#define CEL_CRSF_FRAME_HEADER 0xC8 + +/* CRSF device addresses */ +#define CEL_CRSF_ADDRESS_FC_BROADCAST 0x00 +#define CEL_CRSF_ADDRESS_FC 0x10 +#define CEL_CRSF_ADDRESS_TBS_GROUND_STATION 0x80 +#define CEL_CRSF_ADDRESS_CUSTOM_MODULE 0xEA +#define CEL_CRSF_ADDRESS_RC_DEVICE 0xDD +#define CEL_CRSF_ADDRESS_GPS 0xEC +#define CEL_CRSF_ADDRESS_FLIGHT_CONTROLLER 0xED + +/* CRSF frame types */ +typedef enum { + CEL_CRSF_FRAMETYPE_PACKET_LINK_TELEMETRY = 0x02, + CEL_CRSF_FRAMETYPE_RC_CHANNELS_PACKED = 0x01, + CEL_CRSF_FRAMETYPE_GPS = 0x02, + CEL_CRSF_FRAMETYPE_HEARTBEAT = 0x03, + CEL_CRSF_FRAMETYPE_VERSION = 0x04, + CEL_CRSF_FRAMETYPE_PARAMETER_SETTINGS_ENTRY = 0x05, + CEL_CRSF_FRAMETYPE_PARAMETER_READ = 0x06, + CEL_CRSF_FRAMETYPE_PARAMETER_WRITE = 0x07, + CEL_CRSF_FRAMETYPE_DEVICE_INFO = 0x08, + CEL_CRSF_FRAMETYPE_PARAMETER_LIST = 0x09, + CEL_CRSF_FRAMETYPE_RC_CHANNELS_RAW = 0x16, + CEL_CRSF_FRAMETYPE_MSP_READ = 0x17, + CEL_CRSF_FRAMETYPE_MSP_WRITE = 0x18, + CEL_CRSF_FRAMETYPE_CURR_VOLTAGE_TEMP = 0x1E, + CEL_CRSF_FRAMETYPE_BATTERY_SENSOR = 0x1F, + CEL_CRSF_FRAMETYPE_COMPRESSED_SENSORS = 0x28, + CEL_CRSF_FRAMETYPE_ARM = 0x0D, + CEL_CRSF_FRAMETYPE_SETTING = 0x9E, + CEL_CRSF_FRAMETYPE_SUPERBOX = 0xA0, + CEL_CRSF_FRAMETYPE_DEVICE_SUPERBOX = 0xA1, +} cel_crsf_frame_type; + +/* Parsed CRSF frame */ +typedef struct { + uint8_t destination; + uint8_t source; + uint8_t type; + uint8_t size; + uint8_t payload[255]; + uint8_t crc; +} cel_crsf_frame; + +/* CRC8 calculation over CRSF frame data (CCITT poly 0x07) */ +uint8_t cel_crsf_crc(uint8_t const* data, size_t len); + +/* Validate CRC of a CRSF frame (header already stripped, starts at dest addr) */ +int cel_crsf_frame_validate(cel_crsf_frame const* frame); + +/* Parse a raw buffer into a cel_crsf_frame. Returns 0 on success, -1 on error. + buf should start with 0xC8 header. */ +int cel_crsf_frame_parse(cel_crsf_frame* frame, uint8_t const* buf, size_t len); + +/* Build a CRSF frame into dst buffer. Returns total bytes written. + dst must have space for at least 5 + size bytes (header, addr, src, type, + size byte, payload, crc). */ +size_t cel_crsf_frame_build(uint8_t* dst, uint8_t destination, uint8_t source, + uint8_t type, uint8_t const* payload, uint8_t size); diff --git a/celrs/log_write.c b/celrs/log_write.c new file mode 100644 index 0000000..1a7a0a6 --- /dev/null +++ b/celrs/log_write.c @@ -0,0 +1,6 @@ +#include "celrs/log_write.h" +#include + +void cel_log_write(char const* msg) { + printf("%s\n", msg); +} diff --git a/celrs/log_write.h b/celrs/log_write.h new file mode 100644 index 0000000..b08d340 --- /dev/null +++ b/celrs/log_write.h @@ -0,0 +1,3 @@ +#pragma once + +void cel_log_write(char const* msg); diff --git a/celrs/logger.c b/celrs/logger.c new file mode 100644 index 0000000..17b62b1 --- /dev/null +++ b/celrs/logger.c @@ -0,0 +1,18 @@ +#include "celrs/logger.h" +#include "celrs/log_write.h" +#include + +static cel_log_level s_level = CEL_LOG_DEBUG; + +void cel_logger_set_level(cel_log_level level) { s_level = level; } + +static void emit(char const* prefix, char const* msg) { + char buf[512]; + snprintf(buf, sizeof(buf), "[%s] %s", prefix, msg); + cel_log_write(buf); +} + +void cel_log_debug(char const* msg) { if (s_level <= CEL_LOG_DEBUG) emit("DEBUG", msg); } +void cel_log_info(char const* msg) { if (s_level <= CEL_LOG_INFO) emit("INFO", msg); } +void cel_log_warn(char const* msg) { if (s_level <= CEL_LOG_WARN) emit("WARN", msg); } +void cel_log_err(char const* msg) { if (s_level <= CEL_LOG_ERROR) emit("ERROR", msg); } diff --git a/celrs/logger.h b/celrs/logger.h new file mode 100644 index 0000000..9114649 --- /dev/null +++ b/celrs/logger.h @@ -0,0 +1,9 @@ +#pragma once + +typedef enum { CEL_LOG_DEBUG = 0, CEL_LOG_INFO, CEL_LOG_WARN, CEL_LOG_ERROR, CEL_LOG_NONE } cel_log_level; + +void cel_logger_set_level(cel_log_level level); +void cel_log_debug(char const* msg); +void cel_log_info(char const* msg); +void cel_log_warn(char const* msg); +void cel_log_err(char const* msg); diff --git a/celrs/serial.c b/celrs/serial.c new file mode 100644 index 0000000..dcb89de --- /dev/null +++ b/celrs/serial.c @@ -0,0 +1,69 @@ +#ifdef _MSC_VER +#define _CRT_SECURE_NO_WARNINGS +#endif + +#include "celrs/serial.h" + +/* + * Platform-agnostic serial port implementation. + * + * Windows uses Win32 CreateFile/ReadFile/WriteFile. + * POSIX uses open/read/write with termios. + * + * This is a stub implementation that compiles but does nothing. + * Real platform-specific code will be added when TDD tests drive it. + */ + +#include +#include + +struct cel_serial_port { + char path[256]; + int baud_rate; + int fd; /* platform-specific handle (HANDLE on Win, int on POSIX) */ +}; + +cel_serial_port* cel_serial_open(char const* path, int baud_rate) { + if (path == NULL) return NULL; + + cel_serial_port* port = (cel_serial_port*)calloc(1, sizeof(cel_serial_port)); + if (port == NULL) return NULL; + + strncpy(port->path, path, sizeof(port->path) - 1); + port->path[sizeof(port->path) - 1] = '\0'; + port->baud_rate = baud_rate; + port->fd = -1; + + /* TODO: platform-specific open (CreateFile on Win, open+termios on POSIX) */ + (void)baud_rate; + + return port; +} + +void cel_serial_close(cel_serial_port* port) { + if (port == NULL) return; + /* TODO: platform-specific close */ + free(port); +} + +size_t cel_serial_read(cel_serial_port* port, unsigned char* buf, size_t len, int timeout_ms) { + (void)port; + (void)buf; + (void)len; + (void)timeout_ms; + /* TODO: platform-specific read */ + return 0; +} + +size_t cel_serial_write(cel_serial_port* port, unsigned char const* buf, size_t len) { + (void)port; + (void)buf; + (void)len; + /* TODO: platform-specific write */ + return 0; +} + +void cel_serial_flush(cel_serial_port* port) { + (void)port; + /* TODO: platform-specific flush */ +} diff --git a/celrs/serial.h b/celrs/serial.h new file mode 100644 index 0000000..8972b66 --- /dev/null +++ b/celrs/serial.h @@ -0,0 +1,24 @@ +#pragma once +#include + +/* Opaque serial port handle */ +typedef struct cel_serial_port cel_serial_port; + +/* Open a serial port. path is platform-specific: + * Windows: "COM3" + * Linux/macOS: "/dev/ttyUSB0" + * baud_rate: 400000 is standard for ELRS CRSF + * Returns NULL on failure. */ +cel_serial_port* cel_serial_open(char const* path, int baud_rate); + +/* Close and free the serial port */ +void cel_serial_close(cel_serial_port* port); + +/* Read up to len bytes. Returns bytes read, 0 on timeout/error. */ +size_t cel_serial_read(cel_serial_port* port, unsigned char* buf, size_t len, int timeout_ms); + +/* Write data. Returns bytes written, 0 on error. */ +size_t cel_serial_write(cel_serial_port* port, unsigned char const* buf, size_t len); + +/* Flush output buffer */ +void cel_serial_flush(cel_serial_port* port); diff --git a/deps/Coverage.cmake b/deps/Coverage.cmake new file mode 100644 index 0000000..01159ef --- /dev/null +++ b/deps/Coverage.cmake @@ -0,0 +1,56 @@ +# ============================================================================== +# Coverage +# ============================================================================== +# gcov-based coverage via --coverage, reported by gcovr. +# Works with GCC and Clang on Linux and macOS out of the box. +# Windows requires GCC (e.g. MinGW via scoop install gcc) or a Clang build +# that includes compiler-rt (clang_rt.profile). +# +# Requires: gcovr on PATH (install via: uv tool install gcovr) +# Usage: cmake -DENABLE_COVERAGE=ON ... then ninja coverage +# Report: build/coverage/index.html +# ============================================================================== + +option(ENABLE_COVERAGE "Build with coverage instrumentation" OFF) + +if (ENABLE_COVERAGE) + if (NOT CMAKE_C_COMPILER_ID MATCHES "GNU|Clang") + message(FATAL_ERROR "ENABLE_COVERAGE requires GCC or Clang") + endif() + + find_program(GCOVR_EXE gcovr REQUIRED) + + if (CMAKE_C_COMPILER_ID MATCHES "Clang") + # gcovr needs a single-token gcov executable. Wrap llvm-cov gcov in a + # script placed in the build dir (guaranteed no spaces in path). + # AppleClang ships llvm-cov inside Xcode, reached only via xcrun. + if (WIN32) + find_program(LLVM_COV_EXE llvm-cov REQUIRED) + set(GCOV_EXECUTABLE "${CMAKE_BINARY_DIR}/llvm-gcov.bat") + file(WRITE "${GCOV_EXECUTABLE}" "@echo off\r\n\"${LLVM_COV_EXE}\" gcov %*\r\n") + elseif (CMAKE_C_COMPILER_ID STREQUAL "AppleClang") + find_program(XCRUN_EXE xcrun REQUIRED) + set(GCOV_EXECUTABLE "${CMAKE_BINARY_DIR}/llvm-gcov.sh") + file(WRITE "${GCOV_EXECUTABLE}" "#!/bin/sh\nexec xcrun llvm-cov gcov \"$@\"\n") + file(CHMOD "${GCOV_EXECUTABLE}" + PERMISSIONS OWNER_READ OWNER_WRITE OWNER_EXECUTE + GROUP_READ GROUP_EXECUTE + WORLD_READ WORLD_EXECUTE) + else() + find_program(LLVM_COV_EXE llvm-cov REQUIRED) + set(GCOV_EXECUTABLE "${CMAKE_BINARY_DIR}/llvm-gcov.sh") + file(WRITE "${GCOV_EXECUTABLE}" "#!/bin/sh\nexec \"${LLVM_COV_EXE}\" gcov \"$@\"\n") + file(CHMOD "${GCOV_EXECUTABLE}" + PERMISSIONS OWNER_READ OWNER_WRITE OWNER_EXECUTE + GROUP_READ GROUP_EXECUTE + WORLD_READ WORLD_EXECUTE) + endif() + message(STATUS "Coverage: enabled (gcovr: ${GCOVR_EXE}, gcov: llvm-cov gcov)") + else() + set(GCOV_EXECUTABLE "gcov") + message(STATUS "Coverage: enabled (gcovr: ${GCOVR_EXE})") + endif() + + add_compile_options(--coverage -O0 -g) + add_link_options(--coverage) +endif() diff --git a/deps/FindCMock.cmake b/deps/FindCMock.cmake new file mode 100644 index 0000000..6988bb7 --- /dev/null +++ b/deps/FindCMock.cmake @@ -0,0 +1,92 @@ +# ============================================================================== +# Find CMock +# ============================================================================== +# This module fetches the CMock mocking framework (depends on Unity). +# +# Targets provided: +# CMock::CMock - The CMock library target +# +# Variables set: +# CMock_FOUND - TRUE if CMock is available +# CMock_LIBRARIES - The CMock library target (CMock::CMock) +# CMock_INCLUDE_DIR - Include directories for CMock +# CMock_VERSION - Version of CMock (if available) +# +# Generator variables (set when Ruby is found): +# CMOCK_SCRIPT - Path to lib/cmock.rb +# RUBY_EXECUTABLE - Path to ruby interpreter +# ============================================================================== + +if (DEFINED _FINDCMOCK_INCLUDED) + return() +endif() +set(_FINDCMOCK_INCLUDED TRUE) + +find_package(Unity REQUIRED) + +if (DEFINED CMock_FIND_VERSION AND NOT CMock_FIND_VERSION STREQUAL "") + set(CMOCK_VERSION "${CMock_FIND_VERSION}") +else() + set(CMOCK_VERSION "2.6.0") +endif() + +message(STATUS "Fetching CMock ${CMOCK_VERSION}") + +include(FetchContent) + +FetchContent_Declare( + cmock + URL https://github.com/ThrowTheSwitch/CMock/archive/refs/tags/v${CMOCK_VERSION}.zip + DOWNLOAD_EXTRACT_TIMESTAMP TRUE +) + +# CMock uses Meson — bypass its build system and compile src/cmock.c directly. +# FetchContent_MakeAvailable cannot be used here (no CMakeLists.txt in CMock), +# so we call FetchContent_Populate directly and opt into the old policy. +cmake_policy(PUSH) +cmake_policy(SET CMP0169 OLD) +FetchContent_GetProperties(cmock) +if (NOT cmock_POPULATED) + FetchContent_Populate(cmock) +endif() +cmake_policy(POP) + +# The Ruby generator expects vendor/unity/auto/type_sanitizer.rb — populate +# it from the Unity source we already have rather than needing a git submodule +set(_cmock_vendor_auto "${cmock_SOURCE_DIR}/vendor/unity/auto") +if (NOT EXISTS "${_cmock_vendor_auto}/type_sanitizer.rb") + file(MAKE_DIRECTORY "${_cmock_vendor_auto}") + file(COPY "${unity_SOURCE_DIR}/auto/" DESTINATION "${_cmock_vendor_auto}") +endif() + +if (NOT TARGET cmock) + add_library(cmock STATIC "${cmock_SOURCE_DIR}/src/cmock.c") + target_include_directories(cmock PUBLIC + "${cmock_SOURCE_DIR}/src" + "${unity_SOURCE_DIR}/src" + ) + target_link_libraries(cmock PUBLIC unity) +endif() + +if (NOT TARGET CMock::CMock) + add_library(CMock::CMock ALIAS cmock) +endif() + +set(CMock_FOUND TRUE) +set(CMock_LIBRARIES CMock::CMock) +set(CMock_VERSION "${CMOCK_VERSION}") +set(CMock_INCLUDE_DIR "${cmock_SOURCE_DIR}/src") +set(CMOCK_SCRIPT "${cmock_SOURCE_DIR}/lib/cmock.rb" CACHE FILEPATH "Path to CMock Ruby generator") + +if (CMock_INCLUDE_DIR AND TARGET cmock) + set_target_properties(cmock PROPERTIES + INTERFACE_SYSTEM_INCLUDE_DIRECTORIES "${CMock_INCLUDE_DIR}" + ) +endif() + +find_program(RUBY_EXECUTABLE ruby) +if (NOT RUBY_EXECUTABLE) + message(WARNING "Ruby not found — CMock code generation unavailable") +endif() + +set(CMOCK_LICENSE_FILE "${cmock_SOURCE_DIR}/LICENSE.txt" CACHE FILEPATH "Path to CMock license file") diff --git a/deps/FindUnity.cmake b/deps/FindUnity.cmake new file mode 100644 index 0000000..6d3edb5 --- /dev/null +++ b/deps/FindUnity.cmake @@ -0,0 +1,67 @@ +# ============================================================================== +# Find Unity +# ============================================================================== +# This module fetches the Unity unit testing framework. +# +# Targets provided: +# Unity::Unity - The Unity library target +# +# Variables set: +# Unity_FOUND - TRUE if Unity is available +# Unity_LIBRARIES - The Unity library target (Unity::Unity) +# Unity_INCLUDE_DIR - Include directories for Unity +# Unity_VERSION - Version of Unity (if available) +# ============================================================================== + +if (DEFINED _FINDUNITY_INCLUDED) + return() +endif() +set(_FINDUNITY_INCLUDED TRUE) + +if (DEFINED Unity_FIND_VERSION AND NOT Unity_FIND_VERSION STREQUAL "") + set(UNITY_VERSION "${Unity_FIND_VERSION}") +else() + set(UNITY_VERSION "2.6.1") +endif() + +message(STATUS "Fetching Unity ${UNITY_VERSION}") + +include(FetchContent) + +FetchContent_Declare( + unity + URL https://github.com/ThrowTheSwitch/Unity/archive/refs/tags/v${UNITY_VERSION}.zip + DOWNLOAD_EXTRACT_TIMESTAMP TRUE +) + +FetchContent_MakeAvailable(unity) + +# Unity sets INTERFACE_SYSTEM_INCLUDE_DIRECTORIES to a path inside the build +# tree, which CMake rejects on newer versions. The path stays in +# INTERFACE_INCLUDE_DIRECTORIES so headers are still found. +if (TARGET unity) + set_target_properties(unity PROPERTIES INTERFACE_SYSTEM_INCLUDE_DIRECTORIES "") +endif() + +if (NOT TARGET Unity::Unity) + if (TARGET unity) + add_library(Unity::Unity ALIAS unity) + else() + message(FATAL_ERROR "Could not fetch Unity; no target unity or Unity::Unity available") + endif() +endif() + +set(Unity_FOUND TRUE) +set(Unity_LIBRARIES Unity::Unity) +set(Unity_VERSION "${UNITY_VERSION}") +set(Unity_INCLUDE_DIR "${unity_SOURCE_DIR}/src") + + +if (TARGET unity) + target_compile_definitions(unity PUBLIC + UNITY_OUTPUT_COLOR + UNITY_INCLUDE_PRINT_FORMATTED + ) +endif() + +set(UNITY_LICENSE_FILE "${unity_SOURCE_DIR}/LICENSE.txt" CACHE FILEPATH "Path to Unity license file") diff --git a/deps/Flags.cmake b/deps/Flags.cmake new file mode 100644 index 0000000..28a12cd --- /dev/null +++ b/deps/Flags.cmake @@ -0,0 +1,27 @@ +# ============================================================================== +# Compiler Flags +# ============================================================================== +# Sets BASE_OPTIONS (warning flags) and BASE_DEFINITIONS. +# Apply per-target via target_compile_options / target_compile_definitions +# to avoid polluting fetched dependencies. +# +# Requires: Platform.cmake (for IS_CLANG_OR_GCC / IS_MSVC) +# ============================================================================== + +set(BASE_DEFINITIONS "") +set(BASE_LIBRARIES "") + +set(BASE_OPTIONS "") +if (IS_CLANG_OR_GCC) + set(BASE_OPTIONS + "-Wall" + "-Wextra" + "-Werror" + ) +elseif (IS_MSVC) + set(BASE_OPTIONS + "/W4" + "/WX" + "/utf-8" + ) +endif() diff --git a/deps/IDE.cmake b/deps/IDE.cmake new file mode 100644 index 0000000..1577f6c --- /dev/null +++ b/deps/IDE.cmake @@ -0,0 +1,17 @@ +# ============================================================================== +# IDE Integration +# ============================================================================== +# Groups dependency targets into folders for Visual Studio / Xcode. +# Has no effect on the build. +# ============================================================================== + +function(set_target_folder target folder) + if (TARGET ${target}) + set_target_properties(${target} PROPERTIES FOLDER ${folder}) + endif() +endfunction() + +if (CMAKE_GENERATOR MATCHES "Visual Studio" OR CMAKE_GENERATOR MATCHES "Xcode") + set_target_folder(unity deps) + set_target_folder(cmock deps) +endif() diff --git a/deps/Platform.cmake b/deps/Platform.cmake new file mode 100644 index 0000000..5a5fdcb --- /dev/null +++ b/deps/Platform.cmake @@ -0,0 +1,64 @@ +# ============================================================================== +# Platform Detection +# ============================================================================== +# This module detects the current platform and compiler, setting IS_* variables +# that can be used throughout the build system for conditional logic. +# +# Compiler flags set: +# IS_CLANG_OR_GCC - TRUE if using Clang or GCC compiler +# IS_MSVC - TRUE if using Microsoft Visual C++ compiler +# +# Platform flags set: +# IS_WINDOWS - TRUE if building for Windows +# IS_LINUX - TRUE if building for Linux +# IS_MACOS - TRUE if building for macOS +# IS_IOS - TRUE if building for iOS +# IS_ANDROID - TRUE if building for Android +# IS_EMSCRIPTEN - TRUE if building for WebAssembly via Emscripten +# ============================================================================== + +# ------------------------------------------------------------------------------ +# Compiler Detection +# ------------------------------------------------------------------------------ +set(IS_CLANG_OR_GCC FALSE) +set(IS_MSVC FALSE) + +if(MSVC) + set(IS_MSVC TRUE) +elseif(CMAKE_C_COMPILER_ID MATCHES "Clang|GNU") + set(IS_CLANG_OR_GCC TRUE) +endif() + +# ------------------------------------------------------------------------------ +# Platform Detection +# ------------------------------------------------------------------------------ +set(IS_WINDOWS FALSE) +set(IS_LINUX FALSE) +set(IS_MACOS FALSE) +set(IS_IOS FALSE) +set(IS_ANDROID FALSE) +set(IS_EMSCRIPTEN FALSE) + +if(EMSCRIPTEN) + message(STATUS "Platform: Emscripten") + set(IS_EMSCRIPTEN TRUE) +elseif(ANDROID) + message(STATUS "Platform: Android") + set(IS_ANDROID TRUE) +elseif(APPLE) + if(IOS) + message(STATUS "Platform: iOS") + set(IS_IOS TRUE) + else() + message(STATUS "Platform: macOS") + set(IS_MACOS TRUE) + endif() +elseif(WIN32) + message(STATUS "Platform: Windows") + set(IS_WINDOWS TRUE) +elseif(UNIX) + message(STATUS "Platform: Linux") + set(IS_LINUX TRUE) +else() + message(FATAL_ERROR "Unknown platform!") +endif() diff --git a/deps/Sanitizers.cmake b/deps/Sanitizers.cmake new file mode 100644 index 0000000..d793dcd --- /dev/null +++ b/deps/Sanitizers.cmake @@ -0,0 +1,28 @@ +# ============================================================================== +# Sanitizers +# ============================================================================== +# AddressSanitizer (ASan) support. +# Works with GCC/Clang on Linux and macOS, Clang on Windows (requires +# compiler-rt with sanitizers), and MSVC on Windows (/fsanitize=address). +# +# Usage: cmake -DENABLE_ASAN=ON ... +# ============================================================================== + +option(ENABLE_ASAN "Build with AddressSanitizer" OFF) + +if (ENABLE_ASAN) + if (ENABLE_COVERAGE) + message(FATAL_ERROR "ENABLE_ASAN and ENABLE_COVERAGE cannot be used together") + endif() + + if (MSVC) + add_compile_options(/fsanitize=address) + message(STATUS "ASan: enabled (MSVC)") + elseif (CMAKE_C_COMPILER_ID MATCHES "GNU|Clang") + add_compile_options(-fsanitize=address -fno-omit-frame-pointer) + add_link_options(-fsanitize=address) + message(STATUS "ASan: enabled (${CMAKE_C_COMPILER_ID})") + else() + message(FATAL_ERROR "ENABLE_ASAN: unsupported compiler ${CMAKE_C_COMPILER_ID}") + endif() +endif() diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt new file mode 100644 index 0000000..818aa23 --- /dev/null +++ b/tests/CMakeLists.txt @@ -0,0 +1,93 @@ +find_package(Unity REQUIRED) +find_package(CMock REQUIRED) + +set(MOCK_GEN_DIR "${CMAKE_CURRENT_BINARY_DIR}/mocks") +file(MAKE_DIRECTORY "${MOCK_GEN_DIR}") + +# Generate a CMock mock from a header and attach it to a target. +# Usage: cmock_generate_mock( ) +# CMock names generated files Mock.h/.c (capital M, no separator) +function(cmock_generate_mock target header) + if (NOT RUBY_EXECUTABLE) + message(FATAL_ERROR "Ruby is required for CMock generation") + endif() + get_filename_component(name "${header}" NAME_WE) + get_filename_component(header_dir "${header}" DIRECTORY) + set(mock_src "${MOCK_GEN_DIR}/Mock${name}.c") + set(mock_hdr "${MOCK_GEN_DIR}/Mock${name}.h") + add_custom_command( + OUTPUT "${mock_src}" "${mock_hdr}" + COMMAND "${RUBY_EXECUTABLE}" "${CMOCK_SCRIPT}" + "--mock_path=${MOCK_GEN_DIR}" + "${header}" + DEPENDS "${header}" + COMMENT "CMock: generating Mock${name}" + VERBATIM + ) + target_sources("${target}" PRIVATE "${mock_src}") + target_include_directories("${target}" PRIVATE "${MOCK_GEN_DIR}" "${header_dir}") +endfunction() + +set(TEST_TARGETS "") + +# CRSF tests — pure functions (CRC, parse, build), no mock needed +add_executable(test_crsf test_crsf.c) +target_include_directories(test_crsf PRIVATE "${CMAKE_SOURCE_DIR}") +target_link_libraries(test_crsf PRIVATE celrs_crsf Unity::Unity) +target_compile_features(test_crsf PRIVATE c_std_23) +add_test(NAME test_crsf COMMAND test_crsf) +list(APPEND TEST_TARGETS test_crsf) + +# Serial tests — mocks log_write.h for any logging calls +add_executable(test_serial test_serial.c) +target_include_directories(test_serial PRIVATE "${CMAKE_SOURCE_DIR}") +target_link_libraries(test_serial PRIVATE celrs_serial Unity::Unity CMock::CMock) +target_compile_features(test_serial PRIVATE c_std_23) +cmock_generate_mock(test_serial "${CMAKE_SOURCE_DIR}/celrs/log_write.h") +add_test(NAME test_serial COMMAND test_serial) +list(APPEND TEST_TARGETS test_serial) + +# Logger tests — mocks log_write.h so output calls are intercepted +add_executable(test_logger test_logger.c) +target_include_directories(test_logger PRIVATE "${CMAKE_SOURCE_DIR}") +target_link_libraries(test_logger PRIVATE celrs_logger Unity::Unity CMock::CMock) +target_compile_features(test_logger PRIVATE c_std_23) +cmock_generate_mock(test_logger "${CMAKE_SOURCE_DIR}/celrs/log_write.h") +add_test(NAME test_logger COMMAND test_logger) +list(APPEND TEST_TARGETS test_logger) + +# 'check' builds all suites and runs CTest with full Unity output. +# USES_TERMINAL keeps ANSI colors alive through Ninja's output buffering. +add_custom_target(check + COMMAND ${CMAKE_CTEST_COMMAND} + --test-dir "${CMAKE_BINARY_DIR}" + --output-on-failure + --progress + WORKING_DIRECTORY "${CMAKE_BINARY_DIR}" + USES_TERMINAL + DEPENDS ${TEST_TARGETS} +) + +if (ENABLE_COVERAGE) + set(COVERAGE_DIR "${CMAKE_BINARY_DIR}/coverage") + add_custom_target(coverage + COMMAND ${CMAKE_CTEST_COMMAND} --output-on-failure + COMMAND ${CMAKE_COMMAND} -E make_directory "${COVERAGE_DIR}" + COMMAND ${GCOVR_EXE} + --gcov-executable "${GCOV_EXECUTABLE}" + --root "${CMAKE_SOURCE_DIR}" + --filter "${CMAKE_SOURCE_DIR}/celrs/" + --exclude "${CMAKE_SOURCE_DIR}/tests/" + --exclude ".*Mock.*" + --exclude ".*unity.*" + --exclude ".*cmock.*" + --html-details "${COVERAGE_DIR}/index.html" + --txt + --print-summary + "${CMAKE_BINARY_DIR}" + WORKING_DIRECTORY "${CMAKE_BINARY_DIR}" + USES_TERMINAL + DEPENDS ${TEST_TARGETS} + COMMENT "Coverage report: ${COVERAGE_DIR}/index.html" + ) +endif() diff --git a/tests/test_crsf.c b/tests/test_crsf.c new file mode 100644 index 0000000..e138754 --- /dev/null +++ b/tests/test_crsf.c @@ -0,0 +1,115 @@ +#include "unity.h" +#include "celrs/crsf.h" +#include + +void setUp(void) {} +void tearDown(void) {} + +/* CRC tests */ +void test_crc_empty(void) { + uint8_t data[1] = {0}; + TEST_ASSERT_EQUAL_UINT8(0x00, cel_crsf_crc(data, 0)); +} + +void test_crc_single_byte(void) { + uint8_t data[1] = {0x01}; + uint8_t crc = cel_crsf_crc(data, 1); + TEST_ASSERT_TRUE(crc != 0); /* non-trivial */ +} + +void test_crc_known_value(void) { + /* CRSF heartbeat frame data (dest+src+type+size+payload): + * {0x10, 0x80, 0x03, 0x02, 0x80, 0x01} + * Known CRC for this sequence */ + uint8_t data[6] = {0x10, 0x80, 0x03, 0x02, 0x80, 0x01}; + uint8_t crc = cel_crsf_crc(data, 6); + TEST_ASSERT_TRUE(crc != 0); + /* Verify idempotency */ + uint8_t crc2 = cel_crsf_crc(data, 6); + TEST_ASSERT_EQUAL_UINT8(crc, crc2); +} + +/* Frame parse tests */ +void test_parse_invalid_header(void) { + cel_crsf_frame frame; + uint8_t buf[8] = {0x00, 0x10, 0x80, 0x03, 0x02, 0x80, 0x01, 0x00}; + TEST_ASSERT_EQUAL_INT(-1, cel_crsf_frame_parse(&frame, buf, 8)); +} + +void test_parse_too_short(void) { + cel_crsf_frame frame; + uint8_t buf[2] = {0xC8, 0x10}; + TEST_ASSERT_EQUAL_INT(-1, cel_crsf_frame_parse(&frame, buf, 2)); +} + +void test_parse_null_frame(void) { + uint8_t buf[8] = {0xC8, 0x10, 0x80, 0x03, 0x02, 0x80, 0x01, 0x00}; + TEST_ASSERT_EQUAL_INT(-1, cel_crsf_frame_parse(NULL, buf, 8)); +} + +void test_parse_null_buf(void) { + cel_crsf_frame frame; + TEST_ASSERT_EQUAL_INT(-1, cel_crsf_frame_parse(&frame, NULL, 8)); +} + +/* Frame build tests */ +void test_build_heartbeat(void) { + uint8_t dst[256]; + uint8_t payload[2] = {0x80, 0x01}; + size_t len = cel_crsf_frame_build(dst, 0x00, 0x80, 0x03, payload, 2); + + TEST_ASSERT_GREATER_THAN(0, len); + TEST_ASSERT_EQUAL_UINT8(CEL_CRSF_FRAME_HEADER, dst[0]); + TEST_ASSERT_EQUAL_UINT8(0x00, dst[1]); /* destination */ + TEST_ASSERT_EQUAL_UINT8(0x80, dst[2]); /* source */ + TEST_ASSERT_EQUAL_UINT8(0x03, dst[3]); /* type: heartbeat */ + TEST_ASSERT_EQUAL_UINT8(0x02, dst[4]); /* size */ + TEST_ASSERT_EQUAL_UINT8(0x80, dst[5]); /* payload[0] */ + TEST_ASSERT_EQUAL_UINT8(0x01, dst[6]); /* payload[1] */ +} + +void test_build_roundtrip(void) { + uint8_t dst[256]; + uint8_t payload[4] = {0xAA, 0xBB, 0xCC, 0xDD}; + size_t len = cel_crsf_frame_build(dst, 0x10, 0x80, 0x01, payload, 4); + + /* Parse the built frame back */ + cel_crsf_frame frame; + TEST_ASSERT_EQUAL_INT(0, cel_crsf_frame_parse(&frame, dst, len)); + TEST_ASSERT_EQUAL_UINT8(0x10, frame.destination); + TEST_ASSERT_EQUAL_UINT8(0x80, frame.source); + TEST_ASSERT_EQUAL_UINT8(0x01, frame.type); + TEST_ASSERT_EQUAL_UINT8(4, frame.size); + TEST_ASSERT_EQUAL_UINT8(0xAA, frame.payload[0]); + TEST_ASSERT_EQUAL_UINT8(0xDD, frame.payload[3]); +} + +void test_build_null_dst(void) { + uint8_t payload[2] = {0x01, 0x02}; + TEST_ASSERT_EQUAL_UINT(0, cel_crsf_frame_build(NULL, 0x00, 0x80, 0x03, payload, 2)); +} + +void test_build_null_payload(void) { + uint8_t dst[256]; + size_t len = cel_crsf_frame_build(dst, 0x10, 0x80, 0x03, NULL, 0); + TEST_ASSERT_GREATER_THAN(0, len); + /* Should still have valid CRC for empty payload */ + cel_crsf_frame frame; + TEST_ASSERT_EQUAL_INT(0, cel_crsf_frame_parse(&frame, dst, len)); +} + +int main(void) { + UNITY_BEGIN(); + RUN_TEST(test_crc_empty); + RUN_TEST(test_crc_single_byte); + RUN_TEST(test_crc_known_value); + RUN_TEST(test_parse_invalid_header); + RUN_TEST(test_parse_too_short); + RUN_TEST(test_parse_null_frame); + RUN_TEST(test_parse_null_buf); + RUN_TEST(test_build_heartbeat); + RUN_TEST(test_build_roundtrip); + RUN_TEST(test_build_null_dst); + RUN_TEST(test_build_null_payload); + return UNITY_END(); +} diff --git a/tests/test_logger.c b/tests/test_logger.c new file mode 100644 index 0000000..919731a --- /dev/null +++ b/tests/test_logger.c @@ -0,0 +1,73 @@ +#include "unity.h" +#include "celrs/logger.h" +#include "Mocklog_write.h" + +void setUp(void) { Mocklog_write_Init(); cel_logger_set_level(CEL_LOG_DEBUG); } +void tearDown(void) { Mocklog_write_Verify(); Mocklog_write_Destroy(); } + +void test_log_debug_emits_at_debug_level(void) { + cel_log_write_Expect("[DEBUG] hello"); + cel_log_debug("hello"); +} + +void test_log_info_emits_at_debug_level(void) { + cel_log_write_Expect("[INFO] world"); + cel_log_info("world"); +} + +void test_log_warn_emits_at_warn_level(void) { + cel_logger_set_level(CEL_LOG_WARN); + cel_log_write_Expect("[WARN] alert"); + cel_log_warn("alert"); +} + +void test_log_err_emits_at_warn_level(void) { + cel_logger_set_level(CEL_LOG_WARN); + cel_log_write_Expect("[ERROR] fatal"); + cel_log_err("fatal"); +} + +void test_log_debug_suppressed_at_info_level(void) { + cel_logger_set_level(CEL_LOG_INFO); + cel_log_debug("silent"); +} + +void test_log_info_suppressed_at_warn_level(void) { + cel_logger_set_level(CEL_LOG_WARN); + cel_log_info("silent"); +} + +void test_log_warn_suppressed_at_error_level(void) { + cel_logger_set_level(CEL_LOG_ERROR); + cel_log_warn("silent"); +} + +void test_log_none_suppresses_all(void) { + cel_logger_set_level(CEL_LOG_NONE); + cel_log_debug("silent"); + cel_log_info("silent"); + cel_log_warn("silent"); + cel_log_err("silent"); +} + +void test_level_can_be_raised_then_lowered(void) { + cel_logger_set_level(CEL_LOG_ERROR); + cel_log_info("silent"); + cel_logger_set_level(CEL_LOG_DEBUG); + cel_log_write_Expect("[INFO] now visible"); + cel_log_info("now visible"); +} + +int main(void) { + UNITY_BEGIN(); + RUN_TEST(test_log_debug_emits_at_debug_level); + RUN_TEST(test_log_info_emits_at_debug_level); + RUN_TEST(test_log_warn_emits_at_warn_level); + RUN_TEST(test_log_err_emits_at_warn_level); + RUN_TEST(test_log_debug_suppressed_at_info_level); + RUN_TEST(test_log_info_suppressed_at_warn_level); + RUN_TEST(test_log_warn_suppressed_at_error_level); + RUN_TEST(test_log_none_suppresses_all); + RUN_TEST(test_level_can_be_raised_then_lowered); + return UNITY_END(); +} diff --git a/tests/test_serial.c b/tests/test_serial.c new file mode 100644 index 0000000..dc95205 --- /dev/null +++ b/tests/test_serial.c @@ -0,0 +1,72 @@ +#include "unity.h" +#include "celrs/serial.h" +#include "Mocklog_write.h" + +void setUp(void) { Mocklog_write_Init(); } +void tearDown(void) { Mocklog_write_Verify(); Mocklog_write_Destroy(); } + +void test_open_valid_path(void) { + cel_serial_port* port = cel_serial_open("COM3", 400000); + TEST_ASSERT_NOT_NULL(port); + cel_serial_close(port); +} + +void test_open_null_path(void) { + TEST_ASSERT_NULL(cel_serial_open(NULL, 400000)); +} + +void test_open_preserves_path(void) { + cel_serial_port* port = cel_serial_open("/dev/ttyUSB0", 400000); + TEST_ASSERT_NOT_NULL(port); + /* path is stored internally; verify by roundtrip behavior */ + cel_serial_close(port); +} + +void test_close_null(void) { + /* Should not crash */ + cel_serial_close(NULL); +} + +void test_read_returns_zero_stub(void) { + cel_serial_port* port = cel_serial_open("COM3", 400000); + TEST_ASSERT_NOT_NULL(port); + uint8_t buf[16]; + /* Stub implementation returns 0 */ + size_t n = cel_serial_read(port, buf, sizeof(buf), 100); + TEST_ASSERT_EQUAL_UINT(0, n); + cel_serial_close(port); +} + +void test_write_returns_zero_stub(void) { + cel_serial_port* port = cel_serial_open("COM3", 400000); + TEST_ASSERT_NOT_NULL(port); + uint8_t buf[4] = {0xC8, 0x10, 0x80, 0x03}; + /* Stub implementation returns 0 */ + size_t n = cel_serial_write(port, buf, sizeof(buf)); + TEST_ASSERT_EQUAL_UINT(0, n); + cel_serial_close(port); +} + +void test_flush_no_crash(void) { + cel_serial_port* port = cel_serial_open("COM3", 400000); + TEST_ASSERT_NOT_NULL(port); + cel_serial_flush(port); /* should not crash */ + cel_serial_close(port); +} + +void test_flush_null(void) { + cel_serial_flush(NULL); /* should not crash */ +} + +int main(void) { + UNITY_BEGIN(); + RUN_TEST(test_open_valid_path); + RUN_TEST(test_open_null_path); + RUN_TEST(test_open_preserves_path); + RUN_TEST(test_close_null); + RUN_TEST(test_read_returns_zero_stub); + RUN_TEST(test_write_returns_zero_stub); + RUN_TEST(test_flush_no_crash); + RUN_TEST(test_flush_null); + return UNITY_END(); +} diff --git a/tools/CMakeLists.txt b/tools/CMakeLists.txt new file mode 100644 index 0000000..f705304 --- /dev/null +++ b/tools/CMakeLists.txt @@ -0,0 +1,4 @@ +add_executable(tool_telemetry telemetry.c) +target_include_directories(tool_telemetry PRIVATE "${CMAKE_SOURCE_DIR}") +target_compile_features(tool_telemetry PRIVATE c_std_23) +target_link_libraries(tool_telemetry PRIVATE celrs_crsf celrs_serial celrs_logger celrs_log_write) diff --git a/tools/telemetry.c b/tools/telemetry.c new file mode 100644 index 0000000..5a22636 --- /dev/null +++ b/tools/telemetry.c @@ -0,0 +1,112 @@ +#include "celrs/crsf.h" +#include "celrs/serial.h" +#include "celrs/logger.h" +#include +#include +#include + +/* TX power index to dBm mapping (ELRS standard) */ +static int const s_tx_power_dbm[] = { + 0, 20, 26, 30, 32, 34, 36, 38, + 0, 0, 0, 0, 0, 0, 0, 0 +}; + +/* Parse link telemetry payload (5 bytes) from CRSF frame type 0x02 */ +static int telemetry_parse_link(int16_t* rssi, uint8_t* link_quality, + int8_t* snr, int* tx_power_dbm, + uint8_t* rssi_rc, + uint8_t const* payload, size_t len) { + if (rssi == NULL || payload == NULL) return -1; + if (len < 5) return -1; + + *rssi = (int16_t)payload[0]; /* 0-100% */ + *link_quality = payload[1]; /* 0-100% */ + *snr = (int8_t)payload[2]; /* signed dB */ + uint8_t power_idx = payload[3]; + *tx_power_dbm = (power_idx < sizeof(s_tx_power_dbm) / sizeof(s_tx_power_dbm[0])) + ? s_tx_power_dbm[power_idx] + : 0; + *rssi_rc = payload[4]; /* 0-100% */ + + return 0; +} + +static void print_usage(char const* prog) { + printf("Usage: %s [interval_ms]\n", prog); + printf(" serial_port : COM3 (Windows) or /dev/ttyUSB0 (Linux)\n"); + printf(" interval_ms : poll interval in ms (default 200)\n"); +} + +int main(int argc, char const* argv[]) { + if (argc < 2) { + print_usage(argv[0]); + return 1; + } + + char const* port_path = argv[1]; + int interval_ms = 200; + if (argc >= 3) { + interval_ms = atoi(argv[2]); + if (interval_ms <= 0) interval_ms = 200; + } + + /* Open serial port */ + cel_serial_port* port = cel_serial_open(port_path, 400000); + if (port == NULL) { + cel_log_err("Failed to open serial port"); + return 1; + } + + char msg[256]; + snprintf(msg, sizeof(msg), "Connected to %s (400000 baud)", port_path); + cel_log_info(msg); + + /* Send heartbeat to establish CRSF link */ + uint8_t hb_payload[2] = {CEL_CRSF_ADDRESS_TBS_GROUND_STATION, 0x01}; + uint8_t hb_frame[256]; + size_t hb_len = cel_crsf_frame_build(hb_frame, CEL_CRSF_ADDRESS_FC_BROADCAST, + CEL_CRSF_ADDRESS_TBS_GROUND_STATION, + CEL_CRSF_FRAMETYPE_HEARTBEAT, hb_payload, 2); + cel_serial_write(port, hb_frame, hb_len); + + printf("RX\tLINK\tSNR\tTXP\tRSSI_RC\n"); + + /* Read loop */ + uint8_t buf[256]; + int frames = 0, errors = 0; + + for (int i = 0; i < 20; i++) { /* read up to 20 telemetry frames */ + size_t n = cel_serial_read(port, buf, sizeof(buf), interval_ms); + if (n == 0) continue; + + cel_crsf_frame frame; + if (cel_crsf_frame_parse(&frame, buf, n) != 0) { + errors++; + continue; + } + + if (frame.type != CEL_CRSF_FRAMETYPE_PACKET_LINK_TELEMETRY) { + continue; /* skip non-telemetry frames */ + } + + int16_t rssi; + uint8_t link_quality; + int8_t snr; + int tx_power; + uint8_t rssi_rc; + + if (telemetry_parse_link(&rssi, &link_quality, &snr, + &tx_power, &rssi_rc, + frame.payload, frame.size) == 0) { + frames++; + printf("%d\t%d\t%d\t%d\t%d\n", + rssi, link_quality, snr, tx_power, rssi_rc); + } + } + + snprintf(msg, sizeof(msg), "Frames: %d, Errors: %d", frames, errors); + cel_log_info(msg); + + cel_serial_close(port); + return 0; +}