Compare commits

...

8 Commits

Author SHA1 Message Date
portersky 23050d983e docs: add tag release workflow to AGENTS.md 2026-06-15 00:41:53 +02:00
portersky 974b33e827 chore: rename targets and add ENABLE_TESTING option
Rename library targets from celrs_* to cel* (celcrsf, celserial,
cellogger, cellog) and add cel:: namespace aliases. Add cel::cel
umbrella target that links all core libraries.

Add ENABLE_TESTING option (default ON) to gate Unity/CMock fetch
and test targets for downstream consumers.
2026-06-15 00:39:25 +02:00
portersky 412530df83 fix: drop slow startup ping, log connect time
verify_connection retried DEVICE_INFO pings up to 3x2s,
blocking startup for ~6s even when the module just needs
more time to come up. The main loop already pings every 5s
and shows DEVICE_INFO in the type breakdown, so the upfront
check added latency without useful signal.

Log how long opening the port took instead.
2026-06-15 00:24:14 +02:00
portersky 8ff2542fbc fix: avoid DTR/RTS pulse on serial port open
Previously SetCommState was called with fDtrControl and
fRtsControl left at whatever GetCommState returned (often
enabled), then EscapeCommFunction lowered DTR/RTS after the
fact. This produced a brief low-high-low pulse on connect,
which can reset USB-UART-connected devices.

Set DTR_CONTROL_DISABLE and RTS_CONTROL_DISABLE directly in
the DCB before the single SetCommState call, so the lines
never get pulsed.
2026-06-15 00:17:19 +02:00
portersky df09615d3f feat: improve telemetry dashboard diagnostics
- Show top CRSF frame types received, by raw type byte,
  with a name lookup table (mirrors the Python tool)
- Send DEVICE_PING every 5s so DEVICE_INFO keeps appearing
  in the type breakdown
- Fix NO LINK status to trigger when uplink quality is 0
- Fix SNR display (drop erroneous extra -128 offset)
- Retry the initial DEVICE_INFO ping up to 3 times
- Probe 921600 baud before 400000/420000
2026-06-15 00:17:01 +02:00
portersky f58eb0d976 fix: send RC channels with correct CRSF type
cel_crsf_build_rc_frame tagged RC channel frames with 0x01,
which is not a valid CRSF frame type. The TX module's CRSF
parser never recognized these as channel updates, so it had
no RC data to forward over RF and the receiver could never
report link quality.

Use CEL_CRSF_TYPE_RC_CHANNELS (0x16), the spec-correct RC
Channels Packed type. Drop the bogus 0x01 enum value.
2026-06-15 00:16:50 +02:00
portersky d67d9b29d2 fix: parse CRSF battery as big-endian per protocol spec
CRSF battery frame is big-endian: voltage(u16 BE 0.1V),
current(u16 BE 0.1A), capacity(u24 BE mAh), remaining(u8 %).

