本文最后更新于 2025-12-23,文章内容可能已经过时。

天地飞09SⅡ 无线中继地面站 & ELRS 全套自制指南

一、 系统架构图 (无线中继版)

本项目构建一个独立的地面中继站。天地飞遥控器通过原装 2.4G 链路无线连接到地面上的接收机。地面站接收 PPM 信号后,通过 915M ELRS 和 WiFi 网络双路并行发送控制指令。

  • 输入源: 天地飞原装接收机 (输出 PPM)。

  • 发射链路 A (主): ESP32-A + E22-900M30S (ELRS 1W TX),带 OLED 屏幕与按键。

  • 发射链路 B (备): ESP32-B (WiFi),UDP 转发至云服务器。

  • 接收端 (机载): 自制或成品 ELRS 915M 接收机。

二、 硬件采购清单 (BOM)

1. 地面中继站 (发射端)

组件

规格

数量

备注

主控 A (ELRS TX)

ESP32-WROOM-32 (30/38Pin)

1

915M 发射主控

主控 B (WiFi网桥)

ESP32-WROOM-32

1

网络转发主控

TX 射频模块

Ebyte E22-900M30S

1

SX1262, 1W (30dBm)

显示屏

1.3寸 OLED (SH1106/SSD1306)

1

I2C 接口 (VCC, GND, SDA, SCL)

导航按键

五向导航开关

1

用于 ELRS 菜单控制

电源

UBEC 5V 5A + 3S锂电池

1

1W 发射必须独立供电

地面输入

天地飞原装接收机

1

提供 PPM 信号

2. 机载接收机 (DIY RX)

如果你决定自制接收机,请采购以下元件;否则建议购买 HappyModel ES900RX。

组件

规格

数量

备注

RX 主控

ESP8285 M2ESP-01F

1

体积小,自带 Flash

RX 射频模块

Ebyte E22-900M22S

1

SX1262, 160mW, 小体积

晶振 (可选)

52MHz 温补晶振

1

如果模块没自带,需替换以提高耐温性

天线

915M T型天线 (IPEX1代)

1

必须匹配频段

三、 硬件制作与电路连接

1. 地面站总线连接

地面接收机 (天地飞) 的 PPM 信号引脚分叉,同时连接到两个 ESP32:

  • 接收机 PPM -> ESP32-A (GPIO 4)

  • 接收机 PPM -> ESP32-B (GPIO 4)

  • 接收机 VCC/GND -> UBEC 5V / GND (共地至关重要!)

2. ESP32-A (ELRS 发射机) 详细接线

固件目标:DIY 900 MHz TX ESP32 SX126x

组件引脚

ESP32引脚

功能

E22 MISO

GPIO 19

SPI 数据

E22 MOSI

GPIO 23

SPI 数据

E22 SCK

GPIO 18

SPI 时钟

E22 NSS

GPIO 5

片选

E22 BUSY

GPIO 32

状态

E22 DIO1

GPIO 33

中断

E22 RST

GPIO 14

复位

OLED SDA

GPIO 21

屏幕数据

OLED SCL

GPIO 22

屏幕时钟

按键 Up

GPIO 25

菜单上

按键 Down

GPIO 26

菜单下

按键 Enter

GPIO 27

菜单确认

PPM 输入

GPIO 4

信号源

3. ESP32-B (WiFi 网桥) 接线

  • PPM 输入 -> GPIO 4

  • VCC/GND -> 5V/GND

4. 自制接收机 (DIY RX) 接线

固件目标:DIY 900 MHz RX ESP8285 SX126x

E22-900M22S

ESP8285

备注

MISO

GPIO 12

MOSI

GPIO 13

SCK

GPIO 14

NSS

GPIO 15

BUSY

GPIO 5

部分固件定义可能不同,需核对 hardware.json

DIO1

GPIO 4

RST

GPIO 2

TX (CRSF)

TXD (GPIO 1)

接飞控 RX

RX (CRSF)

RXD (GPIO 3)

接飞控 TX

四、 固件编译与烧录 (完整步骤)

1. ELRS 发射机 (ESP32-A)

  1. 下载安装 ExpressLRS Configurator

  2. Category: DIY 900 MHz

  3. Device: DIY 900 MHz TX ESP32 SX126x

  4. Flashing Method: UART (首次刷写需用 USB 线)

  5. User Defines (复制以下内容):

    -D REGULATORY_DOMAIN_ISM_2400
    -D MY_BINDING_PHRASE="11223344"  // 必须修改为你的密码
    -D LOCK_ON_FIRST_CONNECTION
    -D HAS_OLED
    -D OLED_SDA=21
    -D OLED_SCL=22
    -D OLED_ADDR=0x3C
    -D GPIO_PIN_BUTTON_UP=25
    -D GPIO_PIN_BUTTON_DOWN=26
    -D GPIO_PIN_BUTTON_ENTER=27
    -D BUTTON_PRESSED_STATE=0
    // 强制使用 PPM 输入
    -D RC_SIGNAL_PIN=4 
    
  6. 点击 Build and Flash

  7. 刷写完成后,断电重启。进入 Web 后台 (10.0.0.1),在 Model 选项卡确认为 PPM 模式。

