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
Robotdrains atcfg.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
cfgis anActuatorConfig(id,port,model,mode), read from the module'sactuators:block.
- Name
Sensor- Type
- read
- Description
An input. Its
cfgis aSensorConfig, read from the module'ssensors:block; cameras addwidth,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
Observationand command it in yourAction.
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_offsetis still read. See Calibration.
- Name
interpolator_type- Type
- InterpolatorType
- Description
One of
joint,scalar, orpose. Selects the trajectory interpolator used by the defaultSCHEDULE_L / SCHEDULE_Jhandler. See Scheduling Waypoints.
- Name
execution- Type
- ExecutionMode | None
- Description
Overrides the class's
execution_modefor 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:
Trueto keep the module running.Falseto 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
Datatype for the listed components. Returns{name: Data instance}.
- Name
write(values, normalize=True)- Description
Write
Datavalues 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-servoto find and prepare a servo that is not declared anywhere yet. Optional — the default raisesNotImplementedError, 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_JonModule. - 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.