← Back to Blog

Heltec V3 – Dual-Core LoRa Receiver with Queue Management

Advanced LoRa receiver for Heltec V3 (ESP32-S3) with dual-core architecture – FreeRTOS inter-core communication, message queuing, CRC validation, and offline message storage with automatic retry on WiFi reconnect.

6 min read Roman Swetly

Heltec V3 – Dual-Core LoRa Receiver with Queue Management

Advanced LoRa receiver for Heltec V3 (ESP32-S3) with dual-core architecture – FreeRTOS inter-core communication, message queuing, CRC validation, and offline message storage with automatic retry on WiFi reconnect.


Overview

The Heltec V3 (ESP32-S3) is a powerful dual-core microcontroller with built-in LoRa radio and OLED display. This project demonstrates a production-ready LoRa receiver that leverages both cores:

  • Core 1 (LoRa Reception) – Dedicated to receiving and validating LoRa packets.
  • Core 0 (WiFi/HTTP) – Handles internet connectivity and API communication.

Key features:

  • Dual-core architecture – Real-time LoRa reception on one core, network tasks on the other.
  • FreeRTOS inter-core queue – Thread-safe message passing between cores.
  • CRC validation – Only valid packets are processed and displayed.
  • Offline message queuing – Stores messages when WiFi is disconnected.
  • Automatic retry – Sends queued messages when WiFi reconnects.

System Architecture

HELTEC V3 (ESP32-S3)

Two Queue Systems

1. Inter-Core FreeRTOS Queue (loraQueue)

PropertyValue
TypeFreeRTOS QueueHandle_t
PurposeCommunication between Core 1 → Core 0
LocationGlobal variable in main.ino
Size10 messages
Thread-safe✅ Yes (FreeRTOS managed)
AccessxQueueSend() / xQueueReceive()

2. HTTP API Client Queue (_messageQueue)

PropertyValue
Typestd::vector<QueuedMessage>
PurposeStore messages when WiFi is disconnected
LocationPrivate member of HTTPAPIClient class
Size20 messages
Thread-safe❌ No (only accessed from Core 0)
AccessaddToQueue() / processQueue()

Data Flow Diagram

Data Flow Diagram

Step-by-Step Flow

When WiFi is Disconnected

LoRa Packet → Core 1 (CRC) → xQueueSend(loraQueue) 
    → Core 0 (WiFi Task) → xQueueReceive(loraQueue) 
    → apiClient.sendMessage() 
    → isConnected() == false 
    → addToQueue() → _messageQueue (stored)

When WiFi is Connected

LoRa Packet → Core 1 (CRC) → xQueueSend(loraQueue) 
    → Core 0 (WiFi Task) → xQueueReceive(loraQueue) 
    → apiClient.sendMessage() 
    → isConnected() == true 
    → sendToAPI() → Internet

When WiFi Reconnects (Process Queue First)

1. processQueue() → Sends all queued messages from _messageQueue
2. isSendConnectionGreeting() → Sends "Device online! Queue: 0 pending"

Why process queue first:

  • ✅ Sends all queued historical data from when device was offline.
  • ✅ Clears backlog before sending new status information.
  • ✅ More important data (sensor readings) gets priority over administrative messages.
  • ✅ Greeting reflects the current state after backlog is cleared.

Code Examples

FreeRTOS Queue Declaration (main.ino)

// Thread-safe queues for inter-core communication
QueueHandle_t loraQueue;

void setup() {
    // Create queue with 10 message slots
    loraQueue = xQueueCreate(10, sizeof(LoRaMessage));
}

// LoRa Reception Task (Core 1)
void loraReceptionTask(void *pvParameters) {
    while (1) {
        LoRaMessage loraMsg;
        loraMsg.message = str;
        loraMsg.isValid = (state == RADIOLIB_ERR_NONE);
        loraMsg.rssi = radio.getRSSI();
        loraMsg.snr = radio.getSNR();
        
        // Send to inter-core queue
        xQueueSend(loraQueue, &loraMsg, portMAX_DELAY);
        vTaskDelay(50 / portTICK_PERIOD_MS);
    }
}

