Custom Lobes

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.


What are Lobes?

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:

There 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.


The shape of a lobe

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

The float32 math rule

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.

Declaring parameters

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;

Step 1: Write the header

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.

Step 2: Register the type

Two registrations, both trivial:

  1. 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.

  2. 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.

Step 3: Add golden vectors

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.


Worked example: steering + throttle

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.


Best practices

  1. Semantic in, semantic out — think in [-1, 1]; let the codec own the wire bytes. Reach for as<I>() only when the output is a raw wire quantity (e.g. stepper step counts).
  2. float32 only, no libm — extend LobeMath.hpp instead.
  3. No dynamic allocation, no Qt — the header compiles into an AVR with 2 KB of RAM.
  4. Keep state in the Instance — it lives in placement-new storage and is re-init()ed on stanza switches.
  5. Config constants for tuning — compile-time static constexpr members on the Config (see Config::maxStepRate, Config::legCount); the Phase-18 binding machinery decides whether they are baked or runtime-written.

Next steps

  1. Lobe reference — Lobes Reference
  2. Multiple Agents - Multi-Agent Setup
  3. Central Hub - Hub Setup