2. ELRS 接收机 (DIY RX)

  1. Category: DIY 900 MHz

  2. Device: DIY 900 MHz RX ESP8285 SX126x

  3. User Defines:

    -D MY_BINDING_PHRASE="11223344" // 密码必须与 TX 一致
    
  4. 刷写方法: 使用 USB-TTL 模块。

    • ESP8285 的 TX -> TTL RX

    • ESP8285 的 RX -> TTL TX

    • 关键: 上电前将 ESP8285 的 GPIO 0 接地 进入刷机模式。

  5. 点击 Build and Flash

五、 地面端 ESP32-B 完整代码

此代码用于读取 PPM 并通过 UDP 发送 MAVLink 包。(增加后续ATT用的飞控数据打印,ok)

使用方法:

  1. 安装 Arduino IDE。

  2. 库管理器搜索并安装 MAVLink 库 (by Arduino MAVLink)。

  3. 选择开发板 DOIT ESP32 DEVKIT V1

  4. 修改代码中的 WiFi 和服务器地址,点击上传。

#include <WiFi.h>
#include <WiFiUdp.h>
#include <MAVLink.h>

// ================= 用户配置区 =================
const char* ssid = "UAV402"; 
const char* password = "11223344";
const char* server_ip = "www.cclddk007.top";
const int server_port = 51175;           // 对应服务器 PORT_GND_RC

const int ppmPin = 4;                    // PPM 输入引脚
const int SYSTEM_ID = 255;               // 设为 255 以匹配飞控主地面站权限
const int TARGET_SYSTEM = 1;             // 目标飞控 ID
// =============================================

WiFiUDP udp;
mavlink_message_t msg_send;
uint8_t buf[MAVLINK_MAX_PACKET_LEN];

// PPM 解码变量
volatile unsigned long lastPulseTime = 0;
volatile int currentChannel = 0;
volatile uint16_t channelValues[8] = {1500, 1500, 1000, 1500, 1500, 1500, 1500, 1500};

// --- AAT 遥测数据存储变量 ---
struct TelemetryData {
    int32_t lat;             // 经度 1E7
    int32_t lon;             // 纬度 1E7
    int32_t alt;             // 海拔高度 mm
    float roll;              // 翻滚角 rad
    float pitch;             // 俯仰角 rad
    float yaw;               // 偏航角 rad
    uint16_t heading;        // 机头指向 deg
    float groundspeed;       // 地速 m/s
    uint8_t satellites;      // 卫星数
    uint16_t eph;            // 水平定位精度
    uint8_t battery_remaining; // 剩余电量 %
    uint32_t last_packet_ms;  // 最后收到包的时间
} telem;

// MAVLink 解析状态机
mavlink_message_t msg_receive;
mavlink_status_t status_receive;

// PPM 中断解码逻辑
void IRAM_ATTR ppmInterrupt() {
    unsigned long now = micros();
    unsigned long duration = now - lastPulseTime;
    lastPulseTime = now;
    if (duration > 3000) {
        currentChannel = 0;
    } else {
        if (currentChannel < 8) {
            if (duration > 800 && duration < 2200) channelValues[currentChannel] = duration;
            currentChannel++;
        }
    }
}

void setup() {
    Serial.begin(115200);
    pinMode(ppmPin, INPUT_PULLUP);
    attachInterrupt(digitalPinToInterrupt(ppmPin), ppmInterrupt, RISING);

    Serial.printf("\nConnecting to %s", ssid);
    WiFi.begin(ssid, password);
    while (WiFi.status() != WL_CONNECTED) { 
        delay(500); 
        Serial.print("."); 
    }
    Serial.println("\nWiFi Connected!");
    udp.begin(51175);
}

// 解析 MAVLink 消息
void handle_mavlink_packet(mavlink_message_t* msg) {
    telem.last_packet_ms = millis();
    
    switch (msg->msgid) {
        case MAVLINK_MSG_ID_GPS_RAW_INT: {
            mavlink_gps_raw_int_t packet;
            mavlink_msg_gps_raw_int_decode(msg, &packet);
            telem.lat = packet.lat;
            telem.lon = packet.lon;
            telem.alt = packet.alt;
            telem.satellites = packet.satellites_visible;
            telem.eph = packet.eph;
            break;
        }
        case MAVLINK_MSG_ID_ATTITUDE: {
            mavlink_attitude_t packet;
            mavlink_msg_attitude_decode(msg, &packet);
            telem.roll = packet.roll;
            telem.pitch = packet.pitch;
            telem.yaw = packet.yaw;
            break;
        }
        case MAVLINK_MSG_ID_VFR_HUD: {
            mavlink_vfr_hud_t packet;
            mavlink_msg_vfr_hud_decode(msg, &packet);
            telem.heading = packet.heading;
            telem.groundspeed = packet.groundspeed;
            break;
        }
        case MAVLINK_MSG_ID_SYS_STATUS: {
            mavlink_sys_status_t packet;
            mavlink_msg_sys_status_decode(msg, &packet);
            telem.battery_remaining = packet.battery_remaining;
            break;
        }
        case MAVLINK_MSG_ID_GLOBAL_POSITION_INT: {
            mavlink_global_position_int_t packet;
            mavlink_msg_global_position_int_decode(msg, &packet);
            break;
        }
    }
}

