Modules

A Module is anything connected to the runtime — an arm, a gripper, a camera, a base, a leg, a keyboard, a joystick. It bundles together every actuator and sensor on the same hardware port and ticks at its own configurable rate.

Each module is responsible for:

  • Reading its sensors and writing its actuators on a real-time loop.
  • Publishing observations into a shared-memory ring buffer the Robot drains at cfg.fps.
  • Emitting events for the discrete things that happen to it.
  • Pulling actions off a shared-memory queue and dispatching them to @action_handler-decorated methods.
  • Optional one-time setup (initialize), and per-tick logic (control_step).

Defined in solarian/core/module.py. Module is an abstract class; how it is scheduled is a separate property — see Execution Modes.

Declaring a Module

Subclass Module and attach Actuator / Sensor instances as class attributes. Each attribute name becomes the canonical identifier for that hardware component.

from solarian.core.module import Module
from solarian_feetech.actuator import FeetechServo

class ArmModule(Module):
    shoulder_pan = FeetechServo(model="sts3215")
    shoulder_lift = FeetechServo(model="sts3215")
    elbow_flex = FeetechServo(model="sts3215")
    wrist_flex = FeetechServo(model="sts3215")
    wrist_roll = FeetechServo(model="sts3215", is_continuous=True)
    gripper = FeetechServo(model="sts3215")

Then attach the module to your Robot:

from solarian.core import Robot

class MyRobot(Robot):
    arm = ArmModule()

The attribute name on the robot (arm) is used to load conf/modules/arm.yaml and to key your Observation and Action schemas.

Components

A Component is the smallest addressable piece of hardware: a single actuator (one motor, one servo) or a single sensor (one camera). Components are the class attributes of a module, and the attribute name is the component's identifier.

Defined in solarian/core/component.py.

Component (abstract)
├── Actuator      — output device (motor, servo)
├── Sensor        — input device (camera, IMU, force sensor)
└── Servo         — both: a Sensor + Actuator on the same hardware

Each component declares the Data channels it exposes as class attributes, and the bus_class that knows how to talk to its hardware (see Buses).

  • Name
    Actuator
    Type
    write
    Description

    An output. Its cfg is an ActuatorConfig (id, port, model, mode), read from the module's actuators: block.

  • Name
    Sensor
    Type
    read
    Description

    An input. Its cfg is a SensorConfig, read from the module's sensors: block; cameras add width, height, fps, color_mode, and so on.

  • Name
    Servo
    Type
    read + write
    Description

    Both. The same hardware (a Feetech or Dynamixel motor) accepts position commands and reports its position back, so it is registered on both sides: you read it in your Observation and command it in your Action.

from solarian.core.servo import Servo
from solarian.core.sensor import Sensor
from solarian.core.data import ImageData, PosData, SpeedData
from solarian_dynamixel.bus import DynamixelBus
from solarian_camera.bus import CameraBus

class DynamixelServo(Servo):
    bus_class = DynamixelBus
    pos = PosData()
    speed = SpeedData()

class RGBCamera(Sensor):
    bus_class = CameraBus
    rgb_image = ImageData(shape=(480, 640, 3))

MyRobot.arm.shoulder_pan.pos is then the PosData descriptor that an observation or action field binds to. Components usually ship in plugins (solarian-feetech, solarian-dynamixel, solarian-camera); see Creating a plugin to write one.

Module configuration

Each module gets its own YAML config under conf/modules/<module_attribute_name>.yaml. The fields map to ModuleConfig:

# conf/modules/arm.yaml
actuator_port: /dev/ttyUSB0
frequency: 200
actuators:
  shoulder_pan:
    id: 1
    port: ${actuator_port}
    sim_joint: "arm/shoulder_pan"
    sim_range_min: -1.92
    sim_range_max: 1.92
  shoulder_lift:
    id: 2
    port: ${actuator_port}
  # ...
  • Name
    frequency
    Type
    float
    Description

    Module control loop rate in Hz. Each module ticks independently at this rate.

  • Name
    get_max_k
    Type
    int
    Description

    Max number of observation samples kept in the shared-memory ring buffer (default 128).

  • Name
    commands_per_cycle
    Type
    int
    Description

    Max number of actions drained per control loop iteration.

  • Name
    command_queue_size
    Type
    int
    Description

    Capacity of the shared-memory action queue.

  • Name
    actuators
    Type
    dict[str, ActuatorConfig]
    Description

    Per-actuator configuration keyed by the class attribute name.

  • Name
    sensors
    Type
    dict[str, SensorConfig]
    Description

    Per-sensor configuration keyed by the class attribute name.

  • Name
    actuators.*.real_range_*
    Type
    int | None
    Description

    Measured Present-frame ends of travel, written by solarian calibrate. See Calibration.

  • Name
    actuators.*.center_offset
    Type
    int | None
    Description

    Encoder-zero shift so Present travel is a linear window. Legacy yaml homing_offset is still read. See Calibration.

  • Name
    interpolator_type
    Type
    InterpolatorType
    Description

    One of joint, scalar, or pose. Selects the trajectory interpolator used by the default SCHEDULE_L / SCHEDULE_J handler. See Scheduling Waypoints.

  • Name
    execution
    Type
    ExecutionMode | None
    Description

    Overrides the class's execution_mode for this deployment. See Execution Modes.

