What seq is for
Bringing hardware up is rarely a straight run of function calls. A rail has to settle, a PLL has to lock, a peripheral has to acknowledge. And when something fails half way, the parts already started have to be taken down again, in reverse.
seq is flow’s sibling for exactly that. You describe the order once with the same *, >> and && operators, but each step has two functions — forward and backward — and each returns DONE or NOT_DONE. The built object holds a cursor: you call forward() until it reports DONE, and backward() to unwind.
void() calls; seq keeps a cursor and two arrays of functions, so progress can pause and reverse.| flow | seq | |
|---|---|---|
| Step | void() | seq::status() forward + backward |
| Execution | Runs to completion in one call | Cursor advanced by repeated calls |
| Reversible | No | Yes, in reverse order |
| Built object | Stateless | Holds the cursor — keep it alive |
| Nexus service | Yes | no build it directly |
| Logging | Named flows log every step | never |
Quick start
#include <flow/flow.hpp> // the DSL: * >> &&
#include <seq/builder.hpp>
#include <seq/impl.hpp>
constexpr auto rail_3v3 = seq::step<"rail_3v3">(
[] { // forward
pmic::enable(rail::v3v3);
return pmic::is_up(rail::v3v3) ? seq::status::DONE : seq::status::NOT_DONE;
},
[] { // backward
pmic::disable(rail::v3v3);
return seq::status::DONE;
});
constexpr auto clock_on = seq::step<"clock_on">(
[] { return pll::locked() ? seq::status::DONE : seq::status::NOT_DONE; },
[] { pll::off(); return seq::status::DONE; });
constexpr auto release_reset = seq::step<"release_reset">(
[] { gpio::set(pin::nrst); return seq::status::DONE; },
[] { gpio::clear(pin::nrst); return seq::status::DONE; });
using power_seq = flow::graph_builder<"power", flow::log_policies::none, seq::impl>;
auto [power, leftover] = power_seq::build(
seq::builder<>{}.add(*rail_3v3 >> *clock_on >> *release_reset));
// assert(leftover.empty());
// later, from the main loop:
if (power.forward() == seq::status::DONE) { state = state::running; }
// and to shut down, in exactly the reverse order:
if (power.backward() == seq::status::DONE) { state = state::off; } Link cib_seq. Note that <cib/cib.hpp> does not include seq — include its two headers directly.
Steps
seq::step<"name">(forward, backward) takes two function pointers of type auto (*)() -> seq::status. Both lambdas must be captureless; a capturing one gives no matching function for call to 'step'. Keep state in statics or a driver object the step reaches.
| Return | Means | Effect |
|---|---|---|
seq::status::DONE | This step has finished its work in this direction | The cursor moves on to the next step |
seq::status::NOT_DONE | Not finished yet — call me again | The call returns; the next call re-enters this same step |
Steps must not block: returning NOT_DONE and being called again is the waiting mechanism, so the rest of the system keeps running.
Building
using power_seq = flow::graph_builder<"power", flow::log_policies::none, seq::impl>;
auto [power, leftover] = power_seq::build(
seq::builder<>{}
.add(*rail_3v3 >> *rail_1v8)
.add(*clock_on >> *release_reset)
.add(rail_1v8 >> clock_on)); // ordering between the two pairs seq::builder<Name, LogPolicy> is flow::builder_for<flow::graph_builder<Name, LogPolicy, seq::impl>>, so the whole flow DSL applies: * to add, >> to order, && for “no order between these”, "name"_ref to refer to a step by name, and as many .add() calls as you like.
Adding the same step twice is caught, by flow’s check: One or more steps in the flow (dup) are explicitly added more than once using the * operator. The duplicate steps are: s1.
Running
- Forward runs graph order, backward the exact reverse. With
*a >> *b >> *c: forwarda b c, backwardc b a. - A step that returns
NOT_DONEstops the call. The next call re-enters that same step. A step needing three attempts givesNOT_DONE,NOT_DONE, thenDONE— and the third call goes on to run the following steps. - Changing direction finishes the in-progress step first. If
forward()left a step unfinished, the nextbackward()calls that step’s forward function again. - …and then keeps going. That call returns
NOT_DONEonly while the step is still unfinished. Once it completes, the same call continues unwinding. - Past either end is a no-op.
forward()at the end, orbackward()at the start, returnsDONEand calls nothing. An empty sequence returnsDONEboth ways.
forward() -> NOT_DONE ran: power_on (attempt 1)
backward() -> NOT_DONE ran: power_on (attempt 2) // finishing the forward step
backward() -> DONE ran: power_on (attempt 3), then power_on backward
backward() -> DONE ran: nothing (already at the start) There is no reset. To start over, unwind to the beginning or construct a fresh sequencer.
Sequencer bench
Three steps. power_on needs three forward attempts, release_reset needs two backward attempts, the rest finish immediately. Drive it and watch the cursor: this is the state machine from seq::impl.
power · forward / backward
Highlighted step is where the cursor sits; filled steps are complete.
Sequence
Calls, newest first
Driving a sequence
A seq isn’t a nexus service: seq::service has no uninitialized() hook, so exporting one fails with constraints not satisfied for alias template 'builder_t', and components can’t extend it with cib::extend. Build it directly and call it from something that already runs.
enum struct power_state { off, coming_up, up, going_down };
inline auto power_state_v = power_state::off;
struct power_component {
constexpr static auto STEP = flow::action<"step_power">([] {
switch (power_state_v) {
case power_state::coming_up:
if (power.forward() == seq::status::DONE) { power_state_v = power_state::up; }
break;
case power_state::going_down:
if (power.backward() == seq::status::DONE) { power_state_v = power_state::off; }
break;
default:
break;
}
});
constexpr static auto config = cib::config(cib::extend<cib::MainLoop>(*STEP));
}; Other homes for the call: a periodic timer flow, a state-machine task, or an interrupt handler’s flow when the sequence is waiting on hardware that raises an IRQ.
Rest of cib
| Library | Coupling | What passes between them |
|---|---|---|
| flow | built on | The DSL, the graph, the topological sort and the duplicate-step check are all flow’s. seq::impl is just another renderer passed to flow::graph_builder. |
| nexus | not a service | Build directly; drive from a flow that is a service. |
| log | unused | seq::impl ignores its Name and LogPolicy, and the log_name function each step carries is never called. Log inside your step functions instead. |
| interrupt | pairs well | An ISR flow can advance a sequence that is waiting on hardware. |
Use cases
Power up and power down
The canonical one. Rails, clocks, reset release — and shutdown for free, in the right order, from the same description. No second list to keep in sync when a rail is added.
Radio or sensor bring-up with rollback
Each step is “start this, then wait for its ready bit”. If one never becomes ready, your supervising code stops advancing and unwinds, leaving the hardware as it was.
Low-power entry and exit
Entry is backward() over the bring-up sequence; exit is forward() again. Because reversing finishes an in-progress step first, a sleep request that arrives mid-bring-up still leaves the hardware in a defined state.
Calibration with retries
A calibration step returns NOT_DONE while iterating, counting attempts in a static. On too many attempts, record the failure and return DONE so the sequence stops advancing; the caller then unwinds.
Testing a sequence
auto [s, leftover] = power_seq::build(seq::builder<>{}.add(*a >> *b));
CHECK(leftover.empty());
CHECK(s.forward() == seq::status::DONE);
CHECK(trace == "ab");
CHECK(s.backward() == seq::status::DONE);
CHECK(trace == "abBA"); Assert leftover.empty() in every test that builds a graph — it’s the only cycle report you get.
Pitfalls
| Symptom | Cause | Fix |
|---|---|---|
Jump to a null address on the first forward() | A step was added without *, so it was default constructed | Star every step |
leftover isn’t empty and the graph looks fine | Two un-starred steps collapsed into one node | Star every step |
| A cycle compiles happily | build() reports unplaced edges instead of asserting | Assert leftover.empty() |
| Sequence never finishes | A step returns NOT_DONE forever | Add a retry limit; decide failure yourself |
| Steps run again from the start | The sequencer is a local that was rebuilt | Keep the built object alive |
| No log output from the sequence | seq never logs, whatever the name and policy | Log inside the step functions |
constraints not satisfied for alias template 'builder_t' | seq::service exported to a nexus | Build directly; drive from a flow (§7) |
no matching function for call to 'step' | Capturing lambda | Make it captureless |
no viable conversion … flow::ct_node | A flow::action was added to a seq | Use seq::step, or keep it in a flow |