void loop() {
    unsigned long now = millis();
    static unsigned long lastSend = 0;
    static unsigned long lastHB = 0;
    static unsigned long lastPrint = 0;

    // 1. 发送 RC 覆盖指令 (约 40Hz)
    if (now - lastSend >= 25) {
        lastSend = now;
        uint16_t v[8];
        noInterrupts(); 
        for(int i=0; i<8; i++) v[i] = channelValues[i]; 
        interrupts();

        mavlink_msg_rc_channels_override_pack(SYSTEM_ID, 190, &msg_send, TARGET_SYSTEM, 1,
            v[0], v[1], v[2], v[3], v[4], v[5], v[6], v[7],
            0, 0, 0, 0, 0, 0, 0, 0, 0, 0);
        
        uint16_t len = mavlink_msg_to_send_buffer(buf, &msg_send);
        udp.beginPacket(server_ip, server_port);
        udp.write(buf, len);
        udp.endPacket();
    }

    // 2. 发送心跳包 (1Hz)
    if (now - lastHB >= 1000) {
        lastHB = now;
        mavlink_msg_heartbeat_pack(SYSTEM_ID, 190, &msg_send, MAV_TYPE_GCS, MAV_AUTOPILOT_INVALID, MAV_MODE_MANUAL_ARMED, 0, MAV_STATE_ACTIVE);
        uint16_t len = mavlink_msg_to_send_buffer(buf, &msg_send);
        udp.beginPacket(server_ip, server_port);
        udp.write(buf, len);
        udp.endPacket();
    }

    // 3. 接收并解析回传数据
    while (udp.parsePacket() > 0) {
        while (udp.available()) {
            uint8_t byte_received = udp.read();
            if (mavlink_parse_char(MAVLINK_COMM_0, byte_received, &msg_receive, &status_receive)) {
                handle_mavlink_packet(&msg_receive);
            }
        }
    }

    // 4. 定时打印状态 (0.5秒一次)
    if (now - lastPrint >= 500) {
        lastPrint = now;
        long link_age = now - telem.last_packet_ms;
        bool link_active = (link_age < 3500); // 略微放宽判断标准,减少误报

        if (link_active) {
            // 将弧度转换为角度以便观察
            float roll_deg = telem.roll * 57.2958f;
            float pitch_deg = telem.pitch * 57.2958f;
            float yaw_deg = telem.yaw * 57.2958f;
            
            Serial.printf("[Live] Age:%ldms | R:%.1f P:%.1f Y:%.1f deg | GS:%.1f | Bat:%d%%\n", 
                          link_age, roll_deg, pitch_deg, yaw_deg, telem.groundspeed, telem.battery_remaining);
        } else {
            Serial.printf("[Loss] Link Down for %ld ms - Re-Syncing...\n", link_age);
        }
    }
}

六、 飞控端设置与最终联调

  1. 飞控参数设置 (ArduPilot):

    • SERIALx_PROTOCOL = 2 (MAVLink2) - 针对连接 4G 模块的串口

    • FS_THR_ENABLE = 1 (开启油门失控保护)

    • 关键: 确保 ELRS 接收机失控模式为 Cut/No Pulses

  2. 联调步骤:

    • 步骤 1: 开启天地飞遥控器和地面中继站。

    • 步骤 2: 观察 ESP32-A 的 OLED 屏幕,应显示连接状态。

    • 步骤 3: 观察 ESP32-B 的串口输出,应显示 WiFi ConnectedSending RC...

    • 步骤 4: 连接飞控地面站 (Mission Planner),查看 RC_CHANNELS

      • 正常情况下,数值应跟随摇杆变化 (此时走的是 ELRS)。

      • 测试切换: 拔掉 ELRS 接收机 (模拟失控),数值应继续跟随摇杆变化 (此时已自动无缝切换到 4G/WiFi 链路)。

七、 制作注意事项

  1. 散热: 1W 的 E22 模块发热极其严重,必须使用导热硅胶将其粘贴在散热片上,并用风扇强制散热,否则几分钟内就会热保护降频。

  2. 天线: 地面站建议使用高增益的 915M 莫克森 (Moxon) 天线八木天线 指向飞机方向,可轻松突破 30km+。

  3. 屏蔽: 由于 ESP32-B (WiFi) 和 天地飞接收机 (2.4G) 距离很近,建议用铝箔胶带包裹 ESP32-B 的背面 (注意绝缘),防止 WiFi 信号淹没接收机的灵敏度。