Inital commit
CI / macOS (push) Has been cancelled
CI / Windows / Clang (push) Has been cancelled

This commit is contained in:
2026-06-14 19:48:37 +02:00
commit cf02745b02
30 changed files with 1703 additions and 0 deletions
+14
View File
@@ -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
+60
View File
@@ -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/
+16
View File
@@ -0,0 +1,16 @@
# CMake
build
build-*
cmake-build-*
coverage
# Editors
.vscode
.idea
.ccls
.ccls-cache
.cache
compile_commands.json
# macOS
.DS_Store
+256
View File
@@ -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<name>.cmake` to `deps/`
2. Add `find_package(<name> REQUIRED)` to `CMakeLists.txt`
3. Link with `<name>::<name>` 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 (`<stdlib.h>`, `<string.h>`, 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/<module>.h` with the public prototype.
2. Create `tests/test_<module>.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/<module>.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<name>.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)
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+25
View File
@@ -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)
+21
View File
@@ -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.
+205
View File
@@ -0,0 +1,205 @@
# 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 |
## Adding a new module (TDD workflow)
### 1. Write the header
```c
// celrs/telemetry.h
#pragma once
#include <stdint.h>
typedef struct {
int16_t rssi;
uint8_t link_quality;
int16_t tx_power;
} telemetry_data;
int telemetry_parse(telemetry_data* out, uint8_t const* buf, size_t len);
```
### 2. Write a failing test
```c
// tests/test_telemetry.c
#include "unity.h"
#include "celrs/telemetry.h"
void setUp(void) {}
void tearDown(void) {}
void test_parse_rssi(void) {
telemetry_data data;
uint8_t buf[8] = { /* ... */ };
TEST_ASSERT_EQUAL_INT(0, telemetry_parse(&data, buf, sizeof(buf)));
TEST_ASSERT_EQUAL_INT16(-42, data.rssi);
}
int main(void) {
UNITY_BEGIN();
RUN_TEST(test_parse_rssi);
return UNITY_END();
}
```
### 3. Register the test in `tests/CMakeLists.txt`
```cmake
add_executable(test_telemetry test_telemetry.c)
target_include_directories(test_telemetry PRIVATE "${CMAKE_SOURCE_DIR}")
target_link_libraries(test_telemetry PRIVATE celrs_telemetry Unity::Unity)
target_compile_features(test_telemetry PRIVATE c_std_23)
add_test(NAME test_telemetry COMMAND test_telemetry)
list(APPEND TEST_TARGETS test_telemetry)
```
### 4. Add a stub, confirm RED, then implement GREEN
Stub `celrs/telemetry.c` with `return -1;`, run tests to see the failure,
then implement the real logic and confirm they pass.
+17
View File
@@ -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)
+74
View File
@@ -0,0 +1,74 @@
#include "celrs/crsf.h"
#include <string.h>
/* 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 */
}
+65
View File
@@ -0,0 +1,65 @@
#pragma once
#include <stdint.h>
#include <stddef.h>
/* 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);
+6
View File
@@ -0,0 +1,6 @@
#include "celrs/log_write.h"
#include <stdio.h>
void cel_log_write(char const* msg) {
printf("%s\n", msg);
}
+3
View File
@@ -0,0 +1,3 @@
#pragma once
void cel_log_write(char const* msg);
+18
View File
@@ -0,0 +1,18 @@
#include "celrs/logger.h"
#include "celrs/log_write.h"
#include <stdio.h>
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); }
+9
View File
@@ -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);
+69
View File
@@ -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 <stdlib.h>
#include <string.h>
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 */
}
+24
View File
@@ -0,0 +1,24 @@
#pragma once
#include <stddef.h>
/* 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);
+56
View File
@@ -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()
+92
View File
@@ -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")
+67
View File
@@ -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")
+27
View File
@@ -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()
+17
View File
@@ -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()
+64
View File
@@ -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()
+28
View File
@@ -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()
+93
View File
@@ -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(<target> <absolute-path-to-header>)
# CMock names generated files Mock<Name>.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()
+115
View File
@@ -0,0 +1,115 @@
#include "unity.h"
#include "celrs/crsf.h"
#include <string.h>
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();
}
+73
View File
@@ -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();
}
+72
View File
@@ -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();
}
+4
View File
@@ -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)
+112
View File
@@ -0,0 +1,112 @@
#include "celrs/crsf.h"
#include "celrs/serial.h"
#include "celrs/logger.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
/* 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 <serial_port> [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;
}