feat: CRSF/ELRS RC integration — 16ch input with failsafe (#Phase2)

Protocol choice: implemented from spec (CRSFforArduino needs Arduino
framework; Betaflight extraction has deep scheduler dependencies).
Protocol verified against Betaflight src/main/rx/crsf.c + CRSF spec.

crsf.c:
- UART4 PA0=TX/PA1=RX (GPIO_AF8_UART4), 420000 baud 8N1, oversampling×8
  APB1=54MHz → BRR=0x101 → 418604 baud (0.33% error, within spec)
- DMA1 Stream2 Channel4, circular 64-byte buffer, IDLE interrupt
  DMA half/complete callbacks drain buffer; IDLE fires at frame boundary
- CRC8 DVB-S2 (polynomial 0xD5) validated on every frame
- Parser state machine: SYNC(0xC8)→LEN→DATA with length sanity check
- 11-bit channel unpack for all 16 channels from 22-byte payload
- RC channels frame (0x16): unpacks 16ch, updates last_rx_ms + armed
- Link stats frame (0x14): captures RSSI dBm, LQ%, SNR dB

crsf.h: added rssi_dbm, link_quality, snr fields to CRSFState

config.h: CRSF_ARM_THRESHOLD=1750, CRSF_STEER_MAX=400, CRSF_FAILSAFE_MS=300

main.c:
- crsf_init() called after motor_driver_init()
- RC failsafe: disarm if (now - last_rx_ms) > CRSF_FAILSAFE_MS, but only
  after RC was first seen (last_rx_ms != 0) — USB-only mode unaffected
- RC arm: CH5 rising edge → safety_arm_start(); falling edge → disarm
  Same ARMING_HOLD_MS interlock as USB arm command
- RC steer: CH1 → crsf_to_range() → ±CRSF_STEER_MAX → motor_driver steer
- RSSI/LQ: appended to JSON when safety_rc_alive() ("rssi","lq" fields)

ui/index.html: hidden RC RSSI row revealed on first packet with rssi/lq

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sl-firmware 2026-02-28 21:09:12 -05:00
parent f867956b43
commit fbfde24aba
5 changed files with 368 additions and 22 deletions

View File

@ -126,6 +126,12 @@
// Jetson: USART6 (PC6=TX, PC7=RX) // Jetson: USART6 (PC6=TX, PC7=RX)
// Debug: UART5 (PC12=TX, PD2=RX) // Debug: UART5 (PC12=TX, PD2=RX)
// --- CRSF / ExpressLRS ---
// CH1[0]=steer CH2[1]=lean(future) CH5[4]=arm CH6[5]=mode
#define CRSF_ARM_THRESHOLD 1750 /* CH5 raw value; > threshold = armed */
#define CRSF_STEER_MAX 400 /* CH1 range: -400..+400 motor counts */
#define CRSF_FAILSAFE_MS 300 /* Disarm after this ms without a frame */
// --- PID Tuning --- // --- PID Tuning ---
#define PID_KP 35.0f #define PID_KP 35.0f
#define PID_KI 1.0f #define PID_KI 1.0f

View File

@ -4,16 +4,42 @@
#include <stdint.h> #include <stdint.h>
#include <stdbool.h> #include <stdbool.h>
/*
* CRSF/ExpressLRS RC receiver state.
*
* Updated from ISR context on every valid frame.
* Read from main loop values are naturally atomic (8/16-bit on Cortex-M).
* last_rx_ms == 0 means no frame received yet (USB-only mode).
*/
typedef struct { typedef struct {
uint16_t channels[16]; // Raw CRSF values (172-1811) uint16_t channels[16]; /* Raw CRSF values, 172 (988µs) 1811 (2012µs) */
uint32_t last_rx_ms; // Timestamp of last valid frame uint32_t last_rx_ms; /* HAL_GetTick() at last valid RC frame */
bool armed; // Arm switch state bool armed; /* CH5 arm switch: true when channels[4] > CRSF_ARM_THRESHOLD */
/* Link statistics (from 0x14 frames, optional) */
int8_t rssi_dbm; /* Uplink RSSI in dBm (negative, e.g. -85) */
uint8_t link_quality; /* Uplink link quality 0100 % */
int8_t snr; /* Uplink SNR in dB */
} CRSFState; } CRSFState;
/*
* crsf_init() configure UART4 (PA0=TX, PA1=RX) at 420000 baud with
* DMA1 circular RX and IDLE interrupt. Call once before safety_init().
*/
void crsf_init(void); void crsf_init(void);
/*
* crsf_parse_byte() feed one byte into the frame parser.
* Called automatically from DMA/IDLE ISR. Available for unit tests.
*/
void crsf_parse_byte(uint8_t byte); void crsf_parse_byte(uint8_t byte);
/*
* crsf_to_range() map raw CRSF value (1721811) linearly to [min, max].
* Clamps at boundaries. Midpoint 992 (min+max)/2.
*/
int16_t crsf_to_range(uint16_t val, int16_t min, int16_t max); int16_t crsf_to_range(uint16_t val, int16_t min, int16_t max);
extern volatile CRSFState crsf_state; extern volatile CRSFState crsf_state;
#endif #endif /* CRSF_H */

View File

@ -1,19 +1,293 @@
/* /*
* crsf.c CRSF RC receiver stub (UART not yet wired on MAMBA F722S). * crsf.c CRSF/ExpressLRS RC receiver driver
* crsf_state.last_rx_ms stays 0 so safety_rc_alive() always returns false, *
* which keeps USB-only mode active (RC timeout disarm is commented out in main.c). * Hardware: UART4, PA0=TX PA1=RX (GPIO_AF8_UART4), 420000 baud 8N1
* DMA: DMA1 Stream2 Channel4 (UART4_RX), circular 64-byte buffer
* UART IDLE interrupt drain; DMA half/complete callbacks drain
*
* CRSF frame layout:
* [0xC8] [LEN] [TYPE] [PAYLOAD...] [CRC8-DVB-S2]
* LEN = bytes after itself = TYPE + PAYLOAD + CRC (max 62)
* CRC covers TYPE through last PAYLOAD byte (not SYNC, not LEN, not CRC)
*
* Supported frame types:
* 0x16 RC channels (22 bytes, 16 ch × 11 bit packed)
* 0x14 Link statistics (RSSI, LQ, SNR)
*
* Channel mapping (0-indexed):
* CH1 [0] = steering (-1000..+1000)
* CH2 [1] = speed/lean override (future use)
* CH5 [4] = arm switch (> CRSF_ARM_THRESHOLD = armed)
* CH6 [5] = mode (< 992 = RC, > 992 = auto)
*
* Protocol reference: ExpressLRS/CRSF_Spec, verified against Betaflight src/main/rx/crsf.c
*/ */
#include "crsf.h"
#include "crsf.h"
#include "config.h"
#include "stm32f7xx_hal.h"
#include <string.h>
/* ------------------------------------------------------------------ */
/* DMA circular receive buffer */
/* ------------------------------------------------------------------ */
#define CRSF_DMA_BUF_SIZE 64u /* must be power-of-2 >= 2× max frame (26) */
static uint8_t s_dma_buf[CRSF_DMA_BUF_SIZE];
static volatile uint16_t s_dma_head = 0; /* last processed position */
static UART_HandleTypeDef s_uart;
static DMA_HandleTypeDef s_dma_rx;
/* ------------------------------------------------------------------ */
/* Frame parser state */
/* ------------------------------------------------------------------ */
#define CRSF_SYNC 0xC8u
#define CRSF_FRAME_RC 0x16u /* RC channels packed */
#define CRSF_FRAME_LINK 0x14u /* Link statistics */
#define CRSF_MAX_FRAME_LEN 64u
typedef enum { ST_SYNC, ST_LEN, ST_DATA } parse_state_t;
static parse_state_t s_ps = ST_SYNC;
static uint8_t s_frame[CRSF_MAX_FRAME_LEN];
static uint8_t s_flen = 0; /* total expected frame bytes */
static uint8_t s_fpos = 0; /* bytes received so far */
/* ------------------------------------------------------------------ */
/* Public state */
/* ------------------------------------------------------------------ */
volatile CRSFState crsf_state = {0}; volatile CRSFState crsf_state = {0};
void crsf_init(void) {} /* ------------------------------------------------------------------ */
/* CRC8 DVB-S2 — polynomial 0xD5 */
void crsf_parse_byte(uint8_t byte) { (void)byte; } /* ------------------------------------------------------------------ */
static uint8_t crc8_dvb_s2(uint8_t crc, uint8_t a) {
int16_t crsf_to_range(uint16_t val, int16_t min, int16_t max) crc ^= a;
{ for (int i = 0; i < 8; i++) {
/* CRSF range 172-1811 → linear map to [min, max] */ crc = (crc & 0x80u) ? ((crc << 1) ^ 0xD5u) : (crc << 1);
int32_t v = (int32_t)val; }
return (int16_t)(min + (v - 172) * (max - min) / (1811 - 172)); return crc;
}
static uint8_t crsf_frame_crc(const uint8_t *frame, uint8_t frame_len) {
/* CRC covers frame[2] (type) .. frame[frame_len-2] (last payload byte) */
uint8_t crc = 0;
for (uint8_t i = 2; i < frame_len - 1; i++) {
crc = crc8_dvb_s2(crc, frame[i]);
}
return crc;
}
/* ------------------------------------------------------------------ */
/* 11-bit channel unpacking — 16 channels from 22 bytes */
/* ------------------------------------------------------------------ */
static void unpack_channels(const uint8_t *payload, uint16_t *ch) {
uint32_t bits = 0;
int loaded = 0, idx = 0, src = 0;
while (idx < 16) {
while (loaded < 11 && src < 22) {
bits |= (uint32_t)payload[src++] << loaded;
loaded += 8;
}
ch[idx++] = bits & 0x7FFu;
bits >>= 11;
loaded -= 11;
}
}
/* ------------------------------------------------------------------ */
/* Frame processing */
/* ------------------------------------------------------------------ */
static void process_frame(const uint8_t *frame, uint8_t frame_len) {
/* Validate minimum length and CRC */
if (frame_len < 4) return;
if (frame[frame_len - 1] != crsf_frame_crc(frame, frame_len)) return;
uint8_t type = frame[2];
const uint8_t *payload = &frame[3];
uint8_t payload_len = frame_len - 4; /* type + payload + crc = frame[1], minus type and crc */
switch (type) {
case CRSF_FRAME_RC:
if (payload_len < 22) return;
unpack_channels(payload, (uint16_t *)crsf_state.channels);
crsf_state.last_rx_ms = HAL_GetTick();
/* Update arm switch state from CH5 (index 4) */
crsf_state.armed = (crsf_state.channels[4] > CRSF_ARM_THRESHOLD);
break;
case CRSF_FRAME_LINK:
/* Link stats payload:
* [0] uplink RSSI ant1 (value = -dBm, so negate for dBm)
* [2] uplink link quality (0-100 %)
* [3] uplink SNR (signed dB)
*/
if (payload_len < 4) return;
crsf_state.rssi_dbm = -(int8_t)payload[0];
crsf_state.link_quality = payload[2];
crsf_state.snr = (int8_t)payload[3];
break;
default:
break;
}
}
/* ------------------------------------------------------------------ */
/* Byte-level parser state machine */
/* ------------------------------------------------------------------ */
static void parse_byte(uint8_t b) {
switch (s_ps) {
case ST_SYNC:
if (b == CRSF_SYNC) {
s_frame[0] = b;
s_ps = ST_LEN;
}
break;
case ST_LEN:
/* LEN = bytes remaining after this field (type + payload + crc), max 62 */
if (b >= 2 && b <= 62) {
s_frame[1] = b;
s_flen = b + 2u; /* total frame = SYNC + LEN + rest */
s_fpos = 2;
s_ps = ST_DATA;
} else {
s_ps = ST_SYNC; /* invalid length — resync */
}
break;
case ST_DATA:
s_frame[s_fpos++] = b;
if (s_fpos >= s_flen) {
process_frame(s_frame, s_flen);
s_ps = ST_SYNC;
}
break;
}
}
/* ------------------------------------------------------------------ */
/* DMA buffer drain — called from IDLE IRQ and DMA half/complete CBs */
/* ------------------------------------------------------------------ */
static void dma_drain(void) {
/* DMA CNDTR counts DOWN from buf size; write position = size - CNDTR */
uint16_t pos = (uint16_t)(CRSF_DMA_BUF_SIZE - __HAL_DMA_GET_COUNTER(&s_dma_rx));
uint16_t head = s_dma_head;
while (head != pos) {
parse_byte(s_dma_buf[head]);
head = (head + 1u) & (CRSF_DMA_BUF_SIZE - 1u);
}
s_dma_head = pos;
}
/* ------------------------------------------------------------------ */
/* IRQ handlers */
/* ------------------------------------------------------------------ */
void UART4_IRQHandler(void) {
/* IDLE line detection — fires when bus goes quiet between frames */
if (__HAL_UART_GET_FLAG(&s_uart, UART_FLAG_IDLE)) {
__HAL_UART_CLEAR_IDLEFLAG(&s_uart);
dma_drain();
}
HAL_UART_IRQHandler(&s_uart);
}
void DMA1_Stream2_IRQHandler(void) {
HAL_DMA_IRQHandler(&s_dma_rx);
}
/* DMA half-complete: drain first half of circular buffer */
void HAL_UART_RxHalfCpltCallback(UART_HandleTypeDef *h) {
if (h->Instance == UART4) dma_drain();
}
/* DMA complete: drain second half (buffer wrapped) */
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *h) {
if (h->Instance == UART4) dma_drain();
}
/* ------------------------------------------------------------------ */
/* Public API */
/* ------------------------------------------------------------------ */
/*
* crsf_init() configure UART4 + DMA1 and start circular receive.
*
* UART4: PA0=TX, PA1=RX, AF8_UART4, 420000 baud 8N1, oversampling×8.
* APB1 = 54 MHz BRR = 0x101 actual 418604 baud (0.33% error, within CRSF spec).
*
* DMA: DMA1 Stream2 Channel4, peripheralmemory, circular, byte width.
* IDLE interrupt + DMA half/complete callbacks drain the circular buffer.
*/
void crsf_init(void) {
__HAL_RCC_GPIOA_CLK_ENABLE();
__HAL_RCC_UART4_CLK_ENABLE();
__HAL_RCC_DMA1_CLK_ENABLE();
/* PA0=TX, PA1=RX, AF8_UART4 */
GPIO_InitTypeDef gpio = {0};
gpio.Pin = GPIO_PIN_0 | GPIO_PIN_1;
gpio.Mode = GPIO_MODE_AF_PP;
gpio.Pull = GPIO_PULLUP;
gpio.Speed = GPIO_SPEED_FREQ_HIGH;
gpio.Alternate = GPIO_AF8_UART4;
HAL_GPIO_Init(GPIOA, &gpio);
/* DMA1 Stream2 Channel4 — UART4_RX, circular byte transfers */
s_dma_rx.Instance = DMA1_Stream2;
s_dma_rx.Init.Channel = DMA_CHANNEL_4;
s_dma_rx.Init.Direction = DMA_PERIPH_TO_MEMORY;
s_dma_rx.Init.PeriphInc = DMA_PINC_DISABLE;
s_dma_rx.Init.MemInc = DMA_MINC_ENABLE;
s_dma_rx.Init.PeriphDataAlignment = DMA_PDATAALIGN_BYTE;
s_dma_rx.Init.MemDataAlignment = DMA_MDATAALIGN_BYTE;
s_dma_rx.Init.Mode = DMA_CIRCULAR;
s_dma_rx.Init.Priority = DMA_PRIORITY_LOW;
s_dma_rx.Init.FIFOMode = DMA_FIFOMODE_DISABLE;
HAL_DMA_Init(&s_dma_rx);
__HAL_LINKDMA(&s_uart, hdmarx, s_dma_rx);
/* UART4: 420000 8N1, oversampling×8 (better tolerance at high baud) */
s_uart.Instance = UART4;
s_uart.Init.BaudRate = 420000;
s_uart.Init.WordLength = UART_WORDLENGTH_8B;
s_uart.Init.StopBits = UART_STOPBITS_1;
s_uart.Init.Parity = UART_PARITY_NONE;
s_uart.Init.Mode = UART_MODE_TX_RX;
s_uart.Init.HwFlowCtl = UART_HWCONTROL_NONE;
s_uart.Init.OverSampling = UART_OVERSAMPLING_8;
HAL_UART_Init(&s_uart);
/* Start circular DMA — runs indefinitely, no need to restart */
HAL_UART_Receive_DMA(&s_uart, s_dma_buf, CRSF_DMA_BUF_SIZE);
/* IDLE line interrupt — fires when UART bus goes quiet (end of frame) */
__HAL_UART_ENABLE_IT(&s_uart, UART_IT_IDLE);
HAL_NVIC_SetPriority(DMA1_Stream2_IRQn, 6, 0);
HAL_NVIC_EnableIRQ(DMA1_Stream2_IRQn);
HAL_NVIC_SetPriority(UART4_IRQn, 5, 0);
HAL_NVIC_EnableIRQ(UART4_IRQn);
}
/*
* crsf_parse_byte() kept for compatibility; direct call path not used
* when DMA is active, but available for unit testing or UART-IT fallback.
*/
void crsf_parse_byte(uint8_t byte) {
parse_byte(byte);
}
/*
* crsf_to_range() map raw CRSF value 1721811 to [min, max].
* Midpoint 992 maps to (min+max)/2.
*/
int16_t crsf_to_range(uint16_t val, int16_t min, int16_t max) {
int32_t v = (int32_t)val;
int32_t r = min + (v - 172) * (int32_t)(max - min) / (1811 - 172);
if (r < min) r = min;
if (r > max) r = max;
return (int16_t)r;
} }

