FreeRTOS multitasking on an ESP32 PLC with periodic LoRaWAN re-join
A FreeRTOS-based firmware structure is what keeps remote PLCs alive when nobody is watching. This example, taken from a real water pumping station deployment, splits the firmware of an ESP32 PLC 14 into three tasks: pump control every 100 ms, LoRaWAN telemetry every 60 s, and an hourly OTAA re-join that acts as a communications watchdog. The radio can stall for seconds — the pump logic never notices.
Three tasks, three priorities
Control runs at priority 3 with
vTaskDelayUntil for a drift-free 100 ms period; telemetry runs at priority 1 because a LoRa transmission can legitimately take seconds on high spreading factors; the re-join task sits in between. On the dual-core ESP32 this separation is free — and it means a hung modem command never delays a pump stop.The hourly re-join as a watchdog
LoRaWAN sessions can silently die: a gateway reboot, a lost frame counter, a power blip on the modem. Instead of detecting every failure mode, the firmware simply renegotiates the OTAA session every hour. Worst case, the station loses one hour of uplinks and then recovers on its own — no truck roll to a remote site.
Sharing state between tasks safely
The control task writes single-byte
volatile flags (pump on, overflow, fault) and the telemetry task reads them. Byte reads are atomic on the ESP32, so no mutex is needed for this pattern — keep shared state to single bytes or protect it, and never call the radio from the control task.A snippet from the implementation
Straight from the example as deployed on the ESP32 PLC 14 / 38AR — copy it freely:
void setup() {
Serial.begin(115200);
pinMode(I_FLOAT_MIN, INPUT);
pinMode(I_FLOAT_MAX, INPUT);
pinMode(I_OVERLEVEL, INPUT);
pinMode(I_THERMAL, INPUT);
pinMode(Q_PUMP, OUTPUT);
pinMode(Q_PILOT, OUTPUT);
digitalWrite(Q_PUMP, LOW);
digitalWrite(Q_PILOT, LOW);
lora_init(57600); // RN2xx3 via SerialSC1
lora_join_otaa("APP_EUI", "APP_KEY"); // initial join
// 4096 bytes of stack per task: plenty for digitalRead + AT commands
xTaskCreate(vTaskControl, "control", 4096, NULL, 3, NULL); // 100 ms
xTaskCreate(vTaskLora, "lora", 4096, NULL, 1, NULL); // 60 s
xTaskCreate(vTaskRejoin, "rejoin", 4096, NULL, 2, NULL); // 1 h
}The full example is a complete program — wiring header, setup and main loop — ready to adapt to your application.
Frequently asked questions
Why use FreeRTOS instead of millis() timing in loop()?
Because a blocking radio operation (join, send with retries) would freeze the whole loop. With tasks, the control logic keeps its 100 ms cadence no matter what the modem is doing.
How often should I re-join OTAA?
Once per hour is a good balance for stations sending every 60 s. Re-joining too often wastes airtime and duty cycle; too rarely means long outages after a gateway incident.
Do I need a mutex for the shared variables?
Not for single-byte volatile flags, which are read and written atomically on the ESP32. For multi-byte structures, protect them with a mutex or pass them through a FreeRTOS queue.