SolarOS

Robotics workflow · Tutorial

Build a differential drive robot with SolarOS

Connect two DC motors with quadrature encoders, tune their velocity controllers, and describe the robot in URDF. Then drive it from the shell or send linear and angular velocity together in an OSC message from another machine.

The example uses a GOOUUU ESP32-S3CAM, two DRV8830 motor drivers, and two motor-shaft encoders. You can adapt the same workflow to other supported boards and motor drivers. Commands in text blocks run in the SolarOS shell; desktop commands are marked separately.

What goes where

PartResponsibility
Expansion driversAttach the I2C motor drivers and GPIO encoders.
Motor and encoder servicesExpose normalized motor-drive sinks and calibrated wheel-velocity sources.
OS controller serviceRun PID control and standalone automatic tuning.
Robot libraryLoad the URDF, provide transforms, and start the declared joints and differential-drive component.
Streams and OSCCarry commands and measured feedback between consumers.

Hardware attachment comes first. Loading a URDF validates the description and builds the model; it does not attach drivers or start motors. robot start explicitly starts its controllers and components, which wait for fresh commands.

Prepare the hardware

Use recent matching firmware and a robot library that support URDF loading, differential drive, and multi-value OSC sending. The full flavor includes the OS services and the expansion drivers used here. The robot library is a separately installed native module and requires a supported ESP32-S3 with executable PSRAM. Mount writable storage for the library and URDF.

Keep the wheels clear of the bench during direction checks and tuning. Power off before wiring. Supply the motors through their drivers, use compatible encoder signal levels, and connect a common ground.

This is the example wiring, not a universal ESP32 pin assignment:

ConnectionExample assignment
I2C SDA / SCLGPIO41 / GPIO42 on controller i2c0
Left DRV8830I2C address 0x60, motor device left_mot
Right DRV8830I2C address 0x61, motor device right_mot
Left encoder A / BGPIO1 / GPIO14, device left_enc
Right encoder A / BGPIO21 / GPIO47, device right_enc

Check expansion status and gpio status before using these pins. Reuse an existing compatible i2c0 bus; create it only if it is absent:

expansion bus create i2c i2c0 port=i2c0 sda=gpio41 scl=gpio42 speed=400000
i2c scan i2c0

The scan should show the two driver addresses. Their address-select wiring must make them distinct. On this board the camera reserves the other I2C controller, so the motor drivers share this bus.

Add the motors

For this example, use drivers and motors suitable for a 3 V full-scale voltage limit. Change max_mv to suit your own assembly before attaching:

expansion attach drv8830 left_mot i2c=i2c0 addr=0x60 max_mv=3000
expansion attach drv8830 right_mot i2c=i2c0 addr=0x61 max_mv=3000
motor list
motor status left_mot
motor status right_mot

The motor service publishes mot.left_mot and mot.right_mot. These are scalar sinks for signed normalized drive, from -1 to 1. They do not accept a velocity in rad/s. The magnitude is a fraction of the driver's configured full-scale output, not a measured speed or a universal PWM duty cycle.

Set a drive envelope for the bench experiment:

motor configure left_mot max_drive 0.5
motor configure right_mot max_drive 0.5
motor configure left_mot polarity 1
motor configure right_mot polarity 1

Here 0.5 is an example permitted maximum. It must be suitable for the motor and load, and high enough to produce several measurable speeds during tuning. A limit at the motor's breakaway output leaves no room to identify its response.

Add and calibrate the encoders

Attach both A/B channels and select an explicit decoding mode:

expansion attach pcnt-encoder left_enc a=1 b=14 mode=1
expansion attach pcnt-encoder right_enc a=21 b=47 mode=1
encoder list

The example uses 70 A-channel pulse periods per motor-shaft revolution, a 30:1 motor-to-wheel gearbox, and x1 decoding. Configure each encoder:

encoder configure left_enc ppr 70
encoder configure left_enc gear_ratio 30
encoder configure left_enc encoder_on_output 0
encoder configure left_enc polarity 1
encoder configure left_enc velocity_tau 0.1
encoder configure right_enc ppr 70
encoder configure right_enc gear_ratio 30
encoder configure right_enc encoder_on_output 0
encoder configure right_enc polarity 1
encoder configure right_enc velocity_tau 0.1
encoder status left_enc
encoder status right_enc

