IoT HUB
Published © GPL3+

Modernizing Legacy RC: FlySky RF Module Hack with ESP32

Ditching outdated T6Config PC software by repurposing the FS-HF030A 2.4GHz module into a smartphone-controlled transmitter with hardware-tim

IntermediateFull instructions provided1 hour58
Modernizing Legacy RC: FlySky RF Module Hack with ESP32

Things used in this project

Hardware components

FS-HF030A module
×1
50 ohm micro-coaxial antenna
×1
FS-RB6 receiver
×1

Software apps and online services

Arduino IDE
Arduino IDE
Arduino NANO ESP32
Arduino NANO ESP32

Story

Read more

Schematics

Circuit Diagram

Connect RF Module using this connection, The SW pin is not connected because it is used only for binding the receiver.

Code

Arduino Nano ESP32S3 RF Module code

Arduino
Upload this code using Arduino IDE and connect the FS RF Module using the provided circuit diagram.
#include <WiFi.h>
#include <WebServer.h>
#include <WebSocketsServer.h>
#include "esp_timer.h"

// ------------------- Wi-Fi Configuration -------------------
const char *SSID_NAME = "FlySky Custom TX";
const char *SSID_PASS = "12345678";

// ------------------- Hardware Pinout -----------------------
#define PPM_PIN D2          // PPM signal output to Yellow wire (GPIO 5)
#define NUM_CHANNELS 6      // 6-Channel FlySky AFHDS

// ------------------- PPM Timing Constants ------------------
#define PPM_FRAME_LENGTH 22500  // Standard 22.5 ms total frame time
#define PPM_PULSE_LEN    400    // 400 µs sync pulse width
#define MIN_PULSE        1000   // 1000 µs min
#define MID_PULSE        1500   // 1500 µs center
#define MAX_PULSE        2000   // 2000 µs max

// Channel Values: [CH1: Roll, CH2: Pitch, CH3: Throttle, CH4: Yaw, CH5: AUX1, CH6: AUX2]
volatile uint16_t channels[NUM_CHANNELS] = {MID_PULSE, MID_PULSE, MIN_PULSE, MID_PULSE, MIN_PULSE, MIN_PULSE};

// ------------------- Servers -------------------------------
WebServer server(80);
WebSocketsServer webSocket = WebSocketsServer(81);

// ------------------- Zero-Jitter Hardware PPM ISR ----------
esp_timer_handle_t ppm_timer;
volatile uint8_t ppm_state = 0;
volatile uint8_t cur_channel = 0;
volatile uint32_t elapsed_frame_time = 0;

void IRAM_ATTR onPpmTimer(void* arg) {
    if (ppm_state == 0) { // Start of Low sync pulse
        digitalWrite(PPM_PIN, LOW);
        esp_timer_start_once(ppm_timer, PPM_PULSE_LEN);
        ppm_state = 1;
    } else { // High pulse section
        digitalWrite(PPM_PIN, HIGH);
        if (cur_channel < NUM_CHANNELS) {
            uint16_t ch_val = channels[cur_channel];
            uint32_t high_duration = ch_val - PPM_PULSE_LEN;
            elapsed_frame_time += ch_val;
            cur_channel++;
            esp_timer_start_once(ppm_timer, high_duration);
            ppm_state = 0;
        } else { // Trailing frame space (sync blanking)
            uint32_t remaining = (PPM_FRAME_LENGTH > elapsed_frame_time) ? (PPM_FRAME_LENGTH - elapsed_frame_time) : 4000;
            cur_channel = 0;
            elapsed_frame_time = 0;
            esp_timer_start_once(ppm_timer, remaining);
            ppm_state = 0;
        }
    }
}

