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
| Part | Responsibility |
|---|---|
| Expansion drivers | Attach the I2C motor drivers and GPIO encoders. |
| Motor and encoder services | Expose normalized motor-drive sinks and calibrated wheel-velocity sources. |
| OS controller service | Run PID control and standalone automatic tuning. |
| Robot library | Load the URDF, provide transforms, and start the declared joints and differential-drive component. |
| Streams and OSC | Carry 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:
| Connection | Example assignment |
|---|---|
| I2C SDA / SCL | GPIO41 / GPIO42 on controller i2c0 |
| Left DRV8830 | I2C address 0x60, motor device left_mot |
| Right DRV8830 | I2C address 0x61, motor device right_mot |
| Left encoder A / B | GPIO1 / GPIO14, device left_enc |
| Right encoder A / B | GPIO21 / 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
| Stream | Direction and meaning |
|---|---|
diff_drive.base.cmd_linear | Duplex: commanded forward X velocity, m/s. |
diff_drive.base.cmd_angular | Duplex: commanded left-turn Z velocity, rad/s. |
enc.left_enc, enc.right_enc | Sources: measured wheel velocity, rad/s. |
diff_drive.base.odom_x, .odom_y, .odom_yaw | Sources: integrated wheel-feedback pose, m / m / rad. |
diff_drive.base.odom_linear, .odom_angular | Sources: measured base velocity, m/s / rad/s. |
mot.left_mot, mot.right_mot | Sinks: 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
| Symptom | What to check |
|---|---|
| A positive command gives negative encoder velocity | Check motor and encoder polarity independently before tuning; copy both signs into the URDF. |
Tuning reports no-motion-within-output-limit | Check power, wiring, encoder counts, and permitted headroom above breakaway. Do not increase drive beyond the assembly's allowed envelope. |
| Tuning reports busy or rejects ownership | Stop the robot, stop the standalone controller, and close any setpoint writer before tuning. |
| URDF load reports an unknown attribute | Check the reported line; a parameter uses <param name="kd" value="0.005"/>, with exactly name and value. |
robot start cannot find an item | Compare URDF device names with motor list and encoder list; loading the description did not attach those devices. |
| Motion stops after a brief command | Publish both body command streams periodically and check their timeout and range with stream status. |
| Local writes report a busy stream | Stop the OSC job to release its command writers. |
| Wheel transform is unavailable | Wait 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.