Learn how to write a custom lobe — the C++ template that turns logical inputs into actuator outputs — and plug it into OctoMY™'s firmware pipeline so the SAME code runs on the board and on the host.
Pro Tip
Before creating a custom lobe, check if a built-in lobe already supports your needs (see the Lobes Reference). Custom lobes are powerful but require C++ development and rebuild cycles.
Lobes are the decision-making units of a robot: each one reads a small
set of logical inputs (steering, throttle, gait intent, …) every tick
and writes actuator values. A lobe is a header-only C++ template in
src/libs/libfixo/fixo/lobes/ — a single source that compiles into:
.so the Agent runs when the board doesn't run
lobes itself, andThere is no host-side mirror to keep in sync and no plugin registry to feed at runtime: the code generator instantiates your template directly from the preset.
The lobe headers ship inside the application binary as an asset-pantry bundle, so a robot in the field compiles presets without any source checkout. On a development machine the live layer wins: edit a lobe header in git, trigger a recompile in the app, and the change takes effect without rebuilding OctoMY. See Asset Pantry for how the layers resolve.
Did You Know?
The name "Lobe" comes from brain anatomy — just as brain lobes handle specialized functions (vision, language, motor control), OctoMY™ lobes handle specialized robot functions, and can migrate between the Agent and the controller hardware the way reflexes live closer to the spine than to the cortex.
Every lobe is a struct with a nested Instance<Config> template:
// src/libs/libfixo/fixo/lobes/WaveLobe.hpp
#pragma once
#include "../LobeMath.hpp"
#include "../Types.hpp"
#include "LobeTransitionAPI.hpp"
#include <stdint.h>
namespace fixo {
struct WaveLobe {
static constexpr LobeId id = LobeId::IDENTITY; // wire-level tag
template<typename Config>
struct Instance {
using Inputs = typename Config::LobeInputs;
using Outputs = typename Config::LobeOutputs;
DefaultLobeTransition<Outputs> transition;
float phase{0.0f};
void init() { phase = 0.0f; }
void update(const Inputs &in, Outputs &out, uint16_t deltaMs) {
// Read logical inputs as normalised semantics ([-1, 1]).
const float speed = LobeMath::clamp1(float(in.template semantic<0>()));
const float amp = LobeMath::clamp01(float(in.template semantic<1>()));
phase = LobeMath::wrap01(phase + speed * float(deltaMs) / 1000.0f);
// Write every actuator slot as a semantic value; the shared
// codec turns it into the right wire bytes per actuator type.
out.template setSemantic<0>(amp * LobeMath::triangle(phase * 2.0f));
}
// Phase-7 transition hooks — forward to the default mixin.
void readCurrentState(const Outputs &cur) {
transition.readCurrentState(cur);
}
void interpolate(const Outputs &target, double t, Outputs &out) const {
transition.interpolate(target, t, out);
}
};
};
} // namespace fixo
The important pieces:
| Piece | Rule |
|---|---|
Inputs / Outputs |
Byte buffers typed per slot (ValueRep); never index raw doubles |
semantic<I>() / setSemantic<I>(v) |
Compile-time slot access in normalised space — the shared ValueCodec owns the byte mapping |
semanticAt(i) / setSemanticAt(i, v) |
Runtime-indexed access, valid only when every slot shares one ValueRep (e.g. N identical servos) |
as<I>() |
Raw typed wire value (e.g. int32_t step counts) when your output is a wire quantity |
update(in, out, deltaMs) |
Called every tick (20 ms) on both targets |
Lobe arithmetic must be float (not double) and must not call libm
(std::sin, std::fmod, …). Use the self-contained helpers in
fixo/LobeMath.hpp (clamp1, clamp01, wrap01, triangle,
sineLift, …) or add new ones there. This is what makes the host .so
and the AVR blob produce byte-identical outputs — the parity
harness (test/testLobeParity/) enforces it.
If your lobe has tunable parameters, declare them as ParamInfo
records in the header — they are the single source of truth the host
UI reads via native introspection:
static constexpr ParamInfo params[] = {
// name, bits, flags, rep, default, dynamic
{"wave_gain", 8, 0, 0x04, 50, 1},
};
static constexpr size_t paramCount = 1;
Create src/libs/libfixo/fixo/lobes/MyLobe.hpp following the shape
above. Test it in isolation against a real RobotConfig first:
using Acts = fixo::TypeList<fixo::Servo<fixo::Pin<9>>,
fixo::Servo<fixo::Pin<10>>>;
using Cfg = fixo::RobotConfig<void, fixo::MyLobe, Acts>;
fixo::MyLobe::Instance<Cfg> lobe;
lobe.init();
Cfg::LobeInputs in{};
Cfg::LobeOutputs out{};
in.setSemantic<0>(0.5);
lobe.update(in, out, 20);
If your lobe's inputs are not actuator-shaped, give the Config a
declared input list (fixo::In<ValueRep> slots) — see
DifferentialDriveLobe.hpp for the pattern.
Two registrations, both trivial:
Codegen — add one row to kUnifiedLobeSpecs in
src/libs/libfixo/fixo/controller/FirmwareGenerator.cpp:
{"my_lobe", "fixo/lobes/MyLobe.hpp", "fixo::MyLobe"},
This is THE lobe list: tags, struct emission and buffer sizing all derive from it. A preset naming a lobe type that is not in this list fails the build loudly — there is no silent fallback.
Library — register a FixoLobeType record (name, description,
input/output ports with semantic tags, placement) in
src/libs/libfixo/fixo/preset/Registry.cpp so presets and the
auto-wiring UI can offer it.
Add a parity case to test/testLobeParity/TestLobeParity.cpp: build a
small preset that uses your lobe, instantiate the same template
in-process as the reference, and assert the emitted .so's wire bytes
equal the reference bytes for a handful of input vectors. Copy
testLeggedGaitUnified as the pattern.
qbs build -d . -p testLobeParity profile:qt610
./default/testLobeParity.*/testLobeParity
That's the whole workflow: header, registration, golden vectors.
SteeringThrottleLobe.hpp is the smallest realistic lobe — two
semantic inputs pass through to two servo outputs with clamping:
void update(const Inputs &in, Outputs &out, uint16_t /*deltaMs*/) {
const double forward = std::clamp(in.template semantic<0>(), -1.0, 1.0);
const double yaw = std::clamp(in.template semantic<1>(), -1.0, 1.0);
out.template setSemantic<0>(forward); // throttle
out.template setSemantic<1>(yaw); // steering
}
For a stateful, multi-leg example (runtime-indexed slots, phase
accumulation, transition state estimation) read
LeggedGaitLobe.hpp — it is the reference for everything a lobe is
allowed to do.
as<I>() only when the output is a raw
wire quantity (e.g. stepper step counts).LobeMath.hpp instead.init()ed on stanza switches.static constexpr
members on the Config (see Config::maxStepRate,
Config::legCount); the Phase-18 binding machinery decides whether
they are baked or runtime-written.