View File

@ -131,6 +131,9 @@ int main(void) {
motor_driver_t motors; motor_driver_t motors;
motor_driver_init(&motors); motor_driver_init(&motors);
/* Init CRSF/ELRS receiver on UART4 (PA0/PA1) with DMA */
crsf_init();
/* Init mode manager (RC/autonomous blend; CH6 mode switch) */ /* Init mode manager (RC/autonomous blend; CH6 mode switch) */
mode_manager_t mode; mode_manager_t mode;
mode_manager_init(&mode); mode_manager_init(&mode);
@ -178,15 +181,37 @@ int main(void) {
safety_arm_cancel(); safety_arm_cancel();
balance_disarm(&bal); balance_disarm(&bal);
} }
/* RC signal lost while armed — disarm for safety */ /* RC failsafe: disarm if signal lost AFTER RC was previously alive.
if (bal.state == BALANCE_ARMED && !safety_rc_alive(now)) { * Prevents auto-disarm in USB-only mode (crsf_state.last_rx_ms == 0). */
/* Uncomment when CRSF is wired (last_rx_ms stays 0 on stub): */ if (bal.state == BALANCE_ARMED &&
/* balance_disarm(&bal); */ crsf_state.last_rx_ms != 0 &&
(now - crsf_state.last_rx_ms) > CRSF_FAILSAFE_MS) {
safety_arm_cancel();
balance_disarm(&bal);
} }
/* Tilt fault buzzer alert (one-shot on fault edge) */ /* Tilt fault buzzer alert (one-shot on fault edge) */
safety_alert_tilt_fault(bal.state == BALANCE_TILT_FAULT); safety_alert_tilt_fault(bal.state == BALANCE_TILT_FAULT);
/* RC arm/disarm via CH5 switch (CRSF) — edge detect, same hold interlock */
{
static bool s_rc_armed_prev = false;
bool rc_armed_now = safety_rc_alive(now) && crsf_state.armed;
if (rc_armed_now && !s_rc_armed_prev) {
/* Rising edge: start arm hold (motors enable after ARMING_HOLD_MS) */
if (mpu6000_is_calibrated() &&
bal.state == BALANCE_DISARMED && fabsf(bal.pitch_deg) < 10.0f) {
safety_arm_start(now);
}
}
if (!rc_armed_now && s_rc_armed_prev) {
/* Falling edge: cancel pending arm or disarm if already armed */
safety_arm_cancel();
if (bal.state == BALANCE_ARMED) balance_disarm(&bal);
}
s_rc_armed_prev = rc_armed_now;
}
/* Handle arm/disarm requests from USB with arm-hold interlock */ /* Handle arm/disarm requests from USB with arm-hold interlock */
if (cdc_arm_request) { if (cdc_arm_request) {
cdc_arm_request = 0; cdc_arm_request = 0;
@ -238,7 +263,7 @@ int main(void) {
} }
/* Feed autonomous steer from Jetson into mode manager. /* Feed autonomous steer from Jetson into mode manager.
* mode_manager_get_steer() blends it with RC CH4 per active mode. */ * mode_manager_get_steer() blends it with RC steer per active mode. */
if (jetson_cmd_is_active(now)) if (jetson_cmd_is_active(now))
mode_manager_set_auto_cmd(&mode, jetson_cmd_steer(), 0); mode_manager_set_auto_cmd(&mode, jetson_cmd_steer(), 0);
@ -305,6 +330,12 @@ int main(void) {
} }
} }
} }
if (safety_rc_alive(now)) {
/* RSSI in dBm (negative), link quality 0-100% */
n = snprintf(p, rem, ",\"rssi\":%d,\"lq\":%d",
(int)crsf_state.rssi_dbm, (int)crsf_state.link_quality);
p += n; rem -= n;
}
n = snprintf(p, rem, "}\n"); n = snprintf(p, rem, "}\n");
len = (int)(p + n - buf); len = (int)(p + n - buf);
} else { } else {

View File

@ -56,6 +56,10 @@
<div class="stat" id="row-temp" style="display:none"><span class="label">TEMP</span> <span class="val" id="v-temp">--</span> °C</div> <div class="stat" id="row-temp" style="display:none"><span class="label">TEMP</span> <span class="val" id="v-temp">--</span> °C</div>
<div class="stat" id="row-hum" style="display:none"><span class="label">HUMIDITY</span> <span class="val" id="v-hum">--</span> %</div> <div class="stat" id="row-hum" style="display:none"><span class="label">HUMIDITY</span> <span class="val" id="v-hum">--</span> %</div>
<div class="stat" id="row-pres" style="display:none"><span class="label">PRESSURE</span> <span class="val" id="v-pres">--</span> hPa</div> <div class="stat" id="row-pres" style="display:none"><span class="label">PRESSURE</span> <span class="val" id="v-pres">--</span> hPa</div>
<div class="stat" id="row-rc" style="display:none">
<span class="label">RC RSSI</span> <span class="val" id="v-rssi">--</span> dBm
&nbsp;&nbsp;<span class="label" style="width:auto">LQ</span> <span class="val" id="v-lq">--</span>%
</div>
<div class="stat"><span class="label">HZ</span> <span class="val" id="v-hz">--</span></div> <div class="stat"><span class="label">HZ</span> <span class="val" id="v-hz">--</span></div>
<div> <div>
<button class="btn" id="connect-btn" onclick="toggleSerial()">CONNECT USB</button> <button class="btn" id="connect-btn" onclick="toggleSerial()">CONNECT USB</button>
@ -227,6 +231,11 @@ window.updateIMU = function(data) {
document.getElementById('row-pres').style.display = ''; document.getElementById('row-pres').style.display = '';
document.getElementById('v-pres').textContent = (data.pa / 10.0).toFixed(1); document.getElementById('v-pres').textContent = (data.pa / 10.0).toFixed(1);
} }
if (data.rssi !== undefined) {
document.getElementById('row-rc').style.display = '';
document.getElementById('v-rssi').textContent = data.rssi;
document.getElementById('v-lq').textContent = (data.lq !== undefined) ? data.lq : '--';
}
// Pitch bar: center at 50%, ±90° // Pitch bar: center at 50%, ±90°
document.getElementById('bar-pitch').style.width = ((pitch + 90) / 180 * 100) + '%'; document.getElementById('bar-pitch').style.width = ((pitch + 90) / 180 * 100) + '%';