Most robot UIs that try to expose multiple "modes" run into the same problem: two modes both want to control the same actuator. OctoMY™ handles this with an explicit channel model so the UI can express the coordination instead of hiding it. A multi-channel snapshot — a coordinated combination — is a scene that the user can apply with a single click.
The intuition
Each channel is one independently-runnable behaviour stream: locomotion, arm, turret, lights. Inside a channel, exactly one stanza is active at a time (mutual exclusion). Across channels, stanzas run in parallel — unless their actuator claims overlap, in which case the activation API surfaces the conflict to the operator. A scene names a coordinated combination ("locomotion=walk-6 + arm=hold") that applies atomically.
Pre-Phase-14 readers
What was called a "stanza set" before Phase 14 is now a channel — but it's no longer derived by running a union-find over actuator overlap. Channels are now explicit, declared by the preset author. Overlap detection still exists (the activation API uses it to surface conflicts), but it's a runtime check rather than a static partition.
A stanza is a (lobe, actuator-mapping, input-descriptors) triple inside a preset:
differential_drive, legged_gait, trim, etc.).intent:ground-drive-2d.forward, switch:parking_brake, etc.A channel is a declared group of stanzas with a name and an idle-stanza fallback. The preset author assigns each stanza to a channel via stanza.channelId. Mutually-exclusive stanzas live in the same channel; concurrently-runnable stanzas live in different channels.
A robot in motion runs at most one stanza per channel. Switching the active stanza inside a channel is the user-visible way to switch behaviour modes within that domain ("locomotion: walk → stand still", "arm: hold → grab").
Imagine a hexapod with 18 leg actuators plus a 4-DOF arm:
walk on the locomotion channel — uses all 18 leg actuators via a legged_gait lobe.stand on the locomotion channel — uses the same 18 actuators via an inverse-kinematics lobe.hold on the arm channel — uses the 4 arm actuators.grab on the arm channel — uses the same 4 arm actuators.walk and stand are mutually exclusive — they're in the same channel. Same for hold and grab. But walk runs concurrently with hold (or with grab) because they touch disjoint actuators on different channels.
Cross-channel conflicts only arise when the preset author makes a mistake (e.g. a walk stanza claiming an arm actuator). The activation API catches these at the moment of activation.
Runtime::activate(channelId, stanzaId, transitionMs, policy) is the channel-aware entry point. Before activating, it derives the target stanza's actuator claim and checks for overlap against every other currently-active channel. The conflict policy decides what happens:
Reject — any overlap fails the activation; state stays exactly as it was. The result's conflicts vector names the offending channels.Force — every overlapping channel gets deactivated to its idle stanza and the activation proceeds. Idle activation uses Reject internally so a misconfigured fixture fails loudly rather than cascading.Negotiate — for each overlapping channel, walk its upgradeLadder downward (toward leaner stanzas), stopping at the first entry that no longer claims an overlapping actuator. If the ladder exhausts without resolving, the activation fails.Reject is the sensible default for an autonomous service driving a channel. Force is the right default for operator-initiated scene application (the operator's intent is explicit). Negotiate is the rarely-used middle ground.
A scene is a named map from channelId to stanzaId. Applying a scene activates all of its assignments atomically, sorted by claim-decreasing order so channels giving up actuators always release before channels acquiring them.
scene "grabbing":
locomotion → locomotion.idle (releases the legs)
arm → grab (acquires the arm — was on hold)
turret → aim (acquires the turret)
Activation order resolves to: locomotion first (gives up actuators), then arm and turret. Ties broken alphabetically for determinism.
The Remote-side Scene Picker activity surfaces one button per declared scene. Clicking writes an applyTrigger monotonic counter that the Agent's RuntimeCourier picks up and routes through Runtime::applyScene(sceneId, ConflictPolicy::Force). The lastAppliedScene value drives the active-scene highlight on the picker — only successful applies update it, so partial failures don't flash the highlight.
See the Author scenes howto for the editor + picker walkthrough.
Each channel can declare an upgradeLadder — an ordered list of "richer" stanzas in the same channel. When channel A activates and forces channel B to its idle, the upgrade ladder lets B re-promote itself the moment it can: after every activation, the runtime runs rescanUpgradeLadders() which walks each idle channel's ladder upward looking for a stanza whose claim is now satisfiable.
The practical effect: autonomous services (path-follower, vision-tracker) can release a channel for an operator's explicit action, and the channel auto-promotes back once the operator's action finishes.
A stanza's actuator claim is the set of actuator indices its mapping touches:
QSet<quint16> Stanza::actuatorIndices() const;
Two stanzas conflict iff their claims have non-empty intersection. There's no Jaccard or fuzzy match here — the activation API uses a hard set-intersection check.
The post-Phase-17 Remote exposes the structure through three activities:
StanzaControlActivity) — generic per-channel stanza picker. One combobox per channel; stanzas in the wrong channel are not enumerated. Conflict between channels is rare here because the activity binds per-channel.GroundDriveActivity) — purpose-built activity for intent:ground-drive-2d. Lobe-agnostic: drives differential_drive (wheeled or tracked) and legged_gait_2d interchangeably. See its reference doc.RemoteScenePickerActivity) — one-click application of a pre-authored multi-channel snapshot. See the Author scenes howto.Each activity declares an ActivityManifest that decides when it's enabled. Manifests carry a requiredIntents field (what input shapes the active stanza must expose), a channelScope, explicit bound keys, and an absorbs glob list for the auto-rendered OptionalPortBank below the activity's primary surface.
| Lobe | Stanza | Channel | Actuator | |
|---|---|---|---|---|
| What it is | A piece of control logic (C++ template) | A configured use of a lobe inside a preset | A declared group of mutually-exclusive stanzas | A physical output (servo, motor) |
| How many in a preset | A fixed library of types | Many; the user authors them | Few; the user declares them | One per physical actuator |
| Reusable | Yes — across stanzas, presets, robots | Within one preset, used at most once | Within one preset | One-to-one with hardware |
| Concurrency | N/A | Mutually exclusive within a channel | Concurrent across channels | Shared between concurrent stanzas only via the activation API's conflict check |
A lobe is "what to do". A stanza is "how this preset uses that lobe". A channel is "which behaviour stream this stanza belongs to". An actuator is "the actual servo on pin 9".
The same model flows through firmware codegen:
FirmwareGenerator emits per-channel stanza tables (s_channels[]) keyed by string channelId.fixo_firmware_channel_count and per-channel introspection (fixo_firmware_stanza_id(ctx, channel, index)).RuntimeCourier publishes the channel tree under fixo-runtime/channels/<i>/... for Remote subscribers.Runtime::applyScene orchestrates multi-channel activations in claim-decreasing order with a single conflict policy.The path-following service uses the same model to write intent:ground-drive-2d values into a channel that exposes an autonomous-driving stanza, while leaving other channels (turret, arm, lights) free for the operator.
intent:ground-drive-2d.