5.8 Advanced Project: A Multi-Sensor Data Logger
In this chapter, we will build a complete data logging application that utilizes all the core concepts we have learned: hardware interrupts for precise data acquisition, queues for safe data transfer, multiple tasks with different priorities for processing and logging, and a software timer for periodic status checks.
Project Goal
We will create a system that performs the following actions:
-
A hardware timer will generate an interrupt every 200 milliseconds.
-
The Interrupt Service Routine (ISR) will simulate reading data from two sensors (e.g., temperature and humidity) and will send this data package to a queue.
-
A high-priority "Processing Task" will wait for data to arrive in the queue. When it does, it will perform a simple calculation (e.g., calculate an average) and place the result into a second queue.
-
A low-priority "Logging Task" will wait for results to arrive in the second queue and print them to the Serial Monitor.
-
A software timer will fire every 5 seconds to print a "System OK" status message, demonstrating a non-critical, periodic background action.
This architecture is a common and robust pattern in embedded systems. It decouples the time-critical data acquisition (in the ISR) from the less critical data processing and logging (in the tasks), ensuring the system remains responsive.
You Will Need
-
An ESP32 development board.
-
The Arduino IDE with the ESP32 board package installed.
#include <Arduino.h>
// Define task priorities
#define PROCESSING_TASK_PRIORITY 2
#define LOGGING_TASK_PRIORITY 1
// Define handles for RTOS objects
QueueHandle_t sensorDataQueue;
QueueHandle_t resultQueue;
TimerHandle_t systemHealthTimer;
hw_timer_t *hardwareTimer = NULL;
// Data structure to hold raw sensor readings
typedef struct {
int temperature;
int humidity;
} SensorData;
// Data structure for the processed result
typedef struct {
float averageValue;
} ProcessedResult;
// --- Interrupt Service Routine ---
// This function runs every time the hardware timer fires.
// It must be fast and non-blocking.
void IRAM_ATTR onTimer() {
// Simulate reading sensor data
SensorData data;
data.temperature = random(20, 30); // Simulate temp between 20-29°C
data.humidity = random(40, 60); // Simulate humidity between 40-59%
// Send a COPY of the data to the queue.
// Use the ISR-safe version of the function.
xQueueSendFromISR(sensorDataQueue, &data, NULL);
}
// --- Software Timer Callback ---
// This function runs every time the software timer expires.
void systemHealthCallback(TimerHandle_t xTimer) {
Serial.println("[HEALTH] System OK");
}
// --- High-Priority Task: Processing ---
void processingTask(void *parameter) {
SensorData receivedData;
ProcessedResult result;
while (true) {
// Wait indefinitely until an item arrives in the sensorDataQueue
if (xQueueReceive(sensorDataQueue, &receivedData, portMAX_DELAY) == pdPASS) {
// We have data, now process it.
Serial.println("[PROCESS] Data received. Calculating average...");
result.averageValue = (receivedData.temperature + receivedData.humidity) / 2.0;
// Send the result to the logging task via the resultQueue
xQueueSend(resultQueue, &result, portMAX_DELAY);
}
}
}
// --- Low-Priority Task: Logging ---
void loggingTask(void *parameter) {
ProcessedResult receivedResult;
while (true) {
// Wait indefinitely until a result arrives in the resultQueue
if (xQueueReceive(resultQueue, &receivedResult, portMAX_DELAY) == pdPASS) {
// We have a result, now log it to the console.
Serial.print("[LOG] Processed Average: ");
Serial.println(receivedResult.averageValue);
Serial.println("--------------------");
}
}
}
void setup() {
Serial.begin(115200);
delay(1000);
Serial.println("--- Multi-Sensor Data Logger ---");
// 1. Create the queues
// Queue to hold 10 SensorData structs
sensorDataQueue = xQueueCreate(10, sizeof(SensorData));
// Queue to hold 5 ProcessedResult structs
resultQueue = xQueueCreate(5, sizeof(ProcessedResult));
// 2. Create the software timer for system health checks
systemHealthTimer = xTimerCreate(
"HealthTimer", // Name
pdMS_TO_TICKS(5000), // 5000ms period
pdTRUE, // Auto-reload
(void *) 0, // Timer ID
systemHealthCallback // Callback function
);
// 3. Create the tasks
xTaskCreate(
processingTask, // Function to implement the task
"Processing Task", // Name of the task
2048, // Stack size in words
NULL, // Task input parameter
PROCESSING_TASK_PRIORITY, // Priority of the task
NULL // Task handle
);
xTaskCreate(
loggingTask,
"Logging Task",
2048,
NULL,
LOGGING_TASK_PRIORITY,
NULL
);
// 4. Configure the hardware timer
// Use a prescaler of 80 to get a 1MHz clock (80MHz / 80)
hardwareTimer = timerBegin(0, 80, true);
timerAttachInterrupt(hardwareTimer, &onTimer, true);
// Set alarm for 200,000 counts (200ms at 1MHz)
timerAlarmWrite(hardwareTimer, 200000, true);
timerAlarmEnable(hardwareTimer);
// 5. Start the software timer
if (systemHealthTimer != NULL) {
xTimerStart(systemHealthTimer, 0);
}
Serial.println("System initialized. Starting data acquisition...");
}
void loop() {
// The main loop is empty. All work is done by RTOS tasks and timers.
vTaskDelete(NULL); // Delete the loop task to save resources
}
Code Walkthrough
-
Data Structures: We define two
structs
,SensorData
andProcessedResult
, to create organized data packages that can be sent to queues. This is much cleaner than passing raw variables. -
Hardware Interrupt (
onTimer
): This ISR is the "producer." It runs at a precise interval, generates data, and immediately sends it to thesensorDataQueue
. It does no processing and exits quickly, as a good ISR should. -
Processing Task (
processingTask
): This task is a "consumer-producer." It has a higher priority because we want to process data as soon as it's available. It waits onsensorDataQueue
. When data arrives, it performs a quick calculation and sends the result toresultQueue
. -
Logging Task (
loggingTask
): This is the final "consumer." It has a lower priority because logging is generally not a time-critical operation. It waits for fully processed data onresultQueue
and prints it. Because of its lower priority, it will only run when theprocessingTask
is blocked (waiting for data). -
Software Timer (
systemHealthCallback
): This runs completely independently of the main data flow. Every 5 seconds, it prints a status message, demonstrating how you can easily add periodic, non-critical background functions to your application without interfering with the main logic. -
setup()
Function: This is where we initialize all our RTOS objects. We create the queues, create the tasks, configure the hardware timer, and start the software timer. -
loop()
Function: In a complex RTOS application, the mainloop()
function is often no longer needed. All continuous work is handled by tasks. We can delete the loop task withvTaskDelete(NULL)
to free up its stack memory.
How to Test It
-
Upload the code to your ESP32.
-
Open the Arduino Serial Monitor at 115200 baud.
-
You should see the following pattern:
-
Every 200 milliseconds, the "[PROCESS]" message will appear, indicating the high-priority task has received data from the ISR.
-
Immediately after, the "[LOG]" message will print the calculated average, followed by a separator.
-
Every 5 seconds, a "[HEALTH] System OK" message will appear, independently of the other messages.
-
No comments to display
No comments to display