Header‑only RC link for Arduino/ESP32 with iBUS and SBUS transports, flexible role mapping, per‑channel shaping and filtering, an optional JSON config loader, and receiver‑failsafe signature detection with a selectable apply policy.
The library is lightweight, device‑agnostic, and integrates cleanly with Arduino sketches or ESP32 FreeRTOS projects.
Author: Little Man Builds
License: MIT
- Transports: iBUS (Flysky family) and SBUS (100 000 baud, 8E2, inverted; ESP32‑ready).
- Role mapping:
RC_DECLARE_ROLES,RC_CONFIG,RC_CFG_MAP_DEFAULT. - Per‑channel shaping: raw calibration (µs), deadband, output range, expo, invert.
- Filtering: per‑axis EMA (
setAxisFilter) + epsilon suppression (setEpsilon). - Failsafe: per‑channel policies + receiver‑failsafe signatures (select which roles & expected values).
- Signature apply policy:
apply_rxfs_outputs(true/false)— choose whether signature detection should apply your failsafe outputs or only report it. - Status: unified
RcLinkStatus(fps, link age, protocol flags, CRC errors). - Header‑only core; optional JSON loader (ArduinoJson) and an iBUS calibrator.
- iBUS Calibrator: interactive histogram/clustering helper to generate channel specs.
- RCLink
Arduino IDE: (once published) search RCLink in Library Manager and install.
Manual: download the release ZIP → Sketch → Include Library → Add .ZIP Library…
PlatformIO: add to lib_deps:
lib_deps =
LittleManBuilds/RCLinkOptional dependency: ArduinoJson (only if you use the JSON loader).
- Role – a logical control in your app (
Throttle,Steering, etc.) declared viaRC_DECLARE_ROLES. - Channel – receiver channel index (iBUS: 0..13, SBUS: 0..15).
- Mapping – binds a role to a RX channel index (
cfg.map()or macro helpers). - Axis – continuous input (sticks, knobs) with shaping (deadband, expo, invert, scaling).
- Switch – discrete positions (2..8) with either fixed raw levels or auto‑learn snapping.
- Epsilon – suppress small post‑filter changes to reduce log spam and jitter.
- Failsafe – per‑channel output policy during link loss; independent signature detection can flag when RX enters its own failsafe pattern and, if enabled, apply your policy immediately.
- Status –
RcLinkStatusreports link freshness, fps, last frame age, protocol flags, and counters.
- iBUS (
RcIbusTransport) — standard 115 200 8N1; RX‑only UART. - SBUS (ESP32) (
RcSbusEsp32Transport) — 100 000 baud, 8E2, inverted; exposes protocol failsafe and frame‑lost flags.
Both transports implement the same minimal interface used by RcLink, so you can switch between them without changing higher‑level code.
#include <RCLink.h>
#define FLYSKY_ROLES(X) \
X(Ch1_RH) /* roll */ \
X(Ch2_RV) /* pitch */ \
X(Ch3_LV) /* thr */ \
X(Ch4_LH) /* yaw */
RC_DECLARE_ROLES(Flysky, FLYSKY_ROLES)
RcIbusTransport transport;
RcLink<RcIbusTransport, Flysky> rclink(transport);
void setup() {
Serial.begin(115200);
rclink.begin(Serial2, 115200, /*rx*/18, /*tx*/-1);
RC_CONFIG(Flysky, cfg);
RC_CFG_MAP_DEFAULT(Flysky, cfg); // role0->ch0, role1->ch1, ...
rclink.apply_config(cfg);
}Helpers generated by RC_DECLARE_ROLES:
enum class Flysky : uint8_t { ..., Count };to_string(Flysky::Ch1_RH)→"Ch1_RH"role_count(Flysky{})→ number of roles
The umbrella header
RCLink.hbrings the public types and helpers into scope for sketches.
#include <RCLink.h>
#define ROLES(X) X(Ch1) X(Ch2) X(Ch3) X(Ch4)
RC_DECLARE_ROLES(MyRC, ROLES)
RcIbusTransport transport;
RcLink<RcIbusTransport, MyRC> rclink(transport);
void setup() {
Serial.begin(115200);
rclink.begin(Serial2, 115200, 18, -1);
RC_CONFIG(MyRC, cfg);
RC_CFG_MAP_DEFAULT(MyRC, cfg);
rclink.apply_config(cfg);
}
void loop() {
rclink.update();
if (rclink.changed()) {
RC_PRINT_ALL(rclink, MyRC);
}
delay(10);
}RC_CONFIG(Flysky, cfg);
RC_CFG_MAP_DEFAULT(Flysky, cfg);
// CH1: 1000..2000 µs centered at 1500 µs, deadband 8 µs, output -100..+100, expo 0.35
cfg.axis(Flysky::Ch1_RH)
.raw(1000, 2000, 1500)
.deadband_us(8)
.out(-100.f, 100.f)
.expo(0.35f)
.invert(false)
.failsafe(Failsafe::Mode::Value, 0) // optional per‑channel policy
.done();Notes:
raw(lo, hi, center)defines input bounds for scaling.deadband_us()is in microseconds around the center.expo(0..1)applies a cubic‑mix curve (0= linear).out(lo, hi)sets the scaled range returned byread(role).invert()flips the axis direction..failsafe(mode, def)writes into the config’s failsafe table for this role.
// CH7: 2‑pos switch → values {0,1}
cfg.sw(Flysky::Ch7_SwA).values({0.f, 1.f}).done();
// CH9: 3‑pos switch → values {-1,0,+1}, fixed raw µs levels
cfg.sw(Flysky::Ch9_SwC)
.values({-1.f, 0.f, +1.f})
.raw_levels({1000,1500,2000}) // explicit snap points
.failsafe(Failsafe::Mode::Value, 0)
.done();
// CH10: 2‑pos with auto‑learned levels + hysteresis
cfg.sw(Flysky::Ch10_SwD)
.values({0.f, 1.f})
.auto_levels(true)
.hysteresis_us(60)
.learn_alpha(0.20f)
.done();// EMA filter (0..1). Higher = more smoothing (slower). Axis only.
cfg.setAxisFilter(Flysky::Ch1_RH, 0.20f);
cfg.setAxisFilter(Flysky::Ch2_RV, 0.20f);
cfg.setAxisFilter(Flysky::Ch3_LV, 0.10f);
// Epsilon (scaled units) – only publish when |delta| > epsilon
cfg.setEpsilon(Flysky::Ch1_RH, 1);
cfg.setEpsilon(Flysky::Ch2_RV, 1);
cfg.setEpsilon(Flysky::Ch3_LV, 1);Applied when the link is not ok (stale/lost; see Status).
// Mode::Value -> emits the provided default value
cfg.setFailsafePolicy(Flysky::Ch1_RH, Failsafe::Mode::Value, 0);
// Mode::HoldLast -> freezes at last good filtered value
cfg.setFailsafePolicy(Flysky::Ch2_RV, Failsafe::Mode::HoldLast);
// Clamp to axis output ends (axes only)
cfg.setFailsafePolicy(Flysky::Ch3_LV, Failsafe::Mode::ClampToOutLo);
cfg.setFailsafePolicy(Flysky::Ch4_LH, Failsafe::Mode::ClampToOutHi);A receiver‑failsafe signature detects when the RX is still sending frames but has entered a specific pattern (e.g., the transmitter’s built‑in failsafe posture). You control what happens next via your per‑channel policy and the apply policy:
status().rx_failsafe_sig— set when the incoming scaled values match the configured signature for at leasthold_ms.apply_rxfs_outputs(true/false)— if true, the link immediately applies your failsafe policy outputs (as if the link were lost). If false (default), it only reportsrx_failsafe_sigand continues to pass through the live frames.
Signature macros (scaled space):
// 1) All roles == expected (±tol), must persist hold_ms
RC_SET_FS_SIGNATURE_ALL(Flysky, rclink, /*expected=*/0, /*tol=*/3, /*hold_ms=*/250);
// 2) Default expected with per‑role overrides
RC_SET_FS_SIGNATURE_OVERRIDES(Flysky, rclink, /*default=*/0, /*tol=*/3, /*hold_ms=*/250,
{ {Flysky::Ch3_LV, 10} } // throttle expected 10; others use default
);
// 3) Only check selected roles (others ignored)
RC_SET_FS_SIGNATURE_SELECTED(Flysky, rclink, /*tol=*/3, /*hold_ms=*/250,
{ {Flysky::Ch1_RH, 0}, {Flysky::Ch2_RV, 0}, {Flysky::Ch3_LV, 0}, {Flysky::Ch4_LH, 0} }
);
// Choose how a detected signature affects outputs:
rclink.apply_rxfs_outputs(true); // apply your policy when signature matches
// rclink.apply_rxfs_outputs(false); // (default) report only; keep following framesImplementation detail: signature matching uses the scaled values before epsilon suppression, so detection is not delayed by logging/epsilon settings.
RcLinkStatus provides a live snapshot:
const RcLinkStatus& st = rclink.status();
if (!st.link_ok) Serial.println("Link STALE (no frame within timeout)");
if (st.proto_failsafe) Serial.println("Protocol failsafe flag asserted");
if (st.frame_lost) Serial.println("Transport reports a lost frame");
if (st.rx_failsafe_sig) Serial.println("Receiver failsafe signature matched");Sticky print once example showing RC_PRINT_ALL versatility:
static bool printedFailsafe = false;
void loop() {
rclink.update();
const auto& st = rclink.status();
if (!st.link_ok || st.proto_failsafe || st.rx_failsafe_sig) {
if (!printedFailsafe) {
Serial.println(st.link_ok ? "Failsafe: signature or protoFS" : "Failsafe: link lost");
RC_PRINT_ALL(rclink, Flysky); // applied outputs (if apply policy enabled)
printedFailsafe = true;
}
} else {
if (rclink.changed()) {
RC_PRINT_ALL(rclink, Flysky); // print only when values change
}
printedFailsafe = false; // reset for next time
}
}The optional loader applies mapping + per‑channel specs from a JSON string. This JSON is typically produced by the iBUS Calibration tool (see examples/01_Ibus_Calibration.ino) and can also be read from SPIFFS, SD, or Serial. To use it:
- Include after your
RC_DECLARE_ROLES(...)so the generated helpers exist:
#include <RCLink.h>
RC_DECLARE_ROLES(MyRC, /* ... */)
#include <Json.hpp>- Call
load_json(MyRC{}, cfg, jsonText)and thenrclink.apply_config(cfg).
Schema (high‑level):
map: object of"RoleName" -> channelIndex.axes.RoleName: fieldsraw [lo,hi,center],deadband_us,out [lo,hi],expo,invert.switches.RoleName: fieldsvalues [..], optionalraw_levels [..],auto_levels,hyst_us,learn_alpha.
{
"map": { "RoleName": 0, ... },
"axes": {
"RoleName": {
"raw": [lo, hi, center],
"deadband_us": 0,
"out": [lo, hi],
"expo": 0.0,
"invert": true
}
},
"switches": {
"RoleName": {
"values": [0,1,2],
"raw_levels": [1000,1500,2000],
"auto_levels": true,
"hyst_us": 60,
"learn_alpha": 0.2
}
}
}
A helper to interactively explore raw ranges, switch levels, and produce suggested config (both fluent code and JSON). Typical sketch outline:
#include <RCLink.h>
#include <calibration/Ibus_Calibrate.hpp>
void setup() {
Serial.begin(115200);
calibrate::run_ibus(Serial2, /*rxPin=*/18, /*txPin=*/-1, /*baud=*/115200);
}
void loop() {}Follow the on‑screen instructions to move sticks/switches and copy the suggested JSON settings into your app.
#include <RCLink.h>
#define ROLES(X) X(Ch1) X(Ch2) X(Ch3) X(Ch4)
RC_DECLARE_ROLES(MyRC, ROLES)
RcIbusTransport transport;
RcLink<RcIbusTransport, MyRC> rclink(transport);
void setup() {
Serial.begin(115200);
rclink.begin(Serial2, 115200, 18, -1);
RC_CONFIG(MyRC, cfg); RC_CFG_MAP_DEFAULT(MyRC, cfg);
rclink.apply_config(cfg);
}
void loop() {
rclink.update();
if (rclink.changed()) RC_PRINT_ALL(rclink, MyRC);
delay(10);
}#include <RCLink.h>
#define FLYSKY_ROLES(X) X(Ch1_RH) X(Ch2_RV)
RC_DECLARE_ROLES(Flysky, FLYSKY_ROLES)
RcSbusEsp32Transport transport;
RcLink<RcSbusEsp32Transport, Flysky> rclink(transport);
void setup() {
Serial.begin(115200);
// SBUS: 100000 baud, 8E2, inverted
rclink.begin(Serial2, 100000, /*rx=*/18, /*tx=*/-1);
RC_CONFIG(Flysky, cfg); RC_CFG_MAP_DEFAULT(Flysky, cfg);
rclink.apply_config(cfg);
}
void loop() {
rclink.update();
Serial.printf("RH=%d, RV=%d\n",
rclink.read(Flysky::Ch1_RH),
rclink.read(Flysky::Ch2_RV));
delay(20);
}RC_CONFIG(Flysky, cfg); RC_CFG_MAP_DEFAULT(Flysky, cfg);
cfg.axis(Flysky::Ch1_RH)
.raw(1000,2000,1500).deadband_us(8).out(-100,100).expo(0.35f).done();
cfg.axis(Flysky::Ch2_RV)
.raw(1000,2000,1500).deadband_us(8).out(-100,100).expo(0.35f).done();
cfg.axis(Flysky::Ch3_LV) // throttle
.raw(1000,2000,1000).deadband_us(4).out(0,100).expo(0.15f).done();
cfg.setAxisFilter(Flysky::Ch1_RH, 0.20f);
cfg.setAxisFilter(Flysky::Ch2_RV, 0.20f);
cfg.setAxisFilter(Flysky::Ch3_LV, 0.10f);
cfg.setEpsilon(Flysky::Ch1_RH, 1);
cfg.setEpsilon(Flysky::Ch2_RV, 1);
cfg.setEpsilon(Flysky::Ch3_LV, 1);// Per‑role failsafe outputs when link is stale
cfg.setFailsafePolicy(Flysky::Ch1_RH, Failsafe::Mode::Value, 0);
cfg.setFailsafePolicy(Flysky::Ch3_LV, Failsafe::Mode::ClampToOutLo);
// Signature: RX sends all zeros for ≥250 ms (example)
RC_SET_FS_SIGNATURE_ALL(Flysky, rclink, 0, 3, 250);
// Apply policy when signature is detected (or just report if false)
rclink.apply_rxfs_outputs(true);- iBUS/SBUS parsing uses fixed buffers; no heap allocations or IRQs required by the core.
- Call
update()steadily (e.g., every 5–10 ms). Usefpsinstatus()to observe cadence. - Epsilon suppression (
setEpsilon) avoids spuriouschanged()triggers and reduces serial spam. - Signature matching cost is O(N) over your roles with small constants.
How do I detect the RC is switched off?
If no new frame within cfg.link_timeout_ms, status().link_ok == false. You can also watch last_frame_age and fps.
Does the signature detection respect filters or epsilon?
Detection uses scaled values (pre‑epsilon), so it isn’t blocked by epsilon settings. EMA filters still shape the scaled values; use a reasonable tol.
Do I need ArduinoJson?
Only for the JSON loader. Core RcLink + transports don’t require it.
Can switches auto‑learn raw levels?
Yes. Leave raw_levels unset and enable auto_levels(true). Adjust hysteresis_us and learn_alpha to taste.
What units does read(role) return?
Your configured output range (e.g., -100..+100 or 0..100).
MIT © Little Man Builds