# OpenDubs

Personal Robotics Lab · Oct 2025 – present
Tools: ROS 2, Python, C++, ArduPilot / MAVROS, Pinocchio, MuJoCo, Docker, WebXR
Page: https://ericjgoossen.com/projects/opendubs/
Links: [Arm code (lab repo)](https://github.com/EricJGoossen/openarm_robot-code) [OpenDubs](https://github.com/UW-Allen-School-Robotics/opendubs) [ArmGroup](https://github.com/EricJGoossen/mj_manipulator) [ROS bridge](https://github.com/EricJGoossen/mj_manipulator_ros) [Teleop](https://github.com/EricJGoossen/openarm) [Impedance control](https://github.com/EricJGoossen/openarm_impedance_control) [Cartesian control](https://github.com/EricJGoossen/openarm_cartesian_control) [Mocap](https://github.com/EricJGoossen/open_dubs_mocap)

Summary: A low-cost bimanual mobile manipulator for the Personal Robotics Lab, made for other labs to build on, from drive and localization bring-up to two arms planned and run as one.
Lines of code Eric wrote: 14,669 (the sum of the entries below)

![The robot's two OpenArm arms, one blue and one orange, passing a red can between them over a table and setting it down, rendered in MuJoCo](https://ericjgoossen.com/projects/opendubs/images/hero-handover.jpg)

## Overview

OpenDubs is the Personal Robotics Lab's bimanual mobile manipulator: a holonomic mecanum base, an NVIDIA Jetson Thor and two 7-DOF OpenArm arms, granted by NVIDIA. The point is a low-cost platform that can mostly match what humanoids do, so other labs can take our work and build on it cheaply. I'm on the four-person undergraduate team building its software: I worked on the navigation stack and own the arms, each piece built in simulation first, then brought up on the hardware. Both arms now move in 3D under planned and teleoperated control, and I've led demos of the robot for industry visitors and donors.

## Entries (10, newest first)

### ARCS Night to Ignite Science

Date: 2026-10-17 · URL: https://ericjgoossen.com/projects/opendubs/#arcs-demo
Summary: Presenting OpenDubs to donors at the ARCS Foundation's Night to Ignite Science fundraiser.
Hardware: OpenDubs | Role: Presenter

Links: [ARCS Seattle](https://seattle.arcsfoundation.org/)

On October 17, OpenDubs went downtown for the [ARCS Foundation](https://seattle.arcsfoundation.org/)'s Night to Ignite Science, a fundraiser for STEM PhD fellowships at the UW and Washington State that raised over $900,000. Four of us from the lab presented it to donors. The robot danced and moved its arms to the Monster Mash: a routine others in the lab put together, running on the arm infrastructure I built.

I really enjoyed the chance to talk with and give back a little to the people who make my research at UW possible. I talked the visitors through the motivation behind OpenDubs, my work on the project, and my passion for robotics. It was great to practice pitching my work to people with an interest in the field, but without intense technical depth.

![Four people standing behind OpenDubs at a night event, the robot's two arms spread out in front of them and its tablet face showing two cartoon eyes, with Seattle's lit-up towers through the windows behind them](https://ericjgoossen.com/projects/opendubs/images/arcs-team.jpg)
Caption: With Emma Romig, Sidharth Talia and Daphne Chen.

![Eric standing next to OpenDubs in front of a window full of autumn trees and city buildings; the robot's tablet face shows two cartoon eyes above its arms](https://ericjgoossen.com/projects/opendubs/images/arcs-eric-robot.jpg)
Photo: Jason J. Kim Photography

### Demos for Winbond and Fauna Robotics

Date: 2026-09-24 · URL: https://ericjgoossen.com/projects/opendubs/#winbond-fauna-demos
Summary: Live demos of OpenDubs for Winbond's executives and for Fauna Robotics' team.
Hardware: OpenDubs | Role: Lead
Skills: Teleoperation
Links: [UW News](https://www.washington.edu/news/2026/10/13/coengwinbond/)

The afternoon teleop first ran on the robot, I led a demo for twenty to thirty executives from Winbond, the memory chip maker, shortly before [Winbond's $12 million gift to the UW College of Engineering](https://www.washington.edu/news/2026/10/13/coengwinbond/). I prepared and ran it: the robot drove around the room while I teleoperated both arms and explained how it works.

Two days earlier, Fauna Robotics' team visited the lab, and I gave them the same tour: driving the robot around and talking through how it works.

Video: Eric presents the OpenDubs robot to a group of Winbond visitors in the lab
File: https://ericjgoossen.com/projects/opendubs/images/winbond-demo-talk.mp4; still frame (an image you can view): https://ericjgoossen.com/projects/opendubs/images/winbond-demo-talk.jpg
Caption: Presenting to Winbond, September 24 (with sound).

Video: Eric, in a Quest headset, teleoperates both arms as the visitors watch
File: https://ericjgoossen.com/projects/opendubs/images/teleop-quest-both-arms.mp4; still frame (an image you can view): https://ericjgoossen.com/projects/opendubs/images/teleop-quest-both-arms.jpg
Caption: Teleoperating the arms for them.

### Running both arms at once

Date: 2026-09-01 · URL: https://ericjgoossen.com/projects/opendubs/#parallel-arms
Summary: Both arms start a plan from one shared timestamp, halving the run time and staying inside what the planner checked.
Lines of code Eric wrote: 487 | Language: Python | Tools: ROS 2, MuJoCo | Hardware: 2× OpenArm | Role: Solo | Status: On the robot
Skills: Python, ROS 2, Motion planning
Links: [Bimanual refactor](https://github.com/EricJGoossen/mj_manipulator_ros/commit/c7bedb6)

A two-arm plan is only safe if both arms run it together: it was checked with each arm where the other would be at the same instant. On hardware, though, [mj_manipulator_ros](https://github.com/EricJGoossen/mj_manipulator_ros), the lab's layer that runs those plans on the robot, ran multi-arm plans one arm after the other, which doubled the time and passed through states the planner never checked. Replaying the crossing plan that way in MuJoCo and checking every fourth of its states, the arms intersect in **97 of 1,400.**

I rebuilt execution so every arm starts its trajectory on **one shared start stamp**: the trajectories must share their timestamps, one stamp a moment in the future is computed once, and each arm sends its goal from its own thread against that stamp, so the motors start together however long each goal takes to send. If any arm fails or is aborted, a shared event cancels the others mid-motion. Running two arms from two threads also meant making the shared code thread-safe: both threads stepped one MuJoCo model, and concurrent runs crashed until I serialized every access to it behind one lock. To bring it all up on the real robot safely, I built a hardware-in-the-loop bring-up protocol.

![Line chart of the closest distance between the two arms over time for one crossing plan. Run left then right or right then left, it takes 44.8 seconds and dips below zero, the arms overlapping, near the end. Run together from one start stamp, it finishes at 22.4 seconds without overlapping](https://ericjgoossen.com/projects/opendubs/images/parallel-clearance.svg)
Caption: The same plan run the old way and together: how close the arms come, and when each run finishes.

Video: On the robot, both arms extend out to the sides at the same time
File: https://ericjgoossen.com/projects/opendubs/images/parallel-arms-reach-out.mp4; still frame (an image you can view): https://ericjgoossen.com/projects/opendubs/images/parallel-arms-reach-out.jpg
Caption: Both arms moving at once on the robot, September 22.

### N arms, one planner: ArmGroup (featured)

Date: 2026-08-26 · URL: https://ericjgoossen.com/projects/opendubs/#arm-group
Summary: ArmGroup plans every arm's joints together in the lab's planner, so two arms on one torso never collide; it plans both arms on the robot.
Lines of code Eric wrote: 3,200 | Language: Python | Tools: MuJoCo | Hardware: 2× OpenArm | Role: Solo | Status: On the robot
Skills: Motion planning, Python, Simulation (MuJoCo)
Links: [Pull request](https://github.com/EricJGoossen/mj_manipulator/pull/1)

The lab plans with its own MuJoCo planning library, mj_manipulator, which planned one arm at a time. With two arms on one torso, two plans that are each safe can still hit each other. I added [an ArmGroup](https://github.com/EricJGoossen/mj_manipulator) to it that plans all arms' joints together, so every step is checked arm against arm. On the clip's goals, the two arms planned separately collide in 58 of 210 frames; **ArmGroup's plan bends one gripper about 16 cm around the other and touches in none.** The same group plans to joint goals, gripper poses and lockstep Cartesian paths, keeps 5 mm between all geometry, retimes the trajectory at 125 Hz, and re-checks it at every sample. I made single-arm planning a one-arm group, and ArmGroup is what plans both arms on the robot today.

Video: Two renders side by side of the blue and orange arms reaching for goals whose direct routes cross. On the left, each arm planned on its own, the blue gripper drives straight into the orange one and the frame turns red. On the right, planned together by ArmGroup, the arms reach the same goals without touching, the blue path bending around the orange gripper
File: https://ericjgoossen.com/projects/opendubs/images/arm-group-gripper-cross.mp4; still frame (an image you can view): https://ericjgoossen.com/projects/opendubs/images/arm-group-gripper-cross.jpg
Caption: The same goals, each arm planned on its own (left, red while they intersect) and planned together by ArmGroup (right). The lines are the grippers' paths. MuJoCo, real time.

Video: The two arms passing a red can between them over a table and setting it down, then passing it back
File: https://ericjgoossen.com/projects/opendubs/images/hero-handover.mp4; still frame (an image you can view): https://ericjgoossen.com/projects/opendubs/images/hero-handover.jpg
Caption: A two-arm handover planned with ArmGroup in MuJoCo (the can is carried kinematically).

### Teleop with a Meta Quest, in the browser (featured)

Date: 2026-07-22 · URL: https://ericjgoossen.com/projects/opendubs/#teleop
Summary: Teleop for both arms with a Meta Quest, built from scratch as a web page the headset opens in AR.
Lines of code Eric wrote: 5,072 | Language: Python, JavaScript | Tools: WebXR, WebSockets | Hardware: Meta Quest | Role: Solo | Status: On the robot
Skills: Teleoperation, Python
Links: [Code](https://github.com/EricJGoossen/openarm/tree/a69881c)

For demonstrations and live driving, I built [teleop with a Meta Quest](https://github.com/EricJGoossen/openarm). The apps I tried had no passthrough and left me dizzy, so I wrote my own: instead of an app on the headset, the robot serves a page the Quest's browser opens in AR, so the operator still sees the room, and the page streams both controllers' poses, grip and trigger to the robot over a websocket. Holding a grip engages its arm, and the trigger works the gripper. The headset's page goes dark once it enters AR, so the robot also broadcasts that live telemetry to any other browser pointed at it, and someone at a laptop can watch exactly what the operator is sending. It matches the old SDK's interface, so switching was a one-line change. I built it against the arms in MuJoCo first; at first the loop couldn't keep up, and the bottleneck turned out to be publishing the sim viewer over the network, not the control.

Video: One of the robot's arms, on teleop, swinging quickly out and back over and over
File: https://ericjgoossen.com/projects/opendubs/images/teleop-fast.mp4; still frame (an image you can view): https://ericjgoossen.com/projects/opendubs/images/teleop-fast.jpg
Caption: Teleop on the robot after the speed-ups.

Video: Two renders side by side of the arms following two floating spheres, the hands' targets, as they trace loops: on the left at the repo's speed limits the grippers trail the spheres, on the right with the limits raised they keep up
File: https://ericjgoossen.com/projects/opendubs/images/teleop-sim-speed.mp4; still frame (an image you can view): https://ericjgoossen.com/projects/opendubs/images/teleop-sim-speed.jpg
Caption: My teleop loop in MuJoCo, fed two scripted hands (the spheres): the repo's limits (left) and raised ones like the robot's now (right).

### Fixing the IK solver for the OpenArm (featured)

Date: 2026-07-14 · URL: https://ericjgoossen.com/projects/opendubs/#ik-fix
Summary: I traced the lab's IK solver failing on the OpenArm to a singularity and fixed it: 184 of 200 targets solved instead of 1.
Lines of code Eric wrote: 34 | Language: Python | Tools: ssik | Hardware: OpenArm | Role: Solo | Status: Upstreamed
Skills: Open source, Kinematics & Dynamics, Python
Links: [The fix](https://github.com/EricJGoossen/ssik/commit/38d1c7b) [Pull request](https://github.com/EricJGoossen/ssik/pull/1)

Teleop and planning both lean on ssik, the lab's analytical IK solver, which I didn't write, and on the OpenArm it kept failing. I traced it to a singularity in the solver itself: the OpenArm's joint axes meet at shared points, so its link lengths are zero and every twist is a right angle, and that exact geometry breaks the Raghavan–Roth elimination the solver uses. [My fix](https://github.com/EricJGoossen/ssik) nudges those zero lengths by a tiny fraction of the arm's size, so the solver works on an arm a hair off the real one, then, since forward kinematics is cheap, refines the answer against the true arm's FK, step after step, until it's an exact solution again. The maintainer confirmed the fix would have worked while landing the same one upstream.

![Bar chart of IK targets solved on the OpenArm, 25 per distance from the straight arm, from 0 to 3 radians: before the fix, 1 of 200 in all; after it, every target out to 0.3 radians, 24 at 1 radian and 10 at 3](https://ericjgoossen.com/projects/opendubs/images/ik-fix-sweep.svg)
Caption: My July OpenArm IK (ssik's joint-lock solver) on 200 random reachable targets, run again with ssik just before my fix and with it: 1 solved before, 184 after.

### Arm control: three impedance controllers, then a simpler one (featured)

Date: 2026-07-13 · URL: https://ericjgoossen.com/projects/opendubs/#impedance-control
Summary: Three impedance controllers for the OpenArms, and why the robot runs the motors' own loop with 500 Hz gravity compensation instead.
Lines of code Eric wrote: 3,525 | Language: C++ | Tools: Pinocchio, ROS 2 | Hardware: 2× OpenArm | Role: Solo | Status: On the robot
Skills: Impedance control, C++, ROS 2
Links: [Cartesian control](https://github.com/EricJGoossen/openarm_cartesian_control/tree/176f011) [Joint and null-space control](https://github.com/EricJGoossen/openarm_impedance_control/tree/bea0dfa)

Each arm is a 7-DOF OpenArm whose Damiao motors run their own impedance loop: the Thor sends position, velocity and torque targets with gains over CAN-FD, and each motor closes the loop itself. I measured the stock ROS 2 stack first. A goal is accepted in 2.55 ms, and with both arms holding one block, **47 ms passes between a command to one arm and the torque spike in the other** (σ = 4.7 ms).

What I was after was axial compliance in Cartesian space: a gripper that holds firm along some axes and gives along others. I wrote three controllers with Pinocchio to get it ([Cartesian](https://github.com/EricJGoossen/openarm_cartesian_control), [joint and null space](https://github.com/EricJGoossen/openarm_impedance_control)). Pure Cartesian impedance gave that compliance, but it left the redundant elbow free to drift off the collision-checked path. The combined controller ran joint-space impedance and subtracted the Cartesian terms from it, keeping the axial compliance and control of the null space, but it was numerically unstable. The null-space controller worked, but I switched off it for the much lower latency of the motors' own impedance loops: in the end we gave up Cartesian compliance for speed.

What I settled on is simpler: the planned trajectory goes straight to the motors, plus gravity compensation at 500 Hz from Pinocchio's RNEA (the recursive Newton-Euler algorithm), with every goal checked against position and torque limits in both joint space and Cartesian space (through forward kinematics).

Video: Three clips of a hand pushing one of the robot's arms: on the top left the arm gives way and holds its new pose, on the top right the wrist holds its place sideways but gives way up and down and in rotation, and below the gripper holds its position but turns freely
File: https://ericjgoossen.com/projects/opendubs/images/impedance-control-clips.mp4; still frame (an image you can view): https://ericjgoossen.com/projects/opendubs/images/impedance-control-clips.jpg
Caption: My Cartesian impedance controller on the robot: gravity compensation only (June 26), stiff in x and y but compliant in z and rotation, and stiff in x and y but compliant in rotation.

Video: Four simulated arms, one per controller, reaching out fast, pushed down at the elbow, reaching back and pushed sideways; a live readout shows each one's distance from its plan. The motor-loop arm stays on the plan; the others trail it, and the Cartesian one is left bent
File: https://ericjgoossen.com/projects/opendubs/images/impedance-four-sim.mp4; still frame (an image you can view): https://ericjgoossen.com/projects/opendubs/images/impedance-four-sim.jpg
Caption: The four controllers in MuJoCo, built unchanged from their repos (faint arm: the plan). On the reaches the motors' own loop stays within about 2 cm of the plan while the others trail by 7 to 18 cm; near the straight arm the Cartesian one stays folded once pushed. Demo gains.

### Two 2D lidars, merged into one scan (featured)

Date: 2026-04-09 · URL: https://ericjgoossen.com/projects/opendubs/#lidar
Summary: A node that merges two 2D lidars into one scan for slam_toolbox and Nav2, which drove the robot point to point in simulation.
Language: Python | Tools: slam_toolbox, Nav2 | Hardware: 2× RPLIDAR A1 | Role: Team | Status: In simulation
Skills: State estimation, ROS 2, Hardware bring-up, Python

For localization and a map, the base carries two RPLIDAR A1 2D lidars, one at the front and one at the back. Bringing them up, with the team, was mostly fighting USB: the two units have identical USB IDs, so udev rules name each by the hub port it's plugged into, and they power up a few seconds apart so neither times out.

Mapping and Nav2 expect one scan, so I wrote a node that merges the two. The two scans never line up on their own: nothing guarantees the lidars spin at exactly the same rate or finish a revolution at the same moment, and each reaches the Thor with its own latency. So the node pairs scans by their timestamps (any two within 0.1 s), looks up each scan's transform into the robot's frame at that scan's own timestamp, resamples both to the finer of the two angular resolutions, and keeps the nearest return in each direction of one 360° scan, stamped halfway between the two and dropping hits on the robot itself. On top of it, I set up slam_toolbox and Nav2, which drove the robot point to point in simulation.

![A CAD render of a round 2D lidar sitting in a bright green 3D-printed mount](https://ericjgoossen.com/projects/opendubs/images/lidar-mount-cad.jpg)
Caption: One of the two lidar mounts. CAD render from the lab's build guide.

Video: Top-down view of a simulated lab room being mapped: the robot, a small white rectangle, follows green Nav2 plans to a series of goal stars while its front lidar (orange) and back lidar (blue) light up the walls, and the grey map of the room fills in behind it
File: https://ericjgoossen.com/projects/opendubs/images/sim-slam-nav2.mp4; still frame (an image you can view): https://ericjgoossen.com/projects/opendubs/images/sim-slam-nav2.jpg
Caption: A replay in Gazebo: the robot's URDF, my lidar merger and our slam_toolbox settings, unmodified, mapping a made-up lab room while Nav2 drives. 6× speed.

### Optical flow odometry, checked against mocap

Date: 2026-03-29 · URL: https://ericjgoossen.com/projects/opendubs/#optical-flow
Summary: Optical-flow odometry fed into ArduPilot's EKF, with a motion-capture pipeline to tune it until it stayed within 5 mm.
Lines of code Eric wrote: 2,076 | Language: C++, Python | Tools: micro-ROS, ArduPilot | Hardware: Pixhawk, M5Stack | Role: Solo | Status: Retired

Links: [micro-ROS bridge](https://github.com/EricJGoossen/M5_stack_sensor_transfer/tree/cdbf364) [Mocap package](https://github.com/EricJGoossen/open_dubs_mocap/tree/f8cd5d2)

Before the base could navigate, it had to know where it was. The first source was optical flow: a sensor that watches the floor slide past. I brought it up [over micro-ROS](https://github.com/EricJGoossen/M5_stack_sensor_transfer) and fed it into ArduPilot's EKF alongside the IMU of the Pixhawk the base ran at the time. It only read sensibly within about 10 cm of the floor, so it sits low, lit by its own LED.

To tell whether it worked, I needed ground truth. The lab's motion capture tracks the robot more precisely than anything you could measure by hand, so I wrote [the package that brings it into ROS](https://github.com/EricJGoossen/open_dubs_mocap): the EKF's estimate and the mocap pose land in one bag on one clock, and every run can be compared with the truth point by point. The first runs showed why that mattered: driving 5.5 m, the estimate stayed within 0.74 m of mocap, but spinning in place sent it 155 m off. Once teammates found the sensor's mounting missing from the config, I tuned the flow gain against mocap, run by run, until **the estimate stayed within 5 mm of motion capture.**

![Top-down chart of a simulated drive: with the sensor's mounting missing from the config, the estimate heads 10 meters sideways while the robot drives 6 meters forward and back; with the mounting fixed, it follows the true path](https://ericjgoossen.com/projects/opendubs/images/flow-sim-paths.svg)
Caption: In simulation (ArduPilot SITL, flow only, the sensor 10 cm off the floor): with the mounting missing from the config, driving forward reads as driving sideways.

![Chart of simulated position error over time for three configurations: mounting missing, up to about 11 meters; mounting fixed, about 1 meter; flow gain tuned, about half a meter](https://ericjgoossen.com/projects/opendubs/images/flow-sim-error.svg)
Caption: The same simulated run, error over time for each config. On the robot, against mocap, the tuned estimate stayed within 5 mm.

### Bring-up: the Thor, Docker and the drive code

Date: 2026-02-21 · URL: https://ericjgoossen.com/projects/opendubs/#bringup
Summary: Getting the Jetson Thor and the team's Docker environment ready, and bringing up the first drive base with another intern.
Lines of code Eric wrote: 275 | Language: Python | Tools: ROS 2, Docker | Hardware: Jetson Thor | Role: Team | Status: On the robot
Skills: ROS 2, Hardware bring-up
Links: [Rosbag recorder](https://github.com/EricJGoossen/rosbag_recorder/tree/efb44ad)

OpenDubs runs on an NVIDIA Jetson Thor, so the first job was getting it ready for the whole team. I flashed JetPack onto it, got its IO talking to the rest of the robot, and set up the Docker environment we all develop in: a ROS 2 image with the build tools, Makefile and VS Code tasks to build and debug inside it, and per-person git identities so four people can commit from one shared machine.

The first version of the base drove its four mecanum wheels through a Pixhawk running ArduPilot, over MAVROS; it has since dropped the Pixhawk. With another intern, I brought up that drive code, then added what the rest of the stack needed: a URDF and an SDF with collision geometry for RViz and Gazebo, and a [service that records topics to rosbags](https://github.com/EricJGoossen/rosbag_recorder). Every test after this was a bag I could replay.

The base has since been rebuilt by others in the lab (I didn't work on it), without the Pixhawk, and carries everything else: a 51.2 V LiFePO4 battery, an emergency stop on the deck and a status display, all wired into one aluminum frame, with the arms, the Thor and the lidars riding on top.

![A CAD render of the rover base: an aluminum frame with blue panels, mecanum wheels and an emergency stop on the top deck](https://ericjgoossen.com/projects/opendubs/images/bringup-cad.jpg)
Caption: The base, in CAD.

Video: The rover base, with no arms yet, driving down a long hallway while a person walks ahead of it
File: https://ericjgoossen.com/projects/opendubs/images/bringup-first-drive.mp4; still frame (an image you can view): https://ericjgoossen.com/projects/opendubs/images/bringup-first-drive.jpg
Caption: The base driving down the hallway, August 28.