To add module-specific config fields, set config_class on your subclass:

from solarian.core.configs import ModuleConfig

class ArmConfig(ModuleConfig):
    home_position: list[float] = [0.0] * 6
    max_torque: float = 1.0

class ArmModule(Module):
    config_class = ArmConfig
    shoulder_pan = FeetechServo()
    # ...

Solarian validates conf/modules/arm.yaml against ArmConfig and exposes it as self.cfg.home_position, self.cfg.max_torque, etc.

The initialize method

initialize() runs once in the parent process, after connect() and before start(). Use it for hardware setup that should happen exactly once (setting torque, choosing an operating mode, homing).

class ArmModule(Module):
    shoulder_pan: FeetechServo = FeetechServo()
    # ...

    def initialize(self):
        super().initialize()
        self.disable_torque()
        self.configure_components({"Operating_Mode": 0})  # position mode
        self.enable_torque()

The base implementation is a no-op, so calling super().initialize() is optional but encouraged.

The control_step method

control_step() is called every iteration of the module's run loop, after incoming actions are drained and before observations are published. This is where you put fast inner-loop logic — interpolation, feed-forward terms, velocity smoothing, anything that has to happen at the module's frequency.

from solarian.core.module import Module
from solarian.core.action import ActionType, ModuleAction, action_handler

class ArmModule(Module):
    shoulder_pan = FeetechServo()
    # ...

    def initialize(self):
        super().initialize()
        self.next_position = None

    def control_step(self):
        if self.next_position is not None:
            self.write_actuators(self.next_position)
            self.next_position = None

control_step is abstract: every module says explicitly what it does per tick, even if the answer is nothing. Modules that only stream sensor data — cameras, for instance — override it with an empty body rather than inheriting a silent no-op.

Actuator modules already implement it. ActuatorModule.control_step samples the active trajectory interpolator and writes the result, which is what makes SCHEDULE_L / SCHEDULE_J work; ArmModule and GripperModule inherit that. Override it only if you need something else.

Action handlers

Actions arrive at a module through its shared-memory ModuleActionQueue. The module's run loop drains the queue each tick and dispatches each action to a method registered with @action_handler.

from solarian.core.action import action_handler, ActionType, ModuleAction

class ArmModule(Module):
    shoulder_pan = FeetechServo()
    # ...

    @action_handler(ActionType.MOVE_J)
    def _on_schedule_waypoint(self, action: ModuleAction, t_now: float) -> bool:
        self.next_position = [field.value for field in action.values()]
        return True   # return False to stop the module

The decorator stores the action type on the method (method._handles_action), and Module.__init__ walks the class to build a dict[int, str] mapping action types to method names.

A handler must return a bool:

  • True to keep the module running.
  • False to stop the module's process cleanly.

If an action arrives with no registered handler, the module logs a warning and ignores it.

See Action types for the standard types (STOP, MODULE_ACTION, SCHEDULE_L / SCHEDULE_J, RESTART_PUT) and how to declare your own. For the built-in pose/scalar waypoint path — streaming and exclusive speed-limited moves alike — see Scheduling Waypoints.

Who builds the actions

A module interprets actions; it does not build them for anyone else. That asymmetry is deliberate: the caller is usually a skill, which addresses modules by name so the same skill can run on the controller or on the robot. A name crosses a link, a Module object cannot, so giving modules builder methods would have quietly tied every skill to running in the same process as its hardware.

A skill therefore constructs the SCHEDULE_L / SCHEDULE_J envelope itself, and one Action may key several modules at once.

Named poses