With this calibration, one wheel revolution produces 70 × 1 × 30 = 2100 counts. For x2 or x4 decoding, change mode at attachment; keep ppr=70. For an encoder on the gearbox output, use its actual PPR and encoder_on_output=1 instead of applying the motor-shaft multiplier.

enc.left_enc and enc.right_enc are timestamped scalar sources in wheel rad/s. The filter time constant velocity_tau is in seconds. Status reports fresh values, and you can inspect either source with:

stream echo enc.left_enc --once
stream echo enc.right_enc --rate 20

Ctrl+C ends a continuing echo. There must be encoder motion to observe a nonzero velocity; attaching or reading a stream does not rotate the motor.

Check the directions before closing the loop

Turn each wheel forward by hand and inspect its encoder status. The logical velocity should be positive on both sides. If a channel reports the opposite sign, stop any reader and change that encoder's polarity to -1.

Next, briefly run one motor at a permitted drive that overcomes its breakaway threshold. For example:

motor drive left_mot 0.4
encoder status left_enc
motor coast left_mot

motor drive holds its value until another command stops or changes it. The example is a short direction check, not a timed pulse. Repeat for right_mot and right_enc. A positive logical command must drive each wheel forward and produce positive encoder velocity. Change the motor's polarity if necessary; check encoder polarity independently. Record both signs for the URDF.

Tune a standalone PID controller

Tuning uses the OS motor, encoder, and controller services and works without the robot library installed. If the robot already owns the devices, run robot stop first. Robot-owned controllers cannot be tuned directly.

Create the left test controller with the same calibration and drive envelope you intend to use on the robot:

controller create left_pid pid
controller configure left_pid output_min 0
controller configure left_pid output_max 0.5
controller configure left_pid safe_output 0
controller configure left_pid setpoint_min 0
controller configure left_pid setpoint_max 10
controller configure left_pid period_ms 20
controller configure left_pid timeout_ms 200
controller bind left_pid enc.left_enc stream:mot.left_mot
controller tune left_pid
controller status left_pid

The source is the named encoder stream. The stream: prefix on the output selects a stream target, as opposed to a parameter or PWM output. It is not a different kind of encoder or motor transport.

The example permits a positive-direction experiment with feedback up to 10 rad/s; choose the permitted measurement range and output envelope for your assembly. The tuner starts its worker automatically, finds breakaway, measures several step responses, and tests candidate gains at several operating speeds. You do not supply or maintain a tuning setpoint.

Inspect status until tune=complete, or abort with controller stop left_pid. The optional form is:

controller tune left_pid 0.02 4 120000

The arguments mean a response noise floor of 0.02 rad/s, four response points, and a 120000 ms total timeout. The first number is not motor power, and the last number is not the controller period. This run identifies one direction; a reverse-only output envelope can be used for a separate run.

Status reports candidate kp, ki, kd, and tau. Save all four values for the left wheel. The tuner stops its output on completion and does not replace configured gains or edit a URDF. A failed experiment has no usable candidate; inspect its reason and correct calibration, limits, or feedback before trying again.

Repeat the complete setup for the right assembly:

controller create right_pid pid
controller configure right_pid output_min 0
controller configure right_pid output_max 0.5
controller configure right_pid safe_output 0
controller configure right_pid setpoint_min 0
controller configure right_pid setpoint_max 10
controller configure right_pid period_ms 20
controller configure right_pid timeout_ms 200
controller bind right_pid enc.right_enc stream:mot.right_mot
controller tune right_pid
controller status right_pid

Wait for that experiment to complete and save its candidate gains too. After recording both sets, stop and delete the temporary controllers:

controller stop left_pid
controller stop right_pid
controller delete left_pid
controller delete right_pid

This releases the devices for robot ownership. Keep the chosen motor/encoder polarities and voltage limit; changing the assembly or its scaling changes the system you tuned.

Describe the robot in URDF

Download robot.urdf. It is a simplified four-link robot with 14.5 mm wheel radius and 70.1282 mm wheel separation. The footprint is the ground projection of the axle. Both continuous wheel joints rotate about +Y; +X is forward and +Z is up.

