The Robot
The robot is the program that owns the hardware. It talks to every motor and sensor, reports what they measure, and carries out the actions the controller sends. This chapter writes the robot class, its first module, the six-joint arm, and the scene where simulation gives that arm a body.
What a robot is
A robot is a Python class that extends Solarian's Robot. The scaffold already created yours, an empty So106Robot in so106/robot.py, and so106/settings.py points ROBOT_CLASS at it. solarian run-robot imports that class, builds it with the settings from conf/robot.yaml, and starts it.
The base class does the plumbing: it connects to the relay server, runs every device, and moves messages between the devices and the controller. Once started, the robot does the same work for the whole run:
1. connect every module, to the simulator or to the hardware
2. start each module's own loop, at the rate in its YAML
3. read the modules and send the controller an observation
4. hand each action the controller sends to the modules it names
Your subclass says what the robot is made of, in three pieces:
- A module is one device: the arm, the gripper, a camera. Its class lists the device's components, its actuators and sensors, and says what to do with a command.
- The robot class names its modules, one class attribute each. The attribute name,
right_armhere, is the module's name everywhere else in the project. - The scene is the simulated world: a floor, a light, and the MuJoCo model of each module, mounted where the real device stands.
A robot is a class with one attribute per module. Each module groups the components of one device. The scene is the simulated world, and it mounts each module's body: the arm on the floor, the gripper on the arm's flange, the camera on the gripper.
By the end of this tutorial So106Robot carries all three modules in the diagram. This chapter builds the first one, adds it to the robot, and mounts its model in the scene. The gripper and the camera follow the same three steps in their own chapters.
First module: robot arm
The arm module is a Python class with one attribute per servo and a method that writes commands to them. Solarian calls that method on the module's own loop, and the same class drives the MuJoCo joints in simulation and the Feetech bus on hardware.
Install the servo driver
The SO-106's joints are Feetech serial-bus servos. Their driver ships as a plugin, so install it first:
solarian install feetech
That installs solarian-feetech and adds it to your pyproject.toml. It brings the FeetechServo actuator class the arm uses. The arm module itself stays in your project.
On the machine that will later be wired to the bus, the hardware extra adds the vendor SDK:
pip install "solarian-feetech[hardware]"
Simulation runs without it, so leave it for the real robot.
Declare the joints
The module class declares the six servos and handles one kind of command: a full set of joint targets. Create so106/modules/right_arm.py:
so106/modules/right_arm.py
from solarian.core.action import ActionType, ModuleAction, action_handler
from solarian.core.module import Module
from solarian_feetech.actuator import FeetechServo
class RightArmModule(Module):
shoulder_pan = FeetechServo(model="sts3250")
shoulder_lift = FeetechServo(model="sts3250")
elbow_flex = FeetechServo(model="sts3250")
wrist_flex = FeetechServo(model="sts3250")
wrist_yaw = FeetechServo(model="sts3215")
wrist_roll = FeetechServo(model="sts3215")
def initialize(self):
self._goal = None
@action_handler(ActionType.MOVE_J)
def _handle_move_j(self, action: ModuleAction, t_now: float) -> bool:
# The controller streams a full joint target. Hold it and write it
# on the module's own tick.
self._goal = action.values()
return True
def control_step(self):
if self._goal is not None:
self.write_actuators(self._goal)
The lines that matter:
shoulder_pan = FeetechServo(model="sts3250")declares one servo. The attribute name is the joint's name in observations, actions, and YAML. The model string picks the Feetech product: STS3250 on the four joints that carry the arm's weight, STS3215 on wrist yaw and roll. That matches the SO-106 hardware.initializeruns once, when the module connects. It starts the module with no goal, so the arm holds still until a command arrives.@action_handler(ActionType.MOVE_J)registers_handle_move_jfor joint commands. AMOVE_Jaction carries six numbers, one per joint, and the handler keeps them as the current goal. ReturningTruekeeps the module running.control_stepruns on the module's own loop, at the rate in its YAML, and writes the goal to the servos. In simulation those servos are MuJoCo joints, and on hardware they are the bus. The class reads the same either way.
This module works purely in joint coordinates. Poses and inverse kinematics come in Tutorial 4.
Configure the module
Every module has a YAML file named after its attribute on the robot, in conf/modules/. It gives each servo its bus address and its range of travel. The attribute will be right_arm, so the file is conf/modules/right_arm.yaml.
The file reads the serial port from the deployment, so add one to conf/deployment/localhost.yaml. Simulation ignores it, and Tutorial 3 points it at the real adapter:
conf/deployment/localhost.yaml
actuator_port_right_arm: /dev/ttyUSB0
Then the module file, one block per joint:
conf/modules/right_arm.yaml
actuator_port: ${deployment.actuator_port_right_arm}
frequency: 50
actuators:
shoulder_pan:
id: 1
port: ${actuator_port}
mode: range_m100_100
sim_joint: "right/shoulder_pan"
sim_range_min: -2.2290
sim_range_max: 2.0820
shoulder_lift:
id: 2
port: ${actuator_port}
mode: range_m100_100
sim_joint: "right/shoulder_lift"
sim_range_min: -1.7988
sim_range_max: 1.7802
elbow_flex:
id: 3
port: ${actuator_port}
mode: range_m100_100
sim_joint: "right/elbow_flex"
sim_range_min: -1.7802
sim_range_max: 1.5708
wrist_flex:
id: 4
port: ${actuator_port}
mode: range_m100_100
sim_joint: "right/wrist_flex"
sim_range_min: -3.1416
sim_range_max: 3.1416
wrist_yaw:
id: 5
port: ${actuator_port}
mode: range_m100_100
sim_joint: "right/wrist_yaw"
sim_range_min: -3.1416
sim_range_max: 3.1416
wrist_roll:
id: 6
port: ${actuator_port}
mode: range_m100_100
sim_joint: "right/wrist_roll"
sim_range_min: -3.1416
sim_range_max: 3.1416
The lines that matter:
frequency: 50is the rate of the module's loop, in hertz: how oftencontrol_stepwrites the goal. The controller runs at its own, slower rate, and the two are independent.idis the servo's address on the Feetech bus. You set those IDs on the real servos in Tutorial 3.mode: range_m100_100makes every command a normalized number: −100 is one end of the joint's travel, 100 is the other, and 0 is the middle.sim_jointnames the MuJoCo joint this servo drives. Theright/in front is the prefix the scene gives the arm, further down this page.sim_range_minandsim_range_maxare that same travel in radians, copied from the model's joint limits. Simulation maps −100…100 onto them. Hardware uses a calibration instead, recorded in Tutorial 3, and ignores thesim_*lines.
Add the arm to the robot
The robot class is where the module joins the machine. One class attribute creates the module, and its name, right_arm, ties together the YAML file above, the observation and action fields of the next chapter, and every log line about the arm. Replace the empty class in so106/robot.py:
so106/robot.py
from solarian.core import Robot
from so106.modules.right_arm import RightArmModule
class So106Robot(Robot):
right_arm = RightArmModule()
When the robot starts, Solarian finds right_arm, loads conf/modules/right_arm.yaml into it, connects it, and runs its loop. A second arm later is one more line with another name, such as left_arm = LeftArmModule().
Place the arm in the scene
In simulation the module needs a body: the arm's MuJoCo model, standing in a world with a floor. This section copies the model into the project, writes a small world around it, and tells the robot where the arm stands in that world.
Copy the arm model
The model is published with the solarian-so106 package. Copy its assets folder into the project. This copies data only: the module class is the file you just wrote. solarian-so106 also ships a maintained version of the same module, for projects that would rather install it than keep it.
git clone --depth 1 https://github.com/solarian-dev/solarian-so106.git /tmp/solarian-so106
mkdir -p assets
cp -R /tmp/solarian-so106/solarian_so106/assets assets/so106
You should now have assets/so106/so106.xml and a so106_description/ folder of meshes beside it. The XML finds its meshes by a path relative to itself, so keep the two together.
The arm model has a site named flange at the wrist. The gripper bolts to it in a later chapter.
Write the world
The world around the arm holds what every scene shares: a floor and a light. Create assets/scene.xml:
assets/scene.xml
<mujoco model="so106_scene">
<compiler angle="radian"/>
<visual>
<headlight diffuse="0.6 0.6 0.6" ambient="0.3 0.3 0.3"/>
<global azimuth="120" elevation="-20"/>
</visual>
<asset>
<texture name="grid" type="2d" builtin="checker" rgb1="0.2 0.3 0.4" rgb2="0.1 0.15 0.2"
width="512" height="512" mark="edge" markrgb="0.8 0.8 0.8"/>
<material name="grid" texture="grid" texrepeat="8 8"/>
</asset>
<worldbody>
<light pos="0 0 1.5" dir="0 0 -1" directional="true"/>
<geom name="floor" type="plane" size="1 1 0.05" material="grid"/>
</worldbody>
</mujoco>
The arm stays out of this file. The robot attaches it in Python, which keeps the arm model reusable in any other scene.
Attach the arm
The robot class carries the scene, and the scene lists one attachment per module that has a body. Each attachment says which model belongs to which module and where it is mounted, the same way you would bolt the real arm to a table. Extend So106Robot:
so106/robot.py
from solarian.core import Robot
from solarian.sim import ModuleAttachment, SceneConfig
from so106.modules.right_arm import RightArmModule
class So106Robot(Robot):
# Simulation until Tutorial 3. `sim: null` in conf/robot.yaml lets a
# single run override this with `solarian run-robot sim=false`.
is_sim = True
view = True
right_arm = RightArmModule()
scene = SceneConfig(
world_mjcf="assets/scene.xml",
attachments=[
ModuleAttachment(
module="right_arm",
mjcf="assets/so106/so106.xml",
parent_body="world",
pos=[0.0, -0.2, 0.0],
prefix="right/",
),
],
timestep=0.002,
)
The lines that matter:
is_sim = Trueruns the robot in MuJoCo. The servos become the model's joints, and the module class does not change.view = Trueopens a window that draws the simulation.world_mjcfis the world you just wrote.module="right_arm"pairs the attachment with the module of that name, so the module's servos drive this model's joints.parent_body="world"andpos=[0.0, -0.2, 0.0]bolt the arm's base to the world, 20 cm along −Y from the origin, on the floor.prefix="right/"renames the model's joints inside the scene, soshoulder_panbecomesright/shoulder_pan. That is why the YAML'ssim_jointlines carry the prefix. It also lets a second arm join later without its joint names colliding.timestep=0.002steps the physics at 500 Hz.
Last, let the command line override is_sim. Add this line to conf/robot.yaml, next to fps:
conf/robot.yaml
# null defers to the class attribute. Override with sim=true or sim=false.
sim: null
You can start the robot now. The sim window should show the arm at mid-travel, holding still, because nothing sends it a command yet. A missing so106.xml fails at startup with a file error. A sim_joint that does not match the prefixed model name fails when the world is built, and the message names the joint.
Next: observations and actions.