10 · Capstone — Smart Sensor Device¶
Time to assemble everything. This capstone builds a complete multi-task "smart sensor device" on the ESP32 in Wokwi: a sensor task samples at a fixed rate and streams readings through a queue to a processing task that checks them against a threshold; a button ISR arms/disarms the alarm via a task notification; a software timer drives the status LED; and a serial command task edits a shared configuration protected by a mutex. Every module of this level appears here doing the exact job it was designed for — plus a wiring list, a test plan, and extensions to make it your own.
Architecture¶
┌───────────────┐ Reading ┌────────────────┐
potentiometer ───► │ sensorTask │──── queue ──►│ processTask │──► alarm LED
(GPIO 34) │ 5 Hz, DelayUntil│ (8 deep) │ threshold check│
└───────────────┘ └───────┬────────┘
│ reads config
button (GPIO 27) ──ISR──notification──► armTask │ (mutex)
toggles cfg.armed (mutex) │
│
serial "set/get/stats" ──► commandTask ──── writes config (mutex)
statusTimer (auto-reload) ──► heartbeat LED: slow=disarmed, fast=armed
Design decisions worth noticing before the code:
- Queue for readings — a stream of data items: exactly what queues are for (Module 4). The struct is small, so we queue it by value.
- Notification for the button — one known consumer, no payload: the cheapest correct tool (Modules 7-8).
- Mutex for
config— multi-field shared state read and written by three tasks (Module 5). Readers snapshot it and get out. - Timer for the LED — a small timed side-effect; no task or precision needed (Module 6).
- Priorities — sensor 3 (tightest deadline), processing 2, command and arm 1 (human-speed). All well below the button handling path's requirements? No — the arm task is human-speed too; the ISR is what makes the press never get lost.
Wiring (Wokwi parts)¶
| Part | Connection |
|---|---|
| Potentiometer (our "sensor") | wiper → GPIO 34, ends → 3V3 and GND |
| Pushbutton | one leg → GPIO 27, other leg → GND (internal pull-up) |
| Red LED + 220 Ω | anode → GPIO 25 (alarm) |
| Green LED + 220 Ω | anode → GPIO 26 (status heartbeat) |
In Wokwi: create an ESP32 project, click "+" to add wokwi-potentiometer,
wokwi-pushbutton, and two wokwi-leds, and wire as above.
Full sketch¶
// Smart Sensor Device — Level 1 capstone (ESP32 Arduino / FreeRTOS)
const uint8_t SENSOR_PIN = 34;
const uint8_t BUTTON_PIN = 27;
const uint8_t ALARM_LED = 25;
const uint8_t STATUS_LED = 26;
// ---------- shared configuration (mutex-guarded) ----------
struct Config {
bool armed; // alarm enabled?
int threshold; // raw ADC alarm level (0..4095)
uint32_t periodMs; // sensor sampling period
};
Config config = { true, 3000, 200 };
SemaphoreHandle_t configMutex;
Config getConfig() { // snapshot helper: lock briefly
xSemaphoreTake(configMutex, portMAX_DELAY);
Config c = config;
xSemaphoreGive(configMutex);
return c;
}
// ---------- reading pipeline ----------
struct Reading { uint32_t ms; int raw; };
QueueHandle_t readingQueue;
volatile uint32_t droppedReadings = 0; // stats only
// ---------- button → arm/disarm ----------
TaskHandle_t armTaskHandle;
void IRAM_ATTR buttonISR() {
BaseType_t woken = pdFALSE;
vTaskNotifyGiveFromISR(armTaskHandle, &woken);
portYIELD_FROM_ISR(woken);
}
void armTask(void *pv) {
for (;;) {
ulTaskNotifyTake(pdTRUE, portMAX_DELAY); // wait for a press
xSemaphoreTake(configMutex, portMAX_DELAY);
config.armed = !config.armed;
bool nowArmed = config.armed;
xSemaphoreGive(configMutex);
Serial.printf("[%lu] button: system %s\n", millis(),
nowArmed ? "ARMED" : "DISARMED");
vTaskDelay(pdMS_TO_TICKS(250)); // debounce window
ulTaskNotifyTake(pdTRUE, 0); // drain bounces
}
}
// ---------- status LED timer ----------
TimerHandle_t statusTimer;
void statusCallback(TimerHandle_t t) {
digitalWrite(STATUS_LED, !digitalRead(STATUS_LED));
}
// ---------- tasks ----------
void sensorTask(void *pv) { // priority 3
TickType_t lastWake = xTaskGetTickCount();
for (;;) {
uint32_t period = getConfig().periodMs;
vTaskDelayUntil(&lastWake, pdMS_TO_TICKS(period));
Reading r = { millis(), analogRead(SENSOR_PIN) };
if (xQueueSend(readingQueue, &r, 0) != pdPASS) {
droppedReadings++; // never block the sampler
}
}
}
void processTask(void *pv) { // priority 2
Reading r;
bool alarmLatched = false;
for (;;) {
xQueueReceive(readingQueue, &r, portMAX_DELAY);
Config c = getConfig();
bool over = (r.raw >= c.threshold);
bool alarm = c.armed && over;
if (alarm != alarmLatched) { // log transitions only
alarmLatched = alarm;
digitalWrite(ALARM_LED, alarm ? HIGH : LOW);
Serial.printf("[%lu] ALARM %s (raw=%d thr=%d)\n",
r.ms, alarm ? "ON" : "off", r.raw, c.threshold);
// armed + alarming → fast heartbeat, else slow
xTimerChangePeriod(statusTimer,
pdMS_TO_TICKS(alarm ? 100 : 700), 0);
}
}
}
void commandTask(void *pv) { // priority 1
String line;
for (;;) {
while (Serial.available()) {
char ch = (char)Serial.read();
if (ch == '\n') {
line.trim();
if (line == "get") {
Config c = getConfig();
Serial.printf("armed=%d threshold=%d period=%lu\n",
c.armed, c.threshold, (unsigned long)c.periodMs);
} else if (line.startsWith("set thr ")) {
int v = line.substring(8).toInt();
xSemaphoreTake(configMutex, portMAX_DELAY);
config.threshold = constrain(v, 0, 4095);
xSemaphoreGive(configMutex);
Serial.printf("ok threshold=%d\n", v);
} else if (line.startsWith("set period ")) {
int v = line.substring(11).toInt();
xSemaphoreTake(configMutex, portMAX_DELAY);
config.periodMs = max(50, v);
xSemaphoreGive(configMutex);
Serial.printf("ok period=%d\n", v);
} else if (line == "stats") {
Serial.printf("queued=%u dropped=%lu heap=%u minheap=%u stack=%u\n",
uxQueueMessagesWaiting(readingQueue),
(unsigned long)droppedReadings,
ESP.getFreeHeap(), ESP.getMinFreeHeap(),
uxTaskGetStackHighWaterMark(NULL));
} else if (line.length()) {
Serial.println("cmds: get | set thr N | set period N | stats");
}
line = "";
} else {
line += ch;
}
}
vTaskDelay(pdMS_TO_TICKS(20)); // human-speed polling
}
}
void setup() {
Serial.begin(115200);
pinMode(ALARM_LED, OUTPUT);
pinMode(STATUS_LED, OUTPUT);
pinMode(BUTTON_PIN, INPUT_PULLUP);
// kernel objects FIRST (Module 4's lesson)
configMutex = xSemaphoreCreateMutex();
readingQueue = xQueueCreate(8, sizeof(Reading));
statusTimer = xTimerCreate("status", pdMS_TO_TICKS(700), pdTRUE,
NULL, statusCallback);
if (!configMutex || !readingQueue || !statusTimer) {
Serial.println("FATAL: object creation failed"); for (;;);
}
xTaskCreate(armTask, "arm", 2048, NULL, 1, &armTaskHandle);
xTaskCreate(sensorTask, "sensor", 2048, NULL, 3, NULL);
xTaskCreate(processTask, "process", 3072, NULL, 2, NULL);
xTaskCreate(commandTask, "command", 4096, NULL, 1, NULL);
attachInterrupt(digitalPinToInterrupt(BUTTON_PIN), buttonISR, FALLING);
xTimerStart(statusTimer, 0);
Serial.println("smart sensor up — try: get | set thr N | set period N | stats");
}
void loop() { vTaskDelay(portMAX_DELAY); }
Test plan¶
Run each check in Wokwi and tick it off:
- Boot — banner prints; green status LED blinks slowly (~0.7 Hz); alarm LED off.
- Alarm path — turn the potentiometer up past the threshold: alarm LED
on,
ALARM ONlogged once (not spammed), status blink goes fast. Turn it down:ALARM off, slow blink. (Modules 4, 6: queue flow + timer period change.) - Arm toggle — press the button:
DISARMEDlogs; with the pot still high, alarm LED turns off on the next reading. Press again: re-arms. Hammer the button — exactly one toggle per press. (Modules 7-8: ISR → notification + debounce drain.) - Commands —
getechoes config;set thr 1000makes the alarm trip earlier (verify);set period 1000visibly slows sampling (watch alarm response lag grow); bogus input prints the help line. (Module 5: mutex'd writes while readers keep running.) - Health —
statsshowsdropped=0in normal running, stable heap, and a sane stack mark. Nowset period 50and addvTaskDelay(300)insideprocessTask's loop (simulated slow processing):statsshows the queue filling and drops counting up — back-pressure made visible. Remove the sabotage. (Modules 4, 9.) - Timing integrity — the status LED never stutters during command parsing or alarm printing, because it lives on the timer task and nothing in the system blocks for long. (Module 3's promise, delivered.)
How It Actually Works¶
This capstone puts several kernel mechanisms to work simultaneously, which is
exactly where scheduling bugs hide in real firmware. Every task you created
sits in its own Ready-list slot until it blocks (on vTaskDelay, a queue, or
a semaphore) — at that instant it moves onto a Blocked/Delayed list and the
scheduler's vTaskSwitchContext() picks the next-highest-priority Ready task,
which is why the ISR-driven and sensor tasks can appear to run "at once" on a
single core: each context switch is only a few microseconds, invisible at
human timescales. The queue linking your producer task to your consumer task
is the same fixed-size ring buffer covered in module 4 — if the consumer runs
at lower priority and falls behind, the queue fills and xQueueSend blocks
the producer, which is the kernel's built-in backpressure rather than a bug
to work around. If you added a mutex around shared state, remember its
priority-inheritance bookkeeping is only engaged while a higher-priority
task is actually waiting on it — briefly holding a mutex from your
lowest-priority task while nothing else contends for it costs nothing extra.
Exercise — extensions¶
Pick at least two:
- Latest-value display — add a length-1
xQueueOverwrite"mailbox" holding the newest reading, and adispcommand that prints it — without disturbing the alarm pipeline. - Event-group startup — gate all tasks on a
BIT_READYevent group bit set at the end ofsetup(), removing any chance of racing object creation (then explain why the current create-order already protects you). - Sensor fusion — add a second analog input as "sensor 2" with its own
task sending into the same queue (add a
sourcefield). The processor alarms only if both exceed threshold within 1 s of each other. - Persist config — save
configtoPreferences(ESP32 NVS) on every change and restore at boot. Which task should own the (slow!) flash write, and why notcommandTaskwhile holding the mutex? - Health task — port your Module 9 monitor: every 10 s log all stack high-water marks and heap stats, and alarm on any downward trend.
- Port it — rebuild the pipeline (minus pins) on the FreeRTOS
POSIX/Linux simulator with
getchar()as the "button": what changes, and what (satisfyingly) doesn't?
Finish those and you're genuinely operating an RTOS: you've scheduled, communicated, synchronized, deferred interrupts, and measured memory — the foundation Levels 2-4 build on. Onward → Level 2.