// WiFi HTTP Task (Core 0)
void wifiHTTPTask(void *pvParameters) {
    while (1) {
        LoRaMessage loraMsg;
        
        // Receive from inter-core queue
        if (xQueueReceive(loraQueue, &loraMsg, 0) == pdTRUE) {
            // Pass to API client
            apiClient.sendMessage(loraMsg.message, loraMsg.isValid, 
                                  loraMsg.rssi, loraMsg.snr);
            
            // Check queue status
            UBaseType_t queueBefore = uxQueueMessagesWaiting(loraQueue) + 1;
            UBaseType_t queueAfter = uxQueueMessagesWaiting(loraQueue);
        }
        
        // Process offline queue
        apiClient.processQueue();
        
        vTaskDelay(100 / portTICK_PERIOD_MS);
    }
}

HTTP API Client Queue (HTTPAPIClient.cpp)

bool HTTPAPIClient::sendMessage(const String& message, bool isValid, 
                                 float rssi, float snr) {
    if (!isConnected()) {
        // WiFi OFF: Store in internal API client queue
        Serial.println("📡 WiFi disconnected - queuing valid message");
        if (isValid) {
            return addToQueue(message, isValid, rssi, snr);
        }
        return false;
    } else {
        // WiFi ON: Send immediately
        return sendToAPI(message, isValid, rssi, snr);
    }
}

bool HTTPAPIClient::addToQueue(const String& message, bool isValid, 
                                float rssi, float snr) {
    if (_messageQueue.size() >= _maxQueueSize) {
        Serial.println("⚠️ Message queue full, removing oldest");
        _messageQueue.erase(_messageQueue.begin());
    }
    
    QueuedMessage qMsg;
    qMsg.message = message;
    qMsg.isValid = isValid;
    qMsg.rssi = rssi;
    qMsg.snr = snr;
    qMsg.timestamp = millis();
    
    _messageQueue.push_back(qMsg);
    return true;
}

void HTTPAPIClient::processQueue() {
    if (!isConnected()) {
        return;  // Wait for WiFi to reconnect
    }
    
    if (_messageQueue.empty()) {
        return;  // No queued messages
    }
    
    Serial.printf("📤 Processing %d queued messages...\n", _messageQueue.size());
    
    for (auto &msg : _messageQueue) {
        sendToAPI(msg.message, msg.isValid, msg.rssi, msg.snr);
        delay(100);  // Small delay between messages
    }
    
    _messageQueue.clear();
}

HTTPAPIClient.h – Queue Declaration

class HTTPAPIClient {
private:
    struct QueuedMessage {
        String message;
        bool isValid;
        float rssi;
        float snr;
        unsigned long timestamp;
    };
    
    std::vector<QueuedMessage> _messageQueue;
    const int _maxQueueSize = 20;
};

Key Points

ConceptExplanation
No LoopbackMessages never go back to loraQueue from the API client.
Two-Stage QueuingStage 1: loraQueue (inter-core communication). Stage 2: _messageQueue (offline storage).
Different PurposesloraQueue: Real-time message passing between cores. _messageQueue: Persistent storage when offline.
Thread SafetyloraQueue is thread-safe (FreeRTOS managed). _messageQueue is only accessed from Core 0.
PriorityProcess queue before sending greeting to show accurate queue status.

Key Achievements

  • ✅ Dual-core LoRa reception – Real-time packet processing on dedicated core.
  • ✅ FreeRTOS inter-core queue – Thread-safe message passing.
  • ✅ CRC validation – Only valid packets are processed.
  • ✅ Offline message storage – Messages stored when WiFi is disconnected.
  • ✅ Automatic retry – Queued messages sent when WiFi reconnects.
  • ✅ Accurate status reporting – Process queue before connection greeting.

Technology Stack

CategoryTechnology
MCUESP32-S3 (Heltec V3)
LoRaSX1276 (RadioLib)
RTOSFreeRTOS (ESP-IDF)
QueuesFreeRTOS Queue, std::vector
WiFiESP32 WiFi library
HTTPHTTPClient (Arduino)
DisplaySSD1306 (OLED)


This dual-core LoRa receiver is part of my broader Embedded Systems Engineering practice. For a detailed technical walkthrough or custom firmware design, feel free to reach out.

← Previous Post
RP2040 – The Ideal Choice for Solar-Powered Embedded Systems
Next Post →
STM32F103C8T6 Ultra-Low Power Modes – Deep Sleep & Standby

Related Articles