Same power supply, same antenna, same code (except for the pin numbers).
See the photo. The V4 is on the left, the V3 on the right.
The black box you can just make out below is the transmitter. It’s located less than 20 cm from both receivers.
-26dBm, I agree with you, that seems impossible. And yet…
I’ve attached my sketch. I should mention that I’m using both cores of the ESP.
Core 0 is dedicated to LoRa only.
Summary
#include <RadioLib.h>
#include <Wire.h>
#include <Adafruit_PWMServoDriver.h>
#include <U8g2lib.h>
// — ADAPTATION HELTEC V3.2 —
#define OLED_SDA 17
#define OLED_SCL 18
#define OLED_RST 21 // Parfois inutilisé sur V3 mais défini pour la lib
#define VEXT_CTRL 36
// Pinout LoRa Heltec V3 : NSS: 8, DIO1: 14, NRST: 12, BUSY: 13
SX1262 radio = new Module(8, 14, 12, 13);
// L’écran sur V3 utilise souvent l’adresse 0x3C et les pins 17/18
U8G2_SSD1306_128X64_NONAME_F_HW_I2C u8g2(U8G2_R0, OLED_RST, OLED_SCL, OLED_SDA);
Adafruit_PWMServoDriver pwm = Adafruit_PWMServoDriver();
struct ControlData {
uint32_t msgId;
int16_t ailerons, elevator, rudder, flaps, throttle;
bool autoPilot, gear, lights;
int16_t trimRudder, trimElevator;
} rxData;
// Variables de diagnostic
volatile float lastRSSI = 0;
volatile float lastSNR = 0;
volatile uint32_t packetCount = 0;
volatile uint32_t lastPacketTime = 0;
const uint32_t TIMEOUT_MS = 500;
unsigned long lastDebugTime = 0;
SemaphoreHandle_t mutex;
// Prototypes
void debugControlData(const ControlData &data);
void setupLoRaFastMode();
void TaskLoRaReceive(void *pvParameters);
void TaskControlFlight(void *pvParameters);
void TaskDisplay(void *pvParameters);
void setup() {
Serial.begin(115200);
// 1. Activer l’alimentation Vext (OLED + RF)
pinMode(VEXT_CTRL, OUTPUT);
digitalWrite(VEXT_CTRL, LOW);
delay(150); // Un peu plus long pour la stabilité du régulateur V3
mutex = xSemaphoreCreateMutex();
// 2. Configuration LoRa
setupLoRaFastMode();
// 3. Initialisation OLED & PWM
u8g2.begin();
pwm.begin();
pwm.setPWMFreq(50);
// Tâches
xTaskCreatePinnedToCore(TaskLoRaReceive, “LoRaRecv”, 10000, NULL, 1, NULL, 0);
xTaskCreatePinnedToCore(TaskControlFlight, “FlightCtrl”, 10000, NULL, 2, NULL, 1);
xTaskCreatePinnedToCore(TaskDisplay, “Display”, 10000, NULL, 1, NULL, 1);
}
void setupLoRaFastMode() {
Serial.print(F("[LoRa] Init V3.2… "));
int state = radio.begin();
if (state == RADIOLIB_ERR_NONE) {
Serial.println(F(“Succès !”));
radio.setDio2AsRfSwitch(true); // Indispensable sur Heltec
} else {
Serial.print(F("Échec, code "));
Serial.println(state);
while (true)
;
}
// Paramètres haute vitesse
radio.setFrequency(868.0);
radio.setBandwidth(500.0);
radio.setSpreadingFactor(7);
radio.setCodingRate(5);
radio.setPreambleLength(6);
radio.setSyncWord(0x12);
radio.implicitHeader(sizeof(ControlData));
radio.setCRC(false);
radio.setOutputPower(22);
}
void TaskLoRaReceive(void *pvParameters) {
for (;
{
int state = radio.receive((uint8_t *)&rxData, sizeof(ControlData));
if (state == RADIOLIB_ERR_NONE) {
if (xSemaphoreTake(mutex, portMAX_DELAY)) {
lastPacketTime = millis();
lastRSSI = radio.getRSSI();
lastSNR = radio.getSNR();
packetCount++;
xSemaphoreGive(mutex);
}
}
}
}
void TaskControlFlight(void *pvParameters) {
for (;
{
uint32_t now = millis();
if (xSemaphoreTake(mutex, portMAX_DELAY)) {
// Calcul du temps écoulé depuis le dernier paquet
uint32_t timeSinceLastPacket = now - lastPacketTime;
if (timeSinceLastPacket > TIMEOUT_MS) {
// ==========================================
// MODE FAILSAFE : SIGNAL PERDU
// ==========================================
// 1. Moteurs à l'arrêt (150 = arrêt pour la plupart des ESC)
pwm.setPWM(0, 0, 150);
pwm.setPWM(1, 0, 150);
// 2. Gouvernes en position de sécurité
// On force des valeurs neutres ou de "mise en cercle"
pwm.setPWM(2, 0, 307); // Elevator au neutre (ou légèrement cabré)
pwm.setPWM(3, 0, 350); // Rudder légèrement incliné pour tourner
// 3. Optionnel : Sortir le train d'atterrissage si piloté
// pwm.setPWM(4, 0, 500);
} else {
// ==========================================
// VOL NORMAL : SIGNAL OK
// ==========================================
// Moteurs (0-100%) -> (150-600 PWM)
int mSpeed = map(rxData.throttle, 0, 4095, 150, 600);
pwm.setPWM(0, 0, mSpeed);
pwm.setPWM(1, 0, mSpeed);
// Axes de vol avec Trims
// On utilise constrain pour éviter qu'un trim trop fort ne fasse bugger le servo
int elevatorPos = constrain(rxData.elevator + rxData.trimElevator, 0, 4095);
int rudderPos = constrain(rxData.rudder + rxData.trimRudder, 0, 4095);
pwm.setPWM(2, 0, map(elevatorPos, 0, 4095, 200, 500));
pwm.setPWM(3, 0, map(rudderPos, 0, 4095, 200, 500));
}
xSemaphoreGive(mutex);
}
// Fréquence de rafraîchissement des servos (50Hz = 20ms)
vTaskDelay(pdMS_TO_TICKS(20));
}
}
void debugControlData(const ControlData &data) {
Serial.println(F("\n======= [ RECEIVER DEBUG ] ======="));
Serial.printf(“RSSI: %0.2f dBm | SNR: %0.2f | Pkts: %u\n”, lastRSSI, lastSNR, packetCount);
Serial.printf(“STICKS: A:%4d | E:%4d | R:%4d | T:%4d\n”, data.ailerons, data.elevator, data.rudder, data.throttle);
Serial.printf(“FLAGS : AP:%s | GEAR:%s | LGT:%s\n”,
data.autoPilot ? “ON” : “OFF”, data.gear ? “DOWN” : “UP”, data.lights ? “ON” : “OFF”);
Serial.println(F("===================================="));
}
void TaskDisplay(void *pvParameters) {
for (;
{
u8g2.clearBuffer();
u8g2.setFont(u8g2_font_6x10_tf);
if (xSemaphoreTake(mutex, portMAX_DELAY)) {
bool isFailsafe = (millis() - lastPacketTime > TIMEOUT_MS);
if (isFailsafe) {
u8g2.drawStr(0, 10, "STATUS: !!! FAILSAFE !!!");
} else {
u8g2.drawStr(0, 10, "STATUS: LINK OK");
}
u8g2.setCursor(0, 25);
u8g2.printf("RSSI: %.1f dBm", lastRSSI);
u8g2.setCursor(0, 40);
u8g2.printf("SNR: %.1f PKTS: %u", lastSNR, packetCount);
u8g2.setCursor(0, 55);
u8g2.printf("THR: %d%% GEAR: %s",
map(rxData.throttle, 0, 4095, 0, 100),
rxData.gear ? "DOWN" : "UP");
xSemaphoreGive(mutex);
}
u8g2.sendBuffer();
vTaskDelay(pdMS_TO_TICKS(100));
}
}
void loop() {
vTaskDelete(NULL);
}