Create a custom lobe type
Author a new FixoLobeType with placement and output ports
Built-in lobes (identity, wheeled, tracked, legged_gait, hovering, limb_placement, trim, blink_led) cover most common kinematics, but custom robots often need bespoke control logic. This guide walks through authoring a new FixoLobeType — pick a placement (board-only / native-only / both), declare output ports with semantic tags, and write the C++ template that does the work.
Three big choices
- Placement — does this lobe run on the MCU, on the node, or either? Decides what HAL primitives you can use.
- Output ports — which named, tagged values does the lobe drive? These are what stanza templates auto-connect to actuators via Jaccard similarity.
- Input parameters — what runtime-tunable values does the user / control widget feed in?
Before you start
You need:
- Familiarity with Create a custom actuator type — the workflow is the same; lobes just have more knobs.
- An idea of what your lobe computes (the maths) and what its inputs / outputs are.
- A sense of lobe placement — board-only / native-only / both.
Step 1: Open the Lobe Type Manager
In the Fixo Studio, navigate to Control → Lobe Type Manager. Built-ins are greyed out; custom types are editable.
Step 2: Duplicate or start fresh
Either:
- Duplicate the closest built-in (e.g. start from
wheeledfor a steered-wheel robot,legged_gaitfor a multi-leg variant), rename, edit; - or New, pick a kebab-case id (e.g.
omni-drive), and start from a blank.
Double-click the new entry to open the lobe-type editor.
Step 3: Fill in metadata
Same as actuator types — id, title, description, main header. The Studio writes to <personality>/fixo/lobe-types/<id>/.
Step 4: Choose a placement
The dropdown offers three options:
| Placement | When to use | What you give up |
|---|---|---|
board-only |
Real-time loops with hard timing requirements (e.g. quadrature decoding); needs hardware register access | Can't use Qt, threads, libstdc++ heap; AVR-only ABI |
native-only |
Compute-heavy logic (gait planning, IK, optimisation, neural nets); benefits from full host CPU | Adds round-trip latency over numex; can't run when node is offline |
both |
Lobes whose code is portable C++ with no hardware tricks (mixers, filters, kinematics) | Your code has to actually compile both ways — see Pitfalls below |
bothis the default for "math-only" lobesIf your lobe is pure arithmetic that takes inputs and produces outputs, choose
bothso the runtime can decide per-stanza where to run it. Phase 5 introducedsetStanzaPlacement()precisely so users can dial this without rewriting the lobe.
Step 5: Declare output ports
Each output is a FixoPort:
"outputPorts": [
{"name": "left_wheel", "tags": ["wheel", "left", "motor"], "min": -1.0, "max": 1.0, "direction": "Output"},
{"name": "right_wheel", "tags": ["wheel", "right", "motor"], "min": -1.0, "max": 1.0, "direction": "Output"}
]
Tag conventions (see Fixo Data Model for the full vocabulary):
- Body:
leg,arm,joint,coxa,femur,tibia,shoulder, … - Locomotion:
wheel,track,motor,throttle,steering,brake, … - Orientation:
left,right,front,rear,top,bottom,center - Numbering: append a 0-based index —
leg_0,joint_2
Stanza templates that wrap this lobe will auto-connect to actuators whose semanticTags overlap with these. The more specific your tags, the better the match.
Step 6: Declare input parameters
Input parameters are runtime-tunable values the user (or a control widget) drives:
"inputParameters": ["speed", "steering"]
The corresponding FixoInput records (with name, min, max, default) are filled in per-stanza when the lobe is used. The control widget on Remote (SliderBankWidget by default, or a namedWidget adapter) generates one slider per input.
Step 7: Write the C++ template
The minimal shape for a placement: "both" lobe:
#pragma once
#include <fixo/lobes/LobeBase.hpp>
#include <cstdint>
namespace fixo {
/// Differential-drive mixer.
/// Inputs: throttle [-1, 1], steering [-1, 1]
/// Outputs: left_wheel, right_wheel each in [-1, 1]
class WheeledLobe {
public:
static constexpr int inputCount = 2;
static constexpr int outputCount = 2;
void tick(const double *inputs, double *outputs) {
const double throttle = inputs[0];
const double steering = inputs[1];
double left = throttle + steering;
double right = throttle - steering;
if (left > 1.0) left = 1.0;
if (left < -1.0) left = -1.0;
if (right > 1.0) right = 1.0;
if (right < -1.0) right = -1.0;
outputs[0] = left;
outputs[1] = right;
}
};
} // namespace fixo
Conventions:
- Pure functions where possible. Avoid global state — the runtime instantiates one lobe per stanza, and lifetime is the stanza's.
- For
placement: "both", no Qt, nostd::vector, no<string>— only freestanding C++. The AVR target lacks the heap-using portions of libstdc++. tick(const double*, double*)is the canonical signature; the runtime passesinputCountdoubles in and readsoutputCountdoubles out.- For stateful lobes (PID, gait phase tracking), declare member variables — the runtime owns the instance for the stanza's lifetime.
For placement: "native-only" you can use the full standard library, threads, Qt — but the lobe will only ever run on the node side.
Live errors
The type editor compiles your source after each pause with the LLVM diagnostic engine running against a small synthetic preset. Errors and warnings appear in the panel under the editor; click a row to jump to the offending line. Lines with diagnostics get a coloured gutter mark in the source editor.
The synthetic preset is the smallest valid firmware that uses your lobe
— one board (uno), one stanza, and one actuator/sensor of the type
you're editing. You don't need an open preset to get diagnostics; the
generated source mirrors what would land in a real firmware build.
For placement: "board-only" lobes the synthetic preset compiles for
the AVR target so you'll catch the same template-instantiation errors
the real build would. For placement: "native-only" it compiles for
the host so libstdc++ is available.
If a diagnostic doesn't match anything in your source, the most common
causes are a missing input-port name or an output-port count that
doesn't match what your tick() reads/writes. Both are visible in the
synthesized preset.
Step 8: Save and try it out
- Click Save. The Studio writes the type and reloads the registry.
- Open a preset, add a stanza, pick your lobe type from the Lobe dropdown.
- Build — the firmware generator includes your
.hppin whichever pass(es) the placement allows. - Inject and run; drive the lobe inputs from Remote's
StanzaControlActivity.
Pitfalls
| Symptom | Likely cause |
|---|---|
Linker error mentioning std::vector (board pass only) |
You used STL containers; for both placement, stick to C arrays / std::array / fixed-size buffers |
unresolved reference to vtable (board pass only) |
You used virtual functions; AVR build doesn't support RTTI cleanly — use templates instead |
| Auto-connect doesn't match anything | Your output port tags don't overlap with any actuator's semanticTags. Tag both sides consistently |
| Inputs don't appear in the Remote slider bank | You declared inputParameters but the stanza didn't list them in inputs; cross-check the preset |
| Lobe runs but does nothing | Forgot to write outputs; make sure tick() writes every index in outputs[0..outputCount-1] |
See also
- Fixo Data Model — FixoLobeType — full type reference
- Lobes reference — built-in lobes for inspiration
- Stanza Templates — Jaccard auto-connect
- How firmware is compiled — what happens to your
.hpp