A pose name like "home" is the skill's vocabulary, not a module's — the module knows its ends of travel and its speed limits, which are facts about the hardware, but nothing about what a task considers home. So named poses live in the skill's conf/skills/<config_name>.yaml, keyed pose then module then actuator, in each actuator's own command units (normalized [-100, 100] for a range-mode servo, mm of aperture for a gripper):

# conf/skills/move.yaml
poses:
    home:
        right_arm:
            shoulder_pan: 0.0
            shoulder_lift: -80.4
            elbow_flex: 69.5
            wrist_flex: -46.0
            wrist_yaw: 0.0
            wrist_roll: 0.0
        right_gripper:
            gripper: 85.0

MoveSkill(params={"pose": "home", "arm": "right_arm", "gripper": "right_gripper"}) looks up that pose and sends each module its own targets. A dict in pose is an inline joint or Cartesian target for the arm instead.

Execution modes

What a module is and how it is scheduled are separate concerns. A camera is conceptually a CameraModule; "runs in its own process" is an operational decision about it. Keeping the two apart means you can change one without touching the other — and it keeps the class hierarchy from encoding your deployment topology.

So a module declares a default and the runtime builds the matching runner:

  • Name
    ExecutionMode.PROCESS
    Type
    process
    Description

    Its own OS process. For cameras, arms, grippers — expensive or high-frequency hardware whose timing must not be disturbed by anything else. The default.

  • Name
    ExecutionMode.THREAD
    Type
    thread
    Description

    A daemon thread on the host. For blocking device APIs: joystick/HID readers, serial event sources.

  • Name
    ExecutionMode.INLINE
    Type
    inline
    Description

    Ticked by the host's own control loop. For cheap, event-driven modules like a keyboard, where a dedicated process would buy isolation nobody needs and add a queue hop to every keypress.

class CameraModule(Module):
    execution_mode = ExecutionMode.PROCESS

class KeyboardModule(Module):
    execution_mode = ExecutionMode.INLINE

All three expose the same external interface — start(), stop(), is_alive(), join() — so nothing that uses a module has to know which it got. The class attribute is only a default: override it per instance, or per deployment in YAML.

camera = CameraModule(execution_mode=ExecutionMode.INLINE)   # e.g. in a test
# conf/modules/camera.yaml
execution: process

The same axis applies to skills, which share the runner machinery.

The run loop

module.start() hands the module to its runner, which drives the same three-step worker protocol whatever the execution mode:

worker_setup(reconnect)
   │   reconnect hardware when forked — the parent's handles do not survive fork
   └── reset()

worker_tick()                       ← looped by the runner, or called by the
   ├── drain action_queue  →  _dispatch_action(action, t_now)     host's own
   ├── control_step()                                             tick when INLINE
   └── update_observations()   (writes to shared-memory ring buffer)

worker_teardown(exit_reason)

PROCESS and THREAD runners own the loop and its precise_wait(1/frequency) regulation; the INLINE runner sets up once and ticks on the host's loop instead.

The first successful iteration marks the module ready, telling the parent Robot that it is live and the next observation is real.

Sensor reads and observation publishing

update_observations() walks every sensor on the module, reads its current value (through the ComponentBus if needed), wraps it in the appropriate Data subclass, and writes a snapshot into the module's ObservationBuffer. The robot's _observation_sender thread reads the latest snapshot from each module and combines them into a single Observation at the configured fps.

You don't normally call update_observations() yourself — it's called for you at the end of every iteration.

It is the one way a module reports state, whichever runner hosts it. A module in its own process publishes across shared memory; a module inline on a controller's loop publishes into a local ObservationBuffer.local(), which accepts any Python object rather than only numbers and arrays. Either way a skill reads it the same way, so a leader arm on the controller offers its joint positions to a teleop skill exactly as a follower arm on the robot does.

A module whose state has no entry in the project's Observation schema — a leader arm, a keyboard — has nothing typed to publish, so it overrides update_observations() and puts what it does have:

class KeyboardModule(Module):
    def update_observations(self):
        self.observation_buffer.put({"held_keys": self.held_keys})

Emitting events

Observations say what the world looks like; events say what just happened. A module declares the types it produces and calls emit():

from solarian.core.event import EventType

class FootPedalModule(Module):
    events = (EventType.BUTTON_DOWN, EventType.BUTTON_UP)

    def control_step(self):
        if self._pedal_just_went_down():
            self.emit(EventType.BUTTON_DOWN, button="pedal")

emit() writes to a sink the runner provides — a deque for inline and threaded modules, a multiprocessing.Queue for process-backed ones — and the host reads them back with drain_events() on its own control tick. Keep the payload small and picklable.