The complete description follows. Replace the example PID gains and polarity signs with your own results before starting the robot. Keep max_drive, encoder calibration, and filtering consistent with the tuning setup.

<?xml version="1.0"?>
<robot name="two_wheel_robot">
  <!-- Metres, radians, +X forward, +Y left, +Z up. -->
  <link name="base_footprint"/>
  <joint name="base_joint" type="fixed">
    <parent link="base_footprint"/>
    <child link="base_link"/>
    <origin xyz="0 0 0.0145"/>
  </joint>
  <link name="base_link">
    <visual>
      <origin xyz="-0.033 0 0.0082"/>
      <geometry><box size="0.100 0.045 0.0154"/></geometry>
    </visual>
  </link>

  <joint name="left_wheel_joint" type="continuous">
    <parent link="base_link"/>
    <child link="left_wheel_link"/>
    <origin xyz="0 0.0350641 0"/>
    <axis xyz="0 1 0"/>
  </joint>
  <link name="left_wheel_link">
    <visual>
      <origin rpy="1.57079632679 0 0"/>
      <geometry><cylinder radius="0.0145" length="0.0138"/></geometry>
    </visual>
  </link>
  <joint name="right_wheel_joint" type="continuous">
    <parent link="base_link"/>
    <child link="right_wheel_link"/>
    <origin xyz="0 -0.0350641 0"/>
    <axis xyz="0 1 0"/>
  </joint>
  <link name="right_wheel_link">
    <visual>
      <origin rpy="1.57079632679 0 0"/>
      <geometry><cylinder radius="0.0145" length="0.0138"/></geometry>
    </visual>
  </link>

  <solar_os version="1">
    <!-- Example gains: replace kp, ki, kd and tau with your own tuning results. -->
    <joint name="left_wheel_joint" command="velocity">
      <motor device="left_mot" polarity="1"/>
      <encoder device="left_enc" ppr="70" placement="motor" polarity="1"/>
      <transmission gear_ratio="30"/>
      <param name="kp" value="0.26"/>
      <param name="ki" value="1.72"/>
      <param name="kd" value="0.005"/>
      <param name="tau" value="0.05"/>
      <param name="velocity_tau" value="0.1"/>
      <param name="max_drive" value="0.5"/>
      <param name="velocity_limit" value="4"/>
      <param name="acceleration_limit" value="10"/>
      <param name="period_ms" value="20"/>
      <param name="command_timeout_ms" value="150"/>
      <param name="feedback_timeout_ms" value="100"/>
    </joint>
    <joint name="right_wheel_joint" command="velocity">
      <motor device="right_mot" polarity="1"/>
      <encoder device="right_enc" ppr="70" placement="motor" polarity="1"/>
      <transmission gear_ratio="30"/>
      <param name="kp" value="0.266"/>
      <param name="ki" value="1.84"/>
      <param name="kd" value="0.0045"/>
      <param name="tau" value="0.05"/>
      <param name="velocity_tau" value="0.1"/>
      <param name="max_drive" value="0.5"/>
      <param name="velocity_limit" value="4"/>
      <param name="acceleration_limit" value="10"/>
      <param name="period_ms" value="20"/>
      <param name="command_timeout_ms" value="150"/>
      <param name="feedback_timeout_ms" value="100"/>
    </joint>
    <diff_drive name="base" left_joint="left_wheel_joint" right_joint="right_wheel_joint">
      <geometry wheel_radius="0.0145" wheel_separation="0.0701282"/>
      <odometry frame="odom" base_frame="base_footprint"/>
      <param name="linear_velocity_limit" value="0.04"/>
      <param name="angular_velocity_limit" value="1"/>
      <param name="command_timeout_ms" value="300"/>
    </diff_drive>
  </solar_os>
</robot>

The ordinary URDF describes the geometry. The solar_os block adds bindings from joint names to attached OS device names, the controller parameters, and one differential-drive component named base. All origins are in metres; joint angles are in radians and wheel velocity limits are in rad/s. tau is the PID derivative filter time constant; velocity_tau is the encoder velocity filter time constant. Both use seconds.

The URDF references left_mot, right_mot, left_enc, and right_enc exactly as attached above. A ROS 2 description package can contain the same geometry and extension. If you author it in Xacro, expand it on the desktop:

xacro robot.urdf.xacro -o robot.urdf