// ------------------- Web GUI -------------------------------
const char INDEX_HTML[] PROGMEM = R"rawliteral(
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>FlySky Custom TX</title>
<style>
  * { box-sizing: border-box; -webkit-touch-callout: none; -webkit-user-select: none; user-select: none; }
  body {
    margin: 0; padding: 0; background: #11141a; color: #eee; font-family: 'Segoe UI', Roboto, sans-serif;
    touch-action: none; overflow: hidden; height: 100vh; display: flex; flex-direction: column;
  }
  header {
    background: linear-gradient(180deg, #1f242d 0%, #15181f 100%);
    padding: 8px 16px; display: flex; justify-content: space-between; align-items: center;
    border-bottom: 2px solid #00f0ff; box-shadow: 0 4px 10px rgba(0,240,255,0.15);
  }
  .brand { font-size: 1.1rem; font-weight: 800; color: #fff; letter-spacing: 1px; }
  .brand span { color: #00f0ff; }
  .badge { background: #00f0ff22; color: #00f0ff; border: 1px solid #00f0ff; padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; font-weight: bold; }
  
  .dashboard { display: flex; flex-wrap: wrap; justify-content: space-around; background: #181c24; padding: 6px; font-size: 0.75rem; border-bottom: 1px solid #2a313d; }
  .ch-val { color: #00f0ff; font-weight: bold; }

  .sticks-container {
    flex: 1; display: flex; justify-content: space-around; align-items: center; padding: 10px; gap: 10px;
  }
  .stick-card {
    background: radial-gradient(circle, #1d222c 0%, #101318 100%);
    border: 2px solid #2b3342; border-radius: 50%; width: 44vw; height: 44vw; max-width: 250px; max-height: 250px;
    position: relative; box-shadow: inset 0 0 20px #000, 0 8px 15px rgba(0,0,0,0.5);
  }
  .stick-knob {
    width: 60px; height: 60px; border-radius: 50%; background: linear-gradient(145deg, #2a3240, #14181f);
    border: 2px solid #00f0ff; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
    box-shadow: 0 0 15px rgba(0,240,255,0.4);
  }
  .crosshair { position: absolute; background: rgba(255,255,255,0.05); }
  .ch-h { width: 100%; height: 1px; top: 50%; left: 0; }
  .ch-v { height: 100%; width: 1px; left: 50%; top: 0; }

  .aux-panel {
    background: #15181f; padding: 12px; display: flex; justify-content: space-around; align-items: center;
    border-top: 1px solid #2a313d;
  }
  .switch-btn {
    background: #212732; border: 1px solid #3a4556; color: #fff; padding: 10px 18px; border-radius: 6px;
    font-weight: bold; font-size: 0.85rem; cursor: pointer; transition: 0.2s;
  }
  .switch-btn.active { background: #00f0ff; color: #000; box-shadow: 0 0 10px #00f0ff; }
</style>
</head>
<body>

<header>
  <div class="brand">FLYSKY <span>CUSTOM TX</span></div>
  <div class="badge" id="connStatus">DISCONNECTED</div>
</header>

<div class="dashboard">
  <div>CH1(AIL): <span class="ch-val" id="v1">1500</span></div>
  <div>CH2(ELE): <span class="ch-val" id="v2">1500</span></div>
  <div>CH3(THR): <span class="ch-val" id="v3">1000</span></div>
  <div>CH4(RUD): <span class="ch-val" id="v4">1500</span></div>
  <div>CH5(SA): <span class="ch-val" id="v5">1000</span></div>
  <div>CH6(SB): <span class="ch-val" id="v6">1000</span></div>
</div>

<div class="sticks-container">
  <!-- Left Stick: Throttle (CH3 - Free Float) & Rudder (CH4 - Auto-center) -->
  <div class="stick-card" id="leftZone">
    <div class="crosshair ch-h"></div>
    <div class="crosshair ch-v"></div>
    <div class="stick-knob" id="leftKnob"></div>
  </div>

  <!-- Right Stick: Elevator (CH2 - Auto-center) & Aileron (CH1 - Auto-center) -->
  <div class="stick-card" id="rightZone">
    <div class="crosshair ch-h"></div>
    <div class="crosshair ch-v"></div>
    <div class="stick-knob" id="rightKnob"></div>
  </div>
</div>

<div class="aux-panel">
  <button class="switch-btn" id="btnCH5" onclick="toggleSwitch(5)">ARM (CH5): OFF</button>
  <button class="switch-btn" id="btnCH6" onclick="toggleSwitch(6)">MODE (CH6): 1000</button>
</div>

<script>
  let ws;
  let ch = [1500, 1500, 1000, 1500, 1000, 1000];

  function initWebSocket() {
    ws = new WebSocket('ws://' + window.location.hostname + ':81/');
    ws.onopen = () => {
      document.getElementById('connStatus').innerText = "CONNECTED";
      document.getElementById('connStatus').style.color = "#00ff66";
      document.getElementById('connStatus').style.borderColor = "#00ff66";
    };
    ws.onclose = () => {
      document.getElementById('connStatus').innerText = "LOST LINK";
      document.getElementById('connStatus').style.color = "#ff3333";
      document.getElementById('connStatus').style.borderColor = "#ff3333";
      setTimeout(initWebSocket, 2000);
    };
  }

  function sendChannels() {
    if (ws && ws.readyState === WebSocket.OPEN) {
      ws.send(JSON.stringify({c: ch}));
    }
    for(let i=0; i<6; i++) {
      document.getElementById('v' + (i+1)).innerText = ch[i];
    }
  }

  function setupJoystick(zoneId, knobId, isLeft) {
    const zone = document.getElementById(zoneId);
    const knob = document.getElementById(knobId);
    let rect, touchId = null;

    function handleStart(e) {
      for (let t of e.changedTouches) {
        if (touchId === null) {
          touchId = t.identifier;
          rect = zone.getBoundingClientRect();
          handleMove(e);
        }
      }
    }

    function handleMove(e) {
      for (let t of e.changedTouches) {
        if (t.identifier === touchId) {
          let x = t.clientX - rect.left - (rect.width / 2);
          let y = t.clientY - rect.top - (rect.height / 2);
          let maxR = rect.width / 2;

          let clampedX = Math.max(-maxR, Math.min(maxR, x));
          let clampedY = Math.max(-maxR, Math.min(maxR, y));

          knob.style.transform = `translate(calc(-50% + ${clampedX}px), calc(-50% + ${clampedY}px))`;

          let normX = Math.round(1500 + (clampedX / maxR) * 500);
          let normY = Math.round(1500 - (clampedY / maxR) * 500);

          if (isLeft) {
            ch[3] = normX; // Yaw / Rudder
            ch[2] = normY; // Throttle
          } else {
            ch[0] = normX; // Roll / Aileron
            ch[1] = normY; // Pitch / Elevator
          }
          sendChannels();
        }
      }
    }

    function handleEnd(e) {
      for (let t of e.changedTouches) {
        if (t.identifier === touchId) {
          touchId = null;
          rect = zone.getBoundingClientRect();
          let maxR = rect.width / 2;

          if (isLeft) {
            ch[3] = 1500;
            let currentThrottleY = -((ch[2] - 1500) / 500) * maxR;
            knob.style.transform = `translate(-50%, calc(-50% + ${currentThrottleY}px))`;
          } else {
            ch[0] = 1500;
            ch[1] = 1500;
            knob.style.transform = 'translate(-50%, -50%)';
          }
          sendChannels();
        }
      }
    }

    zone.addEventListener('touchstart', handleStart, {passive: false});
    zone.addEventListener('touchmove', handleMove, {passive: false});
    zone.addEventListener('touchend', handleEnd, {passive: false});
    zone.addEventListener('touchcancel', handleEnd, {passive: false});
  }

  function toggleSwitch(num) {
    if (num === 5) {
      ch[4] = (ch[4] === 1000) ? 2000 : 1000;
      let btn = document.getElementById('btnCH5');
      btn.innerText = "ARM (CH5): " + (ch[4] === 2000 ? "ON" : "OFF");
      btn.classList.toggle('active', ch[4] === 2000);
    } else if (num === 6) {
      if (ch[5] === 1000) ch[5] = 1500;
      else if (ch[5] === 1500) ch[5] = 2000;
      else ch[5] = 1000;
      document.getElementById('btnCH6').innerText = "MODE (CH6): " + ch[5];
    }
    sendChannels();
  }

  window.onload = () => {
    initWebSocket();
    setupJoystick('leftZone', 'leftKnob', true);
    setupJoystick('rightZone', 'rightKnob', false);
  };
</script>
</body>
</html>
)rawliteral";

// ------------------- WebSocket Event Handler ----------------
void onWebSocketEvent(uint8_t num, WStype_t type, uint8_t * payload, size_t length) {
    if (type == WStype_TEXT) {
        char* str = (char*)payload;
        char* ptr = strstr(str, "[");
        if (ptr) {
            ptr++;
            for (int i = 0; i < NUM_CHANNELS; i++) {
                int val = atoi(ptr);
                if (val >= 900 && val <= 2100) {
                    channels[i] = val;
                }
                ptr = strchr(ptr, ',');
                if (!ptr) break;
                ptr++;
            }
        }
    }
}

// ------------------- Setup & Loop ---------------------------
void setup() {
    Serial.begin(115200);

    pinMode(PPM_PIN, OUTPUT);
    digitalWrite(PPM_PIN, HIGH);

    WiFi.mode(WIFI_AP);
    WiFi.setTxPower(WIFI_POWER_8_5dBm);
    WiFi.softAP(SSID_NAME, SSID_PASS);

    server.on("/", HTTP_GET, []() {
        server.send_P(200, "text/html", INDEX_HTML);
    });
    server.begin();

    webSocket.begin();
    webSocket.onEvent(onWebSocketEvent);

    const esp_timer_create_args_t timer_args = {
        .callback = &onPpmTimer,
        .name = "ppm_generator"
    };
    esp_timer_create(&timer_args, &ppm_timer);
    esp_timer_start_once(ppm_timer, PPM_PULSE_LEN);
}

void loop() {
    server.handleClient();
    webSocket.loop();
}

Credits

IoT HUB
6 projects • 7 followers
I’m a student who loves robotics, hacking, creating things with dev boards like ESP32/Arduino, and discovering tricks to simplify tech.

Comments