Overview
Part 3 of this series introduced a QA harness for the STM32 SX1262 encrypted LoRa link — a transmitter that builds hostile and boundary-value frames, and a receiver running the real production parser. It worked, but it had a usability problem: it logged raw results and left the developer to read the output and judge each case by eye. There was no automated verdict. A regression could slip through unnoticed, and the harness ran continuously with no clear “done.”
Part 6 — the final part of this series — reworks that harness into a deterministic pass/fail test suite. Each test now carries an expected outcome; the receiver compares the parser’s actual result against it and emits a PASS or FAIL verdict. The suite runs every test exactly once, survives lost frames, and prints a final tallied report. This installment also covers two correctness bugs found during bring-up and the per-gate reject logging that makes each verdict trustworthy.
The complete project source and Doxygen documentation are available to download in the Project Downloads section below. No code changes are required to follow along.
RFC 4493
What You Will Learn
- Why an observe-by-eye QA harness is not enough for catching regressions
- How each test case declares an expected outcome and the receiver issues a verdict
- How frames are correlated to test cases by a cleartext counter, not arrival order
- How a per-test timeout guarantees the suite always completes
- How the final tallied report summarizes passes and failures
- Two real bugs found during bring-up — a fixed-node-ID fix and a buffer-size truncation
- How per-gate reject logging makes each verdict trustworthy
Prerequisites
This is the last part of the series and builds on everything before it. Most important is Part 3, which introduced the QA harness and the eight hostile and boundary test cases. Part 6 reworks how that harness reports results; it does not change what the individual tests do, so Part 3 remains the reference for the test cases themselves. You should be comfortable with:
- The eight QA test cases from Part 3 (truncated, length-mismatch, CMAC-failure, replay, max-payload, oversized, invalid-nodeId)
- The Wire v3 frame format and the RX parser pipeline
- The
RADIOLINK_QA_TESTbuild switch that selects the QA loop - STM32CubeIDE, the STM32 HAL, and building/flashing the project
Materials List
- STM32 Nucleo-F439ZI development board (2× — one TX, one RX)
- SX1262 LoRa module (2×)
- Jumper wires and breadboard
- Two USB cables for power and ST-LINK debugging
- A serial terminal on the RX board to read the QA suite report
- STM32CubeIDE (for building and flashing the project)
Project Structure
The project layout is the same one used throughout the series. Part 6 changes only the QA harness in qaTests/ — qaApp/qa_app.c and protocol/protocol_qa.c — plus reject-reason logging in radioLink/radio_link.c.
F439_CPP_TX-RX_LoRa_Project_01/
├── ADA1897_MB85RS64B/ <-- SPI FRAM driver (persistence layer)
│ ├── ada1897_mb85rs64b.c
│ └── ada1897_mb85rs64b.h
├── Core/ <-- CubeMX-generated HAL + main.c
│ ├── Inc/
│ │ ├── main.h
│ │ ├── stm32f4xx_hal_conf.h
│ │ └── stm32f4xx_it.h
│ ├── Src/
│ │ ├── main.c
│ │ ├── stm32f4xx_hal_msp.c
│ │ ├── stm32f4xx_it.c
│ │ ├── syscalls.c
│ │ ├── sysmem.c
│ │ └── system_stm32f4xx.c
│ ├── Startup/
│ └── ThreadSafe/
├── Drivers/ <-- ST HAL + CMSIS (CubeMX-managed)
├── SX1262/ <-- SX1262 LoRa radio driver
│ ├── sx1262.c
│ └── sx1262.h
├── radioApp/ <-- Application layer
│ ├── radio_app.c
│ └── radio_app.h
├── radioLink/ <-- Protocol layer (Wire v3, crypto, replay)
│ ├── radio_link.c
│ ├── radio_link.h
│ └── radio_wire.h
├── qaTests/ <-- QA pass/fail harness (covered in Part 6)
│ ├── protocol/
│ └── qaApp/
├── docs/ <-- Doxygen output
├── scripts/ <-- Build / instrumentation helpers
├── F439_CPP_TX-RX_LoRa_Project_01.ioc
├── STM32F439ZITX_FLASH.ld
├── Doxyfile
└── README.md
Hardware Configuration / Pinouts
Overview
Part 6 introduces no hardware changes. The setup is identical to the earlier parts: two boards, each with an SX1262 module on SPI, one flashed as the TX node and one as the RX node. The QA suite is a software change only.
Pinouts & Configurations
The full pin assignment and clock configuration for the project is exported from STM32CubeMX. Download the PDF below for the complete reference; it is unchanged from the earlier parts of the series.
[INSERT FILE BLOCK HERE — IOC PDF]
Project Setup
If you followed the earlier parts, the project is already set up. To run the QA suite, enable the build switch in Core/Src/main.c — uncomment #define RADIOLINK_QA_TEST — then build and flash both boards. With the switch on, main() runs the QA harness instead of the production application loop.
One reminder, which the project’s own notes call out: comment RADIOLINK_QA_TEST back out before any production build. The QA harness is a test tool, not part of the shipping firmware.
Code Walkthrough
The walkthrough follows the rework itself: first the problem with the old harness, then the pass/fail mechanism that replaces it, then the three pieces that make the suite reliable — frame correlation, the per-test timeout, and the final report — followed by the two bugs fixed during bring-up and the reject logging that backs each verdict. The eight test cases themselves are unchanged from Part 3 and are not re-walked here.
The Problem: A Harness With No Verdict
The Part 3 harness transmitted each hostile or boundary frame and logged what the receiver did with it. That is genuinely useful — but the judgement was entirely manual. A developer had to read each line of output, remember what that test was supposed to prove, and decide whether the result was correct. Nothing in the harness said “PASS” or “FAIL.”
For a one-time bring-up check that is tolerable. As a regression test it is not: a subtle change that makes one test behave wrongly produces output that looks almost identical to a correct run, and the error is easy to miss. The rework turns the harness into something that judges itself.
The Pass/Fail Mechanism
The core change is that every test case now declares what the receiver should do with it. Each entry in the case table carries an expectAccept flag — true for the two valid frames, false for the six that must be rejected:
typedef struct QaProtocolCase_t {
QaProtocolCaseId_t id;
const char *name;
bool expectAccept;
const char *expectedRxResult;
} QaProtocolCase_t;
static const QaProtocolCase_t gQaProtocolCases[QA_CASE_COUNT] = {
{ QA_CASE_TRUNCATED, "truncated-frame", false, "rejected: below minimum Wire v3 frame length" },
{ QA_CASE_LENGTH_MISMATCH, "length-mismatch", false, "rejected: declared payload length does not match rxLen" },
{ QA_CASE_CMAC_FAILURE, "cmac-failure", false, "rejected: authentication tag mismatch" },
{ QA_CASE_REPLAY_FIRST, "replay-first", true, "accepted: seeds replay state" },
{ QA_CASE_REPLAY_DUPLICATE, "replay-duplicate", false, "rejected: duplicate sessionSeqId/msgCounter" },
{ QA_CASE_MAX_PAYLOAD, "max-payload", true, "accepted: maximum legal plaintext boundary" },
{ QA_CASE_OVERSIZED_PAYLOAD, "oversized-payload", false, "rejected: declared payload exceeds maximum" },
{ QA_CASE_INVALID_NODE_ID, "invalid-nodeId", false, "rejected: tampered header fails CMAC" }
};With an expected outcome attached to each case, the verdict becomes a single comparison. When the receiver finishes parsing a frame, it checks the parser’s actual accept/reject result against the case’s expectAccept value:
tc = &gQaProtocolCases[resolvedIndex];
printf("\r\n[%u/%u] %s\r\n",
(unsigned)(resolvedIndex + 1U),
(unsigned)QA_CASE_COUNT,
tc->name);
printf(" expected : %s\r\n", tc->expectedRxResult);
accepted = ProtocolQa_LogRxFrameResult(&irqResult);
pass = (accepted == tc->expectAccept);
if (pass) {
gQaRxSuite.passCount++;
printf(" verdict : PASS\r\n");
} else {
gQaRxSuite.failCount++;
gQaRxSuite.failedIds[gQaRxSuite.failedCount++] = resolvedIndex;
printf(" verdict : *** FAIL *** expected %s, parser %s\r\n",
tc->expectAccept ? "ACCEPT" : "REJECT",
accepted ? "ACCEPTED" : "REJECTED");
}If the two agree, the test passes and the pass counter advances. If they disagree — the parser accepted a frame that should have been rejected, or vice versa — the test fails, the failure is recorded by case ID for the final report, and the verdict line spells out exactly what was expected versus what happened. For this to work, ProtocolQa_LogRxFrameResult() was changed to return the parser’s accept/reject result rather than only printing it.
Correlating Frames to Test Cases
A subtle problem: the receiver must know which test a received frame belongs to. The old harness assumed frames arrived in order — but if a single frame is lost over the air, every following frame is matched to the wrong test, and every later verdict is wrong.
The rework correlates frames to tests by content instead of arrival order. The transmitter encodes each test’s identity into the frame’s msgCounter header field as QA_RUN_BASE + caseIndex — a known base value plus the test’s index. Because the Wire v3 header is not encrypted, the receiver can read msgCounter back out of any frame — accepted or rejected — and recover which test it was:
/* Recover the test index from the cleartext msgCounter header field.
* This works for accepted and rejected frames because the header is not
* encrypted. A frame too short to contain msgCounter can only be the
* truncated-frame case; this is a QA suite invariant. */
if (irqResult.payload_len >= RADIOLINK_WIRE_V3_HDR_LEN_DERIVED) {
rxMsgCounter = ProtocolQa_DecodeLe32(
&irqResult.payload[RL_W3_OFF_MSG_COUNTER]);
} else {
rxMsgCounter = QA_RUN_BASE + (uint32_t)QA_CASE_TRUNCATED;
}
resolvedIndex = (uint8_t)((rxMsgCounter - QA_RUN_BASE) %
(uint32_t)QA_CASE_COUNT);
/* REPLAY_DUPLICATE carries REPLAY_FIRST's counter by design. If we
* resolve to REPLAY_FIRST but it already ran, this is the duplicate. */
if ((resolvedIndex == (uint8_t)QA_CASE_REPLAY_FIRST) &&
(gQaRxSuite.completed[QA_CASE_REPLAY_FIRST])) {
resolvedIndex = (uint8_t)QA_CASE_REPLAY_DUPLICATE;
}Two cases need special handling. A truncated frame may be too short to even contain the msgCounter field; since the truncated-frame case is the only deliberately sub-header-length test in the suite, a frame that short is attributed to it by definition. And the replay-duplicate case intentionally reuses the replay-first case’s counter — so if the counter resolves to replay-first but that test has already completed, the frame is the duplicate. With this scheme a lost frame costs exactly one test, not the whole run.
The Per-Test Timeout
Correlation handles a lost frame being matched correctly — but a lost frame still never arrives, and the receiver cannot wait for it forever. The rework gives every test a deadline. Each case i has a timeout window; once the clock passes that case’s deadline without a frame, the case is recorded as a failure and the suite moves on:
for (uint8_t i = 0U; i < (uint8_t)QA_CASE_COUNT; i++) {
uint32_t deadline;
if (gQaRxSuite.completed[i]) {
continue;
}
deadline = gQaRxSuite.suiteStartMs +
((uint32_t)(i + 1U) * QA_RX_TEST_TIMEOUT_MS);
if ((int32_t)(now - deadline) >= 0) {
/* ... print header + FAIL banner for case i ... */
gQaRxSuite.completed[i] = true;
gQaRxSuite.completedCount++;
gQaRxSuite.failCount++;
gQaRxSuite.failedIds[gQaRxSuite.failedCount++] = i;
}
}This is what guarantees the suite always finishes. Whether every frame arrives or several are lost, all eight cases reach a verdict — a real result, or a timeout failure — and the final report always prints. The old harness, by contrast, simply ran forever.
The Final Report
When all eight cases have completed, the suite prints a tallied summary — total tests, passes, failures — and, if anything failed, lists the failing tests by name:
static void QaApp_PrintFinalReport(void)
{
printf("\r\n========================================\r\n");
printf(" QA SUITE COMPLETE\r\n");
printf("========================================\r\n");
printf(" Total tests : %u\r\n", (unsigned)QA_CASE_COUNT);
printf(" Passed : %u\r\n", (unsigned)gQaRxSuite.passCount);
printf(" Failed : %u\r\n", (unsigned)gQaRxSuite.failCount);
if (gQaRxSuite.failCount == 0U) {
printf(" Result : ALL PASS\r\n");
} else {
printf(" Result : *** FAIL ***\r\n");
printf(" Failed tests:\r\n");
for (uint8_t i = 0U; i < gQaRxSuite.failedCount; i++) {
uint8_t id = gQaRxSuite.failedIds[i];
printf(" - [%u] %s\r\n",
(unsigned)(id + 1U),
gQaProtocolCases[id].name);
}
}
printf("========================================\r\n");
}This is the line a developer actually looks for: Result : ALL PASS, or a list of exactly which tests broke. A regression now announces itself instead of hiding in a wall of log output.
Two Bugs Found During Bring-Up
Reworking the harness surfaced two real bugs. Both are worth showing, because both are the kind of mistake that is easy to make and hard to spot.
The first was in key handling. Under the RADIOLINK_QA_TEST build, the two boards derived their keys from their own hardware node IDs — which differ — so the transmitter and receiver ended up with different keys and every authenticated frame failed. The fix: under the QA build, RadioLink_GetNodeId() returns a fixed ID so both boards derive matching keys.
The second is a classic C trap — a cast that silently truncates. The receiver’s plaintext buffer was 256 bytes, but the size passed to the parser was cast to uint8_t:
/* Before: plain[] was 256 bytes, but the size argument was cast to uint8_t.
* (uint8_t)256 == 0, so the parser received a buffer size of zero and
* rejected every payload-bearing frame at header validation. */
uint8_t plain[256];
accepted = RadioLink_ParseWireV3Frame(irqResult->payload,
irqResult->payload_len,
plain,
(uint8_t)sizeof(plain), /* (uint8_t)256 == 0 */
&plainLen);
/* After: buffer sized to the real maximum, and the true size is passed. */
uint8_t plain[RADIOLINK_WIRE_V3_MAX_PLAINTEXT_LEN];
accepted = RadioLink_ParseWireV3Frame(irqResult->payload,
irqResult->payload_len,
plain,
(uint8_t)RADIOLINK_WIRE_V3_MAX_PLAINTEXT_LEN,
&plainLen);A uint8_t cannot hold 256 — (uint8_t)256 wraps to 0. The parser was told the output buffer had zero capacity, so it rejected every payload-bearing frame at header validation. Every accept test would have failed for a reason that had nothing to do with the protocol. The fix sizes the buffer to RADIOLINK_WIRE_V3_MAX_PLAINTEXT_LEN — a value that fits in a uint8_t — and passes the real size. The lesson is general: a cast to a smaller integer type is a silent data loss waiting to happen.
Reject-Reason Logging
A pass/fail verdict answers “was the frame accepted or rejected?” — but for a rejection, it is worth knowing which gate rejected it. A frame could be rejected for the right reason or the wrong one and still produce the same PASS. To make each verdict trustworthy at the gate level, the rework adds an optional debug switch, RADIOLINK_DEBUG_RX_REJECT_REASON_ENABLE. With it enabled, RadioLink_ParseWireV3Frame() logs which pipeline gate — header, CMAC, replay, or decrypt — rejected the frame.
In a passing run, the gate logged for each rejected test should match the defense that test targets: structural cases reject at the header gate, the corrupted-tag case at the CMAC gate, the duplicate frame at the replay gate. One case is worth a note: the invalid-nodeId frame is rejected at the header gate, because the out-of-range node identifier is caught during header validation before CMAC verification runs. The frame is still correctly rejected — the per-gate log simply confirms it happens at the structural stage rather than the authentication stage.
Sample Suite Output
Running the suite with all tests passing produces a report like the following on the RX serial terminal (abridged to three of the eight cases):
========================================
RadioLink Protocol QA Suite
Wire v3 | build: QA_TEST | 8 tests
========================================
[1/8] truncated-frame
expected : rejected: below minimum Wire v3 frame length
QA RX: rejected frame sess=0 ctr=0 RSSI=-104 SNR=9
verdict : PASS
------------------------------------------
[3/8] cmac-failure
expected : rejected: authentication tag mismatch
QA RX: rejected frame sess=1 ctr=1002 RSSI=-105 SNR=9
verdict : PASS
------------------------------------------
[6/8] max-payload
expected : accepted: maximum legal plaintext boundary
QA RX: accepted [MAXPAYLOAD:ABCD...] sess=1 ctr=1005 RSSI=-105 SNR=9
verdict : PASS
------------------------------------------
========================================
QA SUITE COMPLETE
========================================
Total tests : 8
Passed : 8
Failed : 0
Result : ALL PASS
========================================Each test prints its number, name, expected outcome, the parser result, and a verdict. The final block tallies the run. Result : ALL PASS is the single line that confirms the encrypted LoRa protocol — everything built across this series — still behaves correctly against every hostile and boundary frame the harness can produce.
A Note on the Correlation Scheme
One honest limitation, also recorded in the project’s documentation: attributing any too-short frame to the truncated-frame case is correct only because that is the suite’s single deliberately sub-header-length test. If a future test also produced frames shorter than the Wire v3 header, the correlation scheme would need a different mechanism to tell them apart. For the current eight-test suite the assumption holds.
Project Downloads
The complete project source code used in this tutorial is available for download. This includes all necessary files to build and run the project, along with supporting documentation.
- Full STM32CubeIDE Project (Source + Configuration)
- Doxygen Documentation (docs/html/index.html)
- The reworked QA pass/fail harness in
qaTests/
If project source is not linked in the tutorial, it may be available on request — use the email contact option in the site footer.
Documentation
This project includes full Doxygen-generated documentation for all custom source files, including a protocol-verification page that describes the pass/fail suite, frame correlation, the per-test timeout, and reject-reason logging.
The documentation is included within the project download and can be viewed locally by opening:
F439_CPP_TX-RX_LoRa_Project_01/docs/html/index.html
in a web browser.
The documentation provides detailed descriptions of functions, data structures, and module interactions to assist with understanding and extending the project.
If you have questions or run into trouble getting the boards programmed and talking to each other, post in the Tutorial Support forum and I will work through it with you. If project source is not linked in the tutorial, it may be available on request — use the email contact option in the site footer.