Reserve events for transitions. Continuous signals stay observations, so that the event path remains useful for the discrete things it is good at. See Events.

Buses

A ComponentBus knows how to talk to a specific kind of hardware on a specific port: it speaks the wire protocol (a servo's register map, a camera's capture API) so components and modules never do. One bus instance is created per port per module: two Dynamixel servos on /dev/ttyUSB0 share a single DynamixelBus.

Defined in solarian/core/component_bus.py. Concrete subclasses live in plugin packages (solarian-ur5, solarian-dynamixel, …).

ComponentBus (abstract)
├── ActuatorBus  — adds write/calibration/torque control
├── SensorBus    — adds sensor-specific read paths
│   └── CameraBus — adds the archive tap: every frame read is also encoded
└── (plugin)     — UR5Bus, DynamixelBus, FeetechBus, …

Whatever a bus reads is the recording's only source, so anything camera-shaped — where the frame is sent, when it was captured, which channel order it carries — lives on CameraBus and nowhere above it. A servo bus should have no opinion on any of it.

The bus contract

  • Name
    connect()
    Description

    Open the underlying hardware connection (USB serial, TCP socket, V4L2 device, …).

  • Name
    disconnect()
    Description

    Close the connection.

  • Name
    read(data_type, names, normalize=True)
    Description

    Read one Data type for the listed components. Returns {name: Data instance}.

  • Name
    write(values, normalize=True)
    Description

    Write Data values to writable components.

  • Name
    enable_torque(names) / disable_torque(names)
    Description

    For actuator buses with torque control.

  • Name
    configure(params, names)
    Description

    Write raw hardware-level configuration parameters (servo registers, camera modes).

  • Name
    setup_component(name, configs)
    Description

    Interactive setup hook used by solarian setup-module.

  • Name
    read_calibration() / write_calibration(dict)
    Description

    Round-trip calibration parameters between the bus and a JSON file.

  • Name
    drive_to_encoder_center(names)
    Description

    Actuator buses only. Physically rotates each shaft to the middle of its encoder range and returns with torque off, so a horn can be refitted at mid-travel. Requires a free output: the goal is absolute mid-encoder, which a mounted linkage would fight. Unlike set_half_turn_homings, which moves nothing and only shifts the stored frame, this changes no calibration.

  • Name
    available_ports() / scan_port(port) / provision_servo(port, found)
    Description

    Class-level (no components needed), used by solarian calibrate-servo to find and prepare a servo that is not declared anywhere yet. Optional — the default raises NotImplementedError, which the command reports as unsupported for that brand.

ModuleBus and shared ports

A module doesn't talk to a ComponentBus directly — it talks to a ModuleBus, which groups one or more ComponentBus instances (one per port) and routes calls. That is what lets a single module mix actuators on /dev/ttyUSB0 with sensors on /dev/video0, or span two daisy-chained servo buses: Solarian groups actuators by actuators[name].port into self.actuators_by_port and creates one bus per port.

If two different modules share a port, the Robot promotes the bus to a multi-process server: it starts a BusServer in the parent and gives each module a BusClient proxy that forwards calls over a multiprocessing.Pipe. Modules keep using the same ModuleBus interface and need no coordination of their own.

Putting it together

Module (your subclass)
  └── Servo (class attribute, e.g. shoulder_pan = DynamixelServo())
        ├── Data descriptors    (pos = PosData(), speed = SpeedData())
        └── bus_class           (DynamixelBus)
              └── ModuleBus      (one per port)
                   └── BusServer ←→ BusClient   (only when multiple modules share a port)

When the module's run loop ticks, it asks module_bus.read(PosData, ["shoulder_pan", ...]), the bus speaks the wire protocol to the hardware and returns a dict[str, PosData], and the module writes those into its ObservationBuffer. The reverse path runs on write().

See also

  • Calibration — limit sweep, IK alignment, and latency measurements to bring a module up.
  • Scheduling Waypoints — pose and scalar SCHEDULE_L / SCHEDULE_J on Module.
  • Robots — how modules are attached to a robot and dispatched.
  • Observations — the schema your module publishes into.
  • Events — the discrete happenings your module emits.
  • Actions — the schema your module consumes from, and the built-in and custom action types.
  • Simulated servos — how the same components run in MuJoCo.
  • Plug-ins — how reusable modules are packaged and shared.
  • Simulation — run the same module in MuJoCo with is_sim.
  • IK — give a module Cartesian (end-effector) control.

Was this page helpful?