SolarOS reads the resulting flat URDF; it does not run Xacro on the device. Transfer your edited file to /robot.urdf on the mounted device storage. For example, serve its desktop directory with python3 -m http.server 8000, then run on SolarOS, replacing <DESKTOP_IP>:

curl -o /robot.urdf http://<DESKTOP_IP>:8000/robot.urdf

Install the library, load the model, and start

Install the compatible library once:

pkg available
pkg install robot
pkg installed

You can also open pkg and select Robot under Libraries. The catalog must provide a compatible package for your firmware and board. Installing it does not start a robot.

Load and inspect the file:

robot model load /robot.urdf
robot model info
robot model joints
robot model transform base_footprint base_link

The example reports four links, three joints, and one known fixed joint. The footprint-to-base transform has translation [0, 0, 0.0145]. Wheel angles are initially unknown; geometry alone cannot supply measured wheel position.

Start the declared components explicitly:

robot start
robot joint list
controller list
controller status left_wheel_joint
stream list

The two joint controllers now belong to the robot and use the OS controller service. The drive also starts with the robot. They wait with stopped outputs until commands arrive. Live encoder feedback updates the continuous wheel joint positions, so transform queries can show wheel rotation:

robot model transform base_footprint left_wheel_link

The angle is relative to the encoder reference, not an absolute homed wheel angle. A transform query does not command motion.

Drive through streams and inspect feedback

The command path is:

linear m/s + angular rad/s
    -> diff_drive.base
    -> left/right wheel velocity targets in rad/s
    -> joint PID controllers
    -> normalized motor drive
StreamDirection and meaning
diff_drive.base.cmd_linearDuplex: commanded forward X velocity, m/s.
diff_drive.base.cmd_angularDuplex: commanded left-turn Z velocity, rad/s.
enc.left_enc, enc.right_encSources: measured wheel velocity, rad/s.
diff_drive.base.odom_x, .odom_y, .odom_yawSources: integrated wheel-feedback pose, m / m / rad.
diff_drive.base.odom_linear, .odom_angularSources: measured base velocity, m/s / rad/s.
mot.left_mot, mot.right_motSinks: normalized drive, already claimed by the joint controllers.

Here duplex means you can write a command and read back the last accepted command. That readback is not measured motion. The joint controller setpoint streams are also visible, but robot-owned controllers accept commands through the robot API and drive component. Use the two base command streams here.

Inspect the ranges and configured parameters:

stream status diff_drive.base.cmd_linear
stream status diff_drive.base.cmd_angular
control parameter get diff_drive.base.linear_velocity_limit
control parameter get diff_drive.base.angular_velocity_limit

The example limits body commands to ±0.04 m/s and ±1 rad/s. A command such as 0.5 m/s is outside this model's range. Both wheel commands are scaled together if needed to respect their joint velocity limits.

Publish both axes at 20 Hz for two seconds:

stream write diff_drive.base.cmd_linear 0.02 diff_drive.base.cmd_angular 0 --rate 20 --duration 2

This requests forward motion. For an in-place left turn:

stream write diff_drive.base.cmd_linear 0 diff_drive.base.cmd_angular 0.5 --rate 20 --duration 2

Without --rate, a write happens once. Both axes need fresh writes: renewing linear velocity alone does not renew angular velocity. With this URDF the drive stops after 300 ms without fresh commands. Ending a repeated write sends no extra zero, so allow for that timeout; while the device is reachable you can request an immediate zero pair:

stream write diff_drive.base.cmd_linear 0 diff_drive.base.cmd_angular 0

Watch measurements in another shell, or inspect them after a short run:

stream echo enc.left_enc --rate 20
stream echo diff_drive.base.odom_linear --rate 10
stream echo diff_drive.base.odom_x --once
stream echo diff_drive.base.cmd_linear --once

Each continuing echo runs in the foreground until Ctrl+C. Alongside a joint controller, encoder echo observes its feedback without taking ownership. Odometry integrates measured wheel displacement and resets at each robot start. It does not correct wheel slip or fuse an IMU.

Receive one OSC message with both velocities

On the robot, bind two zero-based arguments of one address to the two inputs:

osc bind base-linear receive diff_drive.base.cmd_linear /base/cmd_vel arg=0
osc bind base-angular receive diff_drive.base.cmd_angular /base/cmd_vel arg=1
job start osc listen=9000
osc bindings

Send /base/cmd_vel with two numeric arguments: linear m/s first, angular rad/s second. From another SolarOS device, replacing <ROBOT_IP>:

osc send <ROBOT_IP>:9000 /base/cmd_vel 0.02 0 --rate 20 --duration 2
osc send <ROBOT_IP>:9000 /base/cmd_vel 0 0

The sending device needs neither a robot model nor a running OSC job. Both values travel in one OSC message. The receiver validates the mapped values before writing them; the local stream writes are sequential, not an atomic two-axis transaction. It does not repeat cached commands between packets.

For a desktop sender, download send-velocity.py and run it with Python 3; it needs only the standard library:

python3 send-velocity.py <ROBOT_IP> --linear 0.02 --angular 0 --rate 20 --duration 2

The script sends two OSC float32 values in each UDP packet and sends a zero pair when it finishes or receives Ctrl+C. Packet delivery is not guaranteed; the receiver's command timeout remains necessary. Commands are timestamped on local receipt, not with synchronized remote clocks. OSC itself provides no authentication or encryption; use a trusted network. To restrict the receiving job to one desktop's IPv4 address, stop it and restart with:

job stop osc
job start osc listen=9000 peer=<DESKTOP_IP>

That allowlist does not authenticate the sender. A separate-address setup also works: use /base/linear and /base/angular, both with arg=0. Continue publishing both addresses often enough to meet the command timeout.

Send measured feedback over OSC

Outgoing bindings can publish scalar feedback to a desktop listener:

job stop osc
osc bind measured-linear stream diff_drive.base.odom_linear /base/measured_linear rate=10 send=always
osc bind measured-angular stream diff_drive.base.odom_angular /base/measured_angular rate=10 send=always
job start osc listen=9000 target=<DESKTOP_IP>:9001

The incoming velocity bindings remain configured across a job stop/start. Each outgoing scalar binding sends its own one-argument OSC message. These bindings do not combine feedback fields into a single packet. Add peer=<DESKTOP_IP> to the last command if you also want the receive allowlist.

OSC acquires command writers when valid packets first arrive. A foreground stream write cannot compete with those writers. Use job stop osc to release them before returning to local command tests.

Stop and reload

Stop the transport before releasing the component streams it holds:

job stop osc
robot stop

Close any remaining stream readers, and retry robot stop if cleanup reports a busy resource. The model stays loaded. To change gains, geometry, or limits, edit the URDF and repeat:

robot model load /robot.urdf
robot start

For another tuning session, keep the robot stopped and recreate the standalone controllers. Do not try controller tune left_wheel_joint on a robot-owned instance.

If a step does not work

SymptomWhat to check
A positive command gives negative encoder velocityCheck motor and encoder polarity independently before tuning; copy both signs into the URDF.
Tuning reports no-motion-within-output-limitCheck power, wiring, encoder counts, and permitted headroom above breakaway. Do not increase drive beyond the assembly's allowed envelope.
Tuning reports busy or rejects ownershipStop the robot, stop the standalone controller, and close any setpoint writer before tuning.
URDF load reports an unknown attributeCheck the reported line; a parameter uses <param name="kd" value="0.005"/>, with exactly name and value.
robot start cannot find an itemCompare URDF device names with motor list and encoder list; loading the description did not attach those devices.
Motion stops after a brief commandPublish both body command streams periodically and check their timeout and range with stream status.
Local writes report a busy streamStop the OSC job to release its command writers.
Wheel transform is unavailableWait for live joint feedback after robot start; a newly loaded continuous joint has no measured angle yet.

Restore the setup at boot

Runtime attachment and OSC bindings need to be restored after reboot. Use setterm startup to find the active shell startup file, preserving its existing commands. Once the bench setup is settled, append the bus creation when needed, the four expansion attachments, robot model load /robot.urdf, robot start, then any desired osc bind and job start osc lines, in that order. Omit bus/device attachments already provided by your board profile. Do not add tuning experiments or motion commands to startup. Calibration and controller configuration for normal operation now come from the URDF.

For reference, see motor control, encoder calibration, feedback controllers, robot models, and OSC bindings.