Previous code read little-endian with wrong byte count (7 vs 8)
and wrong scaling (/1000 vs /10), producing 9.98V for a 1S battery.
2026-06-14 23:34:12 +02:00
portersky ef5012b9d4 fix: probe 400000 baud first and relax FC stale threshold
CP210x chips can't hit 921600 exactly so try 400000/420000 first.
Raise FC_STALE_S from 2s to 5s so the dashboard doesn't flicker
STALE when FC telemetry arrives slowly.
2026-06-14 23:25:47 +02:00
14 changed files with 224 additions and 86 deletions
+39
View File
@@ -105,6 +105,45 @@ Implement CRC8-CCITT (poly 0x07) for CRSF frame validation.
Added unit tests for empty, single-byte, and known-value cases.
```
## Tag Releases
Use annotated tags for releases. Write the release notes to a temporary
file, then use it as the tag message. Do **not** commit the release
notes file.
Write release notes:
```sh
cat > RELEASES.md << 'EOF'
# Releases
## 0.1.0 (2026-06-15)
Initial release. Windows-only support.
### Library (`celrs`)
- **CRSF protocol** ...
EOF
```
Create the annotated tag:
```sh
git tag -a v0.1.0 -F RELEASES.md
```
Verify:
```sh
git show v0.1.0
```
Remove the temporary file:
```sh
rm RELEASES.md
```
Release notes follow the same Markdown rules as `AGENTS.md` (80-column
wrap, no em dashes, etc.). Version format is `v<major>.<minor>.<patch>`.
## Documentation (Markdown)
- Wrap normal text and lists at **max 80 columns** (for readability in
+7 -2
View File
@@ -5,6 +5,9 @@ project(celrs VERSION 0.1.0)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/deps")
# Options
option(ENABLE_TESTING "Build unit tests" ON)
# Platform flags
include(Platform)
include(Flags)
@@ -18,8 +21,10 @@ add_subdirectory(celrs)
add_subdirectory(tools)
# Testing
enable_testing()
add_subdirectory(tests)
if (ENABLE_TESTING)
enable_testing()
add_subdirectory(tests)
endif()
# IDE configuration
include(IDE)
+5 -8
View File
@@ -1,13 +1,10 @@
# 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.
A C23 library and CLI tools 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
All dependencies are fetched automatically via CMake `FetchContent`. No
manual installation required beyond the tools listed below.
## Requirements
@@ -18,7 +15,7 @@ manual installation required beyond the tools listed below.
| 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`) |
| gcovr ≥ 6.0 | Coverage reports - optional (`uv tool install gcovr`) |
## Build
+29 -20
View File
@@ -1,32 +1,41 @@
add_library(celrs_crsf STATIC crsf.c crsf_telemetry.c crsf_stream.c
add_library(celcrsf STATIC crsf.c crsf_telemetry.c crsf_stream.c
crsf_param.c)
target_include_directories(celrs_crsf PUBLIC "${CMAKE_SOURCE_DIR}")
target_compile_features(celrs_crsf PRIVATE c_std_23)
target_link_libraries(celrs_crsf PUBLIC celrs_serial)
target_include_directories(celcrsf PUBLIC "${CMAKE_SOURCE_DIR}")
target_compile_features(celcrsf PRIVATE c_std_23)
target_link_libraries(celcrsf PUBLIC celserial)
add_library(cel::crsf ALIAS celcrsf)
# Platform-agnostic serial logic — calls cel_serial_platform_*();
# symbol resolved by celrs_serial_platform (or a mock in tests)
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)
# symbol resolved by celserial_platform (or a mock in tests)
add_library(celserial STATIC serial.c)
target_include_directories(celserial PUBLIC "${CMAKE_SOURCE_DIR}")
target_compile_features(celserial PRIVATE c_std_23)
add_library(cel::serial ALIAS celserial)
# Real platform backend — linked into production binaries only
add_library(celrs_serial_platform STATIC)
target_include_directories(celrs_serial_platform PUBLIC "${CMAKE_SOURCE_DIR}")
target_compile_features(celrs_serial_platform PRIVATE c_std_23)
add_library(celserial_platform STATIC)
target_include_directories(celserial_platform PUBLIC "${CMAKE_SOURCE_DIR}")
target_compile_features(celserial_platform PRIVATE c_std_23)
if (IS_WINDOWS)
target_sources(celrs_serial_platform PRIVATE platform/serial_win.c)
target_link_libraries(celrs_serial_platform PRIVATE advapi32 setupapi)
target_sources(celserial_platform PRIVATE platform/serial_win.c)
target_link_libraries(celserial_platform PRIVATE advapi32 setupapi)
elseif(IS_LINUX OR IS_MACOS)
target_sources(celrs_serial_platform PRIVATE platform/serial_posix.c)
target_sources(celserial_platform PRIVATE platform/serial_posix.c)
endif()
# 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)
add_library(cellogger STATIC logger.c)
target_include_directories(cellogger PUBLIC "${CMAKE_SOURCE_DIR}")
target_compile_features(cellogger PRIVATE c_std_23)
add_library(cel::logger ALIAS cellogger)
# 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)
add_library(cellog STATIC log_write.c)
target_include_directories(cellog PUBLIC "${CMAKE_SOURCE_DIR}")
target_compile_features(cellog PRIVATE c_std_23)
add_library(cel::log ALIAS cellog)
# Umbrella target — links all celrs libraries
add_library(cel INTERFACE)
target_link_libraries(cel INTERFACE celcrsf celserial cellogger cellog)
add_library(cel::cel ALIAS cel)
+1 -1
View File
@@ -63,7 +63,7 @@ size_t cel_crsf_build_rc_frame(uint8_t* dst, int16_t const channels[16]) {
uint8_t length = 1 + 22 + 1; /* type + payload + crc */
dst[0] = 0xC8; /* RC frame address */
dst[1] = length;
dst[2] = CEL_CRSF_TYPE_RC_CHANNELS_PACKED;
dst[2] = CEL_CRSF_TYPE_RC_CHANNELS;
memcpy(dst + 3, packed, 22);
uint8_t crc = cel_crsf_crc(dst + 2, 1 + 22);
dst[2 + length - 1] = crc;
-1
View File
@@ -18,7 +18,6 @@
/* CRSF frame types (ELRS) */
typedef enum {
CEL_CRSF_TYPE_RC_CHANNELS_PACKED = 0x01,
CEL_CRSF_TYPE_GPS = 0x02,
CEL_CRSF_TYPE_VARIO = 0x07,
CEL_CRSF_TYPE_BATTERY = 0x08,
+20 -8
View File
@@ -2,10 +2,20 @@
#include <string.h>
/* Helper: read uint16_t little-endian from buffer */
static uint16_t read_u16(uint8_t const* buf) {
static uint16_t read_u16_le(uint8_t const* buf) {
return (uint16_t)buf[0] | ((uint16_t)buf[1] << 8);
}
/* Helper: read uint16_t big-endian from buffer */
static uint16_t read_u16_be(uint8_t const* buf) {
return ((uint16_t)buf[0] << 8) | (uint16_t)buf[1];
}
/* Helper: read uint32_t big-endian from 3-byte buffer */
static uint32_t read_u24_be(uint8_t const* buf) {
return ((uint32_t)buf[0] << 16) | ((uint32_t)buf[1] << 8) | (uint32_t)buf[2];
}
int cel_crsf_telemetry_parse(cel_crsf_frame const* frame,
cel_telemetry* out) {
if (frame == NULL || out == NULL) return -1;
@@ -32,26 +42,28 @@ int cel_crsf_telemetry_parse(cel_crsf_frame const* frame,
}
case CEL_CRSF_TYPE_BATTERY: {
if (len < 7) return -1;
if (len < 8) return -1;
out->type = CEL_TELEM_BATTERY;
out->data.battery.voltage_mv = read_u16(p);
out->data.battery.current_ma = read_u16(p + 2);
out->data.battery.capacity_mah = read_u16(p + 4);
out->data.battery.remaining_pct = p[6];
/* CRSF battery: voltage(u16 BE 0.1V), current(u16 BE 0.1A),
capacity(u24 BE mAh), remaining(u8 %) */
out->data.battery.voltage_x10 = read_u16_be(p);
out->data.battery.current_x10 = read_u16_be(p + 2);
out->data.battery.capacity_mah = read_u24_be(p + 4);
out->data.battery.remaining_pct = p[7];
return 0;
}
case CEL_CRSF_TYPE_HEARTBEAT: {
if (len < 2) return -1;
out->type = CEL_TELEM_HEARTBEAT;
out->data.heartbeat.origin_addr = read_u16(p);
out->data.heartbeat.origin_addr = read_u16_le(p);
return 0;
}
case CEL_CRSF_TYPE_AIRSPEED: {
if (len < 2) return -1;
out->type = CEL_TELEM_AIRSPEED;
out->data.airspeed.speed_kmh = read_u16(p);
out->data.airspeed.speed_kmh = read_u16_le(p);
return 0;
}
+4 -4
View File
@@ -36,11 +36,11 @@ typedef struct {
int16_t vertical_speed_cms;
} cel_telem_vario;
/* Battery sensor */
/* Battery sensor (CRSF: u16 BE 0.1V, u16 BE 0.1A, u24 BE mAh, u8 %) */
typedef struct {
uint16_t voltage_mv; /* millivolts */
uint16_t current_ma; /* milliamps */
uint16_t capacity_mah; /* mAh consumed */
uint16_t voltage_x10; /* x 0.1V */
uint16_t current_x10; /* x 0.1A */
uint32_t capacity_mah; /* mAh consumed */
uint8_t remaining_pct; /* percentage remaining */
} cel_telem_battery;
+7
View File
@@ -27,6 +27,13 @@ cel_serial_platform_handle cel_serial_platform_open(char const* path, int baud_r
dcb.Parity = NOPARITY;
dcb.StopBits = ONESTOPBIT;
/* Keep DTR/RTS low so the TX module is not reset on connect. Setting
* these in the same SetCommState call (rather than via a later
* EscapeCommFunction) avoids a brief DTR/RTS-high pulse if the port's
* existing DCB has them enabled. */
dcb.fDtrControl = DTR_CONTROL_DISABLE;
dcb.fRtsControl = RTS_CONTROL_DISABLE;
if (!SetCommState(h, &dcb)) {
CloseHandle(h);
return CEL_SERIAL_PLATFORM_INVALID_HANDLE;
+6 -6
View File
@@ -34,7 +34,7 @@ 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_link_libraries(test_crsf PRIVATE celcrsf 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)
@@ -42,7 +42,7 @@ list(APPEND TEST_TARGETS test_crsf)
# CRSF stream tests
add_executable(test_crsf_stream test_crsf_stream.c)
target_include_directories(test_crsf_stream PRIVATE "${CMAKE_SOURCE_DIR}")
target_link_libraries(test_crsf_stream PRIVATE celrs_crsf Unity::Unity)
target_link_libraries(test_crsf_stream PRIVATE celcrsf Unity::Unity)
target_compile_features(test_crsf_stream PRIVATE c_std_23)
add_test(NAME test_crsf_stream COMMAND test_crsf_stream)
list(APPEND TEST_TARGETS test_crsf_stream)
@@ -50,7 +50,7 @@ list(APPEND TEST_TARGETS test_crsf_stream)
# CRSF telemetry tests
add_executable(test_crsf_telemetry test_crsf_telemetry.c)
target_include_directories(test_crsf_telemetry PRIVATE "${CMAKE_SOURCE_DIR}")
target_link_libraries(test_crsf_telemetry PRIVATE celrs_crsf Unity::Unity)
target_link_libraries(test_crsf_telemetry PRIVATE celcrsf Unity::Unity)
target_compile_features(test_crsf_telemetry PRIVATE c_std_23)
add_test(NAME test_crsf_telemetry COMMAND test_crsf_telemetry)
list(APPEND TEST_TARGETS test_crsf_telemetry)
@@ -58,7 +58,7 @@ list(APPEND TEST_TARGETS test_crsf_telemetry)
# CRSF param tests — mocks serial for write/ping/read
add_executable(test_crsf_param test_crsf_param.c)
target_include_directories(test_crsf_param PRIVATE "${CMAKE_SOURCE_DIR}")
target_link_libraries(test_crsf_param PRIVATE celrs_crsf celrs_serial Unity::Unity CMock::CMock)
target_link_libraries(test_crsf_param PRIVATE celcrsf celserial Unity::Unity CMock::CMock)
target_compile_features(test_crsf_param PRIVATE c_std_23)
cmock_generate_mock(test_crsf_param "${CMAKE_SOURCE_DIR}/celrs/platform/serial_internal.h")
add_test(NAME test_crsf_param COMMAND test_crsf_param)
@@ -67,7 +67,7 @@ list(APPEND TEST_TARGETS test_crsf_param)
# Serial tests — mocks the platform backend (serial_internal.h)
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_link_libraries(test_serial PRIVATE celserial Unity::Unity CMock::CMock)
target_compile_features(test_serial PRIVATE c_std_23)
cmock_generate_mock(test_serial "${CMAKE_SOURCE_DIR}/celrs/platform/serial_internal.h")
add_test(NAME test_serial COMMAND test_serial)
@@ -76,7 +76,7 @@ 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_link_libraries(test_logger PRIVATE cellogger 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)
+1 -1
View File
@@ -193,7 +193,7 @@ void test_build_rc_frame_roundtrip(void) {
cel_crsf_frame frame;
TEST_ASSERT_EQUAL_INT(0, cel_crsf_frame_parse(&frame, dst, len));
TEST_ASSERT_EQUAL_UINT8(CEL_CRSF_TYPE_RC_CHANNELS_PACKED, frame.type);
TEST_ASSERT_EQUAL_UINT8(CEL_CRSF_TYPE_RC_CHANNELS, frame.type);
}
void test_build_ping_frame_null_dst(void) {
+12 -8
View File
@@ -60,13 +60,15 @@ void test_parse_link_stats_short(void) {
void test_parse_battery(void) {
uint8_t buf[32];
uint8_t payload[7] = {
0x40, 0x03, /* voltage: 0x0340 = 832 -> 0.832V */
0x00, 0x00, /* current: 0 */
0x00, 0x00, /* capacity: 0 */
/* CRSF battery: voltage(u16 BE 0.1V), current(u16 BE 0.1A),
capacity(u24 BE mAh), remaining(u8 %) = 8 bytes */
uint8_t payload[8] = {
0x03, 0xE8, /* voltage: 0x03E8 = 1000 -> 100.0V (10S LiPo) */
0x00, 0x64, /* current: 0x0064 = 100 -> 10.0A */
0x00, 0x03, 0xE8, /* capacity: 0x0003E8 = 1000mAh */
0x64 /* remaining: 100% */
};
build_frame(buf, 0x08, CEL_CRSF_TYPE_BATTERY, payload, 7);
build_frame(buf, 0x08, CEL_CRSF_TYPE_BATTERY, payload, 8);
cel_crsf_frame frame;
TEST_ASSERT_EQUAL_INT(0, cel_crsf_frame_parse(&frame, buf, sizeof(buf)));
@@ -74,13 +76,15 @@ void test_parse_battery(void) {
cel_telemetry telem;
TEST_ASSERT_EQUAL_INT(0, cel_crsf_telemetry_parse(&frame, &telem));
TEST_ASSERT_EQUAL_UINT(CEL_TELEM_BATTERY, telem.type);
TEST_ASSERT_EQUAL_UINT16(0x0340, telem.data.battery.voltage_mv);
TEST_ASSERT_EQUAL_UINT16(0x03E8, telem.data.battery.voltage_x10);
TEST_ASSERT_EQUAL_UINT16(0x0064, telem.data.battery.current_x10);
TEST_ASSERT_EQUAL_UINT32(0x0003E8, telem.data.battery.capacity_mah);
TEST_ASSERT_EQUAL_UINT8(0x64, telem.data.battery.remaining_pct);
}
void test_parse_heartbeat(void) {
uint8_t buf[32];
uint8_t payload[2] = {0x10, 0x80}; /* origin_addr = 0x8010 */
uint8_t payload[2] = {0x10, 0x80}; /* origin_addr LE = 0x8010 */
build_frame(buf, 0x10, CEL_CRSF_TYPE_HEARTBEAT, payload, 2);
cel_crsf_frame frame;
@@ -94,7 +98,7 @@ void test_parse_heartbeat(void) {
void test_parse_airspeed(void) {
uint8_t buf[32];
uint8_t payload[2] = {0x00, 0x01}; /* speed = 0x0100 = 256 km/h */
uint8_t payload[2] = {0x00, 0x01}; /* speed LE = 0x0100 = 256 km/h */
build_frame(buf, 0x08, CEL_CRSF_TYPE_AIRSPEED, payload, 2);
cel_crsf_frame frame;
+1 -2
View File
@@ -1,5 +1,4 @@
add_executable(telemetry telemetry.c)
target_include_directories(telemetry PRIVATE "${CMAKE_SOURCE_DIR}")
target_compile_features(telemetry PRIVATE c_std_23)
target_link_libraries(telemetry PRIVATE celrs_crsf celrs_serial
celrs_serial_platform celrs_logger celrs_log_write)
target_link_libraries(telemetry PRIVATE cel::cel celserial_platform)
+92 -25
View File
@@ -5,7 +5,6 @@
#include <time.h>
#include "celrs/crsf.h"
#include "celrs/crsf_param.h"
#include "celrs/crsf_telemetry.h"
#include "celrs/logger.h"
#include "celrs/serial.h"
@@ -56,6 +55,11 @@ static void sleep_ms(int ms) {
#endif
}
/* Milliseconds elapsed since a clock() reading - used to time startup. */
static double elapsed_ms(clock_t start) {
return (double)(clock() - start) * 1000.0 / CLOCKS_PER_SEC;
}
/* --------------------------------------------------------------------------- */
/* ANSI helpers - all go to stdout so dashboard owns it */
/* --------------------------------------------------------------------------- */
@@ -79,7 +83,7 @@ static void ansi_cursor_up(int n) {
/* --------------------------------------------------------------------------- */
#define LINK_STALE_S 3.0f
#define FC_STALE_S 2.0f
#define FC_STALE_S 5.0f
typedef enum {
STATUS_LIVE,
@@ -88,9 +92,12 @@ typedef enum {
STATUS_NO_SIGNAL
} status_t;
static status_t compute_status(double now, double link_t, double fc_t) {
static status_t compute_status(double now, double link_t, double fc_t,
int has_link, uint8_t up_lq) {
if (link_t == 0 || (now - link_t) > LINK_STALE_S)
return STATUS_NO_SIGNAL;
if (has_link && up_lq == 0)
return STATUS_NO_LINK;
if (fc_t > 0 && (now - fc_t) > FC_STALE_S)
return STATUS_STALE;
return STATUS_LIVE;
@@ -152,6 +159,7 @@ typedef struct {
/* Counts */
int rx_frames;
int unknown;
uint32_t type_counts[256]; /* indexed by raw CRSF frame type byte */
} dashboard_t;
static void dashboard_init(dashboard_t* d) {
@@ -167,15 +175,15 @@ static void dashboard_update(dashboard_t* d, cel_telemetry const* telem,
d->up_rssi1 = telem->data.link.uplink_rssi1;
d->up_rssi2 = telem->data.link.uplink_rssi2;
d->up_lq = telem->data.link.uplink_quality;
d->up_snr = telem->data.link.uplink_snr - 128;
d->up_snr = telem->data.link.uplink_snr;
d->power_idx = telem->data.link.uplink_power;
d->rf_mode = telem->data.link.rf_mode;
d->link_t = now;
break;
case CEL_TELEM_BATTERY:
d->has_batt = 1;
d->batt_v = telem->data.battery.voltage_mv / 1000.0f;
d->batt_a = telem->data.battery.current_ma / 1000.0f;
d->batt_v = telem->data.battery.voltage_x10 / 10.0f;
d->batt_a = telem->data.battery.current_x10 / 10.0f;
d->batt_mah = telem->data.battery.capacity_mah;
d->batt_pct = telem->data.battery.remaining_pct;
d->batt_t = now;
@@ -227,6 +235,34 @@ static void rssi_color(double dbm) {
else ansi_red();
}
/* Raw CRSF frame type byte -> name, for diagnostics.
Returns NULL for types not in cel_crsf_type. */
static char const* crsf_type_name(uint8_t type) {
switch (type) {
case CEL_CRSF_TYPE_GPS: return "GPS";
case CEL_CRSF_TYPE_VARIO: return "VARIO";
case CEL_CRSF_TYPE_BATTERY: return "BATTERY";
case CEL_CRSF_TYPE_BARO_ALT: return "BARO_ALT";
case CEL_CRSF_TYPE_AIRSPEED: return "AIRSPEED";
case CEL_CRSF_TYPE_HEARTBEAT: return "HEARTBEAT";
case CEL_CRSF_TYPE_RPM: return "RPM";
case CEL_CRSF_TYPE_TEMP: return "TEMP";
case CEL_CRSF_TYPE_VOLTAGES: return "VOLTAGES";
case CEL_CRSF_TYPE_ESC_SENSOR: return "ESC_SENSOR";
case CEL_CRSF_TYPE_LINK_STATS: return "LINK_STATS";
case CEL_CRSF_TYPE_RC_CHANNELS: return "RC_CHANNELS";
case CEL_CRSF_TYPE_ATTITUDE: return "ATTITUDE";
case CEL_CRSF_TYPE_FLIGHT_MODE: return "FLIGHT_MODE";
case CEL_CRSF_TYPE_DEVICE_PING: return "DEVICE_PING";
case CEL_CRSF_TYPE_DEVICE_INFO: return "DEVICE_INFO";
case CEL_CRSF_TYPE_PARAM_ENTRY: return "PARAM_ENTRY";
case CEL_CRSF_TYPE_PARAM_READ: return "PARAM_READ";
case CEL_CRSF_TYPE_PARAM_WRITE: return "PARAM_WRITE";
case CEL_CRSF_TYPE_ELRS_STATUS: return "ELRS_STATUS";
default: return NULL;
}
}
/* --------------------------------------------------------------------------- */
/* Dashboard render - tracks line count for in-place redraw */
/* --------------------------------------------------------------------------- */
@@ -235,7 +271,8 @@ static void render_dashboard(dashboard_t const* d,
char const* port, int baud,
double elapsed, int* lines) {
double now = (double)time(NULL);
status_t status = compute_status(now, d->link_t, d->fc_t);
status_t status = compute_status(now, d->link_t, d->fc_t,
d->has_link, d->up_lq);
/* Return to top of dashboard if already drawn */
if (*lines > 0) {
@@ -286,7 +323,7 @@ static void render_dashboard(dashboard_t const* d,
if (d->batt_v > 3.0f) ansi_green(); else ansi_red();
printf("%.2fV ", d->batt_v);
ansi_reset();
printf("%.1fA %dmah %d%%", d->batt_a, d->batt_mah, d->batt_pct);
printf("%.1fA %umah %d%%", d->batt_a, d->batt_mah, d->batt_pct);
print_age(now, d->batt_t);
} else {
ansi_dim(); printf("waiting..."); ansi_reset();
@@ -326,6 +363,36 @@ static void render_dashboard(dashboard_t const* d,
printf("\n");
n++;
/* Frame type breakdown (top 6 by count) */
ansi_clear_line();
ansi_dim();
printf(" types:");
uint8_t used[256] = {0};
int shown = 0;
for (int k = 0; k < 6; k++) {
int best = -1;
uint32_t best_count = 0;
for (int t = 0; t < 256; t++) {
if (!used[t] && d->type_counts[t] > best_count) {
best_count = d->type_counts[t];
best = t;
}
}
if (best < 0) break;
used[best] = 1;
char const* name = crsf_type_name((uint8_t)best);
if (name != NULL) {
printf(" %s=%u", name, best_count);
} else {
printf(" 0x%02X=%u", best, best_count);
}
shown++;
}
if (shown == 0) printf(" -");
ansi_reset();
printf("\n");
n++;
*lines = n;
fflush(stdout);
}
@@ -359,18 +426,11 @@ static int list_ports(void) {
return 0;
}
/* Send a ping and wait for DEVICE_INFO to verify the module responds. */
static int verify_connection(cel_serial_port* port) {
if (cel_crsf_param_ping(port, 2.0f) != 0) {
cel_log_warn("No DEVICE_INFO response - module may not be connected");
return -1;
}
return 0;
}
int main(int argc, char const* argv[]) {
char const* port_path = NULL;
int baud_rate = 0; /* 0 = auto-probe */
char msg[256];
clock_t t_startup = clock();
for (int i = 1; i < argc; i++) {
if (strcmp(argv[i], "--list") == 0) {
@@ -385,10 +445,13 @@ int main(int argc, char const* argv[]) {
/* Auto-detect port if not specified */
if (port_path == NULL) {
clock_t t = clock();
char detected[256];
if (cel_serial_find_elrs_port(detected, sizeof(detected)) == 0) {
port_path = detected;
cel_log_info("Auto-detected ELRS port");
snprintf(msg, sizeof(msg), "Auto-detected ELRS port (%.0f ms)",
elapsed_ms(t));
cel_log_info(msg);
} else {
cel_log_err("No ELRS-like port found. Use --list to see ports.");
return 1;
@@ -412,15 +475,10 @@ int main(int argc, char const* argv[]) {
return 1;
}
char msg[256];
snprintf(msg, sizeof(msg), "Connected to %s (%d baud)", port_path, actual_baud);
snprintf(msg, sizeof(msg), "Connected to %s (%d baud) in %.0f ms",
port_path, actual_baud, elapsed_ms(t_startup));
cel_log_info(msg);
/* Verify module responds to CRSF ping */
if (verify_connection(port) != 0) {
cel_log_warn("Continuing anyway - telemetry may not arrive");
}
/* Create CRSF stream for incremental parsing */
cel_crsf_stream* stream = cel_crsf_stream_create();
if (stream == NULL) {
@@ -453,6 +511,7 @@ int main(int argc, char const* argv[]) {
double now = (double)time(NULL);
for (int i = 0; i < n; i++) {
dash.type_counts[frames[i].type]++;
cel_telemetry telem;
if (cel_crsf_telemetry_parse(&frames[i], &telem) == 0) {
dashboard_update(&dash, &telem, now);
@@ -469,6 +528,14 @@ int main(int argc, char const* argv[]) {
cel_serial_write(port, rc_buf, rc_len);
}
/* Send DEVICE_PING every 5s so DEVICE_INFO replies keep showing up
* in the dashboard's type_counts, like the Python ping_loop. */
if (rc_count % 250 == 0) {
uint8_t ping_buf[8];
size_t ping_len = cel_crsf_build_ping_frame(ping_buf);
cel_serial_write(port, ping_buf, ping_len);
}
/* Redraw dashboard every 100 ms */
if (rc_count % 5 == 0) {
double elapsed = difftime(time(NULL), t_start);