Project — Multi-Sensor Gateway¶
This project assembles Level 2's toolkit into one device: a gateway that ingests several sensor streams, filters and frames them, ships them out over a single-writer transport, and stays alive under fault conditions — using stream/message buffers, static allocation, queue sets, a gatekeeper task, and watchdog supervision together, the way a real field-deployed data logger would.
Architecture¶
sensorA (10 Hz) ──┐ ┌─► radioGatekeeperTask
sensorB (20 Hz) ──┼─► queue set ──► fusionTask ──────┤ (owns the single-writer
sensorC (5 Hz) ──┘ (3 queues) (filters, frames) │ message buffer)
└─► logs via Module 8
watchdogTask ◄── liveness tokens from every task above gatekeeper pattern
All tasks + queues + the message buffer: created with ...CreateStatic()
Design decisions worth noticing before the code:
- Queue set for fan-in (Module 6) — three independent sensor tasks at
different rates, one
fusionTaskblocking on all of them at once instead of polling three queues or running three consumer tasks. - Message buffer for the radio link (Module 1) — each fused reading is
one discrete, variable-length frame; the radio link's driver is
single-writer, so only
radioGatekeeperTaskever callsxMessageBufferSendon it. - Gatekeeper for both the radio and the log (Module 8) —
fusionTasknever touches the radio driver or the log directly; it hands off frames and log lines and moves on, so a slow/blocked radio never stalls sensor fusion. - Everything statically allocated (Module 2) — this device runs unattended for weeks; nothing about its task/queue population changes at runtime, so there is no reason to risk heap fragmentation after day 40.
- Watchdog supervision (Module 9) — every task updates a liveness
token each iteration; the watchdog task only pets hardware (or, in the
POSIX build, only refrains from calling
abort()) when all tokens are fresh. - Priorities: sensor tasks 3 (tightest, short, periodic),
fusionTask2,radioGatekeeperTask1 (I/O-bound, can be slow),watchdogTask1 but scheduled to run often enough to catch a hang within its timeout window.
Config and static storage¶
// FreeRTOSConfig.h additions on top of Level 1's baseline
#define configSUPPORT_STATIC_ALLOCATION 1
#define configSUPPORT_DYNAMIC_ALLOCATION 0 // prove nothing allocates at runtime
#define configUSE_QUEUE_SETS 1
#define configCHECK_FOR_STACK_OVERFLOW 2
#define configUSE_MALLOC_FAILED_HOOK 0 // no heap linked in at all
#define STACK_WORDS 512
// One macro-generated static task+stack pair per task, to avoid repeating
// this boilerplate five times.
#define DECLARE_STATIC_TASK(name) \
static StackType_t name##Stack[STACK_WORDS]; \
static StaticTask_t name##TCB;
DECLARE_STATIC_TASK(sensorA)
DECLARE_STATIC_TASK(sensorB)
DECLARE_STATIC_TASK(sensorC)
DECLARE_STATIC_TASK(fusion)
DECLARE_STATIC_TASK(radioGatekeeper)
DECLARE_STATIC_TASK(watchdog)
typedef struct { uint8_t source; uint32_t ms; int32_t value; } Reading;
static uint8_t qAStorage[4 * sizeof(Reading)];
static StaticQueue_t qABuf;
static uint8_t qBStorage[4 * sizeof(Reading)];
static StaticQueue_t qBBuf;
static uint8_t qCStorage[4 * sizeof(Reading)];
static StaticQueue_t qCBuf;
static uint8_t frameBufStorage[512];
static StaticStreamBuffer_t frameBufCtrl;
static MessageBufferHandle_t radioFrames; // built with xMessageBufferCreateStatic
#define NUM_SUPERVISED_TASKS 5
static volatile TickType_t lastAlive[NUM_SUPERVISED_TASKS];
enum { TASK_A, TASK_B, TASK_C, TASK_FUSION, TASK_RADIO };
Sensor tasks and the queue set¶
QueueSetHandle_t sensorSet;
QueueHandle_t qA, qB, qC;
void sensorTaskGeneric(uint8_t source, QueueHandle_t q,
TickType_t period, int aliveIdx) {
TickType_t lastWake = xTaskGetTickCount();
for (;;) {
vTaskDelayUntil(&lastWake, period);
Reading r = { source, (uint32_t)xTaskGetTickCount(), readAdc(source) };
xQueueSend(q, &r, 0); // never block a periodic sampler on a full queue
lastAlive[aliveIdx] = xTaskGetTickCount();
}
}
void fusionTask(void *pv) {
for (;;) {
QueueSetMemberHandle_t ready = xQueueSelectFromSet(sensorSet, pdMS_TO_TICKS(200));
if (ready != NULL) {
Reading r;
xQueueReceive(ready, &r, 0); // non-blocking — the set already proved data is there
char frame[48];
int len = snprintf(frame, sizeof(frame), "{\"src\":%u,\"t\":%lu,\"v\":%ld}",
r.source, (unsigned long)r.ms, (long)r.value);
xMessageBufferSend(radioFrames, frame, len, pdMS_TO_TICKS(20));
}
// ready == NULL just means the 200 ms timeout elapsed with nothing —
// still a chance to update the liveness token below
lastAlive[TASK_FUSION] = xTaskGetTickCount();
}
}
Note the queue-set timeout: fusionTask uses a bounded wait, not
portMAX_DELAY, specifically so it can keep proving liveness to the
watchdog even during a real lull in sensor traffic — a portMAX_DELAY
wait with nothing arriving would look identical to a genuine hang.
The gatekeeper: single writer to the radio¶
void radioGatekeeperTask(void *pv) {
// radioFrames is a message buffer — single-writer by construction,
// and fusionTask is the only other task that ever touches it, so this
// task's real job is draining it onto the actual (slow) radio driver.
uint8_t buf[64];
for (;;) {
size_t len = xMessageBufferReceive(radioFrames, buf, sizeof(buf),
pdMS_TO_TICKS(500));
if (len > 0) {
radio_transmit(buf, len); // can be slow — nobody else waits on this
}
lastAlive[TASK_RADIO] = xTaskGetTickCount();
}
}
Watchdog supervision tying it together¶
bool allTasksHealthy(void) {
TickType_t now = xTaskGetTickCount();
for (int i = 0; i < NUM_SUPERVISED_TASKS; i++) {
if (now - lastAlive[i] > pdMS_TO_TICKS(2000)) return false;
}
return true;
}
void watchdogTask(void *pv) {
hw_watchdog_init(pdMS_TO_TICKS(3000));
for (;;) {
if (allTasksHealthy()) {
hw_watchdog_pet();
} else {
logFaultBeforeReset(); // persist which task went stale, then let the WDT fire
}
vTaskDelay(pdMS_TO_TICKS(500));
}
}
Wiring it up¶
void app_init(void) {
qA = xQueueCreateStatic(4, sizeof(Reading), qAStorage, &qABuf);
qB = xQueueCreateStatic(4, sizeof(Reading), qBStorage, &qBBuf);
qC = xQueueCreateStatic(4, sizeof(Reading), qCStorage, &qCBuf);
sensorSet = xQueueCreateSet(4 + 4 + 4);
xQueueAddToSet(qA, sensorSet);
xQueueAddToSet(qB, sensorSet);
xQueueAddToSet(qC, sensorSet);
radioFrames = xMessageBufferCreateStatic(sizeof(frameBufStorage),
frameBufStorage, &frameBufCtrl);
xTaskCreateStatic(fusionTask, "fusion", STACK_WORDS, NULL, 2,
fusionStack, &fusionTCB);
xTaskCreateStatic(radioGatekeeperTask, "radio", STACK_WORDS, NULL, 1,
radioGatekeeperStack, &radioGatekeeperTCB);
xTaskCreateStatic(watchdogTask, "wdt", STACK_WORDS, NULL, 1,
watchdogStack, &watchdogTCB);
// sensorA/B/C creation omitted — each wraps sensorTaskGeneric with its
// own source id, queue handle, period, and lastAlive index
}
Test plan¶
- Steady state — all three sensors running at their nominal rate;
confirm
radio_transmitreceives one well-formed JSON frame per sensor reading, in roughly the order readings occurred (queue-set ordering is not strictly FIFO across members — verify your app can tolerate that, or add per-frame sequence numbers if not). - Back-pressure — stall
radio_transmit(sleep inside it) and confirmradioFramesfills,xMessageBufferSendinfusionTaskstarts timing out, and the sensors keep sampling on schedule regardless (Module 8's whole point: the gatekeeper absorbs the stall, not the producers). - Static allocation proof — build with
configSUPPORT_DYNAMIC_ALLOCATIONat 0 and confirm it still links and boots, proving no code path allocates at runtime. - Watchdog trip — hang one sensor task (infinite loop, no yield) and confirm the watchdog stops petting and the system resets within its configured timeout; confirm the persisted fault log correctly names the stalled task on the next boot.
- POSIX-port smoke test — build the whole pipeline against the POSIX
simulator (Module 5) with
radio_transmitreplaced by aprintf, and confirm the same steady-state and back-pressure behavior is observable without any real hardware attached.
How It Actually Works¶
This project's architecture is a direct composition of three kernel
mechanisms working together, and understanding their interaction is the
real lesson. Each sensor task blocks on its own timing (vTaskDelay) and
pushes readings into a queue that's a member of the queue set — under the
hood this means every send also pushes a handle onto the set's internal
queue, which is what lets the single consumer task block in one place
(xQueueSelectFromSet) instead of polling N queues. The gatekeeper owns the
radio exclusively, so contention for the radio never needs a mutex or its
priority-inheritance bookkeeping — the queue feeding the gatekeeper already
serializes every caller via the same critical section that protects any
queue send. Watchdog supervision sits outside both of these data paths
entirely: it doesn't inspect queue contents, only whether each sensor task's
liveness token has advanced, so a sensor that's alive but stuck retrying a
failed read (still updating its token) will not trip the watchdog even
though its data has stopped flowing — worth remembering when you extend the
system, since liveness and correctness are checked by genuinely different
mechanisms here.
Stretch goals¶
Pick at least two:
- Sequence numbers and loss detection — add a monotonic counter to each frame and have a companion "ground station" script (or a second POSIX-port task) detect gaps, distinguishing genuine radio loss from gatekeeper back-pressure drops.
- Priority-aware queue set draining — modify
fusionTaskso that ifqA(highest-priority sensor) has data, it's always drained beforeqBorqCeven whenxQueueSelectFromSetreturns a lower-priority member first; measure the added worst-case latency for the lower-priority sensors under sustained high-priority traffic. - Config over the same link — add a second message buffer for
inbound commands from the radio, and a small command parser task; work
out why this needs its own gatekeeper-owned single-writer buffer rather
than sharing
radioFrames. - Tickless idle — enable Module 3's tickless idle and measure how
much idle time the gateway actually gets between sensor periods at its
real sampling rates; tune
configEXPECTED_IDLE_TIME_BEFORE_SLEEPto the result. - ESP-IDF port — rebuild on ESP-IDF with sensors pinned to one core
and the radio gatekeeper pinned to the other (Module 4); confirm the
liveness tokens still work correctly when written from one core and
read from another (hint: consider whether
volatile TickType_talone is sufficient on a dual-core SMP target, or whether the read/compare inallTasksHealthy()needs a memory barrier or atomic access). - Graduated fault response — instead of an immediate hardware reset on the first stale token, add a warning state (blink an LED, log a fault) after one missed liveness window, and only let the hardware watchdog actually fire after a second consecutive miss; justify the two-strike threshold against the risk of resetting on a merely slow, not truly hung, task.
Finish those and you've built the Level 2 equivalent of the Level 1 capstone: real fan-in, real backpressure isolation, real static-RAM guarantees, and real fault recovery — the operational concerns that separate a demo from a device you'd actually deploy. Onward to Level 3.