asrt — HIL Testing Framework
asrt is a hardware-in-the-loop (HIL) testing framework. Tests live on the device under test — the host discovers them, triggers execution, and collects results remotely. The target can push diagnostic records, parameters, and collected data to the host without being polled. Communication requires only a byte stream: serial, USB CDC, or TCP.
The target side needs no standard library, no heap, and no OS. Both sides are non-blocking and tick-driven, so they fit naturally into a bare-metal main loop or an event-driven host program.
graph LR
subgraph host
C[controller]
end
subgraph target
R[reactor]
T1[test A]
T2[test B]
T3[test C]
R --- T1
R --- T2
R --- T3
end
C <-->|byte stream| R
The reactor runs on the target, holds a linked list of registered tests, and responds to requests from the host. The controller runs on the host, drives the session — negotiating the connection, enumerating tests, and triggering each one. Results and diagnostic records flow back asynchronously.
Quick Start
Wire up a minimal reactor on your target. The host side of this example uses **asrtio** — a command-line runner bundled with the library as a convenience tool. You are not required to use it: the host API (asrtc / asrtcpp) is a plain C/C++ library you can drive from any host program.
asrt_reac_assm is the recommended entry point — it allocates and wires every channel module (reactor, diagnostics, param, collect, stream) in one call.
Target side — C
#include "asrtr/reac_assm.h"
#include "asrtr/record.h"
static enum asrt_status run_my_test(
struct asrt_record* rec)
{
ASRT_RECORD_CHECK(rec, sensor_read() > 0);
if (rec->
state != ASRT_TEST_FAIL)
rec->
state = ASRT_TEST_PASS;
return ASRT_SUCCESS;
}
int main(void)
{
asrt_reac_assm_init(&assm, "my_device", 500);
asrt_test_init(&my_test, "sensor_present", NULL, run_my_test);
asrt_reactor_add_test(&assm.reactor, &my_test);
for (;;) {
asrt_reac_assm_tick(&assm, get_time_ms());
while ((req = asrt_send_req_list_next(&assm.send_queue)) != NULL) {
my_transport_write(req->
chid, req->
buff);
asrt_send_req_list_done(&assm.send_queue, ASRT_SUCCESS);
}
}
}
Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee ...
Definition: reac_assm.h:28
Mutable test state passed to the test entry point on every tick.
Definition: record.h:46
enum asrt_test_state state
Current state; updated by assertions and by the reactor.
Definition: record.h:47
An outgoing message request placed in a module's send queue.
Definition: chann.h:64
struct asrt_span_span buff
Message payload (scatter-gather).
Definition: chann.h:65
asrt_chann_id chid
Target channel ID, set by asrt_send_enque().
Definition: chann.h:66
Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee ...
Definition: reactor.h:35
Target side — C++
#include "asrtrpp/reac_assm.hpp"
#include "asrtrpp/task_unit.hpp"
struct sensor_present {
static constexpr char const* name = "sensor_present";
if (sensor_read() <= 0)
co_await ecor::just_error(asrt::test_fail);
}
};
int main()
{
for (;;) {
task_ctx.tick();
while (auto req = asrt::next(assm.send_queue)) {
my_transport_write(req->chid, req->buff);
req.finish(ASRT_SUCCESS);
}
}
}
void tick(ref< asrt_cntr_assm > assm, uint32_t now)
Advance all assembly modules by one tick.
Definition: cntr_assm.hpp:32
ecor::task< T, asrt::task_cfg > task
Coroutine task type used throughout asrt's C++ layer.
Definition: task.hpp:68
ASRT_NODISCARD enum asrt_status init(ref< asrt_cntr_assm > assm, asrt_allocator alloc)
Initialise the controller assembly — wires controller, diag, param, collect and stream channels.
Definition: cntr_assm.hpp:25
ASRT_NODISCARD enum asrt_status add_test(ref< asrt_reac_assm > assm, asrt_test &test)
Append test to the assembly's reactor test list.
Definition: reac_assm.hpp:39
Event-loop context that owns a coroutine task scheduler.
Definition: task.hpp:30
Coroutine test adaptor that wraps a definition type T into an asrt_test driven by an ecor coroutine.
Definition: task_unit.hpp:52
Host side
asrtio tcp --host 192.168.1.42 --port 8765
asrtio connects, enumerates all registered tests, runs them in sequence, and exits non-zero on any failure.
TODO: Replace with UART example once the uart transport is implemented. TODO: Add example output here (will be generated from a README-driven test).
</blockquote>
Writing Tests
C — synchronous callback
A test is a function that matches asrt_test_callback. The reactor calls it on every tick until it sets a terminal state (ASRT_TEST_PASS, ASRT_TEST_FAIL, or ASRT_TEST_ERROR) and returns.
#include "asrtr/record.h"
#include "asrtr/diag.h"
static enum asrt_status my_test(
struct asrt_record* rec)
{
ASRT_CHECK( &ctx->diag, rec, measure_voltage() > 2.5f );
ASRT_REQUIRE(&ctx->diag, rec, measure_voltage() < 5.0f );
rec->
state = ASRT_TEST_PASS;
return ASRT_SUCCESS;
}
struct settle_ctx {
int ticks_remaining;
};
static enum asrt_status wait_for_settle(
struct asrt_record* rec)
{
struct settle_ctx* ctx = (
struct settle_ctx*) rec->
inpt->
test_ptr;
if (ctx->ticks_remaining > 0) {
ctx->ticks_remaining--;
return ASRT_SUCCESS;
}
ASRT_CHECK(&ctx->diag, rec, measure_voltage() > 2.5f);
if (rec->
state != ASRT_TEST_FAIL)
rec->
state = ASRT_TEST_PASS;
return ASRT_SUCCESS;
}
static struct settle_ctx ctx = { .diag = &assm.diag, .ticks_remaining = 10 };
asrt_test_init(&test, "voltage_settle", &ctx, wait_for_settle);
asrt_reactor_add_test(&assm.reactor, &test);
Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee ...
Definition: diag.h:28
struct asrt_test_input const * inpt
Immutable per-invocation context.
Definition: record.h:49
C++ — coroutine style
#include "asrtrpp/task_unit.hpp"
#include "asrtrpp/diag.hpp"
struct check_voltage {
static constexpr char const* name = "check_voltage";
float v = measure_voltage();
ASRT_CO_CHECK( diag, rec, v > 2.5f );
ASRT_CO_REQUIRE(diag, rec, v < 5.0f );
}
};
struct voltage_settle {
static constexpr char const* name = "voltage_settle";
for (int i = 0; i < 10; ++i)
co_await asrt::suspend();
float v = measure_voltage();
ASRT_CO_CHECK(diag, rec, v > 2.5f);
}
};
The exec() coroutine is advanced one step per tick. Every co_await is a suspension point — the reactor continues ticking other pending work between resumptions.
For tests that do not need coroutines, use asrt::unit<T> — it wraps the same C-style callback (operator() receives rec directly, same as the C API):
struct pass_test {
char const* name() const { return "pass_test"; }
rec.
state = ASRT_TEST_PASS;
return ASRT_SUCCESS;
}
};
Test adaptor that wraps a synchronous callable T into an asrt_test.
Definition: reactor.hpp:32
Common Patterns
C — multi-step test with state
The C callback is invoked on every tick. Between ticks the reactor continues processing other work — including draining the send queue, receiving channel messages, and delivering param responses.
struct adc_test_ctx {
uint32_t threshold;
int step;
};
static void on_threshold(
{
((struct adc_test_ctx*) q->cb_ptr)->threshold = val;
}
static enum asrt_status adc_sweep(
struct asrt_record* rec)
{
struct adc_test_ctx* ctx = (
struct adc_test_ctx*) rec->
inpt->
test_ptr;
switch (ctx->step) {
case 0:
asrt_param_client_fetch_u32(
&ctx->query, &ctx->param,
asrt_param_client_root_id(&ctx->param),
on_threshold, ctx);
ctx->step = 1;
break;
case 1:
if (asrt_param_query_pending(&ctx->param))
break;
adc_trigger();
ctx->step = 2;
break;
case 2:
if (!adc_ready())
break;
ASRT_CHECK(&ctx->diag, rec, adc_read() > ctx->threshold);
if (rec->
state != ASRT_TEST_FAIL)
rec->
state = ASRT_TEST_PASS;
break;
}
return ASRT_SUCCESS;
}
Param client module — PARAM channel, reactor side.
Definition: param.h:47
Active query state for a pending QUERY or FIND_BY_KEY request.
Definition: param.h:149
The step/state machine pattern keeps control flow explicit and deterministic.
C++ — coroutine sequence
The same test as a straight-line coroutine. asrt::fetch suspends until the PARAM RESPONSE arrives; asrt::suspend yields one tick.
#include "asrtrpp/reac_assm.hpp"
#include "asrtrpp/task_unit.hpp"
#include "asrtrpp/diag.hpp"
#include "asrtrpp/param.hpp"
struct adc_sweep {
static constexpr char const* name = "adc_sweep";
uint32_t threshold = co_await asrt::fetch<uint32_t>(
param, asrt::root_id(¶m));
adc_trigger();
co_await asrt::suspend();
ASRT_CO_CHECK(diag, rec, adc_read() > threshold);
}
};
Each co_await is a suspension point — the reactor continues ticking other work (draining the send queue, receiving responses) between resumptions. The coroutine reads top-to-bottom even though it spans multiple event-loop iterations.
Running Tests with <tt>asrtio</tt>
asrtio is a ready-made command-line runner bundled with the library. It handles connection, test enumeration, result reporting, and param loading out of the box. You can also write your own host program using the asrtc / asrtcpp libraries directly — asrtio is one implementation, not a required component.
asrtio [--verbose] [--timeout <ms>] [--params <json>] <subcommand>
| Subcommand | Description |
tcp --host <addr> --port <port> | Connect to a target over TCP |
rsim [--seed <n>] | Run against the built-in reference simulator (no hardware needed) |
Param config
The optional --params JSON file lets the host push structured configuration values to the target before each test run:
{
"*": { "default": 1 },
"adc_threshold": [{ "val": 100 }, { "val": 200 }]
}
"*" applies to all tests. A named key applies only to the test whose name matches. An array entry causes the test to be executed once per element, with the corresponding tree loaded each time.
TODO: Show example asrtio terminal output (will be generated from a README-driven test).
</blockquote>
Channels
The byte stream is multiplexed into independent channels, each identified by a 16-bit ID in the transported message. All multi-byte fields use big-endian encoding; messages are COBS-framed so the protocol works over any raw byte stream.
| ID | Name | Direction | Purpose |
| 2 | CORE | bidirectional | Test enumeration and execution |
| 3 | DIAG | target → host | Assertion records (file, line, expression) |
| 4 | PARAM | host → target | Structured parameter delivery |
| 5 | COLL | target → host | Collected test output tree |
| 6 | STRM | target → host | High-throughput typed record stream |
Channels are opt-in. If a channel's module is not initialised it has zero runtime cost and the channel is silently ignored. asrt_reac_assm / asrt::init initialise all channels by default — you only need to wire them individually if you want to exclude some.
Diagnostic channel (DIAG)
Records source-location diagnostics from the target. When an assertion fails, the target sends a RECORD message carrying the filename, line number, and expression to the controller. Records are fire-and-forget within the existing reliable stream.
Target side (C)
ASRT_CHECK( &assm.diag, rec, voltage > 2.5f );
ASRT_REQUIRE(&assm.diag, rec, voltage < 5.0f );
Target side (C++)
ASRT_CO_CHECK( diag, rec, voltage > 2.5f );
ASRT_CO_REQUIRE(diag, rec, voltage < 5.0f );
co_await asrt::rec_diag(assm.diag, ASRT_FILENAME, __LINE__,
"voltage low");
void rec_diag(ref< asrt_diag_client > d, char const *file, uint32_t line, char const *extra, callback< asrt_diag_record_done_cb > done_cb)
Enqueue a diagnostic record from file:@p line with optional expression extra.
Definition: diag.hpp:28
Parameter channel (PARAM)
The host delivers a read-only tree of typed values — integers, floats, booleans, strings, objects, arrays — to the target before a test run. The target queries the tree by node ID or by key within a parent object. Responses are cached locally; repeated queries skip the round-trip.
Target side (C)
static void on_value(
{
((struct my_ctx*) q->cb_ptr)->value = val;
}
asrt_param_client_fetch_u32(
&ctx->query, &ctx->param,
asrt_param_client_root_id(&ctx->param),
on_value, ctx);
if (asrt_param_query_pending(&ctx->param))
return ASRT_SUCCESS;
Target side (C++)
uint32_t baud = co_await asrt::fetch<uint32_t>(assm.param, root_id);
float vref = co_await asrt::find<float>(assm.param, root_id, "vref");
Collector channel (COLL)
The target pushes structured measurement results — samples, traces, derived values — to the host during a test run. The host assembles incoming nodes into a flat_tree that can be inspected or exported after the test ends.
Target side (C)
struct coll_ctx {
asrt_flat_id result_id;
int step;
};
static enum asrt_status push_results(
struct asrt_record* rec)
{
struct coll_ctx* ctx = (
struct coll_ctx*) rec->
inpt->
test_ptr;
if (asrt_collect_client_is_busy(ctx->collect))
return ASRT_SUCCESS;
switch (ctx->step) {
case 0:
asrt_collect_client_append_object(
ctx->collect, ctx->collect->root_id, "result",
&ctx->result_id, NULL, NULL);
ctx->step = 1; break;
case 1:
asrt_collect_client_append_u32(
ctx->collect, ctx->result_id, "count", 42, NULL, NULL);
ctx->step = 2; break;
case 2:
rec->
state = ASRT_TEST_PASS;
break;
}
return ASRT_SUCCESS;
}
Reactor-side collect client (ASRT_COLL channel).
Definition: collect.h:41
Target side (C++)
flat_id root = asrt::root_id(assm.collect);
flat_id result = co_await asrt::append<asrt::obj>(assm.collect, root, "result");
co_await asrt::append<uint32_t >(assm.collect, result, "count", 42u);
co_await asrt::append<float >(assm.collect, result, "mean", 3.14f);
Stream channel (STRM)
High-throughput transfer of typed, fixed-size records. The target defines a schema — a sequence of typed fields — once; subsequent DATA messages carry only the raw bytes. Schemas are cleared on test boundaries.
Target side (C)
static const enum asrt_strm_field_type_e schema_fields[] = {
ASRT_STRM_FIELD_U32,
ASRT_STRM_FIELD_FLOAT,
};
#define RECORD_SIZE (4 + 4)
struct strm_ctx {
int step;
uint32_t sample;
};
static enum asrt_status emit_samples(
struct asrt_record* rec)
{
struct strm_ctx* ctx = (
struct strm_ctx*) rec->
inpt->
test_ptr;
if (ctx->stream->state == ASRT_STRM_WAIT)
return ASRT_SUCCESS;
switch (ctx->step) {
case 0:
asrt_stream_client_define(
ctx->stream, 0,
schema_fields, 2,
NULL, NULL);
ctx->step = 1; break;
default: {
if (ctx->sample >= 100) {
rec->
state = ASRT_TEST_PASS;
break;
}
uint8_t buf[RECORD_SIZE];
memcpy(buf, &ctx->sample, 4);
float v = 20.0f + 0.1f * ctx->sample;
memcpy(buf + 4, &v, 4);
asrt_stream_client_emit(ctx->stream, 0, buf, RECORD_SIZE, NULL, NULL);
ctx->sample++;
break;
}
}
return ASRT_SUCCESS;
}
Reactor-side stream client (ASRT_STRM channel).
Definition: stream.h:53
Target side (C++)
auto schema = co_await asrt::define<uint32_t, float>(assm.stream, 0);
for (uint32_t i = 0; i < 100; ++i)
co_await asrt::emit(schema, i * 10, 20.0f + 0.1f * i);
ASRT_NODISCARD status emit(ref< asrt_stream_client > client, uint8_t schema_id, uint8_t const *data, uint16_t data_size, callback< asrt_stream_done_cb > done_cb)
Send one DATA record for schema_id.
Definition: stream.hpp:120
Field type tags
| Tag | Name | Wire size | C type |
| 0x01 | u8 | 1 | uint8_t |
| 0x02 | u16 | 2 | uint16_t |
| 0x03 | u32 | 4 | uint32_t |
| 0x04 | i8 | 1 | int8_t |
| 0x05 | i16 | 2 | int16_t |
| 0x06 | i32 | 4 | int32_t |
| 0x07 | float | 4 | float |
| 0x08 | bool | 1 | bool |
All multi-byte values use big-endian encoding.
Architecture
┌─────────────────────────────────────────────┐
│ asrtio │ host tool (C++, libuv)
├──────────────────────┬──────────────────────┤
│ asrtrpp │ asrtcpp │ C++ wrappers
│ (target / reactor) │ (host / controller) │
├──────────────────────┼──────────────────────┤
│ asrtr │ asrtc │ C core
│ (target / reactor) │ (host / controller) │
├──────────────────────┴──────────────────────┤
│ asrtl │ shared protocol / framing
└─────────────────────────────────────────────┘
target side host side
C / C++ C / C++ / any FFI
Two axes:
- Target vs. host —
asrtr* runs on the device, asrtc* runs on the host. They are independent and can be compiled into different toolchains.
- C vs. C++ — the
*l, *r, *c sub-libraries are pure C. The *lpp, *rpp, *cpp wrappers add a C++ coroutine layer on top. asrtio is C++ only.
| Sub-library | Language | Side | Role |
asrtl | C | shared | Protocol definitions, COBS framing, channel dispatch, flat-tree, allocator |
asrtr | C | target | Reactor: test registration, execution, CORE/DIAG/PARAM/COLL/STRM channel modules |
asrtc | C | host | Controller: connection setup, test enumeration, result collection |
asrtlpp | C++ | shared | Coroutine task type (asrt::task<T>), event-loop context (task_ctx), flat-type traits |
asrtrpp | C++ | target | Sender wrappers around asrtr: co_await assertions, param/collect/stream APIs |
asrtcpp | C++ | host | Sender wrappers around asrtc: co_await param/collect/stream server operations |
asrtio | C++ | host | Command-line test runner (TCP, rsim), progress bar, params JSON loading |
A channel module (e.g. the DIAG module inside asrtr) is a node in a linked list owned by the reactor or controller. It handles one channel ID and can be omitted entirely if the feature is not needed.
Integrating the Library
include(FetchContent)
FetchContent_Declare(
asrt
GIT_REPOSITORY https://github.com/emsro/asrt.git
GIT_TAG main
)
FetchContent_MakeAvailable(asrt)
Available targets:
| Target | What it provides |
asrt::asrtl | Protocol, framing, allocator — no external deps |
asrt::asrtr | Reactor (C) — depends on asrtl |
asrt::asrtc | Controller (C) — depends on asrtl |
asrt::asrtlpp | C++ task/coroutine base — depends on asrtl, ecor |
asrt::asrtrpp | C++ reactor wrappers — depends on asrtr, asrtlpp |
asrt::asrtcpp | C++ controller wrappers — depends on asrtc, asrtlpp |
asrt::asrtl + asrt::asrtr is sufficient for a bare-metal target with no host-side dependency.
Pure C Core
The core libraries — asrtl, asrtr, and asrtc — are pure C99 with no external dependencies.
- The reactor compiles into C, C++, or Rust firmware without glue code.
- The controller C API is wrappable by any language with a C FFI — Python, Go, Rust, etc.
- The C++ wrappers (
asrtrpp, asrtcpp) and the host tool (asrtio) are optional layers. They are not required for integration.