<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Kamath Robotics]]></title><description><![CDATA[Independent roboticist writing about robotics, embedded systems, and creative technologies - sharing technical deep-dives and insights from building open-source projects professionally and as a hobby.]]></description><link>https://kamathrobotics.com</link><image><url>https://cdn.hashnode.com/uploads/logos/63a0f156a59cad9bdbba046e/9e743056-2755-416e-a2c7-16e6722d5348.png</url><title>Kamath Robotics</title><link>https://kamathrobotics.com</link></image><generator>RSS for Node</generator><lastBuildDate>Tue, 13 Oct 2026 19:29:32 GMT</lastBuildDate><atom:link href="https://kamathrobotics.com/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Using Foxglove as an SO-101 Simulator]]></title><description><![CDATA[tl;dr: While on a little hiatus from the LeKiwi robot, I decided to build a new SO-101 arm and, in parallel, decided to work on the software side first - using ROS 2 Control and sts_hardware_interface]]></description><link>https://kamathrobotics.com/using-foxglove-as-an-so-101-simulator</link><guid isPermaLink="true">https://kamathrobotics.com/using-foxglove-as-an-so-101-simulator</guid><dc:creator><![CDATA[Aditya Kamath]]></dc:creator><pubDate>Fri, 25 Sep 2026 17:59:09 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/7fc2b4eb-9f21-46dc-a642-398efcc613cf.webp" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><strong>tl;dr:</strong> While on a little hiatus from the LeKiwi robot, I decided to build a new SO-101 arm and, in parallel, decided to work on the software side first - using ROS 2 Control and <code>sts_hardware_interface</code>'s mock mode to fake the servos, and Foxglove's 3D panel to see whether any of it worked. Over a couple of weekends, I built an inverse kinematics-driven teleoperation node, a manual joint-control fallback, and self-collision checking. I watched each feature work as expected on a simulated arm on the Foxglove app before the real one was fully assembled or calibrated.</p>
<p>Originally written for and published on the <a href="https://foxglove.dev/blog/using-foxglove-as-an-so-101-simulator"><strong>Foxglove blog</strong></a></p>
<hr />
<h2>Project Context</h2>
<p>This post is part of a new series about building a ROS 2 stack for the <a href="https://github.com/TheRobotStudio/SO-ARM100">SO-101</a>, an open-source 5-DoF robot arm - much like I've been doing all year for <a href="https://kamathrobotics.com/series/lekiwi">LeKiwi</a>, a holonomic robot that uses the same <a href="https://www.feetechrc.com/2020-05-13_56655.html">STS3215</a> motors from Feetech. The SO-101 arm, just like LeKiwi, is normally operated using <a href="https://huggingface.co/lerobot">LeRobot</a>, Hugging Face's framework for embodied AI, but I'm using ROS 2 instead, on top of <a href="https://github.com/adityakamath/sts_hardware_interface"><code>sts_hardware_interface</code></a>, the same <a href="https://control.ros.org/kilted/"><code>ros2_control</code></a> hardware interface that already drives LeKiwi's base.</p>
<p>Before I continue, a clarification on the title: I know Foxglove is a visualization and observability platform, not a simulator, and I know the title raises an eyebrow. But alongside the hardware interface, it ended up doing a simulator's job for me - providing the crucial visual feedback that told me whether the software was working before there was any hardware to check it against.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/c2710141-290e-4ce5-b293-36d1bf01e5c4.webp" alt="" style="display:block;margin:0 auto" />

<h2>Mock Mode</h2>
<p>Since assembling/calibrating the physical arm took time, I started working on the software stack in parallel, focusing on the controller configuration and a teleop scheme. Thanks to <code>sts_hardware_interface</code>'s mock mode and the <a href="https://github.com/TheRobotStudio/SO-ARM100/tree/main/Simulation/SO101">SO-101 URDF</a>, I could mock the entire arm in software and didn't have to wait for the real hardware to be ready. In practice, I simply set the mock mode flag in the hardware interface configuration to <code>true</code>, telling the hardware interface to fake the servos' feedback internally, while the rest of the ROS 2 stack has no idea it isn't talking to real motors.</p>
<p>This works because of how <code>ros2_control</code> is structured: everything above the hardware interface - controllers, trajectory planners, collision checks - only talks to a standard set of command and state interfaces. The hardware interface translates commands into whatever data the actual hardware needs and publishes the state interface values that it reads from the hardware. Swap the real hardware with a simulated one, and the other elements in the <code>ros2_control</code> structure cannot tell the difference.</p>
<p>When the mock mode is enabled, instead of writing to a real servo bus, <code>sts_hardware_interface</code> fakes both sides of the interface - not just the commands, but the state feedback too. And it doesn't just echo back whatever you send it; it behaves as a real servo would - the voltage drops under load, temperature climbs with activity, and the current draw follows the commanded effort. In both real and mock cases, the emergency stop service does what it says, but the real motors are affected by gravity, and the arm naturally droops down. In mock mode, the joint motors stay in the same position.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/3fdc370c-2990-47ab-9a5e-d006ebbf08f3.webp" alt="" style="display:block;margin:0 auto" />

<p>Aside from external forces like gravity and collisions with obstacles, mock mode is sufficient to design and test high-level behavior. This meant that I could start building the software stack on my robot against the simulated arm, without needing to plug anything in.</p>
<h2>Teleop with Inverse Kinematics</h2>
<p>The first real feature I wanted was a natural way to move the arm: point at where I want the end effector to go, and let it figure out the joint angles by itself, rather than me driving the joints individually.</p>
<p>To do this, I first built a small virtual target that I could move around in 3D space - nothing exotic, just a simple coordinate frame that I could manipulate using joystick controls, which the end effector would then try to track. I also added a <a href="https://github.com/adityakamath/so_arm_ros2/blob/main/so_arm_control/so_arm_control/target_visualizer_node.py"><code>target_visualizer_node</code></a> that publishes a colored marker at the target's pose, so that I can visualize it from Foxglove's <a href="https://docs.foxglove.dev/docs/visualization/panels/3d">3D panel</a>.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/11867a93-e8a5-4cbf-a6f8-4d620f7b353f.webp" alt="" style="display:block;margin:0 auto" />

<p>To track the target, I then added <a href="https://github.com/adityakamath/so_arm_ros2/blob/main/so_arm_control/so_arm_control/teleop_ik_node.py"><code>teleop_ik_node</code></a>, which runs inverse kinematics (IK) using <a href="https://github.com/stack-of-tasks/pinocchio">Pinocchio</a> to continuously solve for joint positions that get the end effector to its target pose. I won't go into the IK math here - the <a href="https://github.com/adityakamath/so_arm_ros2">repo</a> has all the code if you're curious - but the experience of testing it is what stuck with me: I would nudge the joystick, watch the marker move, and the arm would chase it. Genuinely fun to work with, even in mock mode.</p>
<p><a class="embed-card" href="https://www.youtube.com/watch?v=V7oLxa4nlhU">https://www.youtube.com/watch?v=V7oLxa4nlhU</a></p>

<p>This IK-based teleop made controlling the arm much more intuitive, but I also wanted the ability to control the joint angles individually when needed, especially for debugging. For this, I used <a href="https://github.com/rogy-ken/foxglove-joint-state-publisher">Joint State Publisher</a>, a community-contributed Foxglove extension available in the <a href="https://github.com/foxglove/extension-registry">extension registry</a>. To prevent conflicts between the IK teleop commands and the manual joint commands, I implemented a switching mechanism in <a href="https://github.com/adityakamath/so_arm_ros2/blob/main/so_arm_control/so_arm_control/joint_state_switch_node.py"><code>joint_state_switch_node</code></a> that can toggle between the two commands using a single service call, which can be triggered from a joystick button (using <a href="https://github.com/ros-teleop/teleop_tools/tree/master/joy_teleop"><code>joy_teleop</code></a>), Foxglove's <a href="https://docs.foxglove.dev/docs/visualization/panels/service-call">Service Call panel</a>, or my custom <a href="https://github.com/adityakamath/foxglove_extensions/tree/main/button">Button extension</a>. It is incredibly satisfying to move the individual joints using the sliders, then switch to the teleop mode and watch the IK solver take over and move the joints to optimal positions, while maintaining the same target.</p>
<p><a class="embed-card" href="https://www.youtube.com/watch?v=eh6mlPwLT38">https://www.youtube.com/watch?v=eh6mlPwLT38</a></p>

<h2>Self-Collision Checking</h2>
<p>While testing teleoperation, I realized that the arm could easily be commanded into positions that would cause it to collide with itself. In simulation, this isn't a big deal - the meshes simply pass through each other, but on a real robot, this would break something. So, the next step was to implement self-collision checking that would run on every joint command, regardless of its source.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/afd7e861-3c1f-4c00-8e79-bcba38ca0fa9.webp" alt="" style="display:block;margin:0 auto" />

<p>I used <a href="https://github.com/flexible-collision-library/fcl">FCL</a>, which checks the arm's own geometry against itself using bounding volumes. It's deliberately lightweight compared to using MoveIt, which wasn’t required at this stage - and it checks the whole path to a target, not just the destination, since two individually safe configurations can still cause a collision on the trajectory between them. Rather than just rejecting a colliding command outright, <a href="https://github.com/adityakamath/so_arm_ros2/blob/main/so_arm_control/so_arm_control/so_arm_utils/collision_resolution.py"><code>CollisionResolver</code></a> first tries to clamp the target back to the last safe point along that path, and only rejects it if that fails. This can be seen in the image below.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/8c6925a6-6da6-423b-a1d5-dda7eac23436.webp" alt="" style="display:block;margin:0 auto" />

<h2>The Agent Sidebar</h2>
<p>A few weeks ago, the Foxglove team provided me with a year-long enterprise account along with early access to some of their newest features - and I've been dying to talk about it ever since. One of those features is the <a href="https://docs.foxglove.dev/docs/agents/agent-sidebar">Agent Sidebar</a>, a built-in AI agent that answers questions grounded in whatever data is actually loaded in the layout.</p>
<p>I used it mostly while debugging mock mode. Asking questions in plain English worked, but took a while to get an answer back; asking the same thing with an <code>@</code> topic mention - pointing it straight at <code>dynamic_joint_states</code>, for example - was noticeably faster. Since the original layout did not show the <code>dynamic_joint_states</code>, it opened a raw message panel to capture the data.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/ce717edf-8fd6-4bf0-82e0-81ec572d83c4.webp" alt="" style="display:block;margin:0 auto" />

<p>One thing I liked was expanding the tool-call trace to see what it was actually doing behind the scenes, rather than just trusting the answer. I tried this on LeKiwi too, and it correctly figured out that LeKiwi doesn't use the standard <code>Twist</code> message for velocity commands, but <code>TwistStamped</code> instead, and adjusted what it queried accordingly - a small thing, but exactly the kind of detail that's easy to get wrong if you're not actually looking at the data.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/d82f2306-3c96-4b21-9d1a-e545c8ee34bd.webp" alt="" style="display:block;margin:0 auto" />

<p>The Agent Sidebar can also be pointed at your own MCP-connected agent instead of the built-in one, which I haven't tried yet - that's going in a separate post where I get into the AI tools I actually use day-to-day.</p>
<p>What I liked most was using the agent sidebar to build layouts. I gave the agent a generic prompt - to improve my layout without removing any existing panels - and it provided a detailed layout with three tabs: one for monitoring, one for control, and one for diagnostics. It also worked cleanly with 3rd-party extensions, including the Button panel and the joint state publisher.</p>
<p><a class="embed-card" href="https://www.youtube.com/watch?v=LxpaWsmgMm0">https://www.youtube.com/watch?v=LxpaWsmgMm0</a></p>

<h2>Turning Mock Mode Off</h2>
<p>For the simulated arm, I used <a href="https://docs.ros.org/en/kilted/index.html">ROS 2 Kilted</a> running on <a href="https://pixi.sh/">Pixi</a> on my MacBook, which also brought up the <a href="https://docs.foxglove.dev/docs/visualization/ros-foxglove-bridge">Foxglove Bridge</a>, letting me connect using the Foxglove app on the same device. Joystick inputs came from my <a href="https://kamathrobotics.com/teleoperating-the-lekiwi-from-a-steam-deck">Steam Deck</a>, talking to the ROS 2 graph over <a href="https://docs.ros.org/en/kilted/Installation/RMW-Implementations/Non-DDS-Implementations/Working-with-Zenoh.html">Zenoh</a>.</p>
<p>Once the real arm was ready and calibrated, I simply replaced <code>use_mock:=true</code> with <code>serial_port:=...</code> while running the launch file, and I was good to go. Of course, while useful, the simulation can never fully replicate the arm's real-world dynamics, so I need to do some additional tuning. For a ROS 2 package tested on a mocked arm, performance on the real arm is not too bad.</p>
<p><a class="embed-card" href="https://www.youtube.com/watch?v=yQKSiRwwoDI">https://www.youtube.com/watch?v=yQKSiRwwoDI</a></p>

<h2>What's Next</h2>
<p>During these couple of weeks, I learned just how valuable mock mode can be - if your hardware interface can fake its I/O convincingly, and you have a visualization platform like Foxglove that can render the result in real time, you don't need working hardware to build against. But now that I have the real arm up and running, I want to start exploring its capabilities. The first thing I want to implement is a record-and-replay feature - capturing a sequence of movements of the arm as an <a href="https://mcap.dev">MCAP</a> file, and then replaying it back on demand. I've already started working on this, and there's a rough version taking shape. Once it is done, it'll be a blog post in its own right.</p>
<p>Since I'm thinking of recording MCAP files, I want to naturally try out <a href="https://docs.foxglove.dev/changelog/foxglove/v3.0.0">Foxglove 3.0.0</a>'s new features that they launched recently. I'm looking forward to tinkering with <a href="https://docs.foxglove.dev/docs/visualization/comparison-mode">Comparison Mode</a> to load several recordings onto one timeline and see how closely they line up and where they diverge. This would be useful for comparing mock mode with the real arm, or even for fine-tuning PID coefficients further.</p>
<p>Everything I talked about in this post - the mock mode internals, the IK solver, the collision resolver - is provided in <a href="https://github.com/adityakamath/so_arm_ros2"><code>so_arm_ros2</code></a> and <a href="https://github.com/adityakamath/sts_hardware_interface"><code>sts_hardware_interface</code></a> repositories. <code>sts_hardware_interface</code> is in a stable place, but <code>so_arm_ros2</code> is a work in progress. If you find something worth fixing or building on, I'd love to hear about it.</p>
]]></content:encoded></item><item><title><![CDATA[SO-101: Kicking off a new series]]></title><description><![CDATA[Back in my "6 months in" post, I mentioned an SO-101 arm was next on my list - it's finally here, and a lot's happened since: I got a wrist camera into the URDF, turned a second arm into a lamp, and c]]></description><link>https://kamathrobotics.com/so-101-kicking-off-a-new-series</link><guid isPermaLink="true">https://kamathrobotics.com/so-101-kicking-off-a-new-series</guid><dc:creator><![CDATA[Aditya Kamath]]></dc:creator><pubDate>Sun, 06 Sep 2026 19:28:10 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/2a6ea671-81c0-4928-9bd5-41c8a7fa4de5.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Back in my <a href="https://kamathrobotics.com/kamath-robotics-6-months-in">"6 months in"</a> post, I mentioned an <a href="https://github.com/TheRobotStudio/SO-ARM100/blob/main/README.md">SO-101</a> arm was next on my list - it's finally here, and a lot's happened since: I got a wrist camera into the URDF, turned a second arm into a lamp, and convinced myself to buy a new bipedal robot along the way. It also kicks off a new series, much like <a href="https://kamathrobotics.com/series/lekiwi">LeKiwi</a>, where once again, I'll be using <a href="https://docs.ros.org/en/rolling/">ROS 2</a> and migrating the SO-101's software stack entirely off <a href="https://huggingface.co/lerobot">LeRobot</a>, eventually working toward some Physical AI experiments. This post, however, is just about the hardware - the software side starts next.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/6bd78a12-d49a-4da2-a5cb-5c19435d30da.jpg" alt="" style="display:block;margin:0 auto" />

<h2>Building the SO-101</h2>
<p>I had only built <a href="https://github.com/TheRobotStudio/SO-ARM100/blob/main/SO100.md">SO-100</a>s before, so I went into this one expecting a familiar afternoon of fighting 3D-printed parts to get them to fit. Instead, it was noticeably easier - about 15 minutes, start to finish.</p>
<p>This wasn't just because I was familiar with the assembly - the design itself is more refined. There are fewer printed parts, the tolerances are better, and the fit and finish feel more polished out of the box. The cabling is simpler too: no more squeezing wires between printed parts, since everything now routes through dedicated channels built into the structure.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/db007db4-e757-40b2-953c-d6a353677791.jpg" alt="" style="display:block;margin:0 auto" />

<p>I have one complaint though: on the SO-100, the shoulder pan joint sits on a spare servo horn acting as a bearing of sorts; on the SO-101, it's just bolted to the pan motor and cantilevers freely - a bit of stability traded for easier assembly.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/cdf039ad-680c-47be-990b-a7ebd56693a2.png" alt="" style="display:block;margin:0 auto" />

<p>Not that I'm complaining; the SO-101 is genuinely a breeze to build. Kudos to <a href="https://github.com/TheRobotStudio">The Robot Studio</a> for an elegant redesign.</p>
<h3>Updating the URDF</h3>
<p>The SO-101's gripper has spaces for two hex nuts on the fixed jaw to mount accessories such as a wrist camera, with CAD files included in the <a href="https://github.com/TheRobotStudio/SO-ARM100">SO-ARM100 repository</a>. A wrist camera is super essential for most real applications of this arm, so it's a shame it's missing from the default URDF. I decided to fix that.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/46d8ec2f-9570-41f6-ac2a-f01ff114fab4.png" alt="" style="display:block;margin:0 auto" />

<p>Getting the camera and mount into the URDF early mattered because I'm already planning self-collision checking down the line - whatever checker I build later depends on the robot's geometry from the URDF, so it needs to know that the wrist mount and camera exist from day one.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/79ee1099-078d-4bc3-9b0e-90bc1725266b.jpg" alt="" style="display:block;margin:0 auto" />

<p>The <a href="https://github.com/TheRobotStudio/SO-ARM100/tree/main/Optional/SO101_Wrist_Cam_Hex-Nut_Mount_32x32_UVC_Module">mount mesh</a> itself is straight from the SO-ARM100 repository - I didn't design it, just wired it into <a href="https://github.com/adityakamath/so_arm_ros2/blob/main/so_arm_description/urdf/so101/so101.urdf.xacro"><code>so101.urdf.xacro</code></a> as a chain of fixed-joint links (<code>wrist_camera_mount_link</code> → <code>wrist_camera_body_link</code> → <code>wrist_camera_frame</code> → <code>wrist_camera_optical_frame</code>), ending in a proper <a href="https://www.ros.org/reps/rep-0103.html">REP-103</a> optical frame. SO-ARM100 doesn't ship a camera mesh, so I found one matching the camera I'm actually using - an <a href="https://www.amazon.nl/-/en/InnoMaker-Raspberry-Supports-Windows-Android/dp/B0CNCSFQC1/">Innomaker U20CAM-1080P</a>, a plain UVC webcam - and added it as its own link next to the mount.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/6e097dc3-909d-43c2-8849-0e9e752b10ee.png" alt="" style="display:block;margin:0 auto" />

<p>Now, I understand that not everyone wants a wrist camera, so the whole chain sits behind a <code>wrist_camera_urdf</code> xacro argument, on by default. Turn it off and the mount and camera links just aren't there - the self-collision checker only ever sees geometry that's actually part of the arm.</p>
<p>If you want to use this URDF, I've generated updated URDF and MuJoCo XML files for the SO-101 with the wrist camera integrated, and opened a PR <a href="https://github.com/TheRobotStudio/SO-ARM100/pull/176">here</a> on the SO-ARM100 repository. The configurable Xacro files with the <code>wrist_camera_urdf</code> gating live in the <a href="https://github.com/adityakamath/so_arm_ros2/tree/main/so_arm_description"><code>so_arm_description</code></a> package in the <a href="https://github.com/adityakamath/so_arm_ros2"><code>so_arm_ros2</code></a> repository.</p>
<h3>Custom Wrist Camera Node</h3>
<p>The obvious choice for a UVC camera on ROS 2 is <a href="https://github.com/ros-drivers/usb_cam"><code>usb_cam</code></a> or <a href="https://gitlab.com/boldhearts/ros2_v4l2_camera"><code>v4l2_camera</code></a>, both common ROS 2 packages, and either would have worked on Linux. But I want to ensure that <code>so_arm_ros2</code>'s ROS 2 stack runs equally on my Raspberry Pi and my MacBook via <a href="https://pixi.sh">Pixi</a> and <a href="https://robostack.github.io/">RoboStack</a>, and neither of those Linux-only packages builds for Pixi's <code>osx-arm64</code> target. I didn't want the wrist camera to be the one feature that only worked on the Pi, so I wrote a small generic driver instead: <a href="https://github.com/adityakamath/so_arm_ros2/blob/main/so_arm_bringup/so_arm_bringup/opencv_camera_node.py"><code>opencv_camera_node</code></a>, built on <code>cv2.VideoCapture</code> rather than V4L2 directly. It picks the right backend per platform - V4L2 on Linux, AVFoundation on macOS - and otherwise doesn't care which one it's running on.</p>
<p>This custom node's design follows LeRobot's own <a href="https://github.com/huggingface/lerobot/blob/main/src/lerobot/cameras/opencv/camera_opencv.py"><code>OpenCVCamera</code></a>: a daemon thread owns the blocking <code>cv2.VideoCapture.read()</code> call, and the non-blocking ROS publish timer just reads whatever frame is ready. That way a slow camera or a dropped frame never holds up the rest of the ROS graph. Another change compared to the standard ROS camera stack: neither <code>image_transport</code> nor <code>camera_info_manager</code> has Python bindings in this ROS 2 install, so rather than linking the C++ libraries from Python, the node publishes raw <code>Image</code> messages on <code>image_raw</code> directly and parses <code>CameraInfo</code> straight out of a standard <code>camera_calibration</code>-format YAML file itself.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/7373b61f-27d5-4b2d-9b3c-0eb86387d706.png" alt="" style="display:block;margin:0 auto" />

<p>I tested it on my MacBook via Pixi, and it worked as expected. Still need to calibrate the camera and populate the real intrinsics YAML file, but the basic pipeline runs fine with a dummy calibration file for now.</p>
<h2>SO-Lamp</h2>
<p>I've been working with <a href="https://www.feetechrc.com/2020-05-13_56655.html">STS3215</a> motors long enough to have amassed a fair amount of them, so after finishing the SO-101, I built a second one - this time with a lamp where the gripper would go. I had a spare <a href="https://www.ikea.com/nl/nl/p/strala-snoerset-wit-20366404/">IKEA Strala</a> lamp cord set lying around, and it seemed like a perfect fit.</p>
<p>I kept it simple: 4-dof, with the SO-101's wrist redesigned into a lamp mount. An afternoon of CAD and printing later, the mount was ready. I paired it with a spare E14 bulb and a shade from a <a href="https://makerworld.com/en/models/168157-industrial-strala?from=search#profileId-184590">3D-printable lamp design</a> I found online.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/d7aacb5f-0a68-441c-91a9-44951cc50969.jpg" alt="" style="display:block;margin:0 auto" />

<p>For V2, I wanted it hooked into my home automation, so I ordered a <a href="https://nl.store.tapo.com/collections/slimme-verlichting/products/tapo-l430c-led-lamp2-pack">Tapo E14 smart bulb</a>, which is a bit bigger than the original, so I needed a lamp shade. I used <a href="https://makerworld.com/en/makerlab">MakerLab</a>'s <a href="https://makerworld.com/en/makerlab/makeMyVase?from=makerlab">customizable vase generator</a> to design one, cut the bottom out, and added threads to screw it straight onto the Strala cord set. I really liked how it turned out.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/3733dc40-01ee-4f9b-8378-32d06dea6769.jpg" alt="" style="display:block;margin:0 auto" />

<p>My original goal was simple: I wanted a functional, actuated night lamp that I could control through my home automation, without unnecessary complexity. I briefly considered making it 5-dof, decided against it (despite people on Twitter telling me otherwise), and deliberately kept cameras and microphones off it, keeping the design focused solely on lighting and leaving out any AI features.</p>
<p>However, around this time I started seeing a surge of <a href="https://x.com/irvinxyz/status/2088945113502257552">DIY and commercial robot-arm lamps</a> online (see <a href="https://x.com/irvinxyz/status/2089198471290945584">this survey</a> by <a href="https://x.com/irvinxyz">@irvinxyz</a> on Twitter), which got me rethinking my own build. I settled for the head design from the open-source <a href="https://github.com/autonomous-ai/autonomous-os/tree/main/robots/lamp/hardware">lamp</a> (image below) from <a href="https://www.autonomous.ai/lamp">Autonomous.ai</a> (which includes a 5th motor) and decided to adopt it, keeping the rest of the SO-101's 4-dof structure intact.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/d9bc255b-c5d9-4dcf-a169-e03c9b185f9e.webp" alt="" style="display:block;margin:0 auto" />

<p>After some adjustments, the new lamp head was ready: I added M3 heat-set inserts and scaled down the original design. Mechanically, that's done, but the electronics are still catching up. The plan is to drop the Raspberry Pi for a <a href="https://www.seeedstudio.com/XIAO-ESP32S3-Sense-p-5639.html">XIAO ESP32-S3 Sense</a> - <a href="https://www.seeedstudio.com/">Seeed Studio</a>'s Sense module packs a mic, camera, and microSD slot, which covers most of what I wanted to add - but it hasn't arrived yet. I'll pair it with a <a href="https://www.seeedstudio.com/Bus-Servo-Driver-Board-for-XIAO-p-6413.html">XIAO-compatible Bus Servo Driver</a> to power and control the STS3215 motors directly, which I'm also waiting on. For lighting, I'm swapping the E14 bulb for an off-the-shelf <a href="https://www.seeedstudio.com/Grove-RGB-LED-Ring-24-WS2813-Mini-p-4202.html">LED ring</a> wired through the servo driver. Once it's all in, everything will live in the lamp head with just a single power cable running up to it. I love how the new head and 5-dof design look - the electronics and the 3D-printed cover are just going to take a bit longer.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/def84255-fb4c-4283-a90b-670acf8ae213.jpg" alt="" style="display:block;margin:0 auto" />

<p>I still needed an actual night lamp good enough for reading in bed, so I put the now-freed-up E14 cord set and smart bulb into a <a href="https://makerworld.com/en/models/1751877-octopus-table-lamp-led-lamps-e14">non-robotic 3D-printable lamp</a> I found online. Definitely the most beautiful lamp I've ever 3D-printed.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/743fd1c1-651c-4e66-aa86-da22226efc42.jpg" alt="" style="display:block;margin:0 auto" />

<p>Back to the SO-Lamp: my favorite decision in this whole build was keeping it based on the SO-101 design. No new URDF to generate, no extra controller config beyond the lamp head changes. This makes life easier whenever I get around to simulating or controlling it in ROS 2.</p>
<h2>Microduck</h2>
<p>While I was building the new lamp head, <a href="https://pollen-robotics.com">Pollen Robotics</a> announced the <a href="https://pollen-robotics.com/microduck/">Microduck</a> - a tiny bipedal robot that's compact, versatile, and genuinely cute, for just <a href="https://pollen-robotics.com/microduck/press-kit/">$399</a>. Between LeKiwi, the SO-101, and the SO-Lamp, I was pretty sure I didn't have time for another robot platform, so I almost skipped it.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/e6c3c906-ad36-4685-a33d-dd2602919bd9.jpg" alt="" style="display:block;margin:0 auto" />

<p>A few things changed my mind. The Microduck runs on 15 <a href="https://www.robotis.us/dynamixel/">Dynamixel</a> <a href="https://docs.robotis.com/docs/dxl/model_reference/x_series/xl_series/xl330-m288/">XL330-M288-T</a> motors, which alone cost 30+ EUR each - around 450 EUR just in motors - making $399 a steal before anything else is even counted. It also packs a camera, a ToF sensor (likely the <a href="https://www.st.com/en/imaging-and-photonics-solutions/vl53l5cx.html">VL53L5CX</a>), a <a href="https://radxa.com/products/zeros/zero3w/">Rockchip RK3566</a> with an AI accelerator, two IMUs, a mic, and speakers - worth salvaging for parts alone if I ever get bored of the robot itself.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/fa8fe20e-b371-4cef-bab8-15d2c4af5d54.png" alt="" style="display:block;margin:0 auto" />

<p>It's not just the hardware, either - it ships with a <a href="https://huggingface.co/spaces/pollen-robotics/microduck-simulator">simulation platform</a>, a <a href="https://github.com/pollen-robotics/microduck_rl">sim-to-real pipeline</a>, and <a href="https://github.com/pollen-robotics/microduck">Rust-based firmware</a>, which makes it a solid excuse to tinker with both reinforcement learning and Rust. While waiting for mine to arrive, I couldn't resist getting a head start: I took the meshes and MuJoCo files Pollen Robotics provided and put together a URDF and Xacro file for it, in case I ever want to bring the Microduck into ROS 2 down the line (even though I promised myself I wouldn't). It's up on <a href="https://github.com/adityakamath/microduck_description/">microduck_description</a> if you want to poke around.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/fb9f6b10-2734-4446-a82e-b49d3884108a.png" alt="" style="display:block;margin:0 auto" />

<p>I'm glad I was sold on the Microduck and pre-ordered it quite fast - Pollen Robotics sold 10,000+ Microducks in the first few days, after which they dropped the pre-Christmas delivery promise and pushed the estimate out to 4-6 months. If all goes well, I should have my Microduck by the winter break. Fingers crossed.</p>
<h2>What's next</h2>
<p>So around the holidays I'll be stretched thin between my current robots and the new Microduck. There's another new robot arriving around the same time (still WIP; more details to follow), so time management will matter. My goal is to finish the SO-Lamp before the MicroDuck shows up, then hand the hardware off to someone else (and the open-source community) to take it further.</p>
<p>For the SO-101, the wrist camera's optical frame math is done, but not calibrated - I want to calibrate it just like in my <a href="https://kamathrobotics.com/calibrating-a-monocular-camera-for-the-lekiwi-robot-using-ros-2">earlier blog post</a>. Beyond that, IK teleop and self-collision checking are the two things I keep talking about in this post, but that's its own story, coming soon.</p>
<p>Meanwhile, my UK visa has been approved, and I'm gearing up for my trip to ROSCon UK and the deep-dive session that I will be presenting there. I will be talking about the <a href="https://github.com/adityakamath/sts_hardware_interface"><code>sts_hardware_interface</code></a> and will use the LeKiwi and the SO-101 as examples.</p>
<p>Everything that I've talked about in this post lives in a new repository <a href="https://github.com/adityakamath/so_arm_ros2"><code>so_arm_ros2</code></a>, which also includes a few things that I am yet to write about. As always, these projects are a work in progress and might change significantly over time. Still, feel free to poke around, provide feedback, or contribute if something catches your interest.</p>
]]></content:encoded></item><item><title><![CDATA[Giving LeKiwi a Voice: Audio Indicators]]></title><description><![CDATA[Back when I wrote about adding voice control to LeKiwi, I got the reSpeaker Flex mounted, put the mic array through its paces, and ended the post without testing the speaker, which was wired in but si]]></description><link>https://kamathrobotics.com/giving-lekiwi-a-voice-audio-indicators</link><guid isPermaLink="true">https://kamathrobotics.com/giving-lekiwi-a-voice-audio-indicators</guid><dc:creator><![CDATA[Aditya Kamath]]></dc:creator><pubDate>Sat, 29 Aug 2026 23:14:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/08a79a4c-a272-4cef-ab4d-5e336fcaccd0.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Back when I wrote about <a href="https://kamathrobotics.com/adding-voice-control-to-lekiwi">adding voice control to LeKiwi</a>, I got the <a href="https://www.seeedstudio.com/reSpeaker-Flex-XVF3800-Circular-4-p-6737.html?sensecap_affiliate=8fjl172&amp;referring_service=link">reSpeaker Flex</a> mounted, put the mic array through its paces, and ended the post without testing the speaker, which was wired in but silent. I hadn't configured playback yet, which meant I couldn't test anything that needed audio going <em>out</em> - most notably Acoustic Echo Cancellation (AEC), which only makes sense once there's something to echo.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/6577b9a7-795b-4bd3-a74a-ffbf606c5775.png" alt="" style="display:block;margin:0 auto" />

<p>I finally got around to making the speaker work, but not in the way I expected. My original plan was to first test AEC and then jump straight to the fun part - wake word detection, speech-to-text, integration with AI agents such as Claude or <a href="https://github.com/HKUDS/nanobot"><code>nanobot</code></a>, and finally text-to-speech. Instead, I found a much smaller and more immediately useful problem that I could fix with a speaker.</p>
<h2>The problem</h2>
<p>If you've read <a href="https://kamathrobotics.com/under-the-hood-lekiwi-s-joystick-controls">Under the Hood: LeKiwi's Joystick Controls</a>, you'll know that every button-triggered feature on LeKiwi - e-stop, switching between teleop and <a href="https://nav2.ros.org">Nav2</a>, waypoint recording and following, saving the map - is a plain <a href="https://docs.ros.org/en/kilted/p/std_srvs/srv/SetBool.html"><code>std_srvs/srv/SetBool</code></a> service, callable from the <a href="https://kamathrobotics.com/teleoperating-the-lekiwi-from-a-steam-deck">Steam Deck</a>, <a href="https://foxglove.dev">Foxglove</a> (using either the <a href="https://docs.foxglove.dev/docs/visualization/panels/service-call">Service Call panel</a> or my <a href="https://github.com/adityakamath/foxglove_extensions/tree/main/button">Button extension</a>), a CLI, or anything else that wants to. That's great for control, but it means there's no feedback: to know whether the e-stop actually engaged, or whether I'm in teleop or autonomous mode, I have to rely on how the robot is behaving.</p>
<p>With a working speaker now sitting on the top deck, the obvious first use for it wasn't a conversation - it was just having the robot tell me what it's doing.</p>
<p>With <a href="https://kamathrobotics.com/series/akros">AKROS</a>, my previous robot, I had LED strips that changed color based on robot state, and it was genuinely useful - one glance told me whether the e-stop was engaged without needing to guess or move the joystick to verify. LeKiwi never got the same treatment, mostly because there's nowhere left to attach LEDs. Every mounting point on the chassis is already spoken for, and I still need to fit a docking and charging solution onto it at some point, which only reduces the available space further. The speaker, on the other hand, is already installed from the reSpeaker work, so I didn't have to change any hardware. This made audio indicators the perfect solution given my constraints.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/bb452136-7bf5-4285-9948-5f2499260674.jpg" alt="" style="display:block;margin:0 auto" />

<h2>Decoupling the announcements</h2>
<p>The naive way to do this would be to have every feature node call some kind of <code>speak()</code> function directly - <a href="https://github.com/adityakamath/lekiwi_ros2/blob/main/lekiwi_control/lekiwi_control/twist_switch_node.py"><code>twist_switch_node</code></a> speaks when it flips, <a href="https://github.com/adityakamath/lekiwi_ros2/blob/main/lekiwi_navigation/lekiwi_navigation/waypoint_recorder_node.py"><code>waypoint_recorder_node</code></a> speaks when a waypoint is recorded, and so on. I didn't want that. It's the exact coupling problem I already solved once for control: if the announcement logic lives inside each feature node, every new feature needs its own Text-to-Speech (TTS) wiring, and the node has to know or care that a speaker exists at all. This would become quite messy.</p>
<p>So instead, I wrote <a href="https://github.com/adityakamath/lekiwi_ros2/blob/main/lekiwi_audio/lekiwi_audio/indicator_node.py"><code>indicator_node</code></a>, a standalone node that doesn't touch any feature node at all. It watches ROS 2's built-in <a href="https://docs.ros.org/en/rolling/Concepts/Intermediate/About-Service-Introspection.html">service introspection</a> topics - <code>&lt;service&gt;/_service_event</code> - which every service already publishes regardless of who calls it. That means the announcement fires identically whether the request came from a joystick button, a Foxglove panel, or <code>ros2 service call</code> from a terminal, and adding a new announced service is a config change, not a code change in the feature itself.</p>
<p>Each <code>_service_event</code> stream carries a request event and a response event, so <code>indicator_node</code> matches the two up, remembers what was asked for, and only speaks once the response confirms whether the call actually succeeded. A failed call gets announced too, just with a different phrase - I'd rather hear "No waypoints found" than silence when I try to start a patrol I never recorded.</p>
<h2>Centralizing the phrases</h2>
<p>I didn't want spoken text scattered across the code either, so every phrase lives in one config file, <a href="https://github.com/adityakamath/lekiwi_ros2/blob/main/lekiwi_audio/config/phrases.yaml"><code>phrases.yaml</code></a>, keyed by service name, request value, and outcome:</p>
<pre><code class="language-yaml">services:
  /emergency_stop:
    "true":
      success: "Emergency stop enabled"
    "false":
      success: "Emergency stop disabled"

  /twist_switch:
    "true":
      success: "Autonomous mode"
    "false":
      success: "Tele-op mode"

  /record_waypoint:
    "true":
      success: "Waypoint recorded"
      failure: "Error"
</code></pre>
<p>A missing entry means "say nothing," which turned out to matter more than I expected. <code>/record_waypoint</code>, <code>/reset_waypoints</code>, and <code>/save_map</code> are all one-shot <code>SetBool</code> services where <code>data: false</code> does nothing - not a real state to announce - so I just left that entry out of the table entirely.</p>
<p><a class="embed-card" href="https://youtu.be/o_90obZyr4w">https://youtu.be/o_90obZyr4w</a></p>

<h2>Pre-rendering with Kokoro</h2>
<p>The other decision was when to actually synthesize the audio. Running a TTS model on every announcement, on a Raspberry Pi, in the middle of driving the robot (with computationally heavy features like Nav2, DepthAI), felt like the wrong place to spend CPU - especially since every phrase in <code>phrases.yaml</code> is small, closed-vocabulary, and known ahead of time.</p>
<p>So <a href="https://github.com/adityakamath/lekiwi_ros2/blob/main/lekiwi_audio/lekiwi_audio/render_phrases.py"><code>render_phrases.py</code></a> (built with Claude's help) runs at build time instead, before <code>setup.py</code> collects <code>data_files</code>, and renders every phrase to a WAV file using <a href="https://github.com/thewh1teagle/kokoro-onnx">Kokoro-82M</a> via <a href="https://onnxruntime.ai"><code>onnxruntime</code></a>. It's incremental too - a phrase that already has a WAV gets skipped, and stale WAVs get pruned, so editing <code>phrases.yaml</code> only re-synthesizes the diff. The 116 MB Kokoro model itself isn't committed to the repo; it gets fetched from Kokoro's GitHub release only when there's actually something new to render, so a clean checkout with unchanged phrases never touches it at all.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/bcab9351-8774-47b5-aa9a-ab62a26df75e.png" alt="" style="display:block;margin:0 auto" />

<h2>What's next</h2>
<p>Now that I have a working microphone array, a working speaker, and a working TTS implementation, I can move on to developing the AI-based voice assistant that connects to <code>nanobot</code> or Claude. But first, there are still some things that need to be done. I want to go back and finish what the reSpeaker post left unfinished - testing AEC properly, now that there's real audio coming out of LeKiwi's speaker while the mic is listening.</p>
<p>The second to-do item is to make sure that the indicator node doesn't play a phrase multiple times, as it does now. An example can be seen in the very first video in this post: In my example, the waypoint follower runs infinite patrols, which means that if I register a waypoint on a stationary robot and start the waypoint following, the audio indicator immediately replies with 'goal reached'. Since we have a single waypoint, the patrol loop starts again, and 'goal reached' plays again since the robot hasn't moved. This doesn't stop unless I kill the waypoint follower. This might not be the only issue, and I need to test this feature more extensively.</p>
<p>Beyond that, I honestly don't yet know how I'm going to wire <code>nanobot</code> (or whatever ends up being the AI-powered "brain") into the rest of the ROS 2 stack, let alone how that's supposed to coexist with <code>indicator_node</code> reaching for the same speaker to announce a service call mid-conversation. That's a real problem, and I don't have a good answer for it yet - so I'll leave it for another day.</p>
<p><code>indicator_node</code> was meant to be a small, self-contained detour, but it ended up proving out the entire playback half of the voice assistant pipeline before I'd written a line of the conversational part. While I keep working on the indicator node investigations and investigating how to implement the pipeline, I've already started planning some hardware upgrades for LeKiwi - a battery monitor and a docking station. More on this when I've made some real progress.</p>
<p>The features described in this post are in the <a href="https://github.com/adityakamath/lekiwi_ros2/tree/main/lekiwi_audio"><code>lekiwi_audio</code></a> package. As always, this package, along with the rest of the LeKiwi ROS 2 stack, lives in <a href="https://github.com/adityakamath/lekiwi_ros2"><code>lekiwi_ros2</code></a>; it's a work in progress, so expect breaking or unstable changes once in a while. Meanwhile, feedback and contributions are welcome.</p>
]]></content:encoded></item><item><title><![CDATA[More Autonomous Navigation with LeKiwi]]></title><description><![CDATA[tl;dr: As a follow-up to Autonomous Navigation with LeKiwi, I worked on three features in this post: I first rewired collision_monitor so that teleoperation gets the same collision protection as auton]]></description><link>https://kamathrobotics.com/more-autonomous-navigation-with-lekiwi</link><guid isPermaLink="true">https://kamathrobotics.com/more-autonomous-navigation-with-lekiwi</guid><dc:creator><![CDATA[Aditya Kamath]]></dc:creator><pubDate>Thu, 30 Jul 2026 19:28:14 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/cbc102b5-4693-4d4a-91f7-12e9b9a006b4.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><strong>tl;dr:</strong> As a follow-up to <a href="https://foxglove.dev/blog/autonomous-navigation-with-lekiwi-and-nav2">Autonomous Navigation with LeKiwi</a>, I worked on three features in this post: I first rewired <code>collision_monitor</code> so that teleoperation gets the same collision protection as autonomous mode with Nav2. Next, I built a waypoint recorder that lets the robot patrol a route of waypoints on its own - with controls to add waypoints and start/pause patrolling, straight from Foxglove. Finally, I added keepout and speed-limited zones to the map that Nav2 actually routes around or slows down in, respectively.</p>
<p>Originally written for and published on the <a href="https://foxglove.dev/blog/autonomous-navigation-with-lekiwi-part-2"><strong>Foxglove blog</strong></a></p>
<hr />
<p>This is the next chapter in my <a href="https://kamathrobotics.com/series/lekiwi">ongoing series</a> of building a ROS 2-powered autonomous mobile robot based on the <a href="https://github.com/SIGRobotics-UIUC/LeKiwi">LeKiwi</a> platform. Last time, I got <a href="https://foxglove.dev/blog/autonomous-navigation-with-lekiwi-and-nav2">a simple Nav2 configuration running end-to-end</a> - mapping, localization, and point-to-point navigation, all visualized on <a href="https://docs.foxglove.dev/docs/visualization/panels/3d">Foxglove’s 3D panel</a> - good enough to navigate to single targets, but it left a few things unfinished.</p>
<p>The most glaring issue: LeKiwi’s autonomous mode with Nav2 had collision protection, but the moment I switched to teleoperation, that safety net was gone. I could drive it straight into an obstacle, and the robot would happily comply. Alongside a fix for this issue, this post also covers two other features: waypoint following/patrolling so the robot can loop a route without the need to provide a fresh goal each time manually, and keepout / speed-limited zones painted onto the map so that the robot can stay away or slow down in areas where it might get stuck.</p>
<p>As always, the work covered in this post lives in the <a href="https://github.com/adityakamath/lekiwi_ros2"><code>lekiwi_ros2</code></a> GitHub repository, which includes the full ROS 2 stack implementation for the LeKiwi platform.</p>
<h2><strong>Assisted Teleop and Collision Protection</strong></h2>
<p>My robot has two driving modes: autonomous navigation using <a href="https://docs.nav2.org/">Nav2</a>, and manual teleoperation, and I can switch between them using <a href="https://github.com/adityakamath/lekiwi_ros2/blob/main/lekiwi_control/lekiwi_control/twist_switch_node.py"><code>twist_switch_node</code></a>, a custom script that I wrote. In autonomous mode, Nav2’s <a href="https://docs.nav2.org/configuration/packages/configuring-collision-monitor.html"><code>collision_monitor</code></a> watches the LiDAR scan and stops the robot before a collision. This is a really useful safety feature that unfortunately does not exist in teleop mode - if I drive the robot manually into a wall, it will happily keep going until it hits the wall. I wanted to fix that.</p>
<p>My original idea was to use Nav2’s built-in <a href="https://docs.nav2.org/configuration/packages/configuring-behavior-server.html#assistedteleop-behavior-parameters"><code>AssistedTeleop</code></a> behavior - a <code>behavior_server</code> plugin that forward-simulates a teleop velocity and scales it down before a collision. It looked like a pure config change at first, since my joystick already publishes to <code>/cmd_vel_teleop</code>, matching <code>AssistedTeleop</code>’s expected input topic. Digging deeper, I realized that this does not work with my two-mode setup because <code>AssistedTeleop</code> is a behavior that runs only while Nav2 is active — it’s designed to let you steer the robot manually during an ongoing Nav2 session, not to protect standalone teleop.</p>
<p>I wanted to keep teleop and autonomous modes completely separate, so I had to think of a different approach. So I looked into <code>collision_monitor</code> instead - it structurally does the same job as <code>AssistedTeleop</code>, just with a zone-based check (<code>FootprintApproach</code>) rather than forward simulation. My original Nav2 configuration (the recommended method) places it upstream of <code>twist_switch_node</code>, so it only ever sees Nav2’s own commands. I moved it downstream instead, so it works irrespective of which mode - teleop or Nav2 - is currently active. This means the teleop mode is now protected by the same collision monitor as autonomous navigation, and the autonomous mode is unaffected. The wiring change looks like this:</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/d5a9b9d4-232f-47c8-a1a7-14afa6ab05a7.webp" alt="" style="display:block;margin:0 auto" />

<p>This was much simpler than making <code>AssistedTeleop</code> work in teleop, and it doesn’t interfere with autonomous navigation at all - zero new nodes, just <code>collision_monitor</code> rewired. The only downside is that <code>FootprintApproach</code>’s zone check is a bit more conservative than <code>AssistedTeleop</code>’s forward-simulation, so the robot may stop a little earlier than necessary, which, for the LeKiwi, is not an issue at all. As you can see in the video below, it works as expected - the robot slows down as it approaches an obstacle, eventually stopping before hitting it.</p>
<p><a class="embed-card" href="https://www.youtube.com/watch?v=wtr3r-zxUMM">https://www.youtube.com/watch?v=wtr3r-zxUMM</a></p>

<p>One issue came up during testing: I drove into a tight spot where <code>collision_monitor</code> stopped the robot, and then blocked every direction I tried on the joystick, leaving it completely stuck. To prevent this from happening again, I added a manual override: <a href="https://github.com/adityakamath/lekiwi_ros2/blob/main/lekiwi_navigation/lekiwi_navigation/collision_toggle_node.py"><code>collision_toggle_node</code></a>. This node sets <code>collision_monitor</code>’s <code>FootprintApproach.enabled</code> parameter to <code>false</code> when a preconfigured button is held down, and back to <code>true</code> when the button is released. It subscribes directly to <code>/joy</code>, with the button configured via a node parameter - R1 in my setup. Now, if I’m ever stuck like that again, I can just hold R1 to back away and release it to regain protection.</p>
<h2><strong>Waypoint Recording and Patrolling</strong></h2>
<p>In my previous Nav2 post, I was able to send the robot a single <code>navigate_to_pose</code> goal and watch it go there, but I had no way to make it work through a list of waypoints on its own. Each new target required a manual goal to be sent from Foxglove, which is tedious and not very autonomous. I wanted to be able to record a route of waypoints and have the robot patrol through them on its own.</p>
<p>Nav2 already ships <a href="https://docs.nav2.org/configuration/packages/configuring-waypoint-follower.html"><code>nav2_waypoint_follower</code></a>, a lifecycle node that drives the existing <code>navigate_to_pose</code> action once per waypoint in a loop - no new behavior tree required.</p>
<p>What it doesn’t ship is any convenient way to trigger it - the only interface is its <a href="https://github.com/ros-navigation/navigation2/blob/main/nav2_msgs/action/FollowWaypoints.action"><code>FollowWaypoints</code></a> action, which is callable only via an unwieldy <code>ros2 action send_goal</code>, with no direct way to trigger it from Foxglove. So I wrote <a href="https://github.com/adityakamath/lekiwi_ros2/blob/main/lekiwi_navigation/lekiwi_navigation/waypoint_recorder_node.py"><code>waypoint_recorder_node</code></a>, exposing three <a href="https://github.com/ros2/common_interfaces/blob/rolling/std_srvs/srv/SetBool.srv"><code>std_srvs/srv/SetBool</code></a> services I can either bind to gamepad buttons via <a href="https://index.ros.org/p/joy_teleop/"><code>joy_teleop</code></a>, or call from my <a href="https://github.com/adityakamath/foxglove_extensions/tree/main/button">Button extension</a> on Foxglove. The three services are:</p>
<ul>
<li><p><code>/record_waypoint</code>: captures the robot’s current pose as a waypoint, published as a <a href="https://docs.ros2.org/foxy/api/visualization_msgs/msg/MarkerArray.html"><code>visualization_msgs/msg/MarkerArray</code></a> so the whole route shows up as arrows in Foxglove’s 3D panel</p>
</li>
<li><p><code>/waypoint_follow</code>: starts or resumes the patrol - a toggle, so a single button can alternate between starting/resuming and pausing the patrol</p>
</li>
<li><p><code>/reset_waypoints</code>: clears the route and all waypoints</p>
</li>
</ul>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/d92dd36b-95c0-4c23-a873-bcadec729060.webp" alt="" style="display:block;margin:0 auto" />

<p>In this new node, the waypoints are stored as a vector of <a href="https://github.com/ros2/common_interfaces/blob/rolling/geometry_msgs/msg/PoseStamped.msg"><code>geometry_msgs/msg/PoseStamped</code></a> and sent to <code>nav2_waypoint_follower</code> via its <code>FollowWaypoints</code> action. Since that’s an ordered list with a <code>FollowWaypoints.Goal.goal_index</code>, the patrol can pause and resume mid-route instead of always restarting from the beginning.</p>
<p><a class="embed-card" href="https://www.youtube.com/watch?v=j2M1uxfEog0">https://www.youtube.com/watch?v=j2M1uxfEog0</a></p>

<p>Another feature I added is the ability to send manual <code>navigate_to_pose</code> goals while a patrol is running. In this case, the patrol treats it as a detour rather than a failure - go to the commanded target, then carry on following the original patrol route. Another feature where knowing the goal index is advantageous.</p>
<p><a class="embed-card" href="https://www.youtube.com/watch?v=IvBZlOj1wtg">https://www.youtube.com/watch?v=IvBZlOj1wtg</a></p>

<p>The <code>number_of_loops</code> parameter controls how many times the patrol repeats - 0 (the default) loops forever, any positive number stops the patrol after exactly that many passes. I also implemented the ability to add new waypoints while a patrol is running - they queue up and join the route at the start of the next loop instead of splicing in immediately, which avoids preemption issues and keeps the robot from skipping waypoints.</p>
<p><a class="embed-card" href="https://www.youtube.com/watch?v=eoiM9-Zgxzg">https://www.youtube.com/watch?v=eoiM9-Zgxzg</a></p>

<p>I added two more features to make the patrol behavior more robust:</p>
<ol>
<li><p><strong>Error Handling</strong>: an unreachable waypoint gets retried a bounded number of times before being dropped from the route entirely, so the patrol keeps going instead of stalling forever.</p>
</li>
<li><p><strong>Progress Reporting</strong>: the patrol’s current loop, current leg, ETA, and recovery count are published on <code>/patrol_diagnostics</code> as a <a href="https://github.com/ros2/common_interfaces/blob/rolling/diagnostic_msgs/msg/DiagnosticArray.msg"><code>diagnostic_msgs/msg/DiagnosticArray</code></a> message, readable from <a href="https://docs.foxglove.dev/docs/visualization/panels/diagnostics">Foxglove’s Diagnostics panel</a> once configured to subscribe to <code>/patrol_diagnostics</code>, no custom message type needed.</p>
</li>
</ol>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/b9e5654b-0019-418c-9f0f-87ae6b4067ea.webp" alt="" style="display:block;margin:0 auto" />

<p>This was a big addition - I can set up a patrol route, watch it run, and adjust it on the fly, all from Foxglove or a gamepad. That said, <code>waypoint_recorder_node</code> is nowhere near a polished solution - it’s a messy, partly vibe-coded proof-of-concept, and I plan to refine it iteratively. For now, if you want to use it yourself, proceed with caution and expect breaking changes.</p>
<h2><strong>Keepout and Speed-Limited Zones</strong></h2>
<p>This feature was born out of necessity. While testing my standard Nav2 config, I found a few areas in my apartment that the robot couldn’t navigate through safely. For example, my couch has a high clearance, so the LiDAR sees free space underneath it, but the pan-tilt mechanism gets stuck since it is taller than the clearance. Another example is my standing desk, which has a base that is lower than what the LiDAR sees, so the robot would collide if it tried to go under. I needed a way to tell Nav2 to avoid these areas.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/2324c758-c6fa-4e7a-b51c-5e37e84f2af4.webp" alt="" style="display:block;margin:0 auto" />

<p>While at it, I also decided to experiment with speed-limited zones, where the robot would slow down in certain areas. Not something I need right now, but a fun feature to play with. I wanted to be able to draw these zones on the map and have Nav2 respect them during navigation.</p>
<p>For both these features, I followed the handy tutorials in the Nav2 docs: <a href="https://docs.nav2.org/tutorials/docs/navigation2_with_keepout_filter.html">Keepout Zones</a> and <a href="https://docs.nav2.org/tutorials/docs/navigation2_with_speed_filter.html">Speed-Limited Zones</a>. Both use a separate grayscale mask image layered on top of the navigation map, but they work differently:</p>
<ol>
<li><p><a href="https://docs.nav2.org/tutorials/docs/navigation2_with_keepout_filter.html"><code>KeepoutFilter</code></a> marks any cell where the mask has a black pixel as a no-go zone in the costmap - the planner treats it as an obstacle and routes around it. I have <code>keepout_filter</code> running on both the global and local costmaps, so neither the planner nor the controller will enter those cells regardless of where the goal is.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/5361342f-522c-4c2e-8078-34ee5858f081.webp" alt="" style="display:block;margin:0 auto" />
</li>
<li><p><a href="https://docs.nav2.org/tutorials/docs/navigation2_with_speed_filter.html"><code>SpeedFilter</code></a> maps pixel brightness to a speed limit percentage instead - white means full speed, black means stop, and grays scale proportionally in between. In the image below, the two grey blobs are speed-limited zones, with the darker one indicating a lower speed limit than the lighter grey section. <code>SpeedFilter</code> only runs on the local costmap - it affects what the controller does in real time, but the global planner generates routes without accounting for it. This also means that if a keepout zone overlaps a speed-limited zone, the keepout zone takes precedence, since the global planner will never route a path through it.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/39fdc43d-9018-4c8a-96f3-8a025fbbf0e8.webp" alt="" style="display:block;margin:0 auto" /></li>
</ol>
<p>The main challenge here was the recommended method for creating these masks: create a <code>.pgm</code> file in an image editor with the same size as the map, then overlay the map on top of it as a guide, and draw the zones. I updated the <a href="https://github.com/adityakamath/lekiwi_ros2/blob/main/lekiwi_navigation/lekiwi_navigation/map_saver_node.py"><code>map_saver_node</code></a> script to handle the first part: it now saves all-white <code>.pgm</code> placeholders sized to match the map alongside the rest of the map files, so there’s no need to create a correctly sized canvas from scratch.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/6dafb61c-152a-4e50-be2c-f68368df305f.webp" alt="" style="display:block;margin:0 auto" />

<p>However, to update these masks, I decided to look for a way to do it directly in VS Code, since I am already using it for development. I handed it off to Claude, and less than an hour later, I had a rudimentary <a href="https://github.com/adityakamath/pgm_editor"><code>PGM Editor</code></a> extension that lets me open a blank <code>.pgm</code> file, draw shapes on it, and save it as a filter. In the image below, the black blob is a zone I’ve already drawn, and the red polygon is a new keepout zone being drawn. It is very basic, but it works for my needs. It is available on the <a href="https://marketplace.visualstudio.com/items?itemName=kamathsblog.pgm-editor">VSCode Extensions Marketplace</a>. Feel free to check it out and clone it to make it your own.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/28b22d6c-e1ec-4c35-b7b1-d3b93d922c2b.webp" alt="" style="display:block;margin:0 auto" />

<p>With the masks ready, I updated the Nav2 config and the launch file to include the costmap filter plugins and tested it out. Like before, using the <a href="https://docs.foxglove.dev/docs/connecting-to-data/ros-foxglove-bridge">Foxglove Bridge</a>, I could visualize the costmaps remotely in Foxglove running on my laptop and see both the keepout and speed-limited zones in the 3D panel, as shown in the image below. Both masks are rendered as standard costmaps in Foxglove, so the colors are fixed, but you can switch to custom costmap layers if you want to pick your own.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/d4e8f672-c5c5-402e-ba54-c7d12cb5789d.webp" alt="" style="display:block;margin:0 auto" />

<p>Once the visualization was verified, I tested the robot’s behavior in the real world. The keepout zone worked as expected - the planner never routed the robot into the zone, always routing around it. The speed-limited zones also worked - the robot slowed down according to the speed limit set in the mask and returned to its original speed once it exited the zone. You can see both of these zones in action in the video below. I am super happy with how this feature turned out and relieved that it was so easy to implement.</p>
<p><a class="embed-card" href="https://www.youtube.com/watch?v=rKwyndPRBvg">https://www.youtube.com/watch?v=rKwyndPRBvg</a></p>

<h2><strong>Wrapping up</strong></h2>
<p>With these three features in place, the LeKiwi’s Nav2 setup is starting to feel complete. And like with everything else, Foxglove was one of the instrumental tools that really sped up my development process. From visualization to teleop and now the new navigation features, it works seamlessly whether I’m sitting at my laptop or holding the Steam Deck.</p>
<p>Beyond Nav2, the next major thing I want to tackle is a docking station: somewhere the robot can autonomously navigate to and charge. That breaks down into a few intermediate goals: tracking fiducial markers with the on-board OAK-D S2 camera, writing a custom docking behavior using <a href="https://www.behaviortree.dev/">Behavior Trees</a>, and on the hardware side, designing the docking mechanism and finding a battery monitoring solution that can tell the robot when it’s running low. The hardware will probably take the longest, and might end up being a custom design if nothing off-the-shelf fits.</p>
<p>As for the planner and controller tuning that I promised in the previous post, it is still ongoing. I’ve been fine-tuning it iteratively as I use the robot, but it’s not the kind of thing that makes for an interesting blog post, so I will leave those changes in <a href="https://github.com/adityakamath/lekiwi_ros2">lekiwi_ros2</a> with the rest of the LeKiwi ROS 2 stack. As always, this is a work in progress, so expect regular updates and breaking changes at times. Feedback and contributions are always welcome!</p>
]]></content:encoded></item><item><title><![CDATA[Kamath Robotics: 6 months in]]></title><description><![CDATA[My story started during the COVID-19 lockdown in 2020, when a software-focused screen-facing job left me stuck indoors with more spare time than I knew what to do with, and an itch to finally work wit]]></description><link>https://kamathrobotics.com/kamath-robotics-6-months-in</link><guid isPermaLink="true">https://kamathrobotics.com/kamath-robotics-6-months-in</guid><dc:creator><![CDATA[Aditya Kamath]]></dc:creator><pubDate>Sun, 26 Jul 2026 08:30:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/6bb87361-c727-46b9-89a8-4e3ffa82ff15.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>My story started during the COVID-19 lockdown in 2020, when a software-focused screen-facing job left me stuck indoors with more spare time than I knew what to do with, and an itch to finally work with actual hardware instead of just code. I spent this free time teaching myself CAD, laser cutting, and 3D printing at my employer's innovation lab, and built a custom variant of the <a href="https://docs.donkeycar.com/">Donkey Car</a> project — swapping out its own stack for ROS, because I wanted to sharpen my skills since last using it three years ago.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/abd7bb19-d034-400d-9e0b-d80cb1f23632.jpg" alt="" style="display:block;margin:0 auto" />

<p>I had no idea this would turn into anything more than a hobby. Since then, I've built several robot platforms (including <a href="https://kamathrobotics.com/series/akros">AKROS</a>, which became a major project), written more blog posts than I can count, given talks at local conferences, attended three ROSCons, and picked up a handful of freelance projects along the way.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/72c8fe1a-e228-4ecf-9b40-ef70d5c134e3.jpg" alt="" style="display:block;margin:0 auto" />

<p>Then <a href="https://kamathrobotics.com/roscon-2025-singapore">ROSCon 2025</a> in Singapore happened, and changed my perspective entirely. I spent the week talking to freelancers and independent roboticists, and flew home with an idea I couldn't shake: make this side-hustle official. Also, build a robot, for which I chose <a href="https://www.seeedstudio.com/Lekiwi-Kit-p-6501.html?sensecap_affiliate=8fjl172&amp;referring_service=link">LeKiwi</a> mobile base and ended up buying a kit from <a href="http://seeedstudio.com/LeKiwi-Full-Kit-12V-Verision.html?qid=lt7ei6jq_1786127782892&amp;sensecap_affiliate=8fjl172&amp;referring_service=link">Seeed Studio</a> on my flight back.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/ac332f3b-6b48-44dd-abea-0d40d4e55e31.jpg" alt="" style="display:block;margin:0 auto" />

<p>Eventually, after some thought and some procrastination, I finally registered <a href="https://www.kvk.nl/bestellen/#/99598582000064658783?origin=search">Kamath Robotics</a> as an <em>eenmanszaak</em> — a Dutch sole proprietorship — with the Chamber of Commerce. That was exactly six months ago.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/b9afd4a9-85dd-4bb5-8f0b-debee0c1818d.jpg" alt="" style="display:block;margin:0 auto" />

<h2>The Last Six Months</h2>
<p>The original plan was simple on paper: split my time roughly 24 hours a week between freelance/contract work and my own projects that I hoped to eventually turn into products. In practice, a lot of that time went into <a href="https://kamathrobotics.com/series/lekiwi">LeKiwi</a> — upgrading the hardware, building out its ROS 2 software stack, and writing my own packages along the way, like <a href="https://github.com/adityakamath/sts_hardware_interface"><code>sts_hardware_interface</code></a>, a hardware interface for STS-series servos (the <a href="https://www.seeedstudio.com/STS3215-30KG-Serial-Servo-p-6340.html?sensecap_affiliate=8fjl172&amp;referring_service=link">STS3215</a> in particular) that powers platforms like the <a href="http://seeedstudio.com/SO-ARM-101-Assembled-Kit-Pro-p-6691.html?qid=hl07sut6_1786128116902&amp;sensecap_affiliate=8fjl172&amp;referring_service=link">SO-101,</a> LeKiwi, and <a href="https://github.com/Vector-Wangel/XLeRobot">XLeRobot</a>.</p>
<p>The other development I didn't see coming was writing for <a href="https://foxglove.dev">Foxglove</a>. In Singapore, <a href="https://www.linkedin.com/in/mateuszsadowski/">Mat Sadowski</a> — editor of the <a href="https://www.weeklyrobotics.com/archive/">Weekly Robotics</a> newsletter, now in developer relations at Foxglove — suggested I start writing for <a href="https://foxglove.dev/blog?topic=all">their blog</a>. It was an easy yes: I was already building things, writing about them, and using Foxglove to visualize all of it, so the fit was obvious. Over the last six months, I've published several posts documenting the LeKiwi build while putting Foxglove's visualization capabilities through their paces. It's worked out well for both sides — Foxglove gets high-quality technical content grounded in a real build, and I get to keep doing the two things I like most: building robots and writing about them.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/1a8193fb-05ca-4ed5-9407-3a2bdb56edac.webp" alt="" style="display:block;margin:0 auto" />

<p>That same pattern repeated itself in May at the <a href="https://kamathrobotics.com/ohs-2026-berlin">Open Hardware Summit</a> in Berlin, where I got to talking with Seeed Studio's team at their booth, including <a href="https://www.linkedin.com/in/jingerzeng/">Jinger Zeng</a> from their developer relations team. I also got lucky as their <a href="https://www.seeedstudio.com/reBot-Arm-B601-DM-p-6740.html?srsltid=AfmBOoqRtdpwqo3IAxLBNgJUgVJw9TUNR2FIVva2Crs6E9lY2eRTHwpw&amp;sensecap_affiliate=8fjl172&amp;referring_service=link">ReBot arm</a> demonstrator was stuck, and I was at the right place at the right time to flex my skills and get it working within minutes. This, along with some conversations with the team, ended up in an invitation to apply to their <a href="https://www.seeedstudio.com/ranger-program?srsltid=AfmBOorUbyr0OSNiM_QwNev4x-0fxWAGO3fKg9-VSH8wJdalPXJABhfM">Seeed Rangers</a> and <a href="https://www.seeedstudio.com/seeed-physical-ai-global-workshop-partnership-program?srsltid=AfmBOorq_GgnKj6kOVl5vdelO7kAv-JzKqz4NteuD8Jl9N-70rFChEqm">Physical AI Ambassador</a> programs. Once again, an easy yes — I was already deep into Seeed hardware: LeKiwi is built on their platform, I'd just ordered a <a href="https://www.seeedstudio.com/reSpeaker-Flex-XVF3800-Circular-4-with-XIAO-ESP32S3-p-6739.html?sensecap_affiliate=8fjl172&amp;referring_service=link">reSpeaker Flex</a> mic array after learning about it at the summit, and an SO-101 arm is next on my list to work with.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/a7428dad-f3a2-4be2-815b-70b929db884c.jpg" alt="" style="display:block;margin:0 auto" />

<p>Speaking of conferences and summits, I can't forget this year's <a href="https://roscon.ros.org/2026/">ROSCon</a> in Toronto. Honestly, I couldn't be bothered - it is too far away and probably going to be ridiculously expensive, so I decided to be smart with my money and attend some local ROSCons instead. I sent in a proposal for a 10-minute talk at <a href="https://roscon.org.uk/2026/">ROSCon UK</a> on how I built <a href="https://github.com/adityakamath/sts_hardware_interface"><code>sts_hardware_interface</code></a>, and the organizers came back asking if I'd be okay doing a 45-minute deep-dive instead. And of course I was, 10 minutes was probably a little short to explain creating a hardware interface, but with 45 minutes, I can also do a demo. So ROSCon UK will be my first ROSCon as a speaker rather than just an attendee — my first ROSCon talk, period, and I'm pretty excited about it. Also, can't wait to be back in Edinburgh!</p>
<p>I'm also considering sending in a proposal to <a href="https://roscon.ros.org/be/2026/">ROSCon Belgium</a> about my work with LeKiwi and SO-101. Both talks lean heavily on Seeed Studio hardware, which fits neatly with representing them as an ambassador at both events; I'll effectively be repping both Kamath Robotics and Seeed at the same time.</p>
<p>Other than this, I've also been brewing a few things quietly on the side. One of these things is <a href="https://github.com/adityakamath/ros2-skill">ros2-skill</a>, an agent skill that lets an AI agent control ROS 2 robots directly through natural language, no <a href="https://foxglove.dev/blog/using-rosbridge-with-ros2">rosbridge</a> required. It partly draws inspiration from <a href="https://agenticros.com/">AgenticROS</a> and <a href="https://github.com/robotmcp/ros-mcp-server">ros-mcp-server</a>, and builds on top of <a href="https://github.com/lpigeon/ros-skill">ros-skill</a>. I've been testing it on my own LeKiwi project for a while now, quietly working out the rough edges.</p>
<p>As for the second thing, I can't really talk about it just yet — there's still some administrative work in progress on it. What I will say is that it involves a collaboration, and if it comes together the way I hope (fingers crossed), the Kamath Robotics fleet might be getting a humanoid soon. More on this once I'm actually able to share it.</p>
<h2>Adjusting the Plan</h2>
<p>My original goal of becoming a part-time freelancer and taking on contract work turned out to be harder than I expected. Running Kamath Robotics alongside a full-time job means everything has to happen outside my working hours, per my agreement with my employer. In practice, that's evenings and weekends. And this included freelance work, my personal projects, and blogging. This was getting a little too much.</p>
<p>That's not quite how it has played out, though. The Foxglove partnership, and more recently the one with Seeed, nudged me in a different direction entirely — toward written content, technical documentation, marketing, and product reviews rather than billable technical projects. I've also stopped actively looking for freelance or contract work on the technical side. I'm happy building my own projects and writing about them instead.</p>
<p>This shift has also changed who I reach out to. Rather than chasing client work, I've been talking to companies that build sensors, actuators, compute modules, and other robotics hardware and software, with the idea of incorporating their products into my own builds and then writing detailed articles or technical documentation about them.</p>
<p>A few years ago, a fellow roboticist suggested applying to conferences as "media" — it usually comes with a registration fee waiver and a bit more access than a regular attendee, in exchange for coverage. Back then, I was blogging mostly for myself, mostly as a build journal, and I had no company behind me to make that case. Now that Kamath Robotics is a real, registered business, I'm finally in a position to give it a try.</p>
<p>And that's exactly what I did for ROSCon UK. I shared information about this blog with the <a href="https://www.tartanrobotics.org/">Tartan Robotics Collective</a> (the organizers) and asked them whether they would consider me a media person. Turns out they did! I have already written about my <a href="https://kamathrobotics.com/series/events">previous ROSCon experiences</a>, and I intended to write a blog about ROSCon UK anyway, only now I will be doing that professionally. This definitely validates the new direction I am taking Kamath Robotics in...</p>
<p>That extra breathing room has also opened up time to chase product ideas properly. LeKiwi keeps proving itself as I build on it, but it does come with its fair share of issues - the STS3215 motor variants are either too slow or too weak, and there's no variant that has the right balance, and they are all way too noisy. But while working on the platform, I've learned a lot, and I am ready to translate these learnings onto another robot platform that I will build from scratch. I've already got some ideas thanks to some interesting discussions online, and I've half-jokingly started calling this new robot the unofficial TurtleBot 5. More on this when the time is right...</p>
<p>To be clear (especially to potential clients), I haven't shut the door on freelance or contract work entirely. If something really interesting comes along and fits inside my existing hours, I'm still open to it. I'm just not actively chasing it anymore, unlike the media engagements I mentioned above.</p>
<h2>The Next Six Months</h2>
<p>Looking forward, I've got a few things on the list. First, is the SO-101 arm, I want to get it talking ROS 2 properly, rather than a thin interface with <a href="https://huggingface.co/docs/lerobot/main/en/index">LeRobot</a>, the same way I did with LeKiwi. At the same time, I want to design and implement features that let me control the arm with a <a href="https://kamathrobotics.com/teleoperating-the-lekiwi-from-a-steam-deck">Steam Deck</a> and a VR controller, rather than needing a leader arm.</p>
<p><a class="embed-card" href="https://twitter.com/kamathsblog/status/2083151144310739091">https://twitter.com/kamathsblog/status/2083151144310739091</a></p>

<p>Since I no longer need a second arm, I decided to use the motors for a different purpose. I assembled a second arm, stopped at the wrist flex joint, and attached a lamp to it instead. Less "pick and place", more a desk lamp with a mind of its own. I've got some ideas for this as well, more on this soon...</p>
<p><a class="embed-card" href="https://x.com/kamathsblog/status/2084967976457871722">https://x.com/kamathsblog/status/2084967976457871722</a></p>

<p>Meanwhile, my search for a docking solution for LeKiwi continues. This is one of the last pieces to the LeKiwi hardware puzzle, now that the software is mostly sorted. I am also replacing the power-hungry <a href="https://shop.luxonis.com/products/oak-d-s2?srsltid=AfmBOooi8_V4vjqs8AH14FXkZ_PIrNgD33pcViZNdCRMbENmFxFG_dI5">Luxonis OAK-D S2</a> with a better depth camera that also draws lower current, and this should help with the automated docking. On the software side, I am also working on a few things related to voice control and AI that I will start posting about in the coming months.</p>
<p><a class="embed-card" href="https://x.com/seeedstudio/status/2085635572119523708">https://x.com/seeedstudio/status/2085635572119523708</a></p>

<p>Finally, I want to take <a href="https://github.com/adityakamath/ros2-skill">ros2-skill</a> from "quietly testing it on my own robot" to something I'm actually comfortable pointing people at. That means cleaning it up, documenting it properly, and formally launching it — at which point I'll be actively looking for testers and contributors to help kick the tires on it. I hope I get this working in time to register a lightning talk at one of the upcoming ROSCons that I plan on attending...</p>
<p>Besides the projects themselves, I think Kamath Robotics is in a genuinely good place right now. Six months in, I'm spending my evenings and weekends on exactly what I set out to do — building robots, writing about them, and increasingly getting to do both for people who care about the same things I do. The partnerships and media access have also meant I can keep showing up to conferences and building the things I want to build, without it costing me a small fortune. Six months ago, this was a registration with the Chamber of Commerce and an idea; now it's a real, nearly sustainable way of working, and I'm proud of that.</p>
<p>Here's to the next six months. Onwards!</p>
]]></content:encoded></item><item><title><![CDATA[Under the Hood: LeKiwi's Joystick Controls]]></title><description><![CDATA[The last few months, I've been writing mostly for the Foxglove blog, and it is good fun, but it does mean my own blog has been neglected. Well, it's not because I haven't had the time to write, I've b]]></description><link>https://kamathrobotics.com/under-the-hood-lekiwi-s-joystick-controls</link><guid isPermaLink="true">https://kamathrobotics.com/under-the-hood-lekiwi-s-joystick-controls</guid><dc:creator><![CDATA[Aditya Kamath]]></dc:creator><pubDate>Sun, 19 Jul 2026 10:32:04 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/ea3e9915-a207-4f30-b28f-f2a1559b67ae.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>The last few months, I've been writing mostly for the <a href="https://foxglove.dev/blog">Foxglove blog</a>, and it is good fun, but it does mean my own blog has been neglected. Well, it's not because I haven't had the time to write, I've been busy just enjoying the summer break. I'm in Europe after all, and it's also World Cup season, and this doesn't exactly inspire sitting at a desk writing blog posts. Now that things have calmed down a bit - the temperature is once again in the high-20s, the World Cup is nearly done, and the day job is slowing down for the summer, I finally have some time to write for myself again.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/3f2e72ef-3830-42b2-9ba4-d0cd82e83900.jpg" alt="" style="display:block;margin:0 auto" />

<p>Fair warning: this one is going to be more technical than usual. It's a deep dive into some architectural decisions I made while building out LeKiwi's <a href="https://github.com/adityakamath/lekiwi_ros2/tree/main/lekiwi_control">control layer</a> - the reasoning behind them, the tradeoffs I considered, and the places where I deviated from standard ROS 2 patterns. If you're just here for a demo video or a step-by-step guide, you're out of luck. But if you want to understand why things are built the way they are, read on.</p>
<p>This is part of my <a href="https://kamathrobotics.com/series/lekiwi">ongoing series</a> on building an autonomous mobile robot with the <a href="https://github.com/SIGRobotics-UIUC/LeKiwi">LeKiwi</a> platform. The previous posts covered hardware upgrades, sensor calibration, and autonomous navigation. This one is about something more foundational to how I actually control the robot day-to-day: a service-based control architecture that works equally well from a <a href="https://kamathrobotics.com/teleoperating-the-lekiwi-from-a-steam-deck">Steam Deck</a> or from Foxglove, and a wrapper node I had to write to make the joystick controls work the way I wanted.</p>
<h2>Service-based Control Architecture</h2>
<p>The most common method to control a ROS 2 robot with a joystick is to use <a href="https://github.com/ros-teleop/teleop_twist_joy"><code>teleop_twist_joy</code></a>. It maps joystick axes to a <a href="https://docs.ros2.org/foxy/api/geometry_msgs/msg/Twist.html"><code>Twist</code></a> message and publishes it to <code>/cmd_vel</code>, with an optional deadman button. For simply moving the robot using a joystick, that's all you need. But LeKiwi doesn't use <code>teleop_twist_joy</code>, and the reason comes down to where the button logic lives.</p>
<p>With <a href="https://kamathrobotics.com/series/akros">AKROS</a>, I used <code>teleop_twist_joy</code> like everyone else. I also had other features on AKROS that were triggered by joystick inputs, which I handled with a custom node that subscribed to <a href="https://docs.ros2.org/foxy/api/sensor_msgs/msg/Joy.html"><code>Joy</code></a> messages, read the relevant button states, and triggered features like state change or motor halt directly.</p>
<p>That approach had a structural problem: it coupled every feature to the joystick. If a node subscribed to <code>/joy</code> to drive its own behavior, the only way to reach it is through a joystick, either physical or simulated. And it is difficult to scale either. If I added a new feature that needed a button press, I had to either touch the feature node itself or add a new node that subscribed to <code>/joy</code>.</p>
<p>It wasn't that straightforward to trigger these features from Foxglove either. I could have configured it to simulate my joystick mapping and produce fake <code>/joy</code> messages when digital buttons were pressed, but it was too much work at the time, and I also did not have AI tools at my disposal as I do now. The result was that the joystick features were only accessible from a physical joystick, which wasn't ideal for a robot that I wanted to be able to control from my laptop as well.</p>
<p>For LeKiwi, I went the other way: every non-teleop, button-based feature is a ROS 2 service. <code>/emergency_stop</code> (implemented as a first-class feature in <a href="https://github.com/adityakamath/sts_hardware_interface"><code>sts_hardware_interface</code></a>) , <code>/twist_switch</code> (implemented in the <a href="https://github.com/adityakamath/lekiwi_ros2/blob/main/lekiwi_control/lekiwi_control/twist_switch_node.py"><code>twist_switch_node</code></a> in <a href="https://github.com/adityakamath/lekiwi_ros2"><code>lekiwi_ros2</code></a>), future waypoint-related features - all <a href="https://docs.ros2.org/foxy/api/std_srvs/srv/SetBool.html"><code>std_srvs/srv/SetBool</code></a> services. Like I explained in <a href="https://kamathrobotics.com/autonomous-navigation-with-lekiwi">my Nav2 post</a>, <code>/twist_switch</code> is the node that selects which velocity stream reaches the motors: <code>data: false</code> routes joystick commands through, <code>data: true</code> hands control to <a href="https://nav2.ros.org">Nav2</a>.</p>
<p>The advantage here is that I now need a single node to simply read <code>/joy</code> and call the right service with the right payload. Additionally, I can trigger services from anywhere, so it does not depend on a physical joystick; the caller can be Foxglove, a CLI, or another node. The feature implementations themselves don't need to know or care how they are being called. Besides the single node, I would still need <code>teleop_twist_joy</code> to map the joystick axes to a <code>Twist</code> message, but that is not ideal.</p>
<p>This is where <a href="https://github.com/ros-teleop/teleop_tools"><code>joy_teleop</code></a> comes in. It's a ROS 2 node that subscribes to <code>/joy</code> and calls services or publishes topics based on a configuration file. It can handle multiple buttons, deadman buttons, and even axis mappings for Twist messages. The configuration is centralized, so adding a new feature or changing button assignments doesn't require touching the feature nodes themselves. The initial <a href="https://github.com/adityakamath/lekiwi_ros2/blob/main/lekiwi_control/config/base/teleop.yaml"><code>teleop.yaml</code></a> config looked something like this:</p>
<pre><code class="language-yaml">joy_teleop:
  ros__parameters:
    teleop:
      type: topic
      interface_type: geometry_msgs/msg/TwistStamped
      topic_name: /cmd_vel_teleop
      deadman_buttons: [9]  # L1
      axis_mappings:
        twist-linear-x:
          axis: 1  # Left stick vertical
          scale: 0.2
          offset: 0.0
        twist-linear-y:
          axis: 0  # Left stick horizontal
          scale: 0.17
          offset: 0.0
        twist-angular-z:
          axis: 2  # Right stick horizontal
          scale: 0.68
          offset: 0.0
        header-frame_id:
          value: 'base_link'

    estop_press:
      type: service
      interface_type: std_srvs/srv/SetBool
      service_name: /emergency_stop
      service_request:
        data: true
      buttons: [0]  # A

    estop_release:
      type: service
      interface_type: std_srvs/srv/SetBool
      service_name: /emergency_stop
      service_request:
        data: false
      buttons: [1]  # B

    mode_nav2:
      type: service
      interface_type: std_srvs/srv/SetBool
      service_name: /twist_switch
      service_request:
        data: true
      buttons: [2]  # X

    mode_teleop:
      type: service
      interface_type: std_srvs/srv/SetBool
      service_name: /twist_switch
      service_request:
        data: false
      buttons: [3]  # Y
</code></pre>
<p>I refer to it as the 'initial' config because it has changed slightly since then, and I will explain more in the upcoming sections.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/b2966656-fc75-4b07-b33f-c036ada33aa4.png" alt="" style="display:block;margin:0 auto" />

<h2>Foxglove and the Button extension</h2>
<p>This is where the services approach pays off most visibly. As I've explained in the LeKiwi series, I am using the Raspberry Pi on the LeKiwi in headless mode and without any desktop environment. To control it from my laptop, I use Foxglove for both visualization and control. Foxglove has a <a href="https://docs.foxglove.dev/docs/visualization/panels/service-call">Service Call panel</a> that I can use to trigger any of the services from my laptop without needing a joystick. To make things even easier, I can use my custom <a href="https://github.com/adityakamath/foxglove_extensions/tree/main/button">Button extension</a> (which is available on the <a href="https://github.com/foxglove/extension-registry">Foxglove extension registry</a>, directly from the app) to create a button for each service, so I can trigger topic publishers or service calls with a single click.</p>
<p>The service-based architecture also means all callers can reach the services using multiple methods: <code>joy_teleop</code> via button bindings, Foxglove's Service Call panel, my custom Button extension, a CLI, or another node - the robot doesn't care. The button assignments all live in one place, and adding a new caller never requires touching the feature node itself.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/887dfe88-26d4-47d7-bb0e-a9ff5f700859.png" alt="" style="display:block;margin:0 auto" />

<p>The Button extension is also designed to handle <code>SetBool</code> with a single button. In push mode, pressing calls the service with <code>data: true</code> and releasing calls it with <code>data: false</code>. In toggle mode, each click alternates between the two. Either way, one button is enough - the extension manages the value itself. Foxglove already didn't have this problem. I've written about this extension extensively in an <a href="https://kamathrobotics.com/i-made-a-button">earlier blog post</a>, which is worth reading if you want to understand how it works.</p>
<h2>Two buttons per action</h2>
<p>As I kept adding features, the main limitation of <code>joy_teleop</code> became obvious: <code>joy_teleop</code> doesn't track state between button presses. Each service binding has a fixed payload configured in <a href="https://github.com/adityakamath/lekiwi_ros2/blob/main/lekiwi_control/config/base/teleop.yaml"><code>teleop.yaml</code></a>, and every press sends exactly that payload - same value every time, no memory of what the last press did. Stateless by design.</p>
<p>This means that the <code>SetBool</code> mapping requires two buttons per service: one for <code>data: true</code>, one for <code>data: false</code>. Initially, this was fine - I had two <code>SetBool</code> services, <code>/emergency_stop</code> and <code>/twist_switch</code>, and four face buttons. <code>A</code> to engage the e-stop, <code>B</code> to release it, <code>X</code> to switch to Nav2 mode, <code>Y</code> to switch back to teleop. Four bindings, four buttons, perfect fit.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/6e787ce8-202d-4efb-b252-f392aa344257.jpg" alt="" style="display:block;margin:0 auto" />

<p>Then I added waypoint navigation. That meant at minimum: start the patrol, stop the patrol, record a waypoint, reset the waypoints - 4 buttons in total. I had only one button left - the Settings button, not even a face button. The option was to either give up on the new features or to redesign all of my feature nodes to be <a href="https://docs.ros2.org/foxy/api/std_srvs/srv/Trigger.html"><code>std_srvs/srv/Trigger</code></a> services (which need a single input to toggle between states) instead of <code>SetBool</code> services. First of all, this meant a lot of work, both on refactoring the feature nodes and updating the Button extension to call <code>Trigger</code> services.</p>
<p>I didn't want to do that, because the idempotency of <code>SetBool</code> is actually useful for some features. For example, calling <code>/emergency_stop</code> with <code>data: true</code> multiple times doesn't make a difference; the motors stay stopped. If it were a stateful <code>Trigger</code> instead, a duplicate press from a jittery controller or a network retry could silently re-enable the motors at exactly the wrong moment. The predictable, stateless behavior is the whole point.</p>
<p>Also, I'm lazy. As I mentioned earlier, it's summer, the World Cup was on, and most importantly, I have a day job. I also didn't want to delegate this major refactoring to AI. So, I had to get creative.</p>
<h3>The solution: <code>bool_toggle_node</code></h3>
<p>So I ended up writing <a href="https://github.com/adityakamath/lekiwi_ros2/blob/main/lekiwi_control/lekiwi_control/bool_toggle_node.py"><code>bool_toggle_node</code></a> — a thin wrapper that sits between <code>joy_teleop</code> and the <code>SetBool</code> services. Okay, I'll be honest, I had Claude write it for me, but I did the design and testing. It takes a list of <code>SetBool</code> services and exposes a corresponding list of <code>Trigger</code> services. When a Trigger fires, <code>bool_toggle_node</code> flips an internal boolean and calls the corresponding <code>SetBool</code> service with the new value — so <code>joy_teleop</code> gets one button per action, alternating between enable and disable on each press. The best part is that the feature nodes themselves are untouched, and the Button extension can still call the <code>SetBool</code> services directly without going through the <code>bool_toggle_node</code> layer, which is `joy_teleop '- specific.</p>
<p>The <a href="https://github.com/adityakamath/lekiwi_ros2/blob/main/lekiwi_control/config/base/teleop.yaml"><code>teleop.yaml</code></a> config now includes a section for <code>bool_toggle_node</code> which looks like this:</p>
<pre><code class="language-yaml">bool_toggle_node:
  ros__parameters:
    toggles: ["emergency_stop", "twist_switch"]
    emergency_stop:
      trigger_service: /emergency_stop_toggle
      target_service: /emergency_stop
      initial_state: false
    twist_switch:
      trigger_service: /twist_switch_toggle
      target_service: /twist_switch
      initial_state: false
</code></pre>
<p>The rest of <a href="https://github.com/adityakamath/lekiwi_ros2/blob/main/lekiwi_control/config/base/teleop.yaml"><code>teleop.yaml</code></a> describing the <code>joy_teleop</code> bindings are now updated to look like this:</p>
<pre><code class="language-yaml">    estop_toggle:
      type: service
      interface_type: std_srvs/srv/Trigger
      service_name: /emergency_stop_toggle
      buttons: [1]  # B

    twist_switch_toggle:
      type: service
      interface_type: std_srvs/srv/Trigger
      service_name: /twist_switch_toggle
      buttons: [2]  # X

    save_map:
      type: service
      interface_type: std_srvs/srv/SetBool
      service_name: /save_map
      service_request:
        data: true
      buttons: [4]  # Screenshot
</code></pre>
<p>So now, if I press <code>B</code>, <code>joy_teleop</code> calls <code>/emergency_stop_toggle</code>, which flips the internal state in <code>bool_toggle_node</code> and calls <code>/emergency_stop</code>, stopping the robot. Press <code>B</code> again, and the emergency stop is released. Same for <code>X</code> and <code>/twist_switch</code>, press once to enable autonomous mode, press <code>X</code> again for teleoperation. The remaining face buttons <code>A</code> and <code>Y</code> are now free for the waypoint features.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/ad069b94-479f-4790-bdbd-776a20b0eae6.gif" alt="" style="display:block;margin:0 auto" />

<p><code>/save_map</code> is a service implemented in the <a href="https://github.com/adityakamath/lekiwi_ros2/blob/main/lekiwi_navigation/lekiwi_navigation/map_saver_node.py"><code>map_saver_node</code></a> that saves the current map to disk. In my first Nav2 post, I subscribed to <code>/joy</code> to save the map, but I decided to change it to a service for consistency. The screenshot button on the Steam Deck, which now saves the map, is still a one-shot action and calls <code>/save_map</code> directly.</p>
<p>From Foxglove, nothing changes. The Button extension still calls <code>/emergency_stop</code> directly — a single button in push or toggle mode, the extension itself handles sending <code>data: true</code> and <code>data: false</code>. The <code>bool_toggle_node</code> Trigger layer is a <code>joy_teleop</code>-specific solution; Foxglove never needed it. The underlying <code>SetBool</code> services are untouched.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/68b60bc9-a798-47fb-8aa5-acc86d109bb0.png" alt="" style="display:block;margin:0 auto" />

<p>A couple of implementation details are worth mentioning. Each toggle's <code>SetBool</code> client uses a <a href="https://docs.ros.org/en/kilted/How-To-Guides/Using-callback-groups.html"><code>ReentrantCallbackGroup</code></a> — <code>rclpy</code>'s default <code>MutuallyExclusiveCallbackGroup</code> blocks the executor while a callback is running, so asynchronous calls inside a Trigger handler would wait forever for a response that can never arrive. The <code>ReentrantCallbackGroup</code> lets the executor re-enter and deliver the reply while the handler is suspended. The state flip only commits once the target service responds, so if <code>/emergency_stop</code> is unreachable, the toggle's belief doesn't drift.</p>
<p>One caveat: <code>SetBool</code> has no getter, so each toggle's state initializes from <code>initial_state</code> during launch rather than being queried from the actual service. If something calls a target service directly and bypasses the toggle node, the toggle's belief can drift. Not a problem in practice since I'm either using the Steam Deck (which goes through the toggle node) or the Foxglove Button extension (which calls the target service directly), and I'm not mixing the two. I intend to fix this in the future, but for now it's a known limitation.</p>
<h2>What's next?</h2>
<p>With this toggle infrastructure in place, I now have a few free buttons on the Steam Deck to implement new features - <code>A</code>, <code>Y</code>, and the screenshot button. I also have the <code>R1</code> button free, which I've also used in an upcoming feature. I've actually already implemented these new features, but I will save the details for an upcoming post on <a href="https://foxglove.dev/blog">Foxglove's blog</a>. The new features are all related to waypoint navigation, and the post will cover how I implemented them, the challenges I faced, and how I overcame them.</p>
<p>The LeKiwi build is now nearly complete. I have plans to add a battery monitor and a docking station, but I'm still researching off-the-shelf solutions for both. I'm also facing an issue with my <a href="https://shop.luxonis.com/products/oak-d-s2?srsltid=AfmBOorztjrfQyI87SIgHZEpEm8g4VoS6a_jrFG1BxbxOJmtMHzd2zp7">OAK-D S2</a> camera, which, despite being an amazing camera, draws way too much power from the Raspberry Pi, leading to crashes at run-time. I intend to replace it with a more power-efficient depth camera, and I have an option in mind, and I'm currently in talks to get a sponsored unit. More on this in a future post.</p>
<p>Alongside designing and researching solutions for the docking station, I want to move on to a few new projects. First, the <a href="https://github.com/TheRobotStudio/SO-ARM100">SO-101 robot arm</a>, which I've already assembled. The <a href="https://physical-ai.ros.org/">ROS 2 Physical AI Special Interest Group</a> has a <a href="https://github.com/ros-physical-ai/ros2_so_arm">ROS 2 package for the SO-101</a> as well as a few <a href="https://github.com/ros-physical-ai/demos">ready-to-run examples</a> on their <a href="https://github.com/ros-physical-ai">GitHub</a>, so that's my starting point.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/0d554413-befa-4996-a8db-f6827988325e.jpg" alt="" style="display:block;margin:0 auto" />

<p>Next, I've put together a small differential drive robot with an RPi Zero 2W, and I want to try vibe-coding the entire software stack for it — no writing or reading code myself. I just threw together some parts I had and have no real plans for it, but if I do something cool with it, I'll surely write a blog post.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/a7ca42e0-c2de-49ce-b371-48c33ae03312.jpg" alt="" style="display:block;margin:0 auto" />

<p>Finally, as the LeKiwi build is coming to an end, I want to start adding some documentation to the GitHub repository, so that others can build their own and reproduce my results without having to go through my blog post ramblings. For now, the <a href="https://github.com/adityakamath/lekiwi_ros2"><code>lekiwi_ros2</code></a> repo holds the full implementation, but be warned that it is still actively in development, so expect breaking changes. As always, feedback and contributions are welcome.</p>
]]></content:encoded></item><item><title><![CDATA[Visualizing Pointclouds in Foxglove]]></title><description><![CDATA[tl;dr: In this article, I discuss integrating a Luxonis OAK-D S2 stereo depth camera into my LeKiwi robot’s ROS 2 stack. I cover the steps I took to configure the depthai-ros driver to optimize bandwi]]></description><link>https://kamathrobotics.com/visualizing-pointclouds-in-foxglove</link><guid isPermaLink="true">https://kamathrobotics.com/visualizing-pointclouds-in-foxglove</guid><dc:creator><![CDATA[Aditya Kamath]]></dc:creator><pubDate>Fri, 26 Jun 2026 18:27:32 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/cb8fddda-9812-4abd-9397-a3db7450758e.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><strong>tl;dr:</strong> In this article, I discuss integrating a Luxonis OAK-D S2 stereo depth camera into my LeKiwi robot’s ROS 2 stack. I cover the steps I took to configure the depthai-ros driver to optimize bandwidth, simplify the URDF and launch files, and add pointcloud compression with Cloudini. I also discuss how I visualized RGB images, depth maps, and (compressed and uncompressed) pointclouds in Foxglove’s 3D panel, and the challenges I faced along the way.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/1c2c5501-5bfa-42d9-8b29-7cdaddbc5e16.png" alt="" style="display:block;margin:0 auto" />

<p>Originally written for and published on the <a href="https://foxglove.dev/blog/visualizing-depth-maps-and-pointclouds-from-the-oak-d-s2-in-foxglove"><strong>Foxglove blog</strong></a></p>
<hr />
<h2>Project Context</h2>
<p>Over the past few months, I’ve been steadily upgrading my <a href="https://github.com/SIGRobotics-UIUC/LeKiwi/tree/main">LeKiwi</a>-based mobile robot platform to run autonomously with <a href="https://github.com/adityakamath/lekiwi_ros2">ROS 2</a>. My earlier posts covered <a href="https://foxglove.dev/blog/upgrading-the-lekiwi-into-a-lidar-equipped-explorer">adding a LiDAR for 360-degree perception</a>, <a href="https://foxglove.dev/blog/calibrating-a-monocular-camera-for-the-lekiwi-robot-using-ros-2">calibrating a monocular camera</a> for the base, <a href="https://foxglove.dev/blog/teleoperating-the-lekiwi-from-a-steam-deck">teleoperating the robot using a Steam Deck</a>, and finally <a href="https://foxglove.dev/blog/autonomous-navigation-with-lekiwi-and-nav2">configuring Nav2 to drive the robot autonomously</a>.</p>
<p>This article is slightly tangential, but it focuses on the next major sensor upgrade: adding a stereo depth camera to give the robot 3D perception. The LeKiwi is originally designed to be a mobile manipulator, with space to mount a <a href="https://github.com/TheRobotStudio/SO-ARM100">SO-101 robot arm</a>, which I have always wanted to add. But while building the SO-101 arm, I realized that the first two joints are perfect for a pan-tilt mechanism, and decided to stop there. Then I remembered that I had a couple of <a href="https://shop.luxonis.com/collections/oak-cameras-col">Luxonis OAK depth cameras</a>, and thought it would be fun to mount one on this pan-tilt mechanism to give the robot an active vision system that can look around and perceive depth.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/9f7ac191-ed03-42c1-b78b-bf1a003c14a0.png" alt="" style="display:block;margin:0 auto" />

<p>This would not only be a fun hardware integration challenge and a learning experience, but it would also allow me to experiment with visual SLAM, visual-inertial odometry, VR integration, and dig a little deeper into vision-based AI. In this article, I will only focus on how I integrated the <a href="https://shop.luxonis.com/products/oak-d-s2">OAK-D S2</a> with ROS 2, and everything I did to visualize its data on Foxglove. If you are interested in the pan-tilt mechanism itself, I’ve covered that in a <a href="https://kamathrobotics.com/pan-tilt-controls-using-ros-2">separate post on my blog</a>.</p>
<h2>Why the OAK-D S2?</h2>
<p>The short answer is that I had one lying around.</p>
<p>But I had a choice to make. I have three Luxonis cameras, <a href="https://shop.luxonis.com/products/oak-d">the original OAK-D</a> from their very first Kickstarter, the <a href="https://shop.luxonis.com/products/oak-d-lite-1">OAK-D Lite</a>, and a recently acquired <a href="https://shop.luxonis.com/products/oak-d-s2">OAK-D S2</a>. The original OAK-D is a great camera, but it’s quite big and power-hungry, so I immediately ruled it out.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/40d40f9a-cf99-489d-832b-42c91360613a.png" alt="" style="display:block;margin:0 auto" />

<p>The real choice was between the OAK-D Lite and the OAK-D S2. Both cameras are pretty similar; they have an RGB camera, two monochrome cameras for stereo vision (with the same baseline), and the same Myriad X VPU. The S2 has a higher stereo resolution (800p vs 480p), better low-light performance, and a built-in IMU for visual-inertial odometry. The Lite is smaller and cheaper, but it doesn’t have the IMU and has lower stereo resolution. For my use case, the S2’s improved depth quality and VIO capabilities were worth the extra cost and size.</p>
<p>The real winning feature of the OAK-D S2 (in fact, any camera from Luxonis) is the <a href="https://docs.luxonis.com/software-v3/depthai/">DepthAI</a> software stack, which provides onboard processing for stereo depth computation, VIO, and AI inference. This means I do not need to run heavy computation on my Raspberry Pi 5, which is already running the <a href="https://kamathrobotics.com/wiring-up-ros-2-control-for-lekiwi">ROS 2 Control stack for motor control</a>, LiDAR processing, and navigation.</p>
<p>These cameras can run custom pipelines that output RGB images, depth maps, visual-inertial odometry estimates, and even pointclouds, all with minimal CPU load on the host. This makes it an ideal choice for a mobile robot with limited onboard compute.</p>
<h2>The Integration Journey</h2>
<p>Luxonis maintains an official ROS 2 package called <a href="https://docs.luxonis.com/software-v3/depthai/ros/"><code>depthai-ros</code></a>, which includes a modular driver that can be configured for different camera models and pipelines. I cloned it into my workspace and installed dependencies following the instructions in the repository. The package compiled cleanly, and the example launch files worked out of the box with RGB and depth images streaming immediately.</p>
<h3>Configuration for Bandwidth Optimization</h3>
<p>The depthai_ros_driver uses a pipeline-based architecture configured via YAML parameter files. The package includes several example configurations, but none combine RGB, depth, and VIO. So, I decided to create my own config tailored to my use-case: RGB images for AI inference, depth maps for obstacle avoidance, VIO as an additional odometry source, and optional pointclouds for mapping.</p>
<p>I created two custom configurations:</p>
<ol>
<li><p><a href="https://github.com/adityakamath/pantilt_ros2/blob/main/pt_bringup/config/oakd_vio.yaml"><code>oakd_vio.yaml</code></a>: Default config optimized for low bandwidth</p>
</li>
<li><p><a href="https://github.com/adityakamath/pantilt_ros2/blob/main/pt_bringup/config/oakd_vio_pcl.yaml"><code>oakd_vio_pcl.yaml</code></a>: Adds RGBD pointcloud output to the bandwidth-optimized config (higher CPU load)</p>
</li>
</ol>
<p>The bandwidth optimization was critical for two reasons: First, I’m running it on a Raspberry Pi 5 alongside other ROS 2 nodes, and some additional AI features I plan on adding. Second, I plan on streaming data to visualize on Foxglove, sometimes even remotely. So, I downscaled RGB to 1/4 ISP resolution with JPEG compression, reduced depth to 15 Hz with 2x decimation, disabled raw stereo image publishing, and kept VIO at 60 Hz for smooth camera pose tracking. I created the separate pointcloud config because I don’t need pointclouds during regular teleoperation or simple Nav2 operation. Keeping it separate reduces CPU load when not needed.</p>
<p>When pointcloud mode is enabled, the config changes significantly: depth rate increases from 15 Hz to 30 Hz to match the RGB rate, and the 2x decimation filter is disabled. 2x decimation would reduce the pointcloud to 4x fewer points, making it too sparse for useful visualization or mapping. Instead, bandwidth is managed by compressing the full-resolution pointcloud with <a href="https://github.com/facontidavide/cloudini">Cloudini</a> (more on this later), which preserves spatial detail while still achieving good compression.</p>
<h3>Custom Launch File and URDF Integration</h3>
<p>I started facing issues when I tried to integrate the OAK-D S2 URDF and set up the launch file. The depthai-ros package is designed to be a general-purpose package to support Luxonis’ entire product line of cameras, which means it provides a single <a href="https://github.com/luxonis/depthai-ros/tree/kilted/depthai_descriptions/urdf">parametrized URDF Xacro</a> that can be configured for each camera model, and a <a href="https://github.com/luxonis/depthai-ros/blob/kilted/depthai_ros_driver/launch/driver.launch.py">comprehensive ~300 line launch file</a> with all possible features and configuration options. This makes perfect sense for a general-purpose driver, but for my single-camera robot, this complexity was overkill.</p>
<p>I created a <a href="https://github.com/adityakamath/pantilt_ros2/blob/main/pt_bringup/launch/oakd.launch.py">minimal 80-line launch file</a> and a <a href="https://github.com/adityakamath/pantilt_ros2/blob/main/pt_description/urdf/oakd_s2.module.xacro">custom URDF macro</a> tailored specifically for the OAK-D S2 on my robot. The launch file does the following:</p>
<ol>
<li><p>Switches between <code>oakd_vio.yaml</code> and <code>oakd_vio_pcl.yaml</code> based on a <code>pointcloud</code> argument</p>
</li>
<li><p>Starts the camera driver as a composable node in a container</p>
</li>
<li><p>Overrides TF frame parameters to integrate with my existing robot (parent frame: <code>tilt_link</code> on the pan-tilt mechanism, base frame: <code>oak_link</code>)</p>
</li>
</ol>
<p>The custom URDF was necessary because the automatic URDF generation in <code>depthai-ros</code> maps the OAK-D S2 to the OAK-D Pro mesh (the meshes are identical), but doesn’t include the IMU frame or offsets for the built-in IMU. My custom macro defines the camera base link (<code>oak_link</code>), IMU frame (<code>oak_imu_frame</code>), and uses the correct IMU offset from the OAK-D S2 datasheet.</p>
<h2>Visualizing with Foxglove</h2>
<p>Now that I had the camera configured and the launch file set up, I could test the integration and visualize the data in Foxglove. I launched the pan-tilt mechanism with the OAK-D S2 camera and the combined URDF. On a separate terminal, I launched the Foxglove bridge. On my laptop (on the same network as the robot), I opened Foxglove and connected to the bridge.</p>
<pre><code class="language-shell"># Launch the LeKiwi robot with the pan-tilt mechanism and the OAK-D S2 camera
ros2 launch lekiwi_bringup lekiwi.launch.py

# Launch the pan-tilt mechanism with the OAK-D S2 camera
ros2 launch lekiwi_bringup lekiwi.launch.py config:=pantilt

# Launch the pan-tilt mechanism with the OAK-D S2 camera and pointcloud outputs
ros2 launch lekiwi_bringup lekiwi.launch.py config:=pantilt pointcloud:=true

# Launch the Foxglove bridge on a separate terminal
ros2 launch foxglove_bridge foxglove_bridge_launch.xml
</code></pre>
<h3>3D Panel</h3>
<p>To visualize the camera outputs, I relied completely on the <a href="https://docs.foxglove.dev/docs/visualization/panels/3d">3D panel</a> in the Foxglove app, using which I could visualize the following topics:</p>
<ol>
<li><p>The robot’s URDF model with the pan-tilt mechanism and the camera mesh</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/e10dcb7d-dca2-4ab0-b33a-c340bd990839.png" alt="" style="display:block;margin:0 auto" />
</li>
<li><p>The RGB image projected onto a frustum in 3D space</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/a8c088cf-9d77-475b-8ad6-1132337f808c.png" alt="" style="display:block;margin:0 auto" />
</li>
<li><p>The depth map image, rendered as a pointcloud with depth-based coloring</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/755f5949-37a3-40f5-96f8-769925f473b1.png" alt="" style="display:block;margin:0 auto" />
</li>
<li><p>The depth map image, with the RGB image used as a color map (by changing the RGB topic option on the left panel)</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/d6f04f83-5b7a-4f9d-862e-a8257f2bb4d7.png" alt="" style="display:block;margin:0 auto" />
</li>
<li><p>The pointcloud topic (when enabled using <code>pointcloud:=true</code>), rendered as a colored pointcloud</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/7fa5f515-bd7b-4719-a4de-05a7873d82e6.png" alt="" style="display:block;margin:0 auto" /></li>
</ol>
<h2>Pointcloud Compression using Cloudini</h2>
<p>Despite the bandwidth optimizations, visualizing pointclouds remotely in Foxglove was still a problem. The 3D panel would freeze within minutes due to the sheer volume of data. So, I needed a better solution.</p>
<p>I turned to <a href="https://github.com/facontidavide/cloudini">Cloudini</a>, a pointcloud compression library by <a href="https://x.com/facontidavide">Davide Faconti</a> that achieves impressive compression ratios using lossy quantization. Better yet, it includes a Foxglove extension that lets you visualize compressed pointclouds directly without decompressing them on the client.</p>
<p>The initial setup was simple: clone the repo, build it in my ROS 2 workspace, and <a href="https://github.com/facontidavide/cloudini/blob/main/cloudini_ros/README.md">add it to my launch file</a>. <a href="https://github.com/facontidavide/cloudini/blob/main/README.md">The documentation</a> was clear, and I had it running in about 10 minutes. But I faced some issues along the way.</p>
<h3>Cloudini Foxglove Extension</h3>
<p>To visualize the compressed pointcloud in Foxglove, I needed the Cloudini Converter extension from <a href="https://github.com/foxglove/extension-registry">Foxglove’s extension registry</a>. The extension appears in Foxglove’s available extensions list and can be installed directly from there.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/34482f47-c3e2-4925-a61d-918764314de0.png" alt="" style="display:block;margin:0 auto" />

<p>After installing the extension, Foxglove was able to decode the compressed pointcloud topic, and I could select it from the Topics section of the 3D Panel options. That confirmed the compressed pointcloud data was now being decoded correctly, but it revealed a new issue: all the colors were gone.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/dc492859-939a-4251-bac3-7b3afe28a514.png" alt="" style="display:block;margin:0 auto" />

<h3>The RGB Quantization Problem</h3>
<p>While the uncompressed pointcloud had perfect RGB colors, the compressed pointcloud showed up completely black in the BGR color mode on Foxglove. I even had to change the app’s color scheme to light mode to visualize the pointcloud properly.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/e207eca7-08ad-4098-bcc6-2c25a20085a1.png" alt="" style="display:block;margin:0 auto" />

<p>After digging into the Cloudini source code, I found the issue. The <code>depthai-ros</code> driver stores RGB data as a bit-packed integer inside a <code>FLOAT32</code> field. Cloudini applies lossy quantization to all <code>FLOAT32</code> fields by default, assuming they’re XYZ coordinates. This destroys the bit-packed color data.</p>
<p>The problem is in how the <code>depthai-ros</code> pipeline packages RGB data for pointclouds. Since I did not want to push AI-generated changes to the <code>depthai-ros</code> or Cloudini source code, I ended up writing a <a href="https://github.com/adityakamath/lekiwi_ros2/blob/main/lekiwi_bringup/src/pcl_compressor_node.cpp">custom pointcloud compressor node</a> that uses Cloudini as a library but applies field-specific quantization. The node loops through all pointcloud fields after Cloudini’s initial encoding setup, checks for color field names (rgb, rgba, bgr, bgra), and exempts them from quantization by clearing their resolution parameter. XYZ fields still get the 1mm quantization for compression, but color data stays intact. This does impact the compression ratio, but solves the visualization problem. I updated my launch file to load this custom node when <code>pointcloud:=true</code>.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/9a81a473-d528-40e3-bc7e-66319fc1fa70.gif" alt="" style="display:block;margin:0 auto" />

<p>This worked perfectly. The compressed pointcloud now has full RGB colors, the bandwidth is dramatically reduced, and the visualization is nearly identical to the uncompressed pointcloud. Most importantly, the visualization no longer freezes. There’s still some lag when I move the pan-tilt mechanism, as the compressed pointcloud takes a moment to update, but it is not too bad and quite usable for remote visualization.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/c49fef4a-97f6-48d1-a475-41d350bd6d40.gif" alt="" style="display:block;margin:0 auto" />

<h2>Wrapping Up</h2>
<p>This integration took longer than I expected, but I learned a lot about depth cameras, bandwidth optimization, and the quirks of pointcloud data formats. Foxglove was essential throughout; being able to visualize RGB, depth, TF frames, and pointclouds simultaneously made debugging so much faster.</p>
<p>Cloudini is an amazing library, and I’m grateful for the open-source work that Davide Faconti has put into it. While I’m not entirely sure how well compressed pointclouds will work for features like visual SLAM, it is a fantastic solution for remote visualization.</p>
<p>At this point, the OAK-D S2 is fully integrated. RGB streams at 30 FPS, depth at 15 Hz, VIO at 60 Hz. Compressed pointclouds can be visualized really well in Foxglove with full color preservation. While the pan-tilt mechanism and the camera are ready for visual SLAM and AI-based perception, that’s a project for another day. For now, the next step is to dig deeper into my LiDAR-based Nav2 implementation and implement some advanced features.</p>
<p>All the code related to the pan-tilt mechanism and the OAK-D S2 can be found in the <a href="https://github.com/adityakamath/pantilt_ros2/tree/main">pantilt_ros2</a> GitHub repository, and it has been integrated into <a href="https://github.com/adityakamath/lekiwi_ros2">lekiwi_ros2</a> as a git submodule, if you want to dig into the details. As always, this project is an active work in progress, so expect regular changes. Feel free to reach out if you’re working on similar camera integrations - I’m happy to share what worked (and what didn’t).</p>
]]></content:encoded></item><item><title><![CDATA[Adding Voice Control to LeKiwi]]></title><description><![CDATA[I've been gradually turning LeKiwi into a proper ROS 2 robot as part of an ongoing series, and alongside that, tinkering with AI agents that live on LeKiwi's Raspberry Pi. So far, I've set up nanobot ]]></description><link>https://kamathrobotics.com/adding-voice-control-to-lekiwi</link><guid isPermaLink="true">https://kamathrobotics.com/adding-voice-control-to-lekiwi</guid><dc:creator><![CDATA[Aditya Kamath]]></dc:creator><pubDate>Sun, 21 Jun 2026 19:22:24 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/3d28a9d5-42a0-4697-a609-7406c9440c1c.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>I've been gradually turning <a href="https://github.com/adityakamath/lekiwi_ros2">LeKiwi</a> into a proper ROS 2 robot as part of an <a href="https://kamathrobotics.com/series/lekiwi">ongoing series</a>, and alongside that, tinkering with AI agents that live on LeKiwi's Raspberry Pi. So far, I've set up <a href="https://github.com/HKUDS/nanobot"><code>nanobot</code></a> (a lightweight OpenClaw alternative), and have been vibe-coding <a href="https://github.com/adityakamath/ros2-skill"><code>ros2-skill</code></a>- a local <a href="https://agentskills.io/home">Agent Skill</a> consisting of rules and scripts for running ROS 2 commands via natural language. I decided to build my own, as a learning experience, inspired by projects like <a href="https://github.com/robotmcp/ros-mcp-server"><code>ros-mcp-server</code></a>, <a href="https://agenticros.com/"><code>AgenticROS</code></a> and <a href="https://github.com/lpigeon/ros-skill"><code>ros-skill</code></a>. Currently, I can communicate with my <code>nanobot</code> agent over Discord by typing commands and reading the responses back; however, the obvious next step is actually to speak with it. That means adding a microphone and a speaker to LeKiwi.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/b8a8d291-bd9a-467f-b085-f933b4049cda.jpg" alt="" style="display:block;margin:0 auto" />

<p>Easier said than done. The robot's base is already packed - three motors, a battery, the servo control board, a voltage regulator, and a webcam - and the top deck has the Pi, the <a href="https://kamathrobotics.com/upgrading-the-lekiwi-into-a-lidar-equipped-explorer">LiDAR</a>, the <a href="https://kamathrobotics.com/bno055-imu-hardware-abstraction">IMU</a>, and the <a href="https://kamathrobotics.com/bno055-imu-hardware-abstraction">pan-tilt mechanism</a> with an <a href="https://shop.luxonis.com/products/oak-d-s2?srsltid=AfmBOoqwsy9A8cDipJMi3mKFkaX_GCBNmmDXqWJoTcLYWP0mzEpuOG4G">OAK-D S2 depth camera</a> attached. Most of the mic-and-speaker combos on the market are single boards shaped like smart-speaker pucks, built to sit on their own rather than be tucked into the gaps of an already-full chassis. I looked at a handful of options, and none of them solved this problem.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/47b9bdd1-2c98-4c31-8716-582fb3f97682.jpg" alt="" style="display:block;margin:0 auto" />

<p>It took a trip to Berlin to find one that met my requirements. At the <a href="https://kamathrobotics.com/ohs-2026-berlin">Open Hardware Summit 2026</a>, I spent some time at <a href="https://www.seeedstudio.com/">Seeed Studio</a>'s booth and came across their new <a href="https://www.seeedstudio.com/catalogsearch/result/?q=respeaker+flex&amp;warehouse=CN+Warehouse+In+Stock%2CCN+or+US+Warehouse+In+Stock%2CCN+or+DE+Warehouse+In+Stock">reSpeaker Flex</a> lineup. Out of everything I'd looked at, this was the first solution that was actually compact enough to fit onto the LeKiwi. Just to be clear, this is only one of Seeed's reSpeaker lineup; they also make the smart-speaker pucks I was talking about earlier.</p>
<p>This post covers the capabilities of the reSpeaker Flex, how I mounted it on LeKiwi, and a demo script that tests the microphone features.</p>
<h2>Meet the reSpeaker Flex (XVF3800)</h2>
<p>The reSpeaker Flex is Seeed's mic array system built around the <a href="https://www.xmos.com/xvf3800">XMOS XVF3800 DSP</a>, sold as two separate boards - a mic array board, and a control board carrying the DSP, USB-C, and the speaker output, connected by an FPC cable. That split is exactly what allowed me to mount it on the LeKiwi: the mic array can sit wherever it needs a clear view, and the control board can go wherever there's room, instead of needing one spot for both.</p>
<p>The mic array board comes in two variants. The <a href="https://www.seeedstudio.com/reSpeaker-Flex-XVF3800-Circular-4-p-6737.html">Circular-4</a> arranges its four microphones in a ring for 360° pickup. The <a href="https://www.seeedstudio.com/reSpeaker-Flex-XVF3800-Linear-4-p-6738.html">Linear-4</a> instead puts the same four mics in a row for more directional capture. Both mic arrays pair with the same control board.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/b94ff5f4-7015-41a3-a41f-49c6a87b6b76.jpg" alt="" style="display:block;margin:0 auto" />

<table style="min-width:50px"><colgroup><col style="min-width:25px"></col><col style="min-width:25px"></col></colgroup><tbody><tr><td><p>Chip</p></td><td><p>XMOS XVF3800</p></td></tr><tr><td><p>Microphones</p></td><td><p>4-mic array, 44mm spacing (Circular-4) / 33mm (Linear-4)</p></td></tr><tr><td><p>Interface</p></td><td><p>USB Audio Class 2.0 + USB control</p></td></tr><tr><td><p>On-chip DSP</p></td><td><p>Acoustic Echo Cancellation (AEC), Automatic Gain Control (AGC), beamforming, noise suppression, de-reverberation, Direction of Arrival (DoA), Voice Activity Detection (VAD)</p></td></tr><tr><td><p>Expansion</p></td><td><p><a target="_self" rel="noopener noreferrer nofollow" class="text-primary underline underline-offset-2 hover:text-primary/80 cursor-pointer" href="https://www.seeedstudio.com/catalogsearch/result/?q=xiao&amp;warehouse=CN+Warehouse+In+Stock%2CCN+or+US+Warehouse+In+Stock%2CCN+or+DE+Warehouse+In+Stock" style="pointer-events:none">XIAO</a> expansion footprint on the control board</p></td></tr></tbody></table>

<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/8f5ae29b-6ffe-4976-9fcb-888756e1acdc.jpg" alt="" style="display:block;margin:0 auto" />

<p>For LeKiwi, the Circular-4 was the obvious pick - a robot gets approached from any direction, so 360° pickup matters more than aiming at one side of a room. And since the Raspberry Pi already runs the rest of the stack, I skipped <a href="https://wiki.seeedstudio.com/respeaker_flex_xiao_introduction/">the XIAO option</a> and wired the control board's USB-C straight into the Pi.</p>
<h2>Mounting on LeKiwi</h2>
<p>With Circular-4 picked, the next question was where on LeKiwi each board would actually go.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/f0694564-d64f-43c7-bf9a-5fec46ab8ff4.png" alt="" style="display:block;margin:0 auto" />

<p>The mic array board is a ring, and it turns out that the ring fits perfectly around LeKiwi's <a href="https://www.ldrobot.com/images/2023/05/23/LDROBOT_LD19_Datasheet_EN_v2.6_Q1JXIRVq.pdf">LD19</a> LiDAR. That puts the microphones up high, away from the wheels and motors, with a (nearly) clear 360° view for picking up voices from any direction.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/6ff07fb5-f80b-42d5-be9c-6e8533f2dcc1.jpg" alt="" style="display:block;margin:0 auto" />

<p>The control board went the other way - tucked between the base plates, alongside the Pi and the rest of the electronics, just behind where a webcam used to sit. One cable runs from the control board up to the mic ring around the LiDAR, and a single USB cable connects the control board to the Pi. I decided to remove the webcam, which not only freed up space but also a USB port on the Pi. Since I already have an OAK-D S2 camera on the pan-tilt mechanism, the lower-quality webcam was redundant.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/947bd729-14c1-4996-90d2-e872633b1caa.jpg" alt="" style="display:block;margin:0 auto" />

<p>For the speaker, I just grabbed a small, off-the-shelf 5W 8Ω driver from Amazon, which plugged right into the control board's JST-PH connector. It's mounted on the top deck, right next to the mic array. That said, I haven't configured audio playback yet - the speaker is connected but silent for now, which means anything that requires simultaneous playback and capture (like AEC) couldn't be tested in this round.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/36cd9d6d-b70a-440a-be60-b380e186ef4b.jpg" alt="" style="display:block;margin:0 auto" />

<p>With the hardware in place, it was time to get it running and see what the microphone could actually do.</p>
<h2>Getting It Running</h2>
<p>Plugging it in was the easy part - one USB cable into the Pi, a udev rule so I don't need root to talk to it, and <a href="https://github.com/pyusb/pyusb"><code>pyusb</code></a> installed. From there, everything below is really just a parameter on the XVF3800 that I can read over the same USB connection.</p>
<p>I first plugged it into the Raspberry Pi via the USB-C cable. Next, to get the host to actually talk to the respeaker, I needed to install <code>pyusb</code> and set up a <code>udev</code> rule for the reSpeaker device.</p>
<pre><code class="language-bash">python3 -m pip install pyusb
sudo apt install alsa-utils
</code></pre>
<p><code>pyusb</code> is what the test script uses to send control commands over USB. <code>alsa-utils</code> provides <code>arecord</code>, which the script shells out to for the recordings in the far-field and noise-suppression tests.</p>
<p>By default, talking to a USB device's control interface needs root, which would mean the test script would need to be run with <code>sudo</code> every single time. A <code>udev</code> rule fixes that by opening up permissions for this specific device's vendor/product ID as soon as it's plugged in. I saved this as <code>/etc/udev/rules.d/99-respeaker.rules</code>:</p>
<pre><code class="language-plaintext">SUBSYSTEM=="usb", ATTR{idVendor}=="2886", ATTR{idProduct}=="001e", MODE="0666", TAG+="uaccess"
</code></pre>
<p>I then reloaded <code>udev</code> and replugged the device for it to take effect:</p>
<pre><code class="language-bash">sudo udevadm control --reload-rules
sudo udevadm trigger
</code></pre>
<p>To get an understanding of the microphone's capabilities, I wrote the <a href="https://gist.github.com/adityakamath/40d02028f3a0332b4bff32cf09f1a4fc"><code>test_mic.py</code></a> script, which runs through DoA, far-field capture, and noise suppression in one go, saving audio clips along the way:</p>
<pre><code class="language-bash">python3 test_mic.py
</code></pre>
<p>Here's what each of those tests looks like in practice.</p>
<h3>Direction of Arrival and Voice Activity Detection</h3>
<p>First up was the direction of arrival (DoA). The XVF3800 can estimate which way a voice is coming from - 0 to 359° around the mic array - along with a voice activity flag (VAD) for whether anyone's actually talking at any given instant. For a robot, that's the obvious starting point: before LeKiwi can turn to face whoever's speaking, it needs to know which way to turn.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/604c0bca-c35f-4a49-8ea8-4e5e21a61da3.gif" alt="" style="display:block;margin:0 auto" />

<p><code>python3 test_mic.py --tests doa --doa-duration 30</code> Just polls DoA and VAD for 30 seconds and prints the results. Walk around the mic array while talking, and the angle should update while the VAD flag indicates whether someone's speaking.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/bb34bb20-7e1d-45d2-8b38-d22e45853339.png" alt="" style="display:block;margin:0 auto" />

<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/7d139706-da9e-4f08-803d-9e6c39cab490.png" alt="" style="display:block;margin:0 auto" />

<blockquote>
<p>Impressions: I tested DoA and VAD by walking around the robot while counting, and it was able to track the DoA quite decently. The beamforming provides a general direction of the sound source quite well, but it wasn't perfect, the angle would jump around a bit, but it could easily be attributed to the distance from the microphone and different surfaces around the microphone which could reflect the sound. For the LeKiwi, I would say that the DoA is good enough to turn the robot towards the person speaking, and then use the camera to identify the person and track them. The VAD worked perfectly, detecting actual speech and ignoring background noises.</p>
</blockquote>
<h3>Far-Field Capture and Automatic Gain Control</h3>
<p>Next was far-field capture - can it actually hear me from across the room, not just right up against it? The XVF3800 leans on beamforming for the spatial side, paired with automatic gain control (AGC) to even out the volume, so a whisper from a meter away and a normal voice from across the room come out in roughly the same range.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/1394f717-e243-4a78-97b4-7df2a991c0ff.jpg" alt="" style="display:block;margin:0 auto" />

<p><code>python3 test_mic.py --tests farfield</code> records the same phrase at 0.5m, 2m, and 4m, prompting me for each one and logging the gain alongside DoA/VAD so the clips and the numbers line up.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/622632a2-2ae2-47d0-a86a-7a2150f88357.png" alt="" style="display:block;margin:0 auto" />

<blockquote>
<p>Impressions: As per the prompts from the script, I counted the same numbers with the same volume at 0.5m, 2m, and 4m away from the robot. The VAD was a bit weird and did not work at times, but the AGC adjusted the gain accordingly - a promising sign that LeKiwi should be able to hear commands from across the room, though I haven't done a real side-by-side listen of the clips yet to confirm.</p>
</blockquote>
<h3>Noise Suppression</h3>
<p>Last up was noise suppression. The LeKiwi, especially with the STS3215 motors, is quite a noisy robot, which must be able to detect voices while operating with all that noise. Luckily for us, the XVF3800 handles both the steady stuff (fan hum) and the sudden stuff (a door slamming) entirely on-chip, with no extra work for the Pi.</p>
<p><a class="embed-card" href="https://youtu.be/aiFu5fVe94Y">https://youtu.be/aiFu5fVe94Y</a></p>

<p><code>python3 test_mic.py --tests noise</code> records the same phrase with background noise running, once with that processing switched off and once with it on - a direct A/B, plus a rough before/after noise-floor comparison.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/d48a960d-dc8c-4c01-a43b-0e168c450a11.png" alt="" style="display:block;margin:0 auto" />

<blockquote>
<p>Impressions: In both cases, I drove the robot around while standing at the same place and counting with the same volume. The test runs in two phases, once with the noise suppression off and once when turned on. Comparing the two, it determines the amount of noise that was suppressed, and it seems to be working well. It was also able to detect speech using VAD in both cases, and the DoA also varied as I moved the robot around and rotated it, although this wasn't very accurate.</p>
</blockquote>
<h2>What's Next</h2>
<p>With the mic array performing well for my voice-control application, the next step is to configure audio playback so that the speaker is actually usable, which will allow me to validate features like Acoustic Echo Cancellation (AEC).</p>
<p>Once the audio playback is working fine, I want to set up a pipeline that uses wake word detection and records voice commands using the mic-array, converts the speech to text, passes this to nanobot, and converts nanobot's response to speech, which is played back from the speaker.</p>
<p>Of course, I need to fine-tune this plan further. For now, I'm glad to have assembled the reSpeaker flex onboard the LeKiwi and made sure that it is working. The test script I used can be found in <a href="https://gist.github.com/adityakamath/40d02028f3a0332b4bff32cf09f1a4fc">this GitHub Gist</a>. You can find more resources and example applications in <a href="https://wiki.seeedstudio.com/respeaker/">Seeed's reSpeaker Wiki</a>.</p>
<p>As always, feedback and contributions are welcome. If you have ideas or experience setting up voice-control or voice assistants, then please reach out! This is my first time doing anything with audio, and I have a lot of questions.</p>
]]></content:encoded></item><item><title><![CDATA[Autonomous Navigation with LeKiwi]]></title><description><![CDATA[tl;dr: This article details how I added autonomous navigation to my LeKiwi robot using Nav2, covering LiDAR filtering, mapping, and localization, and configuring Nav2 for a holonomic platform. I used ]]></description><link>https://kamathrobotics.com/autonomous-navigation-with-lekiwi</link><guid isPermaLink="true">https://kamathrobotics.com/autonomous-navigation-with-lekiwi</guid><dc:creator><![CDATA[Aditya Kamath]]></dc:creator><pubDate>Sat, 13 Jun 2026 08:30:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/a39c4cb8-7511-4826-853b-1721204f9a26.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><strong>tl;dr:</strong> This article details how I added autonomous navigation to my LeKiwi robot using Nav2, covering LiDAR filtering, mapping, and localization, and configuring Nav2 for a holonomic platform. I used Foxglove’s 3D panel throughout to watch the map grow during mapping, set the initial pose estimate for localization, and send navigation goals directly on the map. The result is a mobile base that navigates autonomously, with a seamless switch between teleop and Nav2 control.</p>
<p>Originally written for and published on the <a href="https://foxglove.dev/blog/autonomous-navigation-with-lekiwi-and-nav2">Foxglove blog</a></p>
<hr />
<h2>Project Context</h2>
<p>This is the next chapter in my <a href="https://kamathrobotics.com/series/lekiwi">ongoing series</a> of building a ROS 2-powered autonomous mobile robot based on the <a href="https://github.com/SIGRobotics-UIUC/LeKiwi">LeKiwi</a> platform. So far, I’ve <a href="https://foxglove.dev/blog/upgrading-the-lekiwi-into-a-lidar-equipped-explorer">integrated a LiDAR</a>, <a href="https://foxglove.dev/blog/calibrating-a-monocular-camera-for-the-lekiwi-robot-using-ros-2">calibrated the onboard camera</a>, implemented <a href="https://foxglove.dev/blog/teleoperating-the-lekiwi-from-a-steam-deck">teleoperation using a Steam Deck</a>, and added a <a href="https://kamathrobotics.com/bno055-imu-hardware-abstraction">BNO055 IMU</a> with <a href="https://kamathrobotics.com/sensor-fusion-on-lekiwi">sensor fusion</a> for reliable odometry.</p>
<p>With the perception stack, teleoperation, and odometry in place, autonomous navigation was the obvious next step. This post covers how I got <a href="https://docs.nav2.org/">Nav2</a>, a popular ROS 2 navigation library, running on LeKiwi, from the sensor prerequisites through to sending navigation goals in Foxglove.</p>
<p>The implementation lives in the <a href="https://github.com/adityakamath/lekiwi_ros2/tree/main/lekiwi_navigation"><code>lekiwi_navigation</code></a> package in my <a href="https://github.com/adityakamath/lekiwi_ros2"><code>lekiwi_ros2</code></a> repository.</p>
<h2>Step 1: Setting Everything Up</h2>
<p>For the basic Nav2 setup, you need a reliable odometry transform (<code>odom → base_link</code>) and a clean LiDAR scan. I could also have gone down the vision route and used the on-board cameras for visual-inertial odometry (VIO) or visual SLAM (vSLAM), but I chose to keep it simple for now.</p>
<h3>Odometry and Sensor Fusion</h3>
<p>The <a href="https://kamathsblog.com/serial-bus-servo-motors-in-2025">STS3215</a> motors used in the LeKiwi provide accurate state feedback (position and velocity), so the odometry from the <a href="https://control.ros.org/master/doc/ros2_controllers/omni_wheel_drive_controller/doc/userdoc.html"><code>omni_wheel_drive_controller</code></a> is quite reliable. However, since I had already integrated the BNO055 IMU, I fused its measurements with the wheel odometry using the Extended Kalman Filter (EKF) node from <a href="https://github.com/cra-ros-pkg/robot_localization"><code>robot_localization</code></a> to get a more robust odometry estimate. This provides a stable <code>odom → base_footprint</code> transform that both Nav2 and slam_toolbox rely on. I’ve written a <a href="https://kamathrobotics.com/sensor-fusion-on-lekiwi">detailed article</a> about the sensor fusion implementation earlier on this blog.</p>
<p><a class="embed-card" href="https://www.youtube.com/watch?v=PFRJnBID5ME">https://www.youtube.com/watch?v=PFRJnBID5ME</a></p>

<p>I’ve now got a working odometry source that runs at 50 Hz and publishes filtered/fused odometry messages on <code>/odometry/filtered</code>, along with the required <code>odom → base_footprint</code> transform (Note: on my robot, <code>base_footprint</code> is the root frame of my platform, not <code>base_link</code>).</p>
<h3>LiDAR Filtering</h3>
<p>When I covered the LiDAR integration previously, the raw scan provided a 360° scan, but since then, I’ve added a <a href="https://kamathrobotics.com/pan-tilt-controls-using-ros-2">pan-tilt camera mechanism</a> that sits right in front of the LiDAR. Without filtering, part of the scan would show up as a permanent obstacle during mapping.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/1e3b4ff0-64a9-45f9-b5a5-9f485a0d4b1c.webp" alt="" style="display:block;margin:0 auto" />

<p>To fix this, I used <a href="https://index.ros.org/p/laser_filters/">laser_filters</a>. The <code>scan_to_scan_filter_chain</code> node subscribes to the raw LiDAR output (now remapped to <code>/scan_raw</code>), applies an angular range filter that masks out the sector occupied by the pan-tilt mechanism (nearly 70°), and republishes on <code>/scan</code>. Everything downstream subscribes to this filtered scan topic.</p>
<pre><code class="language-yaml"># scan_filter.yaml (excerpt)
scan_filter_chain:
  - name: angle_filter
    type: laser_filters/LaserScanAngularBoundsFilterInPlace
    params:
      lower_angle: -0.6 # rad
      upper_angle: 0.6 # rad
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/14d22d3f-06d8-4e59-9a17-d118d12b79a4.webp" alt="" style="display:block;margin:0 auto" />

<p>With the filter in place, the scan displayed in Foxglove’s 3D panel looks as expected: a clean scan around the robot, with the obstructed sector correctly filtered out.</p>
<h2>Step 2: Creating the Map</h2>
<p>With odometry and a clean scan in place, the next step was to build a map so that I could navigate in it.</p>
<h3>Mapping using <code>slam_toolbox</code></h3>
<p>I used the recommended approach of using <a href="https://github.com/SteveMacenski/slam_toolbox"><code>slam_toolbox</code></a> in asynchronous mapping mode to build the map. This package implements Simultaneous Localization and Mapping (SLAM) using a pose graph approach and builds a map incrementally as the robot moves. The asynchronous mode lets it process scans as fast as possible without blocking the rest of the system, which matters on a Pi 5.</p>
<p>The main tuning I did was to reduce the scan processing rate (the LiDAR runs at 10 Hz, which is more than <code>slam_toolbox</code> needs for a slow-moving indoor robot), cap the laser range to something sensible for indoor use (8 meters in my case), and push down the map rasterization frequency since that step is expensive and the default is unnecessarily aggressive. Nothing fancy, just bringing the defaults in line with the hardware.</p>
<p>I then drove the robot around until the map looked complete in Foxglove’s <a href="https://docs.foxglove.dev/docs/visualization/panels/3d">3D Panel</a>, watching it grow and close loops in real time.</p>
<p><a class="embed-card" href="https://www.youtube.com/watch?v=CI1aBMa5S0s">https://www.youtube.com/watch?v=CI1aBMa5S0s</a></p>

<h3>Saving the Map</h3>
<p>You could technically keep using mapping mode for navigation; <code>slam_toolbox</code> would localize and update the map simultaneously. The problem is that it starts from scratch every session, which is not ideal. So, in order to have a consistent map for navigation, I needed to save the map and use it with a dedicated localization method for autonomous navigation.</p>
<p><code>slam_toolbox</code> lets you save maps using service calls, but this means that I need to interrupt my mapping session, switch to a terminal, and call the service every time I want to save a map. To make my life easier, I wrote a small <a href="https://github.com/adityakamath/lekiwi_ros2/blob/main/lekiwi_navigation/lekiwi_navigation/map_saver_node.py"><code>map_saver_node</code></a> script that listens to <code>/joy</code> messages and triggers the save when I press the R1 button on the controller. This way, I can save the map on the fly. When triggered, the node does two things in sequence:</p>
<ol>
<li><p>Calls <code>/slam_toolbox/save_map</code> service, which writes <code>map.pgm</code> and <code>map.yaml</code> for use with <a href="https://docs.nav2.org/configuration/packages/configuring-amcl.html">AMCL</a> and <a href="https://index.ros.org/p/nav2_map_server/"><code>nav2_map_server</code></a>.</p>
</li>
<li><p>Calls <code>/slam_toolbox/serialize_map</code> service, which writes <code>map.posegraph</code> and <code>map.data</code>, the binary format <code>slam_toolbox</code> needs for its own localization mode.</p>
</li>
</ol>
<h2>Step 3: Localize in the Map</h2>
<p>With the map saved, the next question is where the robot is within it. Nav2 needs a <code>map → odom</code> transform to plan and execute paths, which is the job of the localization layer. My original plan was to use <code>slam_toolbox</code>’s built-in localization mode, which can do this, but I also wanted to try AMCL for comparison, since I’ve been using it since ROS 1 and am more familiar with it. Eventually, I decided to keep both and use a launch argument to switch between them.</p>
<h3>Option A: Localization using <code>slam_toolbox</code></h3>
<p><code>slam_toolbox</code> offers a localization mode that reuses the serialized pose graph from the mapping step. It loads the previously saved <code>map.posegraph</code> and <code>map.data</code> files and runs scan matching against the stored structure, so it can handle loop closures and continuously refine the map as the robot moves. The robot starts from the map origin or from a known position configured at launch; either way, once scan matching locks in, it stays solid.</p>
<p><a class="embed-card" href="https://www.youtube.com/watch?v=DYTZX7KawUA">https://www.youtube.com/watch?v=DYTZX7KawUA</a></p>

<p>The catch is that the robot’s starting position needs to be somewhere recognizable in the saved map. On my setup, I just start the robot from the same spot every session, and localization snaps in immediately. In the above video, while the localization is perfect, the map has changed; I’ve closed a door and added an obstacle. Despite this, the localization is never lost, and in addition, the map is eventually updated, which can then be saved again.</p>
<h3>Option B: AMCL</h3>
<p><a href="https://docs.nav2.org/configuration/packages/configuring-amcl.html">AMCL</a> (Adaptive Monte Carlo Localization) works from the static <code>map.yaml</code> loaded by <a href="https://docs.nav2.org/configuration/packages/configuring-map-server.html"><code>nav2_map_server</code></a>. It runs a particle filter and can converge from any starting position; you just need to give it an initial pose estimate. The map stays fixed (no loop closures, no incremental updates), but in return, you can start anywhere and re-initialize at any time.</p>
<p><a class="embed-card" href="https://www.youtube.com/watch?v=ZyANdSa4yMw">https://www.youtube.com/watch?v=ZyANdSa4yMw</a></p>

<p>For AMCL, I provide the initial pose estimate directly from Foxglove. The <a href="https://docs.foxglove.dev/docs/visualization/panels/3d">3D panel</a> lets you publish a 2D pose estimate by clicking on the map, which publishes a <a href="https://docs.ros.org/en/foxy/api/geometry_msgs/html/msg/PoseWithCovarianceStamped.html"><code>geometry_msgs/PoseWithCovarianceStamped</code></a> on <code>/initialpose</code>, exactly what AMCL listens to. In the video above, I provide an approximate position estimate that is not too far away from the robot’s actual position. However, as I move the robot around, AMCL improves the position estimate. This can be seen from the covariance ellipse, which shows the uncertainty and reduces in size as the localization improves. One downside of AMCL is that it does not update the map, even though some minor changes are clearly visible. Despite this, the localization is still quite precise. However, if the environment changes drastically, I would not recommend AMCL unless a new map is created first.</p>
<h3>Switching between Modes</h3>
<p>Now, I have three separate operating modes: <code>slam_toolbox</code> mapping, <code>slam_toolbox</code> localization, and AMCL. I implemented all three in a single <a href="http://slam.launch.py"><code>slam.launch.py</code></a> with a <code>nav_mode</code> launch argument for switching between modes, which is set in the top-level bringup launch file and passed down. Here’s how to use it:</p>
<pre><code class="language-shell"># Mapping with slam_toolbox
ros2 launch lekiwi_navigation navigation.launch.py nav_mode:=slam

# Localization with slam_toolbox
ros2 launch lekiwi_navigation navigation.launch.py nav_mode:=slam map_name:=studio

# Localization with AMCL
ros2 launch lekiwi_navigation navigation.launch.py nav_mode:=amcl map_name:=studio
</code></pre>
<p>Note that the <code>map_name</code> argument specifies which map to load for localization - it corresponds to the name of the folder created by the <code>map_saver_node</code> script, which contains all the necessary files for both localization modes.</p>
<h2>Step 4: Navigating the Map</h2>
<p>Now that I have everything in place - reliable odometry, a clean scan, a map, and the ability to localize in it - the last step is configuring Nav2 itself.</p>
<h3>Nav2 Configuration</h3>
<p>I wanted to get the basic configuration up and running, so I started with <a href="https://github.com/ros-navigation/navigation2/blob/main/nav2_bringup/launch/navigation_launch.py"><code>navigation_launch.py</code></a> from <a href="https://github.com/ros-navigation/navigation2/blob/main/nav2_bringup/README.md"><code>nav2_bringup</code></a> as a reference, and then stripped down the example configuration to use only what I needed. I put this in a custom <a href="https://github.com/adityakamath/lekiwi_ros2/blob/main/lekiwi_navigation/config/nav2/nav2.yaml"><code>nav2.yaml</code></a>.</p>
<p>Nav2 is not a monolithic stack; it’s a set of separate nodes orchestrated by <code>bt_navigator</code>. When a goal arrives, <code>bt_navigator</code> runs a Behavior Tree: it asks <code>planner_server</code> for a global path, hands it to <code>controller_server</code> to follow it, and calls <code>behavior_server</code> if the robot gets stuck. I used the default Behavior Tree config, which is all you need for a simple point-to-point navigation project.</p>
<p>Since LeKiwi is holonomic, I went with <a href="https://docs.nav2.org/configuration/packages/configuring-mppic.html">MPPI</a> as the controller choice with <code>motion_model: Omni</code>. For the planner, I went with <a href="https://docs.nav2.org/configuration/packages/smac/configuring-smac-2d.html">SMAC 2D</a>: A* on the costmap grid with no kinematic constraints, which suits a holonomic robot well. One thing to watch: velocity limits need to match the hardware and be set consistently across all Nav2 components, or MPPI will plan trajectories that the controller will reject. Once again, my goal was to get a simple configuration working first, so I did not dig into the advanced configuration options. My full configuration can be found <a href="https://github.com/adityakamath/lekiwi_ros2/blob/main/lekiwi_navigation/config/nav2/nav2.yaml">here</a>.</p>
<p><a class="embed-card" href="https://www.youtube.com/watch?v=gJRcThqgZx8">https://www.youtube.com/watch?v=gJRcThqgZx8</a></p>

<p>To send navigation goals, I once again used Foxglove’s 3D panel, which has a built-in navigation goal publisher (I had to change the default topic name to <code>/goal_pose</code>). I click a point on the map, drag to set the heading, and the robot starts navigating. Watching the costmap update, the planned path appear, and the robot follow it in real time in the same panel is what makes Foxglove particularly satisfying for this kind of work.</p>
<p><a class="embed-card" href="https://www.youtube.com/watch?v=nb6_P3YfnfQ">https://www.youtube.com/watch?v=nb6_P3YfnfQ</a></p>

<p>In the video above, I also show how to run the navigation stack in mapping mode. While it is fun to play with and quite useful for mapping unknown spaces without teleoperation, I would not choose this method over navigating on a pre-made map. The map provides crucial information about the robot’s environment, and the process of creating it is computationally expensive, so it is best to create it once and reuse it.</p>
<h3>Switching between Twist Messages</h3>
<p>With Nav2 generating velocity commands alongside teleoperation, I needed a way to switch between the two, as both publish to the same base controller and would conflict.</p>
<p>So, I wrote a <a href="https://github.com/adityakamath/lekiwi_ros2/blob/main/lekiwi_control/lekiwi_control/twist_switch_node.py"><code>twist_switch_node</code></a> that lives in <a href="https://github.com/adityakamath/lekiwi_ros2/tree/main/lekiwi_control"><code>lekiwi_control</code></a>, subscribes to Twist messages from both teleop and Nav2, and forwards one to the base controller via a <code>SetBool</code> service (<code>/twist_switch</code>) that selects the active source. I then configured <a href="https://index.ros.org/p/joy_teleop/"><code>joy_teleop</code></a> to call this service based on button presses on the controller, so I can toggle between the two Twist sources without switching to a terminal. In the video below, I was using my laptop to visualize and control the robot via Foxglove, so I decided to use my handy <a href="https://github.com/adityakamath/foxglove_extensions/tree/main/button">Button extension</a> for switching. You can find this extension in <a href="https://github.com/foxglove/extension-registry">Foxglove’s extension registry</a>, and I’ve also <a href="https://kamathrobotics.com/i-made-a-button">written about it</a> if you are interested in reading how I built it.</p>
<p><a class="embed-card" href="https://www.youtube.com/watch?v=_G4hcKK4TVI">https://www.youtube.com/watch?v=_G4hcKK4TVI</a></p>

<p>This node made testing genuinely fast and intuitive. I could send a navigation goal while still in teleop, manually reposition the robot, and watch the planned path update in real time in Foxglove’s 3D panel. Switching to autonomous mode at any point would immediately kick off navigation along the latest path. I’ve demonstrated this in the video above. In the video, I also show how the navigation stack responds when I set a new goal while the robot is already moving towards another goal.</p>
<h2>What's next?</h2>
<p>Getting Nav2 running on the LeKiwi was a fun and rewarding process, and Foxglove was a huge help throughout. The ability to visualize the map, the robot’s position, and the planned paths, while providing initial pose estimates and navigation goals directly from the same interface, made the whole process much smoother. The fact that I could do all of this remotely from my laptop, without needing a ROS 2 installation, was a game-changer for the development experience.</p>
<p>Now that the navigation stack is working well enough to be useful, I can start navigating around the house while iteratively optimizing the Nav2 configuration. Next, I want to explore more advanced topics such as different planners and controllers, speed-limited and no-go zones, autonomous mapping/remapping, and eventually implement waypoint following for more complex missions.</p>
<p>The full implementation so far is available in the <a href="https://github.com/adityakamath/lekiwi_ros2">lekiwi_ros2</a> package on GitHub. As always, please proceed with caution, as the package is constantly evolving and may contain breaking changes. That said, feel free to check it out, replicate it on your own robots, and let me know if you have suggestions for improvements. Happy navigating!</p>
]]></content:encoded></item><item><title><![CDATA[OHS 2026 - Berlin 🇩🇪]]></title><description><![CDATA[I first heard about the Open Hardware Summit at ROSCon last year and was immediately intrigued. When I discovered it fell on the same weekend as my birthday, attending became a no-brainer. My original]]></description><link>https://kamathrobotics.com/ohs-2026-berlin</link><guid isPermaLink="true">https://kamathrobotics.com/ohs-2026-berlin</guid><dc:creator><![CDATA[Aditya Kamath]]></dc:creator><pubDate>Fri, 29 May 2026 18:00:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/69782cb5-ed27-4a3e-b1a5-89840ad00141.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>I first heard about the <a href="https://2026.oshwa.org/">Open Hardware Summit</a> at ROSCon last year and was immediately intrigued. When I discovered it fell on the same weekend as my birthday, attending became a no-brainer. My original plan was modest: spend the first day listening to talks, visiting exhibits, and networking; use the birthday to explore Berlin, catch up with friends, and have a birthday dinner before flying home. I seriously underestimated how much fun the summit would be, and ended up staying far longer than I'd planned. Here are some highlights from the event.</p>
<h2>Talks</h2>
<p>I flew from Amsterdam to Berlin on the morning of May 23rd and reached the summit venue - the <a href="https://www.tu.berlin/en/math">TU Berlin mathematics building</a> - by 10:30 AM.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/0fb3deee-37f2-4afc-babc-58c21953c489.jpg" alt="" style="display:block;margin:0 auto" />

<p>At registration, I picked up my badge and goodie bag. I'd seen <a href="https://x.com/kscottz">Kat Scott</a>'s <a href="https://www.linkedin.com/feed/update/urn:li:activity:7463542767219085312/">LinkedIn post</a> beforehand and had some idea what to expect, but I was still pleasantly surprised. Coming from developer conferences and trade shows, I'm used to branded notebooks, pens, and stickers. Here, the goodie bag included microcontrollers, a DIY air quality monitor kit, an <a href="https://www.ifixit.com/">iFixit</a> precision screwdriver set, a <a href="https://nl.tonyschocolonely.com/?utm_source=googleads&amp;utm_medium=ppc&amp;utm_campaign=CLVN_Tonys_NL_NL_GGL_PPC-Brand_RSA_B2B%7C%7C%7C%7C___&amp;utm_format=rsa&amp;utm_source=google&amp;utm_medium=cpc&amp;utm_campaign=CR-NL.BE-S-BR&amp;gad_source=1&amp;gad_campaignid=21060908380&amp;gbraid=0AAAAABRrNiDyoXcmNZ1dSKUuFtGU8YziK&amp;gclid=Cj0KCQjwlerQBhDMARIsAB16H-UZxW6P4B3FZgjdqNE1yRUN8IFI1gQpxAaVm9bd_6SCRrLHjtwox3oaAmnFEALw_wcB">Tony's Chocolonely</a> bar (best chocolate in the world, btw), a calendar, a book, and a tote bag. Actual useful stuff. The badge wasn't paper either - it was a PCB (designed by <a href="https://bleeptrack.de/">bleeptrack</a>), bundled with some parts: a coin cell battery, battery holder, headers, LEDs, and wires.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/915f2c76-623d-4831-b997-00df5811e865.jpg" alt="" style="display:block;margin:0 auto" />

<p>I couldn't check into my hotel yet and was already tired of lugging my backpack around, so I dropped into the auditorium to rest, listen to some talks, and plan my next move. I ended up sitting through quite a few across the day, with breaks to visit the exhibition area and talk to people. The full schedule is on the <a href="https://2026.oshwa.org/talks/">OHS26 website</a>, and all recordings are on the <a href="https://www.youtube.com/watch?v=wvvGG3PTaVc&amp;list=PLN2I5IwhHQ4pM4CL1MtvIW4Nc7xitNRfi">OSHWA YouTube channel</a>. Here are the talks that stood out to me:</p>
<h3>Wearable Projectors for the Opera</h3>
<p>This talk was already in progress when I entered the auditorium, so I only caught the second half. The presenters built wearable projectors for an opera performance at <a href="https://www.metmuseum.org/plan-your-visit/met-cloisters?utm_source=google&amp;utm_medium=cpc&amp;utm_campaign=&amp;utm_term=the%20met%20cloisters&amp;utm_content=39536&amp;mkwid=s&amp;pcrid=610533365647&amp;pmt=b&amp;pkw=the%20met%20cloisters&amp;pdv=c&amp;slid=&amp;product=&amp;gad_source=1&amp;gad_campaignid=17750219881&amp;gbraid=0AAAAADmlGN6G-7AxL_Y1TlefAaB236hZR&amp;gclid=Cj0KCQjwlerQBhDMARIsAB16H-W2jGnWd8tGTX1_ZotreaauWhquKSOPIbONVtdU91LIJtki4g46cs4aArUSEALw_wcB">The Met Cloisters</a> - a museum where cable runs weren't possible. It is always fun to see open hardware show up in the arts. They mentioned having the projectors with them at the event, and I wish I'd tracked them down - I'd have loved to see them working in person.</p>
<p><a class="embed-card" href="https://www.youtube.com/watch?v=m8o45XHPeHk">https://www.youtube.com/watch?v=m8o45XHPeHk</a></p>

<h3>Building a DIY Scanning Tunneling Microscope</h3>
<p>Given my background in high-precision mechatronics, this one caught my attention. Precision microscopes require both coarse and fine positioning stages, typically through two separate actuators. <a href="https://www.linkedin.com/in/sam-harley-13752b109/">Sam Harley</a> found a way to combine both into a single actuator - genuinely impressive engineering - and then open-sourced the whole thing. Definitely on my list of topics to dig into when I get the chance.</p>
<p><a class="embed-card" href="https://www.youtube.com/watch?v=Xmqj9LZ6IAg">https://www.youtube.com/watch?v=Xmqj9LZ6IAg</a></p>

<h3>AirGradient's Open Source Hardware Advantage</h3>
<p><a href="https://www.linkedin.com/in/achim-haug-ag/">Achim Haug</a>, founder and CEO of <a href="https://www.airgradient.com/">AirGradient</a>, talked about building an open-hardware company that has shipped 50,000 air quality monitors to 80+ countries, and what open-source actually makes possible along the way - community integrations, open schematics, reduced lock-in. He also touched on AI-assisted development, including vibe-coding Tetris onto their product's display. A lot of this was directly relevant to where I'm thinking about taking Kamath Robotics, more on that later. As a sponsor, AirGradient also included a partial DIY kit of their <a href="https://www.airgradient.com/documentation/kb/one-v9">indoor air quality monitor</a> in the goodie bag, and I'm looking forward to putting it together.</p>
<p><a class="embed-card" href="https://www.youtube.com/watch?v=DNvcKuE2FbE">https://www.youtube.com/watch?v=DNvcKuE2FbE</a></p>

<h3>A Practical Guide to EU Regulations and Directives</h3>
<p>I kept spotting <a href="https://x.com/mayermakes">Clemens Mayer</a> around the event, thinking he looked familiar, and eventually placed him as a host on <a href="https://www.youtube.com/element14presents">Element14Presents</a>, a YouTube channel I frequently watch. His talk is essential viewing for anyone building and selling hardware in Europe. Key takeaways:</p>
<ul>
<li><p>CE compliance for open-source hardware? It depends on the product</p>
</li>
<li><p>General Product Safety Regulation (GPSR) applies to almost everything</p>
</li>
<li><p>EU Directives are adopted into national law; EU Regulations are directly applicable EU-wide</p>
</li>
</ul>
<p>Watch the full talk for the complete list of directives and regulations worth knowing about.</p>
<p><a class="embed-card" href="https://www.youtube.com/watch?v=kk2fLntkyAU">https://www.youtube.com/watch?v=kk2fLntkyAU</a></p>

<h3>The State of Open Hardware</h3>
<p><a href="https://mastodon.social/@maltman23">Mitch Altman</a> presented an honest look at the slow erosion of openness among the big names in the space. <a href="https://www.arduino.cc/qualcomm">Arduino's acquisition by Qualcomm</a> effectively ended its open-source stance. I knew <a href="https://www.prusa3d.com/page/open-source-at-prusa-research_236812/">Prusa</a> had been drifting away from open-source, but wasn't aware of the specific licensing changes until this talk - changes that technically mean they're no longer open-source despite still using the label. Mitch also touched upon what happens when you bring AI into the mix. Worth watching if you care about where the ecosystem is heading.</p>
<p><a class="embed-card" href="https://www.youtube.com/watch?v=ZeEtrrLqNSM">https://www.youtube.com/watch?v=ZeEtrrLqNSM</a></p>

<h2>Exhibits</h2>
<p>The exhibition area, was distributed into four rooms, with some really creative projects being showcased. They also had empty tables where people could bring and share their projects. <a href="https://www.youtube.com/@FalkoRichter">Falko Richter</a> made a really nice overview video of the exhibition area.</p>
<p><a class="embed-card" href="https://www.youtube.com/watch?v=9H0Zhv0ZLKw">https://www.youtube.com/watch?v=9H0Zhv0ZLKw</a></p>

<p>While there were many cool projects, here are some that caught my eye.</p>
<h3><strong>OpenScan</strong></h3>
<p><a href="https://openscan.eu/">OpenScan</a> is an open‑source 3D scanner that uses two NEMA stepper motors and a Raspberry Pi camera, with the firmware running on a Raspberry Pi and the chassis made from 3D‑printed parts. They even brought a giant <a href="https://en.wikipedia.org/wiki/3DBenchy">Benchy</a> printed from a 3D scan to demonstrate its scanning performance; you can see it (and the scanner itself) in Falko’s video above; unfortunately, I forgot to take pictures.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/b732a94a-d559-4adf-bad2-c71bdf282a62.jpg" alt="" style="display:block;margin:0 auto" />

<p>They were kind enough to give me a PCB kit to build my own, and I'm eagerly waiting for a free weekend to put it together. I’ve already soldered the PCB kit and started 3D‑printing the necessary parts.</p>
<h3><strong>Open Vacuum</strong></h3>
<p>A company (or group?) called <a href="https://caznic.xyz/">Caznic</a> showcased their open hardware vacuum cleaner built using existing vacuum cleaner components, a custom control board, and 3D printed parts.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/1a7009cf-8330-4802-9e80-aa7deeedde02.jpg" alt="" style="display:block;margin:0 auto" />

<p>For me, there were two standout design choices: first, the control PCB can be physically snapped into pieces and reconnected with wires for flexible mounting, and second, it runs on a power tool battery. We had a good chat about making it autonomous on a robot - something I might try. They also presented a <a href="https://www.youtube.com/watch?v=SCmusEfDB24">talk at the summit</a>.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/d9aa52b5-0513-4a92-96b0-b2d6891acd4c.jpg" alt="" style="display:block;margin:0 auto" />

<h3><strong>Open Spider</strong></h3>
<p>This creator showcased a bunch of legged robots, and it was a lot of fun watching them run about. The tiny ones used small servo motors, a <a href="https://www.seeedstudio.com/XIAO-ESP32S3-Sense-p-5639.html">XIAO ESP32 S3 Sense</a> board, and a battery. The mechanical parts were made from a single PCB that you could simply snap apart. I really liked the one with the furry cover, but it was being charged, so I couldn't see it walking around. There was also a larger one on display, as you can see below.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/d766d04c-79b2-4bb4-856d-17481fe98bda.jpg" alt="" style="display:block;margin:0 auto" />

<p>I totally forgot to get this creator's name or the name of this project, so I don't have links to it. If you are reading this and know more about this project, do let me know.</p>
<h3><strong>Open Hardware Boat</strong></h3>
<p>Another project I do not know much about is this boat with brushless motors for motion, a 5G/LTE HAT (I think) for communications, GPS, and a Unitree 4D LiDAR. It also had magnets on the side, which I think are for docking.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/7d4a959e-6cb2-46ed-a68b-1335daa23419.jpg" alt="" style="display:block;margin:0 auto" />

<p>Nobody was at the stand when I was around, but they had a couple of diagrams showing their architecture, which included micro-ROS, ROS 2, Pixhawk, and Foxglove for visualization. I wish I could ask them more questions in person.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/56840199-8fe2-49f1-bcf0-5445423b9ce8.jpg" alt="" style="display:block;margin:0 auto" />

<h3><strong>Seeed Studio</strong></h3>
<p>It was really nice to see <a href="https://www.seeedstudio.com/">Seeed Studio</a> at the event. I've been buying their products for years, and I'm especially a big fan of their <a href="https://www.seeedstudio.com/XIAO-Dev-Boards-c-2477.html">XIAO series</a> of microcontrollers. While they had a lot of them at their booth, and they even gave one to attendees in their goodie bags, a few other things stood out for me:</p>
<ol>
<li><a href="https://www.seeedstudio.com/catalogsearch/result/?q=respeaker&amp;warehouse=CN+Warehouse+In+Stock%2CCN+or+US+Warehouse+In+Stock%2CCN+or+DE+Warehouse+In+Stock">reSpeaker</a>: a series of microphone-based products for all sorts of applications, including AI and robotics. I really liked their <a href="https://www.seeedstudio.com/reSpeaker-Flex-XVF3800-Circular-4-p-6737.html">circular microphone array</a> that seemed perfect for my <a href="https://kamathrobotics.com/series/lekiwi">LeKiwi</a> build. I've got an <a href="https://github.com/HKUDS/nanobot">AI agent</a> running on it, and I've been thinking about interacting with it using my voice. I also learnt that their linear microphone array is the same one that is used in the <a href="https://huggingface.co/reachy-mini">Reachy Mini</a>, and turns out Seeed manufactures some other components for it.</li>
</ol>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/d05bd9bd-f6a6-496a-a820-0644b788d418.jpg" alt="" style="display:block;margin:0 auto" />

<ol>
<li><em>Smart Glasses Project</em>: A collab with an independent creator whose name I didn't quite catch. I really liked the idea of replacing the stem of an off-the-shelf pair of glasses with a PCB that includes a XIAO board, a battery, and a vibration motor. And voila, you have a pair of smart glasses. Not <a href="https://www.meta.com/nl/ai-glasses/shop-all/?utm_productname=ray-ban&amp;utm_source=gg&amp;utm_medium=ps&amp;utm_campaign=22991550457&amp;utm_term=meta%20ray%20ban&amp;utm_content=774349445980&amp;utm_ad=188032731267&amp;utm_location=9225470&amp;utm_location2=&amp;utm_placement=kwd-1624975788126&amp;utm_device=c&amp;utm_matchtype=b&amp;utm_feed=&amp;utm_adposition=&amp;utm_product=&amp;ads_rl=9213737374&amp;&amp;gclsrc=aw.ds&amp;gad_source=1&amp;gad_campaignid=22991550457&amp;gbraid=0AAAAAo_xvTn1J_5axAPfCJUDcAiLVJY0G&amp;gclid=Cj0KCQjwlerQBhDMARIsAB16H-XQdabQ14vtEPvbk_aIaCeog-mODM3RsjihYU9g3fTg-ALa7bFd2mMaAjjAEALw_wcB">Meta Ray-Ban</a> territory, but a solid DIY starting point.</li>
</ol>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/df453bac-341d-42c6-b5ec-e22f2b7dc1b2.jpg" alt="" style="display:block;margin:0 auto" />

<ol>
<li><a href="https://www.seeedstudio.com/reBot-Arm-B601-DM-p-6740.html"><em>reBot Arm</em></a><em>:</em> A 6+1 DoF, ~4.5 kg robot arm with a 1.5 kg payload, paired with the <a href="https://www.seeedstudio.com/StarAi-Arm-Violin-p-6571.html">StarAI Leader Arm</a> for teleoperation. I had a nice conversation with <a href="https://www.linkedin.com/in/jingerzeng/">Jinger Zeng</a> from Seeed's Developer Relations about it, and other robotics products from Seeed, like the LeKiwi (yes, my project is built on top of a Seeed product). When she realized I was a robotics engineer, she asked if I could fix their stuck demo, and even though I had never worked with the reBot arm before, I wasn't going to say no. I expected to dig through <a href="https://huggingface.co/docs/lerobot/main/en/index">LeRobot</a> scripts; turns out Seeed has their own webserver-based GUI (I unfortunately couldn't find more information about this on their website) for setup, also compatible with <a href="https://github.com/TheRobotStudio/SO-ARM100">SO100/SO101</a> arms. The demo was back up in minutes, all thanks to the GUI. I'll be trying this with my own SO100 when I start working on it.</li>
</ol>
<p><a class="embed-card" href="https://x.com/kamathsblog/status/2058621048694755336">https://x.com/kamathsblog/status/2058621048694755336</a></p>

<h2>Badge Hacking</h2>
<p>Available on both days, the badge hacking area had a community-contributed box of components and everything you needed to get started. I began by soldering the coin cell battery and switch, which powered the badge, including the <a href="https://hackaday.io/project/52950-shitty-add-ons">SAO (Shitty Add-On)</a> connectors - my first encounter with SAOs, custom add-ons that people build and trade at such events. Several attendees had entire backpacks covered in them. Unfortunately, I didn't get pictures, but some were really creative.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/ee3941fb-b2c6-472d-be46-5ce4332c2f89.jpg" alt="" style="display:block;margin:0 auto" />

<p>I made a couple of mistakes: installed the SAO connectors backwards, and forgot to add resistors to the LEDs, so they now light up with uneven brightness. I'll fix them when I get the chance. I also added the XIAO from the goodie bag and soldered it directly without headers (like a psychopath); I have way too many of them at home, so I can afford to fix one to this badge permanently.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/b55be6e4-d2a6-4dfa-b16a-b641409dc3d0.jpg" alt="" style="display:block;margin:0 auto" />

<p>This turned out to be the best place to network - helping people with builds, picking up tips, and making new friends. The common consensus on the soldering tools was that <a href="https://pine64.org/devices/pinecil/">Pinecil</a> or similar products were far superior to what was provided, and I agree. The <a href="https://www.ifixit.com/en-eu/products/ifixit-mandible-flush-cutters?lnid=Cj0KCQjwlerQBhDMARIsAB16H-WBp9_si4pt6t2Q3QYkTp7Iy5-w0-flJrtQIkw4Di51J4aNMBJ7FbMaAnavEALw_wcB&amp;utm_source=google&amp;utm_medium=cpc&amp;utm_campaign=18278891415&amp;utm_term=&amp;utm_content=&amp;tw_source=google&amp;tw_adid=&amp;tw_campaign=18278891415&amp;gad_source=1&amp;gad_campaignid=21879375512&amp;gbraid=0AAAAADI8Wjg6MaHIf4I3wdGGhIUeqF63M&amp;gclid=Cj0KCQjwlerQBhDMARIsAB16H-WBp9_si4pt6t2Q3QYkTp7Iy5-w0-flJrtQIkw4Di51J4aNMBJ7FbMaAnavEALw_wcB">iFixit flush cutters</a>, on the other hand, were superb, and I ordered a set on my way back.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/71c90289-592e-472c-8587-c38d7bbf8b92.jpg" alt="" style="display:block;margin:0 auto" />

<h2>Workshops</h2>
<p>There were some really cool <a href="https://2026.oshwa.org/workshops/">workshops on Day 2</a>, but I had already made plans for the day, so I didn't register for many. I had free time in the afternoon, so I decided to join the KiCAD workshop by <a href="https://chaos.social/@cccpresser">cpresser</a>. It is a shame that all the workshops that I wanted to attend were during the same time slot, so I picked one that will actually be useful to me.</p>
<p>I had attempted to learn <a href="https://www.kicad.org/">KiCAD</a> a few times before, but had given up since the interface isn't beginner-friendly, especially when you're learning on your own. This one-hour session walked through the full PCB design workflow from schematic to routing, and suddenly, everything clicked. I'm excited to see what I can actually design now.</p>
<p>During the workshop, one attendee followed the <a href="https://gitlab.com/evils/kicad-workshop">entire exercise</a> on a <a href="https://kamathrobotics.com/teleoperating-the-lekiwi-from-a-steam-deck">Steam Deck</a>. I already use mine for programming, controlling, simulating, and visualizing robots. Turns out KiCAD installs natively without <a href="https://distrobox.it/">Distrobox</a> workarounds, same for <a href="https://www.freecad.org/">FreeCAD</a> and <a href="https://github.com/OrcaSlicer/OrcaSlicer">OrcaSlicer</a> (although I haven't tried those yet). The Steam Deck might genuinely be the perfect all-in-one device for end-to-end robotics development.</p>
<p><a class="embed-card" href="https://x.com/kamathsblog/status/2059015501385236758">https://x.com/kamathsblog/status/2059015501385236758</a></p>

<h2>Turning 33</h2>
<p>Day 2 was my 33rd birthday. My only commitment was a KiCAD workshop, so I spent the morning at the Berliner Dom, grabbed souvenirs at the Humboldt Forum, and stopped for a smoothie in the courtyard—then realized I hadn’t installed KiCAD. I downloaded it over the free city Wi‑Fi and, while waiting, connected to my LeKiwi in Eindhoven; within a minute, I was visualizing the robot and driving its motors with almost no lag, hundreds of kilometres away.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/3781a042-1a34-4a0c-8095-173cb764eb2a.jpg" alt="" style="display:block;margin:0 auto" />

<p>For lunch, I had the best döner of my life - Dutch places really need to up their game, but maybe that’s for the best, or I’d eat it every day. Exhausted and overheated, I headed back to TU Berlin for a few unconference talks before the workshop. On the way, I stopped by an antiques market and bought myself a vintage beer stein and a model digger as birthday presents. Although I don’t drink anymore, the stein is already serving as a water glass for summer.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/db97b71d-9852-441d-bd68-da88c8a4d759.jpg" alt="" style="display:block;margin:0 auto" />

<h2>Wrapping up</h2>
<p>This was my first hardware-focused event, and it felt nothing like developer conferences like ROSCon. Instead of attendees trying to talk about work or trying to sell you their product, people genuinely wanted to help, share, and learn - probably because most attendees are builders first, not rather than founders or employees. I caught up with <a href="https://www.kimberlymcguire.com/">Kimberly McGuire</a>, a friend from ROSCon and fellow independent roboticist, and we talked about all things robotics. I also met other solo entrepreneurs, engineers, scientists, and artists - people from all sorts of different backgrounds; the mix of electronics with robots, music, textiles, health, and art made for genuinely interesting conversations.</p>
<p>As for project ideas, after seeing custom badges everywhere, I now want to make a badge for myself that I can wear to such conferences. I also want to build the AirGradient and OpenScan kits when I get the chance. I've ordered the reSpeaker kit from Seeed Studio, and I'm currently designing parts to attach it to the LeKiwi while it is in transit. To make space, I've removed the webcam from the LeKiwi base; having two cameras was a little too much anyway. The OAK-D S2 on the pan-tilt mechanism is now the only camera on the robot.</p>
<p>Several talks hit close to home: AirGradient's scaling story, Clemens's EU certification guide, and the unconference session on starting a hardware company. I've been considering moving away from consulting and freelancing, focusing instead on hardware product development, sustained by technical content creation. The talks and the conversations with other attendees validated that this pivot isn't a bad idea, even if it won't be easy.</p>
<p>I'm definitely looking forward to more hardware events now. I have the <a href="https://makerdays.nl/">Eindhoven Maker Days</a> to look forward to in September this year, and I'm already making plans for the <a href="https://makerfairerome.eu/en/">Rome Maker Faire</a> in October. I missed the <a href="https://hackaday.com/tag/2026-hackaday-europe/">Hackaday Europe</a> event this year, but it is on my radar for next year. I don't know where the next OHS will be, but if it is in Europe, I'll definitely be there.</p>
]]></content:encoded></item><item><title><![CDATA[Sensor Fusion on LeKiwi]]></title><description><![CDATA[I've been gradually converting my LeKiwi mobile robot to run using ROS 2, and upgrading the original hardware piece by piece. Over the past few months, I've added a LiDAR, a pan-tilt mechanism with a ]]></description><link>https://kamathrobotics.com/sensor-fusion-on-lekiwi</link><guid isPermaLink="true">https://kamathrobotics.com/sensor-fusion-on-lekiwi</guid><dc:creator><![CDATA[Aditya Kamath]]></dc:creator><pubDate>Sat, 09 May 2026 19:40:01 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/ec2777b4-a757-4bb1-9195-ae42f6aa411e.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>I've been gradually converting my <a href="https://github.com/adityakamath/lekiwi_ros2">LeKiwi</a> mobile robot to run using ROS 2, and upgrading the original hardware piece by piece. Over the past few months, I've added a <a href="https://foxglove.dev/blog/upgrading-the-lekiwi-into-a-lidar-equipped-explorer">LiDAR</a>, a <a href="https://kamathrobotics.com/pan-tilt-controls-using-ros-2">pan-tilt mechanism</a> with a depth camera, and wired up <a href="https://kamathrobotics.com/wiring-up-ros-2-control-for-lekiwi">wheel odometry using <code>ros2_control</code></a>.</p>
<p>The wheel odometry works well (especially on short runs), thanks to the <a href="https://www.feetechrc.com/2020-05-13_56655.html">STS3215 servo motors</a> providing accurate and reliable state feedback. But omnidirectional robots have a fundamental problem with dead reckoning: wheels slip sideways, and you can physically move the base without any encoder movement. Once the heading drifts, the dead-reckoned position estimate diverges along with it. You need something to anchor the heading.</p>
<p>And that something, in most cases, is an IMU. I recently added a <a href="https://www.adafruit.com/product/2472?srsltid=AfmBOopfl_2l3l1Yp9M0RjAYZtzsXMM8xtyjXXwTAbBdBf9_IBs_pyvl">BNO055</a> IMU and vibe-coded a <a href="https://kamathrobotics.com/bno055-imu-hardware-abstraction"><code>ros2_control</code> hardware interface</a> for it using the <a href="https://github.com/adityakamath/bno055_hardware_interface">bno055_hardware_interface</a> package. Now it is time to integrate it into the LeKiwi stack and fuse measurements with wheel odometry using an Extended Kalman Filter from <a href="https://github.com/cra-ros-pkg/robot_localization"><code>robot_localization</code></a>. Later in this post, I also compare <code>robot_localization</code> with the newer <a href="https://github.com/manankharwar/fusioncore"><code>fusioncore</code></a> project.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/44d27825-dd29-492e-853c-85ad00c9e3ed.webp" alt="" style="display:block;margin:0 auto" />

<h2>Integrating the BNO055 IMU</h2>
<p>The BNO055 is a 9-DOF absolute orientation sensor that does on-chip sensor fusion. It combines an accelerometer, gyroscope, and magnetometer, and outputs a quaternion in NDOF (Nine Degrees of Freedom) mode. The output is gravity-referenced and utilizes the magnetometer to correct drift, ensuring the orientation remains accurate over time without accumulating errors that can occur with raw gyro integration. This post focuses on the sensor fusion side and assumes you're already familiar with the hardware interface implementation covered in the <a href="https://kamathrobotics.com/bno055-imu-hardware-abstraction">earlier post</a>, at least for this section.</p>
<h3>The <code>ros2_control</code> Hardware Interface</h3>
<p>I added the <code>&lt;ros2_control&gt;</code> block to the URDF in <a href="https://github.com/adityakamath/lekiwi_ros2/tree/main/lekiwi_description"><code>lekiwi_description</code></a>, declaring the BNO055 as a <code>sensor</code> type — read-only, with no command interfaces:</p>
<pre><code class="language-xml">&lt;ros2_control name="lekiwi_imu" type="sensor"&gt;
  &lt;hardware&gt;
    &lt;plugin&gt;bno055_hardware_interface/BNO055HardwareInterface&lt;/plugin&gt;
    &lt;param name="i2c_bus"&gt;1&lt;/param&gt;
    &lt;param name="i2c_addr"&gt;28&lt;/param&gt;
    &lt;param name="axis_remap"&gt;P1&lt;/param&gt;
    &lt;param name="sensor_mode"&gt;NDOF&lt;/param&gt;
    &lt;param name="enable_mock_mode"&gt;false&lt;/param&gt;
  &lt;/hardware&gt;
  &lt;sensor name="bno055"&gt;
    &lt;!-- Orientation quaternion --&gt;
    &lt;state_interface name="orientation.x"/&gt;
    &lt;state_interface name="orientation.y"/&gt;
    &lt;state_interface name="orientation.z"/&gt;
    &lt;state_interface name="orientation.w"/&gt;
    &lt;!-- Angular velocity --&gt;
    &lt;state_interface name="angular_velocity.x"/&gt;
    &lt;state_interface name="angular_velocity.y"/&gt;
    &lt;state_interface name="angular_velocity.z"/&gt;
    &lt;!-- Linear acceleration --&gt;
    &lt;state_interface name="linear_acceleration.x"/&gt;
    &lt;state_interface name="linear_acceleration.y"/&gt;
    &lt;state_interface name="linear_acceleration.z"/&gt;
  &lt;/sensor&gt;
&lt;/ros2_control&gt;
</code></pre>
<p>The plugin exposes ten state interfaces: a quaternion for orientation, three axes of angular velocity, and three axes of linear acceleration. Once the controller manager loads this hardware interface, these state interfaces become available for broadcasting.</p>
<h3>Broadcasting to ROS 2 topics</h3>
<p>To make the IMU data available to the rest of the stack, I configured the <a href="https://control.ros.org/kilted/doc/ros2_controllers/imu_sensor_broadcaster/doc/userdoc.html"><code>imu_sensor_broadcaster</code></a> in <a href="https://github.com/adityakamath/lekiwi_ros2/tree/main/lekiwi_control"><code>lekiwi_control</code></a>. This controller reads the state interfaces and publishes a <code>sensor_msgs/Imu</code> message to <code>/imu_sensor_broadcaster/imu</code>.</p>
<p>I actually discovered the <code>imu_sensor_broadcaster</code> while working on the <a href="https://control.ros.org/master/doc/ros2_controllers/omni_wheel_drive_controller/doc/userdoc.html">omniwheel controller</a> for the LeKiwi, and that's what motivated me to create the BNO055 hardware interface in the first place - I simply wanted to try it out.</p>
<pre><code class="language-yaml">imu_sensor_broadcaster:
  ros__parameters:
    sensor_name: bno055  # Must match URDF &lt;sensor name="..."&gt;
    frame_id: imu_frame
    
    # ±2.5° magnetometer heading accuracy → 0.002 rad²
    static_covariance_orientation: [0.002, 0.0, 0.0,
                                    0.0, 0.002, 0.0,
                                    0.0, 0.0, 0.002]
    
    # Gyro noise density at 100 Hz → 1e-5 (rad/s)²
    static_covariance_angular_velocity: [1.0e-5, 0.0, 0.0,
                                         0.0, 1.0e-5, 0.0,
                                         0.0, 0.0, 1.0e-5]
    
    # Accelerometer noise
    static_covariance_linear_acceleration: [2.0e-4, 0.0, 0.0,
                                            0.0, 2.0e-4, 0.0,
                                            0.0, 0.0, 2.0e-4]
</code></pre>
<p>The covariance values come from the BNO055 datasheet and represent the measurement uncertainty for each sensor component. These values indicate to the EKF how much to trust each measurement during fusion; higher covariance means the filter trusts that measurement less. In <code>robot_localization</code>, these covariances remain static throughout the operation, so getting them right at the start matters.</p>
<p>The broadcaster spawns alongside the other controllers in the launch file:</p>
<pre><code class="language-python">imu_broadcaster_spawner = Node(
    package='controller_manager',
    executable='spawner',
    arguments=['imu_sensor_broadcaster', '-c', '/controller_manager'],
)

# Delayed to ensure controller manager is ready
delayed_base_controller_spawner = TimerAction(
    period=2.5,
    actions=[base_controller_spawner, imu_broadcaster_spawner],
)
</code></pre>
<p><a class="embed-card" href="https://www.youtube.com/watch?v=MdPBkdxYOkc">https://www.youtube.com/watch?v=MdPBkdxYOkc</a></p>

<h2>EKF using robot_localization</h2>
<p>For LeKiwi, I use the <a href="https://docs.ros.org/en/kilted/p/robot_localization/"><code>robot_localization</code></a> EKF node in <a href="https://github.com/adityakamath/lekiwi_ros2/tree/main/lekiwi_navigation"><code>lekiwi_navigation</code></a> to fuse wheel odometry with IMU data. The integration centers on a key design choice: the wheel controller provides velocities in the body frame, while the IMU provides drift-free heading. The EKF combines these to produce a corrected odometry estimate.</p>
<p>First, I disable TF publishing from <code>base_controller</code> so the EKF owns the <code>odom → base_footprint</code> transform:</p>
<pre><code class="language-yaml"># controller_config.yaml
base_controller:
  ros__parameters:
    enable_odom_tf: false
</code></pre>
<p>The controller still publishes <code>/base_controller/odom</code> as a topic, but only the EKF publishes the transform.</p>
<p>From wheel odometry, I fuse only body-frame velocities (<code>vx</code>, <code>vy</code>). Position and heading are deliberately excluded - the controller computes them by dead reckoning, which drifts when the robot gets bumped or manually rotated. By giving the EKF velocities instead, the filter integrates them using the IMU's correct heading:</p>
<pre><code class="language-yaml">odom0_config: [false, false, false,  # position - excluded
               false, false, false,  # orientation - excluded
               true,  true,  false,  # vx, vy - fused
               false, false, false,  # angular velocities - excluded
               false, false, false]  # accelerations - excluded
</code></pre>
<p>From the IMU, I fuse absolute orientation (roll, pitch, yaw) and angular rates. Linear acceleration is excluded - motor vibration injects too much noise, and wheel velocities are already cleaner:</p>
<pre><code class="language-yaml">imu0_config: [false, false, false,  # position - excluded
              true,  true,  true,   # roll, pitch, yaw - fused
              false, false, false,  # linear velocities - excluded
              true,  true,  true,   # angular velocities - fused
              false, false, false]  # linear acceleration - excluded
</code></pre>
<p>The filter runs at 50 Hz to match the <code>controller_manager</code>, and I configure it for 3D operation (<code>two_d_mode: false</code>) so it tracks roll and pitch, which is not necessary at the moment but would be useful on uneven surfaces:</p>
<pre><code class="language-yaml">frequency: 50
two_d_mode: false
sensor_timeout: 0.1
imu0_differential: false  # BNO055 NDOF gives global orientation
imu0_remove_gravitational_acceleration: false  # BNO055 firmware already strips gravity
</code></pre>
<p>For debugging, the stack supports three launch modes: normal (fuses both sources), IMU-only (bench testing when wheels spin freely), and odometry-only (verifies encoders in isolation, auto-enables in mock mode). Even in single-sensor modes, the EKF still runs - it's not fusing anything, but this keeps TF ownership consistent across all modes rather than conditionally enabling TF from the individual sensor nodes:</p>
<pre><code class="language-bash">ros2 launch lekiwi_bringup lekiwi.launch.py # Normal (default)
ros2 launch lekiwi_bringup lekiwi.launch.py imu_only:=true
ros2 launch lekiwi_bringup lekiwi.launch.py odom_only:=true
</code></pre>
<h2>Validation and testing</h2>
<p>The fusion pipeline (focusing on the IMU) looks like this:</p>
<pre><code class="language-plaintext">BNO055 (I2C) 
  → bno055_hardware_interface 
    → imu_sensor_broadcaster 
      → robot_localization EKF (also includes wheel odometry)
        → /odometry/filtered + odom→base_footprint TF
</code></pre>
<p>I tested the sensor fusion by manually driving the LeKiwi around a loop, rotating it while stationary, and giving it gentle bumps during motion. Meanwhile, I monitored the <code>/base_controller/odom</code> and <code>/odometry/filtered</code> side by side on Foxglove. This is by no means perfect, and still needs a SLAM implementation on top to provide the <code>map</code> to <code>odom</code> transform. Still, as internal odometry goes, the sensor fusion is quite effective.</p>
<p><a class="embed-card" href="https://www.youtube.com/watch?v=PFRJnBID5ME">https://www.youtube.com/watch?v=PFRJnBID5ME</a></p>

<p>I'll be honest, the only real advantage of the IMU is to detect external impacts such as bumps or manual pushes when not using SLAM. In the future, it should also be useful to detect motion over bumps and on ramps, since the IMU measures in 9 DoF.</p>
<p>The wheel odometry by itself is quite impressively accurate, so it can dead reckon very well. If I add a SLAM method directly on top of the wheel odometry, it will still be able to correct the localization in case of external movement. So technically, on flat surfaces, fusing the IMU is not necessary, but it is definitely a fun learning experience. And it gave me a chance to experiment with vibe-coding hardware interfaces, which was quite enlightening.</p>
<h2>Why robot_localization</h2>
<p><a href="https://github.com/cra-ros-pkg/robot_localization"><code>robot_localization</code></a> is technically deprecated - no active roadmap, though community PRs still land, even in the latest ROS 2 distributions.</p>
<p>The proposed successor is <a href="https://github.com/locusrobotics/fuse"><code>Fuse</code></a>, a graph-based optimization framework from <a href="https://locusrobotics.com/">Locus Robotics</a>, deployed in thousands of warehouse robots globally and genuinely field-tested at scale. However, the challenge here is that <code>Fuse</code> is much more complex to get started with and configure. I will skip this for now.</p>
<p><a href="https://github.com/manankharwar/fusioncore"><code>fusioncore</code></a> is newer - ROS 2-native with a 22-state UKF, automatic IMU bias estimation, adaptive noise, outlier rejection, and advanced GPS features. However, despite an impressive features list, <code>fusioncore</code> is unproven in real-world deployments, and its documentation contains misleading claims, especially about <code>robot_localization</code>'s capabilities, raising concerns. I did try it out and had decent results, but did not dig deeper.</p>
<p>Despite the alternatives, and the somewhat promising results from <code>fusioncore</code>, I chose <code>robot_localization</code> anyway. The deciding factor: <em><strong>per-axis fusion selectivity</strong></em>. <code>robot_localization</code> lets me exclude specific measurement axes - fuse only wheel velocities (not position/heading) and only IMU orientation/rates (not linear acceleration), for example. <code>fusioncore</code> always fuses everything; you can only down-weight unwanted axes via noise inflation, which is an approximation rather than exclusion. Additionally, <code>fusioncore</code>'s improvements over <code>robot_localization</code> lie in its GPS-related features, which, for the current state of LeKiwi, add no value.</p>
<p>For the LeKiwi use-case - indoor, no GPS, BNO055 handles calibration and sensor-fusion on-chip - <code>robot_localization</code>'s maturity, active deployments, years of field testing, and especially the per-axis control, outweigh <code>fusioncore</code>'s advanced features.</p>
<p>I will write a blog post about working with <code>fusioncore</code> soon, and will try to include an evaluation of <code>Fuse</code> as well, but that is for another day.</p>
<h2>What's next?</h2>
<p>The <code>robot_localization</code> setup covers odometry within the <code>odom</code> frame. The next step towards full autonomy is adding a map layer using <a href="https://docs.nav2.org/">Nav2</a>. First, I want to go back to the pan-tilt mechanism and finish the implementation. The control part is complete, but since I've added an <a href="https://shop.luxonis.com/products/oak-d-s2?srsltid=AfmBOopmRxbWbGnU-vsFw8OCdZj0EhS0D_qXGLTqSUAtuUd5wUWEq5OH">OAK-D S2</a> depth camera to the pan-tilt component, I want to utilize it fully and integrate at least a basic launch file into the LeKiwi ROS 2 stack.</p>
<p>The OAK-D S2 includes 2 monochrome cameras for depth/disparity calculation, an RGB camera, an on-board processor for inferencing, and an IMU. This makes it perfect for <a href="https://docs.openvins.com/">visual-inertial odometry</a> (VIO) and <a href="https://docs.ros.org/en/kilted/p/rtabmap_demos/">visual SLAM</a> - some fun concepts to experiment with. My final objective, however, is to eventually integrate it with my <a href="https://developers.meta.com/horizon/blog/introducing-oculus-quest-2-the-next-generation-of-all-in-one-vr/">Oculus Quest 2</a> headset - use the head tracking data to move the pan-tilt mechanism, while streaming RGB video to the headset, and use the handheld controllers to teleoperate the LeKiwi.</p>
<p>I've also ended up with another robot, maybe two. I purchased an Ikea Råskog trolley to attach to the LeKiwi, inspired by the XLeRobot project. As soon as I brought it home, I realized that the middle shelf fit perfectly on top of a mecanum wheeled robot I had brought home from work. Meanwhile, I also had two spare hoverboard wheels, which looked perfect for the bottom shelf and the caster wheels - this is still a work in progress.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/00679b8a-9e3c-44d2-aae3-4c397d8f2e8c.jpg" alt="" style="display:block;margin:0 auto" />

<p>This mecanum robot has a <a href="https://www.pjrc.com/store/teensy41.html">Teensy 4.1</a> in its base, driving the motor controllers. I want to use this platform to tinker with <a href="https://zenoh.io/blog/2021-10-04-zenoh-pico-guide/">Zenoh-Pico</a> and <a href="https://github.com/Pico-ROS/Pico-ROS-software">Pico-ROS</a>, as an alternative to <a href="https://micro.vulcanexus.org/">micro-ROS</a>. But this is for another day; for now, it's simply a regular trolley for storage, but with motors and mechanum wheels instead of caster wheels.</p>
<p>But that's for another time. For now, I'm back to working with the LeKiwi. If you want to poke around, here are the repos:</p>
<ul>
<li><p><a href="https://github.com/adityakamath/lekiwi_ros2"><code>lekiwi_ros2</code></a>: top-level directory containing <code>lekiwi_control</code>, <code>lekiwi_description</code>, <code>lekiwi_navigation</code>, and <code>lekiwi_bringup</code></p>
</li>
<li><p><a href="https://github.com/adityakamath/bno055_hardware_interface"><code>bno055_hardware_interface</code></a>: the <code>ros2_control</code> sensor plugin and diagnostics node</p>
</li>
</ul>
<div>
<div>⚠</div>
<div>Note that the <code>lekiwi_ros2</code> repository is in active development, and may include breaking changes. So, please use it with caution.</div>
</div>

<p>As always, feedback and contributions are welcome!</p>
]]></content:encoded></item><item><title><![CDATA[Teleoperating the LeKiwi from a Steam Deck]]></title><description><![CDATA[tl;dr: This article details how I turned my Steam Deck into a controller and visualization station for my LeKiwi mobile base. Using Pixi to install ROS 2, Distrobox to run the Foxglove desktop app, an]]></description><link>https://kamathrobotics.com/teleoperating-the-lekiwi-from-a-steam-deck</link><guid isPermaLink="true">https://kamathrobotics.com/teleoperating-the-lekiwi-from-a-steam-deck</guid><dc:creator><![CDATA[Aditya Kamath]]></dc:creator><pubDate>Fri, 01 May 2026 15:30:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/10b8015b-bfb4-4c6f-9507-255d59d5e2bf.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><strong>tl;dr:</strong> This article details how I turned my Steam Deck into a controller and visualization station for my LeKiwi mobile base. Using Pixi to install ROS 2, Distrobox to run the Foxglove desktop app, and the built-in controller with the ROS 2 joy node, I now have a portable, all-in-one setup for driving the robot and monitoring its data in Foxglove simultaneously.</p>
<p>Originally written for and published on the <a href="https://foxglove.dev/blog/teleoperating-the-lekiwi-from-a-steam-deck">Foxglove blog</a></p>
<hr />
<h2><strong>Context / Why a Steam Deck?</strong></h2>
<p>A few years ago, I wrote about <a href="https://kamathrobotics.com/steam-deck-as-a-robot-controller">using a Steam Deck as a robot controller</a> to drive and visualize my mecanum-wheeled robot <a href="https://kamathrobotics.com/retiring-the-akros-platform">AKROS</a>. That setup worked well, but getting ROS 2 running on the Steam Deck’s operating system required some workarounds at the time. A lot has changed since then.</p>
<p><a class="embed-card" href="https://www.youtube.com/watch?v=q8D-sAIVN-s">https://www.youtube.com/watch?v=q8D-sAIVN-s</a></p>

<p>Last year, I retired the AKROS platform and started working on a new robot - <a href="https://github.com/SIGRobotics-UIUC/LeKiwi">LeKiwi</a> - to convert it to ROS 2 and make minor hardware upgrades as I progressed. The build has progressed a lot - I covered the <a href="https://foxglove.dev/blog/upgrading-the-lekiwi-into-a-lidar-equipped-explorer">LiDAR integration</a> and the <a href="https://foxglove.dev/blog/calibrating-a-monocular-camera-for-the-lekiwi-robot-using-ros-2">camera calibration</a>. The robot now has a complete perception stack, <a href="https://kamathrobotics.com/wiring-up-ros-2-control-for-lekiwi">ROS 2 Control integration</a>, and communicates via <a href="https://docs.ros.org/en/kilted/Installation/RMW-Implementations/Non-DDS-Implementations/Working-with-Zenoh.html">Zenoh</a> and <a href="https://tailscale.com/">Tailscale</a>.</p>
<p>With all of that in place, I wanted a portable way to drive and monitor it without hauling a laptop around, which brought me back to the Steam Deck. The idea is straightforward: use the Steam Deck’s built-in controller to drive the LeKiwi, and use Foxglove to visualize what the robot sees and does - all from a single handheld device. And since the Steam Deck is a full-fledged computer, I can even develop code on it when I need to.</p>
<h2><strong>Setting up ROS 2 using Pixi</strong></h2>
<p>When I first set up ROS 2 on the Steam Deck two years ago, I used <a href="https://distrobox.it/">Distrobox</a> to create a full Ubuntu container and installed ROS 2 inside it. The Steam Deck runs on SteamOS, which is based on Arch Linux, so there is no native ROS 2 support available. It worked, but having to enter a container to run ROS commands added friction, and managing dependencies between the host and container was annoying.</p>
<p>This time, I used <a href="https://pixi.sh/">Pixi</a>, a package manager that can install ROS 2 packages via the <a href="https://robostack.github.io/">RoboStack</a> project. Unlike Distrobox, Pixi does not need a container - it resolves and installs packages into a project-local directory, similar to how conda works. No separate OS image to maintain, just native binaries managed by a single <code>pixi.toml</code> file. I already use this to run ROS 2 on my MacBook, so extending the same workflow to the Steam Deck was straightforward.</p>
<h3>Installing Pixi</h3>
<p>Installation is a one-liner using the following command. Then follow <a href="https://pixi.sh/latest/tutorials/ros2/">this tutorial</a> to set up your favorite ROS 2 distro (I am currently using Kilted).</p>
<pre><code class="language-shell">curl -fsSL https://pixi.sh/install.sh | sh
</code></pre>
<h3><strong>Creating a ROS 2 workspace</strong></h3>
<p>Once Pixi is installed, setting up a ROS 2 workspace is easy:</p>
<pre><code class="language-shell">pixi init ros2_ws -c robostack-kilted -c conda-forge
cd ros2_ws
</code></pre>
<p>This creates a new Pixi project where you can add any ROS 2 packages you need using <code>pixi add &lt;package-name&gt;</code>.</p>
<h2><strong>Setting up Foxglove</strong></h2>
<p>There are two ways to run Foxglove on the Steam Deck: the desktop app or the browser app. Both methods connect to your robot using the <a href="https://docs.foxglove.dev/docs/visualization/ros-foxglove-bridge">Foxglove bridge</a>, which runs on the LeKiwi’s Raspberry Pi and exposes ROS 2 topics over a WebSocket connection. The bridge is the only additional piece needed on the robot side.</p>
<h3><strong>Option 1: Desktop app via Distrobox</strong></h3>
<p>Foxglove is not available on the Pixi registry, and there is no native Arch Linux package for it either. So for the desktop app, Distrobox is still my preferred option.</p>
<p>First, install Podman and Distrobox, then create and enter an Ubuntu container:</p>
<pre><code class="language-shell">sudo pacman -S podman distrobox
distrobox create --name ubuntu24 --image ubuntu:24.04
distrobox enter ubuntu24
</code></pre>
<p>Inside the container, install Foxglove following the <a href="https://foxglove.dev/download">official instructions</a>:</p>
<pre><code class="language-shell">sudo apt update &amp;&amp; sudo apt install foxglove-studio libasound2t64
</code></pre>
<p>The trick I learned this time is to export it as a host application, so you do not have to enter the container every time. This creates an executable on SteamOS that you can pin to the desktop or taskbar:</p>
<pre><code class="language-shell">distrobox-export --app foxglove-studio
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/b0c3e624-8578-4dfe-9fde-13bda084e02f.webp" alt="" style="display:block;margin:0 auto" />

<h3><strong>Option 2: Browser app</strong></h3>
<p>If you would rather skip the Distrobox setup entirely, Foxglove also has a <a href="https://app.foxglove.dev/">browser-based version</a>. No installation or workarounds - just open it in the Steam Deck’s browser and connect to the Foxglove bridge running on the robot. In my testing, both options performed about the same, though I did not push either to its limits. I ended up using the desktop app since I already had Distrobox set up, but the browser option is a good alternative if you want to avoid that overhead entirely.</p>
<h2><strong>Controller mapping</strong></h2>
<p>From the Steam Deck settings, you can map the controller inputs to buttons and axes of a game controller in desktop mode. I reused the mapping I used with my previous robot: trackpads and back buttons as desktop mouse controls, and the remaining buttons mapped to a standard gamepad.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/d3e48e57-b9b3-47f7-bede-064f9ff34b33.webp" alt="" style="display:block;margin:0 auto" />

<p>With this or any other mapping, you can run the default <a href="https://index.ros.org/p/joy/">joy node</a> without any special configuration.</p>
<h2>Joy node setup</h2>
<p>One of the nice things about the Pixi setup is how easy it is to add ROS 2 packages. Getting the <a href="https://index.ros.org/p/joy/">joy</a> node running is as simple as:</p>
<pre><code class="language-shell">pixi add ros-kilted-joy
</code></pre>
<p>After that, you can launch it using:</p>
<pre><code class="language-shell">pixi shell
ros2 run joy joy_node

# OR
pixi run ros2 run joy joy_node
</code></pre>
<p>The joy node reads input from the controller and publishes <a href="https://docs.ros2.org/latest/api/sensor_msgs/msg/Joy.html"><code>sensor_msgs/msg/Joy</code></a> messages, which can then be consumed on the LeKiwi side to drive the robot.</p>
<p>You can also create a task in your <code>pixi.toml</code>:</p>
<pre><code class="language-plaintext">[tasks]
joy = "ros2 run joy joy_node"
</code></pre>
<p>With this, a single <code>pixi run joy</code> command is all you need for teleoperation.</p>
<h2>Auto-start on boot</h2>
<p>To take this one step further, I created a <a href="https://systemd.io/">systemd</a> service that automatically runs <code>joy</code> when the Steam Deck boots up and kills the process on shutdown:</p>
<pre><code class="language-plaintext">[Unit]
Description=Joy Node (pixi run joy)
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=deck
WorkingDirectory=/home/deck/ros2_ws
ExecStartPre=/bin/sleep 15
ExecStart=/home/deck/.pixi/bin/pixi run joy
ExecStop=/bin/bash -c 'pkill -f "lib/joy/joy_node" || true'
Restart=on-failure
RestartSec=10

[Install]
WantedBy=multi-user.target
</code></pre>
<p>To enable the service:</p>
<pre><code class="language-shell">sudo cp joy.service /etc/systemd/system/joy.service
sudo systemctl daemon-reload
sudo systemctl enable joy.service
sudo systemctl start joy.service
</code></pre>
<p>Now, when the Steam Deck turns on, it immediately starts publishing Joy messages. I would not recommend it if you have more than one robot on your network, but for a single robot, it is not a problem.</p>
<h2>Integration and testing</h2>
<p>With everything in place - connectivity between the Steam Deck and the robot’s Raspberry Pi, the joy node reading controller input, and Foxglove visualizing data - it was time to put it all together.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/fdfd75df-7124-4c08-adf5-4d64b29692b0.webp" alt="" style="display:block;margin:0 auto" />

<p>My workflow looks like this:</p>
<ol>
<li><p>I start up the Steam Deck in desktop mode, which automatically launches the joy node via the systemd service. On the LeKiwi side, the Raspberry Pi is already running its ROS 2 stack with the Foxglove bridge (I run these manually for now, but I am considering adding them as services on the robot as well).</p>
</li>
<li><p>I open the Foxglove app on the Steam Deck, connect to the bridge, and I immediately see live sensor data - LiDAR scans, camera feeds, and the robot model in 3D (the meshes do take about a minute to load).</p>
</li>
<li><p>Then I can just pick up the Steam Deck and drive. The built-in analog sticks control the robot’s omnidirectional movement, and I can visualize everything on Foxglove at the same time.</p>
</li>
</ol>
<p><a class="embed-card" href="https://www.youtube.com/watch?v=TPuJHzK34iQ">https://www.youtube.com/watch?v=TPuJHzK34iQ</a></p>

<p>My layout shows only the 3D panel, which displays the robot model with live LiDAR scans, the image stream, and the URDF.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/3678fb56-3c6a-42a2-a5c1-6dd3984f541f.webp" alt="" style="display:block;margin:0 auto" />

<h2><strong>Wrapping up</strong></h2>
<p>The Steam Deck was already quite a capable platform for robotics work, but with the added service and tools like Pixi, my workflow has become significantly easier. Adding Foxglove to the mix reveals exactly what the robot sees and does as I drive it around. I can simply turn on the Steam Deck, and in a few clicks, I am ready to drive the robot and visualize.</p>
<p>This setup has now become my go-to for testing and teleoperating the LeKiwi. It is portable, self-contained, and requires zero extra hardware. Additionally, with Tailscale for networking and Zenoh as middleware, I do not need to worry about communication anymore.</p>
<p>My work on the ROS 2-powered LeKiwi is available on <a href="https://github.com/adityakamath/lekiwi_ros2">GitHub</a>, but please note that this is an active project, so expect rapid and sometimes breaking changes.</p>
]]></content:encoded></item><item><title><![CDATA[Pan-Tilt Controls using ROS 2 ]]></title><description><![CDATA[In a previous post, I described designing and implementing the sts_hardware_interface — a ros2_control hardware interface for Feetech STS3215 servo motors. The interface supports position, velocity, a]]></description><link>https://kamathrobotics.com/pan-tilt-controls-using-ros-2</link><guid isPermaLink="true">https://kamathrobotics.com/pan-tilt-controls-using-ros-2</guid><dc:creator><![CDATA[Aditya Kamath]]></dc:creator><pubDate>Mon, 27 Apr 2026 18:28:01 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/3b7319bd-ea7c-4fff-9189-28223ffbf92b.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>In a <a href="https://kamathrobotics.com/hardware-abstraction-for-sts3215-servos">previous post</a>, I described designing and implementing the <a href="https://github.com/adityakamath/sts_hardware_interface"><code>sts_hardware_interface</code></a> — a <a href="https://control.ros.org/"><code>ros2_control</code></a> hardware interface for <a href="https://www.feetechrc.com/2020-05-13_56655.html">Feetech STS3215</a> servo motors. The interface supports position, velocity, and effort/PWM control modes, but I had only tested velocity mode, as my main focus was the <a href="https://github.com/adityakamath/lekiwi_ros2">LeKiwi</a> build. I assumed position mode would work fine, especially for the [0, 2π] range. But a recent <a href="https://github.com/adityakamath/sts_hardware_interface/issues/5">issue report</a> on GitHub from <a href="https://github.com/andreasBihlmaier">@andreasBihlmaier</a> revealed a real problem: negative angles didn't work as expected. As I dug deeper, I found a few more issues.</p>
<p>This post walks through what went wrong, how I tracked down the root cause, the fix, and how I tested it on a new build - a <a href="https://github.com/adityakamath/pantilt_ros2">Pan-Tilt</a> mechanism, a two DoF pan-tilt mechanism built from <a href="https://github.com/TheRobotStudio/SO-ARM100">SO-100/101</a> parts.</p>
<h2>The Problem</h2>
<p>The STS3215 uses a 12-bit magnetic encoder, giving 4096 discrete steps (0–4095) per revolution. The motor counts clockwise, but ROS 2 follows <a href="https://www.ros.org/reps/rep-0103.html">REP-103</a>, where positive angles are counterclockwise. This means the hardware interface must invert direction when converting between raw steps and radians.</p>
<p>Here’s how the original conversion functions worked:</p>
<pre><code class="language-cpp">// Read: motor steps → radians
inline double raw_position_to_radians(int raw_position)
{
  return static_cast&lt;double&gt;(STS_MAX_POSITION - raw_position) * STEPS_TO_RAD;
  // STS_MAX_POSITION = 4095, STEPS_TO_RAD = 2π / 4096
}

// Write: radians → motor steps
inline int radians_to_raw_position(double position_rad)
{
  double normalized = std::fmod(-position_rad, 2.0 * M_PI);
  if (normalized &lt; 0.0) {
    normalized += 2.0 * M_PI;
  }
  int raw = static_cast&lt;int&gt;(normalized * RAD_TO_STEPS);
  return std::clamp(raw, 0, STS_MAX_POSITION);
}
</code></pre>
<p>This mapping only works for joints that move in the [0, 2π] range. But when you set joint limits to [-π, π], a few problems appear:</p>
<h3>1. Position feedback is always positive</h3>
<p><code>raw_position_to_radians</code> always returns values in [0, 2π] regardless of joint limits, which only clamp outgoing commands. A servo at the center reports π rad instead of 0.</p>
<h3>2. Negative commands take the long way around</h3>
<p>Let's say you give the command to move -0.1 rad, the servo should move a small amount in the clockwise direction, around 65 encoder steps. Since 0 rad sits at step 4095, the servo travels 4030 steps to get to step 65 — nearly a full revolution in the wrong direction.</p>
<h3>3. The read/write round-trip was broken</h3>
<p>I also discovered that the two functions weren’t true inverses:</p>
<table>
<thead>
<tr>
<th>Operation</th>
<th>Result</th>
</tr>
</thead>
<tbody><tr>
<td>Write 0 rad</td>
<td><code>fmod(-0, 2π)</code> → step <strong>0</strong></td>
</tr>
<tr>
<td>Read step 0</td>
<td><code>(4095 - 0) × STEPS_TO_RAD</code> → <strong>2π rad</strong></td>
</tr>
</tbody></table>
<p>So, writing 0 radians gives step 0, but reading step 0 returns 2π radians. The functions disagree about which end of the encoder is 0.</p>
<p>All three problems share the same root cause.</p>
<h2>Root Cause</h2>
<p>Two formulas, neither being algebraic inverses of each other:</p>
<ul>
<li><p><strong>Read</strong>: step 4095 is 0 rad</p>
</li>
<li><p><strong>Write</strong>: step 0 is 0 rad</p>
</li>
</ul>
<p>For all positive angles strictly inside (0, 2π), both formulas produce results that look plausible, so the inconsistency is hidden during normal testing. The bug only surfaces at the boundaries and for negative angles.</p>
<h2>The Fix: A Configurable Center</h2>
<p>To fix this, a single unified formula with a configurable <code>center</code> parameter was implemented, with the <code>center</code> parameter being the encoder step that maps to 0 rad:</p>
<pre><code class="language-cpp">constexpr int STS_DEFAULT_CENTER = 4095;

inline double raw_position_to_radians(int raw_position, int center = STS_DEFAULT_CENTER)
{
  return static_cast&lt;double&gt;(center - raw_position) * STEPS_TO_RAD;
}

inline int radians_to_raw_position(double position_rad, int center = STS_DEFAULT_CENTER)
{
  double steps = std::round(static_cast&lt;double&gt;(center) - position_rad * RAD_TO_STEPS);
  return static_cast&lt;int&gt;(std::clamp(steps, 0.0, static_cast&lt;double&gt;(STS_MAX_POSITION)));
}
</code></pre>
<p>Values outside the encoder's physical range are now clamped to [0, 4095] rather than wrapped with <code>fmod</code>. This default <code>center=4095</code> preserves existing behavior, while fixing the bugs.</p>
<h3>How to Use It</h3>
<p>Set <code>position_center_steps</code> to the encoder step (0–4095) that maps to the center position, or 0 rad for each joint:</p>
<pre><code class="language-xml">&lt;ros2_control name="my_hardware" type="system"&gt;
  &lt;hardware&gt;
    &lt;plugin&gt;sts_hardware_interface/STSHardwareInterface&lt;/plugin&gt;
    &lt;param name="serial_port"&gt;/dev/ttyACM0&lt;/param&gt;
    &lt;!-- ... --&gt;
  &lt;/hardware&gt;
  &lt;joint name="pan_joint"&gt;
    &lt;param name="motor_id"&gt;1&lt;/param&gt;
    &lt;param name="operating_mode"&gt;0&lt;/param&gt;
    &lt;param name="position_center_steps"&gt;2048&lt;/param&gt;
    &lt;!-- ... --&gt;
    &lt;command_interface name="position"/&gt;
    &lt;state_interface name="position"/&gt;
  &lt;/joint&gt;
&lt;/ros2_control&gt;
</code></pre>
<p>Set it to 2048 for a symmetric [-π, +π] range, or 4095 for [0, 2π]. Values outside the [0, 4095] range are rejected at startup. The Pan Tilt 100 uses this exact parameter: 2048 for the pan axis and 2646 for the tilt axis, which is calibrated to that motor's physical resting position.</p>
<p>Note that this only applies in position mode (<code>operating_mode=0</code>), it is ignored with a warning on velocity or PWM mode joints.</p>
<h3>What Changes for Existing Users?</h3>
<p>Almost nothing, the <code>center</code> position defaults to 4095, matching the old behavior for all non-zero inputs. All other angles are unaffected. To see this working on real hardware, the next section walks through the Pan Tilt 100 build.</p>
<h2>Real-World Example: Pan Tilt 100</h2>
<p>The original plan was to attach a <a href="https://github.com/TheRobotStudio/SO-ARM100">SO100</a> arm to the LeKiwi. I had some spare motors, so I decided to assemble an arm, and I started printing fresh parts in white PLA. Unfortunately, I ran out of filament halfway through. While waiting for more filament to arrive, it clicked: two motors are all I need to test the hardware interface in position mode, and it also works perfectly as a pan-tilt mechanism. So, I stopped the arm build right there, bolted an <a href="https://docs.luxonis.com/hardware/products/OAK-D%20S2">OAK-D S2</a> to the second link with a 3D printed mount, and inspired by the SO100, called it the PT100, where PT stands for Pan Tilt.</p>
<p>The camera integration - VIO, VSLAM, and whatever else follows - is a topic for another post. Getting the <a href="https://github.com/luxonis/depthai-ros">depthai-ros</a> wrapper to work was its own <s>adventure </s> nightmare.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/9224220f-309e-4655-a11f-7779550b72fa.png" alt="" style="display:block;margin:0 auto" />

<h3>URDF and hardware configuration</h3>
<p>The PT100 has two joints — <code>pan_joint</code> and <code>tilt_joint</code>, both running in position mode through <code>sts_hardware_interface</code>. The relevant part of the control xacro:</p>
<pre><code class="language-xml">&lt;ros2_control name="pt100_control" type="system"&gt;
  &lt;hardware&gt;
    &lt;plugin&gt;sts_hardware_interface/STSHardwareInterface&lt;/plugin&gt;
    &lt;param name="serial_port"&gt;/dev/ttySERVO&lt;/param&gt;
    &lt;param name="use_sync_write"&gt;true&lt;/param&gt;
    &lt;param name="max_velocity_steps"&gt;3400&lt;/param&gt;
  &lt;/hardware&gt;

  &lt;joint name="pan_joint"&gt;
    &lt;param name="motor_id"&gt;1&lt;/param&gt;
    &lt;param name="operating_mode"&gt;0&lt;/param&gt;
    &lt;param name="min_position"&gt;-1.5708&lt;/param&gt;
    &lt;param name="max_position"&gt;1.5708&lt;/param&gt;
    &lt;param name="max_velocity"&gt;4.43&lt;/param&gt;
    &lt;param name="position_center_steps"&gt;2048&lt;/param&gt;
    &lt;command_interface name="position"/&gt;
    &lt;state_interface name="position"/&gt;
    &lt;!-- velocity, effort, voltage, temperature, current, is_moving --&gt;
  &lt;/joint&gt;

  &lt;joint name="tilt_joint"&gt;
    &lt;param name="motor_id"&gt;2&lt;/param&gt;
    &lt;param name="operating_mode"&gt;0&lt;/param&gt;
    &lt;param name="min_position"&gt;-1.5708&lt;/param&gt;
    &lt;param name="max_position"&gt;1.5708&lt;/param&gt;
    &lt;param name="max_velocity"&gt;4.43&lt;/param&gt;
    &lt;param name="position_center_steps"&gt;2646&lt;/param&gt;
    &lt;command_interface name="position"/&gt;
    &lt;state_interface name="position"/&gt;
    &lt;!-- velocity, effort, voltage, temperature, current, is_moving --&gt;
  &lt;/joint&gt;
&lt;/ros2_control&gt;
</code></pre>
<p>The pan joint was assembled at its original mid-point, and uses <code>position_center_steps: 2048</code>, providing a symmetric [-π/2, +π/2] range. The tilt joint uses <code>position_center_steps: 2646</code>, calibrated to its physical resting position, which isn't at the exact midpoint due to how the link is assembled.</p>
<p><code>max_velocity</code> is capped at 85% of the hardware max (3400 steps/s → 2890 steps/s ≈ 4.43 rad/s), leaving headroom for the motor to decelerate without overshooting.</p>
<p>The controller is a <a href="https://control.ros.org/kilted/doc/ros2_controllers/forward_command_controller/doc/userdoc.html"><code>ForwardCommandController</code></a> on the position interface, running at 50 Hz. With <code>use_sync_write: true</code>, both joints are commanded in a single serial transaction each cycle.</p>
<p>One non-obvious URDF detail: both joints set <code>velocity="1e6"</code> in their <code>&lt;limit&gt;</code> elements and <code>has_velocity_limits: false</code> in the controller config. <code>joy_teleop</code> sends absolute position targets, so when the joystick moves quickly, the commanded jump can be nearly π rad in a single cycle. <code>ros2_control</code>'s <code>JointSaturationLimiter</code> would clamp that to the URDF velocity limit and log spurious "command out of limits" errors every cycle. Setting the limit to a physically unreachable value disables that clamping without touching position enforcement. The <code>sts_hardware_interface</code> still caps actual motor speeds via <code>max_velocity</code>.</p>
<p>For manual control, I use the <a href="https://kamathrobotics.com/steam-deck-as-a-robot-controller">Steam Deck</a>'s gamepad with <a href="https://index.ros.org/p/joy/"><code>joy</code></a> and <a href="https://index.ros.org/p/joy_teleop/"><code>joy_teleop</code></a>: left stick for pan/tilt (±π/2 rad at full deflection), L1 as a deadman switch. The A and B buttons call the <code>/emergency_stop</code> service — provided by <code>sts_hardware_interface</code> to enable or disable a hard stop independently of the deadman switch.</p>
<p><a class="embed-card" href="https://www.youtube.com/watch?v=avtmbQLGMdY">https://www.youtube.com/watch?v=avtmbQLGMdY</a></p>

<h3>The fix in action</h3>
<p>The results, including plots showing joint command tracking is shown in the video below, visualized on <a href="https://foxglove.dev">Foxglove</a>. For some time, you also see the input command plot changing, but the joint states don't move; this is because I had pressed the emergency stop at that time.</p>
<p><a class="embed-card" href="https://www.youtube.com/watch?v=lExx9SFCsuE">https://www.youtube.com/watch?v=lExx9SFCsuE</a></p>

<h3>E-Stop and out-of-range positions</h3>
<p>During testing, I ran into a second issue, separate from the conversion bug.</p>
<p>When the e-stop is active, torque is disabled, and both joints can be moved freely by hand. If a joint drifts, maybe by gravity, or it is deliberately pushed past its URDF-defined limits while torque is off, <code>ros2_control</code>'s <code>JointSaturationLimiter</code> detects the out-of-bounds position the moment the e-stop is released and immediately deactivates the controller. No warning or recovery path, the controller just stops.</p>
<p>The fix: always clamp the position reported by <code>read()</code> to within the URDF limits whenever limits are defined:</p>
<pre><code class="language-cpp">if (has_position_limits_[i]) {
  hw_state_position_[i] = std::clamp(hw_state_position_[i], position_min_[i], position_max_[i]);
}
</code></pre>
<p><code>ros2_control</code> now always sees a position within bounds. The write path already clamps commands to the same range, so on the first cycle after e-stop release, the motor is commanded to the nearest limit and moves back into range, while the controller stays active throughout.</p>
<h2>Takeaways</h2>
<ul>
<li><p>If two conversion functions are supposed to be inverses, always test that they actually are. A simple round-trip test (<code>read(write(x)) ≈ x</code>) would have caught this bug early.</p>
</li>
<li><p>Be cautious with <code>fmod</code> for angle wrapping in servo code. It can silently map valid negative commands into the wrong part of the encoder range.</p>
</li>
<li><p>When hardware can move outside software limits — during e-stop, power loss, or manual handling — clamp the reported state unconditionally before it reaches <code>ros2_control</code>. Otherwise, this could deactivate the controller, which needs manual intervention to reactivate.</p>
</li>
<li><p>By defaulting to backward-compatible behavior (center=4095) and exposing the new parameter, existing deployments like <code>lekiwi_ros2</code> running in velocity mode or position mode, motors operating in the [0, 2π] range are unaffected.</p>
</li>
</ul>
<p>You can find these changes in <a href="https://github.com/adityakamath/sts_hardware_interface"><code>sts_hardware_interface</code></a>. If you use STS3215 servos in position mode and need negative angles, set <code>position_center_steps</code> to your servo’s calibrated center position. The Pan-Tilt implementation can be found in the <a href="https://github.com/adityakamath/pantilt_ros2"><code>pantilt_ros2</code></a> repository.</p>
<p>Alongside these fixes, the recently merged <a href="https://github.com/adityakamath/sts_hardware_interface/pull/10">PR #10</a> adds a ready-to-use single-motor position mode example, including a URDF, controller config, launch file, and integration tests. You can test the new parameter with <code>use_mock:=true</code> before connecting any hardware.</p>
<h2>What’s Next?</h2>
<p>With the hardware interface bug fixed and the Pan Tilt 100 running smoothly in position mode, the next steps are to integrate with <code>lekiwi_ros2</code>. I changed my mind about the SO100 arm; I prefer having a pan-tilt mechanism with an OAK-D S2 depth camera instead. The LeKiwi build is now finally taking shape, moving from a mobile manipulator build to a complete mobile perception platform.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/8c2f1766-a83c-4a12-937f-ee414661bfad.png" alt="" style="display:block;margin:0 auto" />]]></content:encoded></item><item><title><![CDATA[BNO055 IMU Hardware Abstraction]]></title><description><![CDATA[One of the things I had planned for my ongoing LeKiwi build was adding an IMU. The wheel odometry is already quite accurate, but the more sensors, the better. Especially if it is as good as the BNO055]]></description><link>https://kamathrobotics.com/bno055-imu-hardware-abstraction</link><guid isPermaLink="true">https://kamathrobotics.com/bno055-imu-hardware-abstraction</guid><dc:creator><![CDATA[Aditya Kamath]]></dc:creator><pubDate>Wed, 15 Apr 2026 15:30:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/18dab351-5113-4e8d-a423-e348ed4bd2eb.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>One of the things I had planned for my ongoing <a href="https://github.com/SIGRobotics-UIUC/LeKiwi">LeKiwi</a> build was adding an IMU. The wheel odometry is already quite accurate, but the more sensors, the better. Especially if it is as good as the <a href="https://www.bosch-sensortec.com/products/smart-sensor-systems/bno055/">BNO055</a> IMU from Bosch. I genuinely haven't used an easier IMU to work with - it self-calibrates, does sensor fusion onboard, and produces clean results with minimal noise - definitely a must-have for any roboticist's toolkit.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/6b79c31a-ec97-4785-afc1-183f908d54e0.jpg" alt="" style="display:block;margin:0 auto" />

<p>I've used this sensor before, and I normally use it with the <a href="https://github.com/flynneva/bno055"><code>bno055</code></a> ROS 2 (Python) package, but I had recently learned about the <code>SensorInterface</code> and the <a href="https://control.ros.org/kilted/doc/ros2_controllers/imu_sensor_broadcaster/doc/userdoc.html"><code>imu_sensor_broadcaster</code></a> controller in <a href="https://control.ros.org/kilted/index.html"><code>ros2_control</code></a> while developing the <a href="https://github.com/adityakamath/sts_hardware_interface"><code>sts_hardware_interface</code></a> package, and wanted to try it out. But I got busy with another project, so I decided to give vibe-coding a shot for this one.</p>
<p>The <code>sts_hardware_interface</code> package, although AI-assisted, was meticulously designed, reviewed, and tested manually. With the new <code>bno055_hardware_interface</code>, I just gave Claude (Sonnet 4.6, using a mix of Claude Code and GitHub Copilot) a bunch of references, including <code>sts_hardware_interface</code>, sensor datasheets, and <a href="https://github.com/bdholt1/ros2_bno055_sensor/tree/master"><code>ros2_bno055_sensor</code></a>, a ROS 2 package I found, which is written in C++. I then asked it to write me a hardware interface using these references, with occasional guidance along the way. I mostly just focused on my other project.</p>
<p>I did use some Claude skills to do this: in-built skills like <a href="https://www.mindstudio.ai/blog/claude-code-simplify-batch-commands"><code>/simplify</code></a>, review skills from <a href="https://www.coderabbit.ai/?utm_source=google&amp;utm_medium=cpc&amp;utm_campaign=20944421732&amp;utm_content=158532047035&amp;utm_term=coderabbit&amp;matchtype=e&amp;kw=coderabbit&amp;cpn=20944421732&amp;utm_term=coderabbit&amp;utm_campaign=CodeRabbit+-+Branded&amp;utm_source=adwords&amp;utm_medium=ppc&amp;hsa_acc=9779635742&amp;hsa_cam=20944421732&amp;hsa_grp=158532047035&amp;hsa_ad=697792407004&amp;hsa_src=g&amp;hsa_tgt=kwd-2227824575182&amp;hsa_kw=coderabbit&amp;hsa_mt=e&amp;hsa_net=adwords&amp;hsa_ver=3&amp;gad_source=1&amp;gad_campaignid=20944421732&amp;gbraid=0AAAAAqBNyQVuHECVulCIbNZyavjl7Db_h&amp;gclid=Cj0KCQjwv-LOBhCdARIsAM5hdKfimPrtT5qhWdVmpGzt77L9syNISi9Bhlsnh7hR6GSZPA3bMiWuuPAaAprCEALw_wcB"><code>coderabbit</code></a>, and planning skills from <a href="https://github.com/obra/superpowers"><code>superpowers</code></a> to name a few. I didn't touch the code once. I let the AI agents do everything, from checking whether the sensor was detected over I2C, to running the ROS 2 example and executing the AI-generated tests. After a few iterations and brief manual code reviews, I had it working as expected.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/f192d283-10e0-444b-a3e6-ee51874079a1.png" alt="" style="display:block;margin:0 auto" />

<p>I even asked Claude to write the documentation. I don't mind sloppy code, but I absolutely hate wrong documentation, so I manually verified that the docs matched the implementation.</p>
<h2>How it works</h2>
<p>The <a href="https://github.com/adityakamath/bno055_hardware_interface"><code>bno055_hardware_interface</code></a> is a <code>ros2_control</code> <code>SensorInterface</code> plugin that connects ROS 2 to the BNO055 over I2C. Instead of a standalone node that publishes IMU data directly, it plugs into the <code>ros2_control</code> ecosystem — orientation, angular velocity, and linear acceleration are exposed as typed state interfaces consumed by the <code>imu_sensor_broadcaster</code>. Same idea as the <code>sts_hardware_interface</code>, except that one is a <code>SystemInterface</code> (reads and commands actuators) while this one is a <code>SensorInterface</code> (read-only).</p>
<p>The BNO055 runs sensor fusion on-chip, so the host just reads already-fused values. While the sensor supports multiple modes, I limited the hardware interface to three fusion modes, selectable via the <code>sensor_mode</code> launch parameter: <code>NDOF</code> (9-DOF absolute, default), <code>NDOF_FMC_OFF</code> (9-DOF without fast magnetometer calibration), and <code>IMUPLUS</code> (6-DOF, no magnetometer). The IMU normally self-calibrates, but the package also supports calibration persistence: save self-calibrated offsets to a YAML file and reload them at boot.</p>
<p>Most of the work happens in <a href="https://github.com/adityakamath/bno055_hardware_interface/blob/main/src/bno055_hardware_interface.cpp"><code>bno055_hardware_interface.cpp</code></a>, which implements the <code>SensorInterface</code> lifecycle and exposes state interfaces for orientation, angular velocity, and linear acceleration. <code>on_configure</code> handles I2C init, calibration loading, axis remapping, and fusion mode activation. <code>read()</code> updates the state interfaces with scaled sensor data each control loop tick. A mock mode (<code>enable_mock:=true</code>) skips all I2C operations for testing without hardware.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/b4c6cd3a-63e9-4ee4-b86b-35772f9fa2f2.jpg" alt="" style="display:block;margin:0 auto" />

<p>The axis remapping in <code>on_configure</code> is worth mentioning: the BNO055 has 8 predefined placement configurations (P0–P7, per <a href="https://www.bosch-sensortec.com/media/boschsensortec/downloads/datasheets/bst-bno055-ds000.pdf">datasheet §3.4</a>) that remap sensor axes to match your mounting orientation, so you don't have to rotate data in software. I used P1, which lines up with the axis labels on the PCB silkscreen.</p>
<p>The plugin uses the official <a href="https://github.com/BoschSensortec/BNO055_driver">Bosch BNO055 C driver</a> and a thin I2C adapter borrowed from <a href="https://github.com/bdholt1/ros2_bno055_sensor"><code>ros2_bno055_sensor</code></a>. The <a href="https://github.com/adityakamath/bno055_hardware_interface/tree/main/config"><code>config/</code></a> folder includes a URDF xacro, an <code>imu_sensor_broadcaster</code> configuration, and a template for calibration offsets.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/ba070e9d-e3f2-46c4-96a1-f47ab02f8cbe.png" alt="" style="display:block;margin:0 auto" />

<p>There are also two optional companion nodes. <a href="https://github.com/adityakamath/bno055_hardware_interface/blob/main/src/bno055_diagnostics.cpp"><code>bno055_diagnostics.cpp</code></a> publishes sensor health (calibration status per sub-sensor, system errors, temperature) to <code>/diagnostics</code> at 1 Hz. <a href="https://github.com/adityakamath/bno055_hardware_interface/blob/main/src/imu_tf_broadcaster.cpp"><code>imu_tf_broadcaster.cpp</code></a> republishes IMU orientation as a <code>world → base_link</code> TF transform so that rotations can be visualized in RViz or Foxglove. The video below demonstrates this. However, I don't intend on using this on the LeKiwi since the transform is provided by fusing the IMU data with wheel odometry in the robot's navigation stack (more on this in a future post).</p>
<p><a class="embed-card" href="https://youtu.be/MdPBkdxYOkc?si=vfwHCUjZUXJJPjQN">https://youtu.be/MdPBkdxYOkc?si=vfwHCUjZUXJJPjQN</a></p>

<p>The <a href="https://github.com/adityakamath/bno055_hardware_interface/blob/main/launch/bno055.launch.py"><code>launch file</code></a> brings up <code>robot_state_publisher</code>, <code>ros2_control_node</code>, the IMU broadcaster, and the companion nodes (optional; these can be disabled), with hardware parameters exposed as launch arguments.</p>
<h2>Wrapping up</h2>
<p>After all this, I ended up with a functional, tested package with features I probably wouldn't have bothered implementing myself: mock mode, diagnostics, calibration persistence, and three fusion modes. Would I vibe-code something safety-critical? Probably not. But for a sensor interface on a hobby robot, the trade-off made sense.</p>
<p>The <a href="https://github.com/adityakamath/bno055_hardware_interface"><code>bno055_hardware_interface</code></a> is available on GitHub. My work on the ROS 2-powered LeKiwi is also online in the <a href="https://github.com/adityakamath/lekiwi_ros2">lekiwi_ros2</a> package, expect rapid and sometimes breaking changes as it is in active development. As always, I welcome feedback and contributions.</p>
<div>
<div>⚠</div>
<div><strong><em>WARNING:</em></strong><em> This is a completely vibe-coded piece of software, made with minimal human intervention. It is not slop, but it is nearly there. Test it thoroughly before integrating with a robot, and use it at your own risk.</em></div>
</div>

<p>UPDATE: I was in a meeting at work with some GitHub employees, and I learned about a new term - Spec-Driven Development, or SDD. It turns out that was kind of what I did - get Claude to write a spec for me and then have it implemented. So, I guess this hardware interface is not vibe-coded, then? Either way, please use with caution, especially in safety-critical applications.</p>
]]></content:encoded></item><item><title><![CDATA[Wiring up ROS 2 Control for LeKiwi]]></title><description><![CDATA[Last month, I built sts_hardware_interface, the ros2_control plugin for Feetech's STS servo motors. That post primarily focused on the hardware abstraction. This one is about connecting it to actual R]]></description><link>https://kamathrobotics.com/wiring-up-ros-2-control-for-lekiwi</link><guid isPermaLink="true">https://kamathrobotics.com/wiring-up-ros-2-control-for-lekiwi</guid><dc:creator><![CDATA[Aditya Kamath]]></dc:creator><pubDate>Sun, 15 Mar 2026 09:00:29 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/44f88952-daab-460f-a6b2-22ce2045135c.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Last month, I built <a href="https://github.com/adityakamath/sts_hardware_interface"><code>sts_hardware_interface</code></a>, the <a href="https://control.ros.org/kilted/index.html"><code>ros2_control</code></a> plugin for <a href="https://www.feetechrc.com/sts_ttl_series%20servo.html">Feetech's STS servo motors</a>. <a href="https://kamathrobotics.com/hardware-abstraction-for-sts3215-servos">That post</a> primarily focused on the hardware abstraction. This one is about connecting it to actual <a href="https://control.ros.org/kilted/doc/ros2_controllers/doc/controllers_index.html">ROS 2 controllers</a> and getting the robot to move the way I want it to. In this post, I focus on three things: the <code>&lt;ros2_control&gt;</code> block in the URDF, the controller config in <a href="https://github.com/adityakamath/lekiwi_ros2/tree/main/lekiwi_control"><code>lekiwi_control</code></a>, and the fine-tuning (including a rotation issue during strafing that took longer to fix than I'd like to admit).</p>
<h2>Setting up ros2_control in the URDF</h2>
<p>If you've worked with <code>ros2_control</code> before, the first step is always to set up the URDF. I can write an entire post about it, but to keep this post short, I will direct you to the <a href="https://github.com/ros-controls/roscon2022_workshop"><code>ros2_control</code> workshop from ROSCon 2022</a>. This is a perfect primer to get started with <code>ros2_control</code>, and personally, a great reference guide that I keep coming back to.</p>
<h3>The URDF block</h3>
<p>The <code>&lt;ros2_control&gt;</code> block in the URDF tells the <a href="https://control.ros.org/kilted/doc/ros2_control/controller_manager/doc/userdoc.html">controller manager</a> which hardware plugin to load and what interfaces each joint exposes. The controller manager is the central node of the <code>ros2_control</code> framework, and manages the <a href="https://design.ros2.org/articles/node_lifecycle.html">lifecycle</a> of the controllers and handles access to the hardware interfaces.</p>
<pre><code class="language-xml">&lt;ros2_control name="lekiwi_base" type="system"&gt;
  &lt;hardware&gt;
    &lt;plugin&gt;sts_hardware_interface/STSHardwareInterface&lt;/plugin&gt;
    &lt;param name="serial_port"&gt;$(arg serial_port)&lt;/param&gt;
    &lt;param name="baud_rate"&gt;${baud_rate}&lt;/param&gt;
    &lt;param name="use_sync_write"&gt;${use_sync_write}&lt;/param&gt;
    &lt;param name="enable_mock_mode"&gt;$(arg use_mock)&lt;/param&gt;
    &lt;param name="max_velocity_steps"&gt;${sts_max_velocity_steps}&lt;/param&gt;
    &lt;param name="proportional_acc_max"&gt;${proportional_acc_max}&lt;/param&gt;
    &lt;param name="proportional_acc_deadband"&gt;${proportional_acc_deadband}&lt;/param&gt;
  &lt;/hardware&gt;
  &lt;!-- Left wheel joint (Motor ID 7) --&gt;
  &lt;joint name="left_wheel_joint"&gt;
    &lt;param name="motor_id"&gt;${left_motor_id}&lt;/param&gt;
    &lt;param name="operating_mode"&gt;${operating_mode}&lt;/param&gt;
    &lt;command_interface name="velocity"/&gt;
    &lt;command_interface name="acceleration"/&gt;
    &lt;state_interface name="position"/&gt;
    &lt;state_interface name="velocity"/&gt;
    &lt;state_interface name="effort"/&gt;
    &lt;state_interface name="voltage"/&gt;
    &lt;state_interface name="temperature"/&gt;
    &lt;state_interface name="current"/&gt;
    &lt;state_interface name="is_moving"/&gt;
  &lt;/joint&gt;
  &lt;!-- back_wheel_joint (Motor ID 8) and right_wheel_joint (Motor ID 9) follow the same pattern --&gt;
&lt;/ros2_control&gt;
</code></pre>
<p>Let's talk about the parameters.</p>
<ul>
<li><p><code>baud_rate</code>, <code>xxx_motor_id</code>, and <code>operating_mode</code> are self-explanatory.</p>
</li>
<li><p><code>use_mock</code> enables the controller in <a href="https://control.ros.org/kilted/doc/ros2_control/hardware_interface/doc/mock_components_userdoc.html">mock mode</a>, which has been explained in detail in the <code>sts_hardware_interface</code> <a href="https://kamathrobotics.com/hardware-abstraction-for-sts3215-servos">post</a>.</p>
</li>
<li><p><code>use_sync_write</code> batches all three velocity targets into a single packet instead of three sequential commands. This results in lower latency, and all wheels get their updated targets at the same time.</p>
</li>
<li><p><code>sts_max_velocity_steps</code> is the maximum rated velocity of the motor in steps. I am using <a href="https://www.feetechrc.com/2020-05-13_56655.html">STS3215</a>, but other STS series motors have different speed ratings.</p>
</li>
<li><p>We will come to the <code>proportional_acc_max</code> and <code>proportional_acc_deadband</code> parameters in a bit.</p>
</li>
</ul>
<p>None of these parameter values are hardcoded. They're all <a href="https://docs.ros.org/en/kilted/Tutorials/Intermediate/URDF/Using-Xacro-to-Clean-Up-a-URDF-File.html"><code>xacro:property</code></a> definitions in <a href="https://github.com/adityakamath/lekiwi_ros2/blob/main/lekiwi_description/urdf/base/base.control.xacro"><code>base.control.xacro</code></a> (in <a href="https://github.com/adityakamath/lekiwi_ros2/tree/main/lekiwi_description"><code>lekiwi_description</code></a>), so the main URDF stays readable, and all the tunable numbers are in one place.</p>
<pre><code class="language-xml">&lt;xacro:property name="serial_port" default="/dev/ttySERVO"/&gt;
&lt;xacro:property name="baud_rate" default="1000000"/&gt;
&lt;xacro:property name="use_mock" default="false"/&gt;
&lt;xacro:property name="use_sync_write" default="true"/&gt;
&lt;xacro:property name="sts_max_velocity_steps" value="3400"/&gt;

&lt;xacro:property name="proportional_acc_max" value="25"/&gt;
&lt;xacro:property name="proportional_acc_deadband" value="0.05"/&gt;
  
&lt;xacro:property name="left_motor_id" value="7"/&gt;
&lt;xacro:property name="back_motor_id" value="8"/&gt;
&lt;xacro:property name="right_motor_id" value="9"/&gt;
&lt;xacro:property name="operating_mode" value="1"/&gt;
</code></pre>
<p>Note that there are two velocity limits. <code>sts_max_velocity_steps = 3400</code> is the STS3215 hardware maximum, used as a hard clamp on every write. <code>max_velocity_steps</code> is the self-defined working limit, set to 85% of the rated maximum, for two main reasons:</p>
<ol>
<li><p>First, I don't trust the unbranded LiPo that came with the <a href="https://www.seeedstudio.com/Lekiwi-Kit-p-6501.html?srsltid=AfmBOorY-J2Vq8dyRsfaTikiknPCdlWGLoOUMGyKAGyPdxM1I97NE003">LeKiwi kit</a> (no datasheet, the QR code goes to an app store). So, best to limit the current draw.</p>
</li>
<li><p>At full speed, the motors start missing their velocity targets, which causes odometry to drift. This 85% keeps them tracking.</p>
</li>
</ol>
<p>From <code>max_velocity_steps</code>, the joint limits and other values in <a href="https://github.com/adityakamath/lekiwi_ros2/blob/main/lekiwi_control/config/base/control.yaml"><code>control.yaml</code></a> (in <a href="https://github.com/adityakamath/lekiwi_ros2/tree/main/lekiwi_control"><code>lekiwi_control</code></a>) cascade automatically; change one value and everything updates.</p>
<pre><code class="language-xml">&lt;xacro:property name="max_velocity_steps" value="${int(sts_max_velocity_steps * 0.85)}"/&gt; 
&lt;xacro:property name="max_velocity_rads" value="${max_velocity_steps*2*pi/4096}"/&gt;

&lt;!-- Maximum allowable linear velocity per axis --&gt;
&lt;xacro:property name="max_linear_velocity_x" value="${wheel_radius * max_velocity_rads / (sqrt(3)/2)}"/&gt;
&lt;xacro:property name="max_linear_velocity_y" value="${wheel_radius * max_velocity_rads / 1.0}"/&gt;
&lt;xacro:property name="max_angular_velocity_z" value="${wheel_radius * max_velocity_rads / wheel_base_offset}"/&gt;
</code></pre>
<p>Finally, each wheel joint, after declaring its motor ID and operating mode, declares which interfaces it exposes. The hardware interface reads back <code>position</code>, <code>velocity</code>, <code>effort</code>, <code>voltage</code>, <code>temperature</code>, <code>current</code>, and <code>is_moving</code> as state interfaces every cycle. All is defined in the hardware interface; no extra custom code needed.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/d7bd65c1-002c-45ce-be14-0cd3602ed87f.png" alt="" style="display:block;margin:0 auto" />

<h3>Configuring the Controllers</h3>
<p>The control configuration is defined in <code>lekiwi_control</code> , which is mostly YAML and launch files, no code. And the configuration is quite simple: just two controllers, running at <code>50 Hz</code>.</p>
<pre><code class="language-yaml">controller_manager:
  ros__parameters:
    update_rate: 50 # Hz
    statistics_publish_rate: 0.0  # Disable /controller_manager/statistics/* topics
    
    # Command and error handling
    enforce_command_limits: true  # Enforce URDF joint limits via JointSaturationLimiter
    handle_exceptions: true       # Graceful error handling
    
    # Hardware component initialization
    defaults:
      switch_controller:
        strictness: strict
      allow_controller_activation_with_inactive_hardware: false
      deactivate_controllers_on_hardware_self_deactivate: true

    joint_state_broadcaster:
      type: joint_state_broadcaster/JointStateBroadcaster

    base_controller:
      type: omni_wheel_drive_controller/OmniWheelDriveController

joint_state_broadcaster:
  # joint state broadcaster parameters...

base_controller:
  # omni wheel drive controller parameters...
</code></pre>
<p>Key takeaways:</p>
<ul>
<li><p><code>enforce_command_limits: true</code> tells the controller manager to run <code>ros2_control</code>'s <a href="https://control.ros.org/kilted/doc/api/classjoint__limits_1_1JointSaturationLimiter.html"><code>JointSaturationLimiter</code></a>, which clamps commands to the URDF velocity limits before they reach the hardware.</p>
</li>
<li><p><a href="https://control.ros.org/kilted/doc/ros2_controllers/joint_state_broadcaster/doc/userdoc.html"><code>JointStateBroadcaster</code></a> publishes all state interfaces to <a href="https://docs.ros2.org/foxy/api/sensor_msgs/msg/JointState.html"><code>/joint_states</code></a> and <a href="https://docs.ros.org/en/kilted/p/control_msgs/msg/DynamicJointState.html"><code>/dynamic_joint_states</code></a>. Temperatures, currents, voltages, stall flags: all of it is accessible using standard ROS 2 tools.</p>
</li>
<li><p><a href="https://github.com/adityakamath/lekiwi_ros2/blob/main/lekiwi_control/src/motor_diagnostics_node.cpp"><code>motor_diagnostics_node</code></a> subscribes to <code>/dynamic_joint_states</code> and fires warnings when things go out of range, but that's a separate post for another time.</p>
</li>
<li><p><a href="https://control.ros.org/kilted/doc/ros2_controllers/omni_wheel_drive_controller/doc/userdoc.html"><code>OmniWheelDriveController</code></a> handles the omni-wheel kinematics using the robot's geometry as parameters. By setting <code>open_loop: false</code>, the controller measures wheel velocities for <a href="https://docs.ros2.org/foxy/api/nav_msgs/msg/Odometry.html">odometry</a> rather than commanded targets.</p>
</li>
</ul>
<p><a class="embed-card" href="https://www.youtube.com/watch?v=cCn6vuQvdrg">https://www.youtube.com/watch?v=cCn6vuQvdrg</a></p>

<p>In this video, you can see the robot being driven around as the dynamic joint state topic values update in real time.</p>
<h3>Teleoperation</h3>
<p>A single <a href="https://index.ros.org/p/teleop_twist_joy/"><code>teleop_twist_joy</code></a> node converts incoming joystick inputs (from the <a href="https://index.ros.org/p/joy/"><code>joy</code></a> package) to velocity commands and publishes to <code>/base_controller/cmd_vel</code>.</p>
<p>One thing worth flagging: the parameter <code>publish_stamped_twist</code> must be set to <code>true</code>. The <code>OmniWheelDriveController</code> subscribes to <a href="https://docs.ros2.org/foxy/api/geometry_msgs/msg/TwistStamped.html"><code>TwistStamped</code></a>, not plain <a href="https://docs.ros2.org/foxy/api/geometry_msgs/msg/Twist.html"><code>Twist</code></a>. Publish bare <code>Twist</code>, and the robot does nothing, which is annoying to debug.</p>
<p><strong>UPDATE:</strong> I switched from <code>teleop_twist_joy</code> to <a href="https://index.ros.org/p/joy_teleop/"><code>joy_teleop</code></a> instead. <code>joy_teleop</code> allows me to call ROS 2 services as well, which allowed me to configure buttons for enabling and disabling the emergency stop. The normal teleoperation works identically to <code>teleop_twist_joy</code>. I have also reused the same config file for this switch.</p>
<p>The configuration is defined in the <a href="https://github.com/adityakamath/lekiwi_ros2/blob/main/lekiwi_control/config/base/teleop.yaml"><code>teleop.yaml</code></a> file in <a href="https://github.com/adityakamath/lekiwi_ros2/tree/main/lekiwi_control"><code>lekiwi_control</code></a>.</p>
<p><a class="embed-card" href="https://www.youtube.com/watch?v=wDOlI_AGhKk">https://www.youtube.com/watch?v=wDOlI_AGhKk</a></p>

<p>Do note that I'm not using this teleop method at the moment. Since I'm working remotely on my robot, I'm using a vibe-coded teleop extension on Foxglove instead. It still has some issues, and once I fix them, I'll write a separate post about it.</p>
<h2>Fine-Tuning</h2>
<p>Fine-tuning the control system took a few sessions and unearthed some issues. Let me start with the biggest problem first.</p>
<h3>The Rotation Problem</h3>
<p>This was a confusing one. Every time I tried to move the robot linearly in the Y direction (strafing), the robot rotated a little bit. Let go of the strafe button, and the robot twitched back.</p>
<p><a class="embed-card" href="https://youtu.be/I22CLfwls-g">https://youtu.be/I22CLfwls-g</a></p>

<p>Turns out the cause of this is the three-wheel geometry. For a purely sideways strafe, each wheel gets a different target velocity.</p>
<pre><code class="language-plaintext">ω_back  = +vy / r         (full change, back wheel at 180°)
ω_left  = −vy / (2r)      (half change, 60° projection)
ω_right = −vy / (2r)      (half change, 300° projection)
</code></pre>
<p>This means that the back wheel has to reach twice the speed of the front two. Assuming we are starting from zero, the back wheel has twice the velocity delta to cover. At the time, the <code>ACC</code> register of the motors was set to <code>0</code> in <code>sts_hardware_interface</code>. This does not mean zero acceleration; this means zero ramp - all motors just chase their targets as fast as they physically can. So, by the time the front wheels have reached their targets, the back wheel is still catching up, so the speed ratios are all wrong and the chassis rotates. Same thing on deceleration, in reverse.</p>
<h4>Synchronised motor ramps</h4>
<p>The most straightforward fix is to use the motor's <code>ACC</code> register differently. Instead of sending the same <code>ACC = 0</code> to all three wheels, <code>sts_hardware_interface</code> now sets it proportionally: the wheel with the biggest speed change gets the highest <code>ACC</code>, the others get lower values. Since <code>ramp time ≈ Δv / ACC</code>, this guarantees equal ramp durations: if one wheel's <code>Δv</code> is twice another's, its ACC is twice as high, so both finish in the same time. All three arrive together:</p>
<pre><code class="language-cpp">// From sts_hardware_interface write() — SyncWriteSpe path
double max_delta_rad_s = 0.0;
for (size_t j = 0; j &lt; velocity_motor_indices_.size(); ++j) {
    size_t idx = velocity_motor_indices_[j];
    double target = conversions::apply_limit(hw_cmd_velocity_[idx], ...);
    velocity_sync_velocities_[j] = conversions::rad_s_to_raw_velocity(target, max_velocity_steps_);
    velocity_sync_deltas_[j] = std::abs(target - hw_state_velocity_[idx]);
    max_delta_rad_s = std::max(max_delta_rad_s, velocity_sync_deltas_[j]);
}
for (size_t j = 0; j &lt; velocity_motor_indices_.size(); ++j) {
    if (max_delta_rad_s &lt; proportional_acc_deadband_rad_s_) {
        velocity_sync_accelerations_[j] = 0;  // tiny step — glide to target
    } else {
        int acc = static_cast&lt;int&gt;(
            std::round((velocity_sync_deltas_[j] / max_delta_rad_s) * proportional_acc_max_));
        velocity_sync_accelerations_[j] = static_cast&lt;u8&gt;(std::clamp(acc, 1, STS_MAX_ACCELERATION));
    }
}
servo_-&gt;SyncWriteSpe(ids, n, velocities, accelerations);
</code></pre>
<p>Two parameters in <code>base.control.xacro</code> tune this:</p>
<ul>
<li><p><code>proportional_acc_max</code>: This is the acceleration set for the motor with the highest delta velocity. The other motors will get <code>ACC</code> values that are proportional to this. The range is [0, 254]</p>
</li>
<li><p><code>proportional_acc_deadband</code>: This value defines a deadband, which stops the ramp from firing on tiny corrections. If a wheel delta is within this tolerance value, then <code>ACC=0</code>.</p>
</li>
</ul>
<p>Even after this change, the rotation issue doesn't disappear completely; that's a geometry property you can't tune away. But with this fix, and the lower velocity/acceleration limits (covered next), the rotation is minimized significantly.</p>
<h3>Velocity Limits</h3>
<p>There are three layers of velocity limits, each serving a different purpose:</p>
<ul>
<li><p>Hardware ceiling (<code>sts_max_velocity_steps = 3400</code>): absolute motor maximum, straight from the motor's datasheet. The motor cannot physically go past this limit.</p>
</li>
<li><p>Operating limit (<code>max_velocity_steps = sts_max_velocity_steps * 0.85</code>): 85% of the physical ceiling, allowing for some headroom, and cascades into <code>base_controllers.yaml</code>.</p>
</li>
<li><p>Controller limits (defined in <code>base_controllers.yaml</code>): deliberately lower than the joint operating limits, and the reason will become clear in a bit.</p>
</li>
</ul>
<p>When I originally calculated the joint limits in SI units based on the operating limit in steps, I did it one axis at a time. But in real life, a mobile robot, especially a holonomic one like the LeKiwi, rarely moves one axis at a time. It is usually a combination of axes in motion at any one time. My original calculation was <code>0.226 m/s</code> for linear Y and <code>1.71 rad/s</code> for yaw. But at max strafe + max rotation, the back wheel needs:</p>
<pre><code class="language-python">ω_back = (vy + R × ωz) / r = (0.226 + 0.132239 × 1.71) / 0.051 = 8.86 rad/s - 2× the joint limit
</code></pre>
<p>When <code>enforce_command_limits</code> clips one wheel but not the others, the speed ratios break, and once again, the robot rotates. Same symptom as before, different cause. The fix is to set the controller limits low enough that any multi-axis combination stays within the joint limit</p>
<p>That means lower single-axis ceilings: lateral speed drops from <code>0.226 m/s -&gt; 0.13 m/s</code>, rotation from <code>1.71 rad/s -&gt; 0.44 rad/s</code>. The teleop scales match exactly, so full joystick deflection reaches the limit (<code>~4.43 m/s</code>) without breaking it.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/5d9fec8c-d9b8-4729-bc19-f71eb78a1e0e.png" alt="" style="display:block;margin:0 auto" />

<h3>Per-Wheel Acceleration</h3>
<p>There are two separate acceleration controls, and it's worth being clear about which does what.</p>
<p>The hardware <code>ACC</code> is handled by the proportional scheme explained above. The controller leaves the acceleration interface at zero and <code>sts_hardware_interface</code> updates it every cycle based on each wheel's velocity delta. A big step means high <code>ACC</code> on the back wheel and lower on the front two; a small nudge might not even clear the deadband, and the motors just glide with <code>ACC = 0</code>.</p>
<p>The max acceleration in <code>base_controllers.yaml</code> is a different thing altogether. It limits how fast the speed target is allowed to change, before wheel speeds are even calculated:</p>
<pre><code class="language-yaml">linear:
  x:
    max_velocity: 0.13
    max_acceleration: 0.65   # m/s²  — ~200 ms ramp to full speed
  y:
    max_velocity: 0.11
    max_acceleration: 0.55   # m/s²  — ~200 ms ramp to full speed
angular:
  z:
    max_velocity: 0.44
    max_acceleration: 2.2   # rad/s² — ~200 ms ramp to full speed
</code></pre>
<p>At <code>50 Hz</code>, <code>0.55 m/s²</code> gives a per-cycle step of <code>0.011 m/s</code> (<code>0.216 rad/s</code> at the back wheel), well above the <code>0.05 rad/s</code> deadband. That matters: set <code>max_acceleration</code> too low and the per-cycle step falls below the threshold, <code>ACC</code> stays at zero, and the back wheel races to its target unchecked. And now you're back to the rotation problem.</p>
<p><a class="embed-card" href="https://youtu.be/M--6u0VrTXU">https://youtu.be/M--6u0VrTXU</a></p>

<p>I know this sounds quite confusing; it took me nearly a day to wrap my head around what's happening. But once I set the new velocity and acceleration values, a lot of my issues were resolved, as you can see in the video. Yes, the robot is significantly slower, but there are no surprises in its motion anymore, and no "out of limits" errors.</p>
<h2>What's Next?</h2>
<p>Next up is integrating my <a href="https://github.com/TheRobotStudio/SO-ARM100">SO-100/101</a> arm with the LeKiwi base: position-mode arm joints and a PWM/effort-controlled gripper alongside the velocity-mode wheels. That's the mixed-mode case <code>sts_hardware_interface</code> was designed for, and a good real-life stress test for my implementation.</p>
<p>In parallel, I also want to get into <a href="https://docs.nav2.org/">autonomy</a> for the mobile base; the sensor hardware (<a href="https://foxglove.dev/blog/upgrading-the-lekiwi-into-a-lidar-equipped-explorer">LiDAR</a> and <a href="https://foxglove.dev/blog/calibrating-a-monocular-camera-for-the-lekiwi-robot-using-ros-2">Camera</a>) and ROS 2 driver stack are ready.</p>
<p>If you want to poke around with the packages mentioned in this article, here are the GitHub links:</p>
<ul>
<li><p><a href="https://github.com/adityakamath/SCServo_Linux"><code>SCServo_Linux</code></a>: the C++ SDK for controlling Feetech motors. I didn't mention it explicitely, but it is very important for <code>sts_hardware_interface</code></p>
</li>
<li><p><a href="https://github.com/adityakamath/sts_hardware_interface"><code>sts_hardware_interface</code></a>: the hardware interface implementation</p>
</li>
<li><p><a href="https://github.com/adityakamath/lekiwi_ros2/tree/main"><code>lekiwi_ros2</code></a>: top-level directory that contains the following packages</p>
<ul>
<li><p><a href="https://github.com/adityakamath/lekiwi_ros2/tree/main/lekiwi_bringup"><code>lekiwi_bringup</code></a>: bringup package with configs and launch files</p>
</li>
<li><p><a href="https://github.com/adityakamath/lekiwi_ros2/tree/main/lekiwi_control"><code>lekiwi_control</code></a>: control-related package with configs, nodes, and launch files</p>
</li>
<li><p><a href="https://github.com/adityakamath/lekiwi_ros2/tree/main/lekiwi_description"><code>lekiwi_description</code></a>: URDF and meshes for LeKiwi</p>
</li>
</ul>
</li>
</ul>
<p>As always, feedback is welcome, PRs/contributions are really appreciated. More LeKiwi stuff (and a cool Agentic AI project) coming soon...</p>
]]></content:encoded></item><item><title><![CDATA[Calibrating a monocular camera for the LeKiwi robot using ROS 2]]></title><description><![CDATA[tl;dr: This article details my process of calibrating a USB webcam for the LeKiwi robot. Due to constraints, I was unable to use the standard ROS 2 camera calibration process, so I modified the origin]]></description><link>https://kamathrobotics.com/calibrating-a-monocular-camera-for-the-lekiwi-robot-using-ros-2</link><guid isPermaLink="true">https://kamathrobotics.com/calibrating-a-monocular-camera-for-the-lekiwi-robot-using-ros-2</guid><dc:creator><![CDATA[Aditya Kamath]]></dc:creator><pubDate>Thu, 05 Mar 2026 09:30:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/cb433ce6-f895-4feb-8126-037f3fddd1c4.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><strong>tl;dr:</strong> This article details my process of calibrating a USB webcam for the LeKiwi robot. Due to constraints, I was unable to use the standard ROS 2 camera calibration process, so I modified the original <code>camera_calibration</code> node to run remotely and without a display - logging progress to the terminal and saving results automatically on exit. With this, Foxglove’s Image and 3D panels, I was able to validate the calibration process and the results. The result is a calibrated camera and a completed perception stack, now ready for autonomy.</p>
<p>Originally written for and published on the <a href="https://foxglove.dev/blog/calibrating-a-monocular-camera-for-the-lekiwi-robot-using-ros-2">Foxglove blog</a></p>
<hr />
<h2>Project Context</h2>
<p>I purchased a <a href="https://github.com/SIGRobotics-UIUC/LeKiwi">LeKiwi</a> mobile robot kit late last year - an omnidirectional platform originally designed for the <a href="https://huggingface.co/lerobot">LeRobot</a> framework - and have been gradually rebuilding it to run autonomously with ROS 2. The first major addition was a LiDAR, which I covered in my <a href="https://foxglove.dev/blog/upgrading-the-lekiwi-into-a-lidar-equipped-explorer">previous post</a>. Alongside that, I worked on the URDF and set up the motion control using <a href="https://control.ros.org/"><code>ros2_control</code></a>.</p>
<p>The next piece of the puzzle is the camera. LeKiwi includes a camera on the mobile base, and I’m using a cheap, generic webcam that fits perfectly with the mount the kit shipped with. Before it can be useful for anything beyond a live video feed, it needs to be calibrated. This post explains how I achieved this to complete the perception stack for the robot.</p>
<h2>Why do we even need Calibration?</h2>
<p>Every camera lens bends light slightly differently. Cheaper webcams (like the one I'm using on LeKiwi) often introduce significant barrel distortion, making straight lines appear curved, especially near the edges of the frame. This might be barely visible when streaming images, but it's a nightmare for robotics applications that require accurate depth perception, object detection, or visual odometry.</p>
<p>This is where camera calibration comes in. It is the process of determining the camera's mathematical properties: its focal length, optical center, and lens distortion characteristics. These “intrinsic parameters” describe how it transforms the 3D world into a 2D image, and can be used by robotics applications to mathematically correct the lens imperfections.</p>
<h2>Camera Calibration 101</h2>
<p>Camera intrinsics are captured in a <a href="https://en.wikipedia.org/wiki/Camera_matrix">camera matrix</a> (also called the intrinsic matrix), which contains:</p>
<ul>
<li><p><strong>Focal length</strong> (fx, fy): The scaling factor between 3D scene coordinates and 2D image pixels - two values to account for potentially non-square pixels</p>
</li>
<li><p><strong>Principal point</strong> (cx, cy): The optical center of the image (usually near the middle, but not always!)</p>
</li>
</ul>
<p>The intrinsic matrix <code>K</code> is defined as a 3x3 matrix:</p>
<pre><code class="language-plaintext">    [fx  0 cx]
K = [ 0 fy cy]
    [ 0  0  1]
</code></pre>
<p>Additionally, the calibration process generates:</p>
<ul>
<li><p><strong>Distortion coefficients</strong>: Parameters that model radial and tangential lens distortion, depending on the distortion model used, like <code>plumb_bob</code> or <code>rational_polynomial</code>.</p>
</li>
<li><p><strong>Rectification matrix</strong>: Rotation matrix for aligning stereo images (for stereo cameras only)</p>
</li>
<li><p><strong>Projection matrix</strong>: Intrinsic matrix of the projected (rectified) images</p>
</li>
</ul>
<p>In ROS 2, this information lives in a <a href="https://docs.ros2.org/latest/api/sensor_msgs/msg/CameraInfo.html"><code>sensor_msgs/msg/CameraInfo</code></a> message, which camera drivers publish alongside image data. Downstream nodes, such as those performing visual odometry, for instance, subscribe to both the raw image topic and the camera info topic, and correct for distortions internally.</p>
<h2>The “official” ROS 2 calibration process</h2>
<p>The standard approach in ROS 2 uses the <a href="https://index.ros.org/p/camera_calibration/"><code>camera_calibration</code></a> package with a printed checkerboard pattern. Here's the workflow:</p>
<ol>
<li><strong>Print a checkerboard</strong>: You'll need a checkerboard pattern with known dimensions. Make sure it's mounted flat on a rigid surface. <a href="http://calib.io/">Calib.io</a> has a really intuitive pattern generator that I used to print my checkerboard patterns.</li>
</ol>
<img src="https://cdn.prod.website-files.com/66a37d395dfadcdb65dcdf45/69a858c23d59096060feca9a_ea52c51b.jpeg" alt="" style="display:block;margin:0 auto" />

<ol>
<li><p><strong>Launch the calibration node</strong>: Run the calibrator from the <a href="https://docs.ros.org/en/kilted/p/camera_calibration/"><code>camera_calibration</code></a> package, specifying your checkerboard dimensions and camera topics.</p>
</li>
<li><p><strong>Move the checkerboard</strong>: Move the board around to capture diverse angles - left, right, top, bottom, tilted, close, far. The calibration GUI shows progress bars that track coverage across the X, Y, and size dimensions. Hold each position until the window highlights the captured frame.</p>
</li>
<li><p><strong>Calibrate and save</strong>: Once you've collected enough data, hit the Calibrate button. After processing, review the undistorted result, then save the parameters to a YAML file, which you can then load into your camera driver using the <code>camera_info_url</code> parameter.</p>
</li>
</ol>
<p>The full tutorial with detailed instructions is available in the ROS documentation: <a href="https://docs.ros.org/en/kilted/p/camera_calibration/doc/tutorial_mono.html">ROS 2 monocular camera calibration tutorial</a>.</p>
<h2>How I actually did it</h2>
<p>The <a href="https://docs.ros.org/en/kilted/p/camera_calibration/doc/tutorial_mono.html">official calibration process</a> provides a GUI that needs a display, which I don't have since I'm running the LeKiwi on <a href="https://ubuntu.com/download/server">Ubuntu Server</a> without a desktop GUI installed. I also cannot find my USB-A to USB-C adapter, so I can't even connect the camera to my laptop directly. So I had to get creative. I copied the camera calibration script to my laptop and modified it to run "headless".</p>
<p>To do this, I copied the original <code>camera_calibration</code> script and updated it to display coverage and progress information in the terminal rather than the GUI window. I then added the calibration and commit steps to the termination handler, so that once I had collected enough data, I could simply stop the script and it would automatically calibrate and save the parameters. This script can be found as a GitHub Gist <a href="https://gist.github.com/adityakamath/de2a8253c7673de3a0df39ad2aa978b0">here</a><strong>.</strong> It is a hacky solution, but it got the job done without needing any fancy setup.</p>
<p>For visual feedback, I used Foxglove and its <a href="https://docs.foxglove.dev/docs/visualization/panels/image">Image Panel</a> to visualize the camera feed, while I moved the checkerboard around in front of the camera and monitored the terminal output on the side. LeKiwi's Raspberry Pi and my laptop were on the same Wi-Fi network and using <a href="https://docs.ros.org/en/kilted/Installation/RMW-Implementations/Non-DDS-Implementations/Working-with-Zenoh.html">Zenoh</a> for communication. For Foxglove, I simply started the <a href="https://docs.foxglove.dev/docs/visualization/ros-foxglove-bridge">Foxglove bridge</a> on the Raspberry Pi and connected to it from the Foxglove app on my laptop.</p>
<img src="https://cdn.prod.website-files.com/66a37d395dfadcdb65dcdf45/69a858c23d59096060feca9d_00ee6d4f.png" alt="" style="display:block;margin:0 auto" />

<div>
<div>💡</div>
<div><strong>Implementation note:</strong> I only did this headless hack out of necessity. If you can do it using the normal GUI method, I would recommend it. However, if you are in the same situation as me and want to try out this headless mode, make sure you have a stable internet connection and that your camera is publishing images at a decent frame rate.</div>
</div>

<h2>What’s really happening under the hood</h2>
<p>Both implementations share the same core calibration math. For each captured frame, <a href="https://opencv.org/">OpenCV</a> locates the checkerboard corners and then performs a non-linear least-squares optimization to find the camera matrix K and distortion coefficients D. The results are saved as a YAML file containing the camera matrix, distortion coefficients, rectification matrix, and projection matrix.</p>
<p>The main difference is in quality control. The official GUI tool lets you see detected corners in real-time, and expects you to hold the checkerboard still, so you naturally avoid capturing blurry or poorly-lit frames - the human is the filter. Without a display, my headless script can't do that, so it compensates algorithmically: it detects and removes outliers, then re-runs calibration on the cleaned dataset.</p>
<p>There are also minor differences in the OpenCV functions used: The original method uses <code>findChessboardCorners</code> + <code>cornerSubPix</code> for corner detection, my method uses <code>findChessboardCornersSB</code>. The original uses <code>calibrateCamera</code>; mine uses <code>calibrateCameraExtended</code>, an extended version that provides additional data, which I then use for outlier detection. That said, the underlying math is identical.</p>
<p>The detailed explanation about camera calibration can be found in the OpenCV documentation <a href="https://docs.opencv.org/4.x/d9/d0c/group__calib3d.html">here</a>.</p>
<h2>Spot the difference: before and after calibration</h2>
<p>With the calibration file in hand, it's time to put it to use. I saved the <code>camera.yaml</code> output from calibration into the <a href="https://github.com/adityakamath/lekiwi_ros2/blob/main/lekiwi_bringup/config/webcam_calibration.yaml"><code>lekiwi_bringup/config/</code></a> directory. Then I updated the <a href="https://github.com/adityakamath/lekiwi_ros2/blob/main/lekiwi_bringup/launch/webcam.launch.py"><code>webcam.launch.py</code></a> launch file (from the <a href="https://index.ros.org/p/camera_ros/"><code>camera_ros</code></a> package I'm using for the USB webcam) to load the calibration parameters via the <code>camera_info_url</code> parameter.</p>
<p>Now, when the camera node launches, it publishes:</p>
<ul>
<li><p>Raw images on <code>/camera/image_raw</code></p>
</li>
<li><p>Compressed images on <code>/camera/image_raw/compressed</code></p>
</li>
<li><p>Camera intrinsics on <code>/camera/camera_info</code></p>
</li>
</ul>
<p>Any downstream node that needs geometrically accurate images can subscribe to both the image and <code>camera_info</code> topics and apply undistortion. For visualization in Foxglove, I used the compressed image stream, which is more bandwidth-efficient.</p>
<p>Foxglove's Image Panel makes it trivially easy to visualize the impact of calibration. The panel has a Rectify toggle that, when off, shows the raw or compressed images straight from the camera, and, when on, applies rectification using the <code>camera_info</code> message.</p>
<img src="https://cdn.prod.website-files.com/66a37d395dfadcdb65dcdf45/69a85fad8f41eedc4325c774_image1_2_3.gif" alt="" style="display:block;margin:0 auto" />

<p>The difference is pretty subtle on this particular camera. The barrel distortion isn't severe enough to be immediately obvious in casual viewing. However, for applications like visual odometry or fiducial detection, even subtle distortions can throw off measurements by centimeters or more. If you use a fisheye lens, for instance, the rectification makes a dramatic difference.</p>
<h2>Visualizing in 3D</h2>
<p>While the Image Panel is great for inspecting rectification, Foxglove's <a href="https://docs.foxglove.dev/docs/visualization/panels/3d">3D Panel</a> takes things a step further - it can render the camera feed directly in 3D space, anchored to the camera's coordinate frame. Since <a href="https://github.com/adityakamath/lekiwi_ros2/tree/main/lekiwi_description">LeKiwi's URDF</a> already defines a fixed joint between the camera and the robot body, the camera image is automatically positioned and oriented relative to the robot, so you can see the camera's view from the correct position and perspective within the full 3D scene.</p>
<img src="https://cdn.prod.website-files.com/66a37d395dfadcdb65dcdf45/69a858c23d59096060fecaa4_6b33d515.png" alt="" style="display:block;margin:0 auto" />

<p>In the image above, the 3D panel renders the <code>/camera/camera_info</code> topic as a frustum in the scene, showing the camera’s FOV. It then renders the actual camera images, either raw or compressed, on that frustum. This is a surprisingly satisfying sanity check.</p>
<p>In addition to the <a href="https://docs.foxglove.dev/docs/visualization/panels/3d#default">default</a> perspective projection, the 3D panel offers a couple more ways to render images.</p>
<ul>
<li>The <a href="https://docs.foxglove.dev/docs/visualization/panels/3d#plane-projection">plane projection</a> option projects the image on the XY plane. This is quite useful when you have a large FOV, like with fisheye lenses. Here’s an example of the plane projection mode in action:</li>
</ul>
<img src="https://cdn.prod.website-files.com/66a37d395dfadcdb65dcdf45/69a861137089ac356e58c91b_image_2_4s.gif" alt="" style="display:block;margin:0 auto" />

<p>Try it yourself by changing the render mode of the cameras in <a href="https://app.foxglove.dev/~/view?ds=foxglove-sample-stream&amp;ds.recordingId=rec_0dmTeq3hJ5GgLpa9&amp;ds.overrideLayoutId=lay_0dpXV9kBJffgAEg6&amp;time=1980-01-06T01%3A07%3A47.829241740Z">this</a> example.</p>
<ul>
<li>The <a href="https://docs.foxglove.dev/docs/visualization/panels/3d#depth-map">depth map</a> option renders a depth image as a pointcloud in 3D space. This does not apply to the LeKiwi, since it uses a monocular camera.</li>
</ul>
<h2>Wrapping up</h2>
<p>Camera calibration isn't the most glamorous part of robotics, but it's one of those foundational steps that quietly enable everything else. And throughout this process - from waving a checkerboard at a robot to verifying rectification results - using Foxglove made the whole thing significantly less painful.</p>
<p>With the camera now calibrated and ready for use, the LeKiwi is starting to feel like a real robot. Between my earlier article about integrating the LiDAR and this one, the pieces are coming together. The perception stack is now in place, the URDF is ready, and the <code>ros2_control</code> integration is nearly complete, with some final tuning left. Soon, I will be ready to dive into autonomous navigation with Nav2.</p>
<p>My work on the ROS 2-powered LeKiwi is available on <a href="https://github.com/adityakamath/lekiwi_ros2/tree/webcam">GitHub</a>. It's very much a work in progress - new features will be added and existing ones improved as the project evolves. That said, feedback and contributions are always welcome!</p>
<div>
<div>⚠</div>
<div>Note that the webcam has now been removed from the LeKiwi build in order to make space for other components. A snapshot of the <code>lekiwi_ros2</code> configuration with the webcam is stored in the <a target="_self" rel="noopener noreferrer nofollow" class="text-primary underline underline-offset-2 hover:text-primary/80 cursor-pointer" href="https://github.com/adityakamath/lekiwi_ros2/tree/webcam" style="pointer-events:none"><code>webcam</code></a> branch of <code>lekiwi_ros2</code> for posterity.</div>
</div>]]></content:encoded></item><item><title><![CDATA[I made a button]]></title><description><![CDATA[As I continue tinkering with the LeKiwi robot and Foxglove, I realized I needed a digital e-stop button (for obvious reasons). I had something similar on AKROS, but it was just an indicator on Foxglov]]></description><link>https://kamathrobotics.com/i-made-a-button</link><guid isPermaLink="true">https://kamathrobotics.com/i-made-a-button</guid><dc:creator><![CDATA[Aditya Kamath]]></dc:creator><pubDate>Thu, 26 Feb 2026 16:44:47 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/ed91ff79-22ca-4338-89a2-abb180dbd330.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>As I continue tinkering with the <a href="https://kamathrobotics.com/series/lekiwi">LeKiwi</a> robot and <a href="https://foxglove.dev/">Foxglove</a>, I realized I needed a digital e-stop button (for obvious reasons). I had something similar on <a href="https://kamathrobotics.com/series/akros">AKROS</a>, but it was just an indicator on Foxglove, with the real button on my physical controller. Surprisingly, there wasn't a button panel on the Foxglove <a href="https://github.com/foxglove/extension-registry">extensions registry</a> (either created by Foxglove or the community) that could publish its state as a Boolean ROS 2 message, so I decided to create one myself.</p>
<p>With zero <a href="https://react.dev/">React</a> experience, I dove into <a href="https://foxglove.dev/blog/building-a-custom-react-panel-with-foxglove-studio-extensions">a tutorial</a> on Foxglove’s blog. A bit of AI help later, I managed to whip up a button that could toggle between two modes: a toggle switch and a push-button. It changes color with each state change - red for inactive, green for active - and publishes a Boolean message (<code>true</code> for active, <code>false</code> for inactive). Plus, I added a feature to select the topic name from the settings panel.</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1771095429842/2437fe53-2d9b-4be2-9fba-71d499f1e2fa.png" alt="" style="display:block;margin:0 auto" />

<p>Getting to this point was no easy feat, and by then, I had lost interest in doing this by myself. So, I handed over some ideas to Claude and let it do its thing. After a few tweaks, I ended up with a sleek, rounded rectangle button with animations that also lets users change state colors and add text labels.</p>
<p>I gave it a whirl over the weekend, and it turned out to be a handy tool. Around the same time, I also realized that the e-stop feature in <code>sts_hardware_interface</code> needed to switch from a subscriber to a ROS 2 service, so I vibe-coded those changes into the custom button too.</p>
<p>But here's the twist: the Boolean publisher button is super useful beyond just an emergency stop. I didn't want to lose that feature, so I made it flexible. Now, users can choose between a Publisher and a Service Call. I now have the following options:</p>
<ul>
<li><p><strong>Output Mode:</strong> Pick between a Publisher or a Service call.</p>
<ul>
<li><p><strong>Publisher:</strong> Publish a <code>std_msgs/msg/Bool</code> on state change, switching between <code>true</code> and <code>false</code>.</p>
</li>
<li><p><strong>Service Call:</strong> Triggers a <code>std_srvs/srv/SetBool</code> service on state change, switching between <code>true</code> and <code>false</code>.</p>
</li>
</ul>
</li>
<li><p><strong>Topic/Service Name:</strong> Select the ROS 2 Topic or Service name based on your Output Mode.</p>
</li>
<li><p><strong>Button Type:</strong> Choose your style - Toggle or Push-button.</p>
<ul>
<li><p><strong>Toggle:</strong> Click once to change the state, click again to revert.</p>
</li>
<li><p><strong>Push:</strong> Hold to change the state, release to revert.</p>
</li>
</ul>
</li>
</ul>
<p><a class="embed-card" href="https://youtu.be/IX-E-L1Qqso">https://youtu.be/IX-E-L1Qqso</a></p>

<p>I've been testing it locally on my Foxglove app for a few days, and it's been a game-changer. I mostly use the toggle mode with the Service call to trigger the <code>/emergency_stop</code> service. I've already submitted a <a href="https://github.com/foxglove/extension-registry/pull/69">PR</a> to add it to Foxglove’s extension registry. <s>Once it's merged,</s> It is now merged and you can grab it directly from the extensions section in the Foxglove app. But if you're eager to try it out locally, check out my <a href="https://github.com/adityakamath/foxglove_extensions">Foxglove Extensions</a> GitHub repo. It's just the <a href="https://github.com/adityakamath/foxglove_extensions/tree/main/button">Button</a> panel for now, but I plan to add more vibe-coded extensions as I go.</p>
<p><strong>What's next?</strong> I'm all about vibe-coding these apps, especially when I'm not keen on diving deep into a tech that I won’t use regularly (like React). Creating the button panel was a breeze, and now I'm pumped to tackle more. Up next is a teleop extension. A <a href="https://github.com/usedhondacivic/foxglove-teleop-twist-keyboard">current community extension</a> mimics <code>teleop_twist_keyboard</code> but lacks features like strafing or holonomic motion, which I need for my LeKiwi platform. I’m confident I can vibe-code something better. I still don't know whether to contribute AI-generated code to someone's repo or create a separate extension myself. Either way, this one's going to be a bit more complex as I need to take my time testing it thoroughly to ensure it's not just AI slop.</p>
<p>The button extension was just a side-quest, built out of necessity. And so will be the teleop extension. So it will be a while before I actually vibe-code, test, review, and publish it. Meanwhile, I’m progressing on my LeKiwi build, more about it soon…</p>
]]></content:encoded></item><item><title><![CDATA[Hardware Abstraction for STS3215 Servos]]></title><description><![CDATA[Ever since I got my hands on the LeKiwi, I've been on a mission to transform it into a proper ROS 2 robot. Step one was building the robot and adding a LiDAR for future autonomy. Step two: upgrading t]]></description><link>https://kamathrobotics.com/hardware-abstraction-for-sts3215-servos</link><guid isPermaLink="true">https://kamathrobotics.com/hardware-abstraction-for-sts3215-servos</guid><dc:creator><![CDATA[Aditya Kamath]]></dc:creator><pubDate>Sun, 08 Feb 2026 19:07:12 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1770575476108/ae4f08d9-36a4-4823-841b-a8bd920f6900.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Ever since I got my hands on the <a href="https://github.com/SIGRobotics-UIUC/LeKiwi/tree/main">LeKiwi</a>, I've been on a mission to transform it into a proper ROS 2 robot. Step one was building the robot and <a href="https://foxglove.dev/blog/upgrading-the-lekiwi-into-a-lidar-equipped-explorer">adding a LiDAR for future autonomy</a>. Step two: upgrading the <a href="https://github.com/adityakamath/SCServo_Linux/tree/main"><code>SCServo_Linux</code></a> SDK, which I’ve documented <a href="https://kamathrobotics.com/serial-bus-servo-motors-in-2025">here</a>. Now comes the real challenge - using <a href="https://control.ros.org/"><code>ros2_control</code></a> to implement the controls. The first step to this is building a <code>ros2_control</code> hardware interface for the STS3215 motors, but I decided to make it general-purpose for <a href="https://www.feetechrc.com/sts_ttl_series%20servo.html">all STS series servos</a> from Feetech.</p>
<p>Sure, there are existing implementations out there from other makers, but none are quite aligned with my requirements. I wanted something flexible enough to handle my current omni-wheel setup <em>and</em> the <a href="https://github.com/TheRobotStudio/SO-ARM100">SO-101</a> arm I'm planning to add, and, of course, all the other projects I plan to make with these motors in the future. So naturally, I built my own.</p>
<p>In this post, I will cover my design and implementation process and the challenges I encountered along the way.</p>
<h1>Why ros2_control?</h1>
<p>The <code>ros2_control</code> framework provides a plugin-based architecture that cleanly separates hardware from controllers. The idea is simple - write your control logic once, then swap hardware implementations without touching application code. This modular architecture has three main components:</p>
<ul>
<li><p><a href="https://control.ros.org/rolling/doc/ros2_control/hardware_interface/doc/hardware_components_userdoc.html"><strong>Hardware Interfaces</strong></a><strong>:</strong> Talk directly to motors, handling the low-level communication details</p>
</li>
<li><p><a href="https://control.ros.org/rolling/doc/ros2_controllers/doc/controllers_index.html"><strong>Controllers</strong></a><strong>:</strong> Implement the control algorithms - PID loops, trajectory tracking, omni-wheel kinematics</p>
</li>
<li><p><a href="https://control.ros.org/rolling/doc/ros2_control/controller_manager/doc/userdoc.html"><strong>Controller Manager</strong></a><strong>:</strong> Orchestrates everything, loading and unloading controllers on the fly</p>
</li>
</ul>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1770414340675/151b3552-e3c2-4d63-8890-76c1b1caa19e.png" alt="" style="display:block;margin:0 auto" />

<h2>Implementation Overview</h2>
<p>The hardware interface extends <code>ros2_control</code>'s <a href="https://control.ros.org/rolling/doc/api/classhardware__interface_1_1SystemInterface.html"><code>SystemInterface</code></a> class, which handles lifecycle management and exposes state/command interfaces:</p>
<pre><code class="language-cpp">class STSHardwareInterface : public hardware_interface::SystemInterface {
  // Lifecycle: on_init, on_configure, on_activate, on_deactivate, on_cleanup, on_error
  // Core: read(), write()
  // Exports: export_state_interfaces(), export_command_interfaces()
};
</code></pre>
<h1>Key Design Decisions</h1>
<h2>Bus-Level Architecture: One Interface to Rule Them All</h2>
<p>Most implementations I found were single-motor affairs or locked to specific robot configurations. I needed flexibility - control an entire serial bus with multiple motors, potentially in different operating modes.</p>
<p>Here's the thing about STS servos: they're daisy-chained on one communication line. Each motor has a unique ID, but they all share the same serial port. With my approach, one plugin handles the entire chain. One motor or ten motors in mixed modes - doesn't matter.</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1770573641524/35c912f1-4504-4206-8d5e-e4d230fe2012.jpeg" alt="" style="display:block;margin:0 auto" />

<h2>Mixed Operating Modes</h2>
<p>Right now, LeKiwi's three omni-wheels run in velocity mode. But when I add the SO-101 arm (it's happening, I promise), I'll need position mode for the joints, all on the same serial bus. The solution: configure each joint's operating mode independently.</p>
<pre><code class="language-cpp">// Parse operating_mode (per joint)
if (joint.parameters.count("operating_mode")) {
  try {
    operating_modes_[i] = std::stoi(joint.parameters.at("operating_mode"));
  } catch (const std::exception &amp;) {
    RCLCPP_ERROR(logger_, "Joint '%s': Invalid operating_mode value: '%s' (must be 0, 1, or 2)",
      joint.name.c_str(), joint.parameters.at("operating_mode").c_str());
    return hardware_interface::CallbackReturn::ERROR;
  }
} else {
  // Default to velocity mode if not specified
  operating_modes_[i] = MODE_VELOCITY;
}

// Validate operating mode
if (operating_modes_[i] &lt; MODE_SERVO || operating_modes_[i] &gt; MODE_PWM) {
  RCLCPP_ERROR(logger_, "Joint '%s': Invalid operating_mode: %d (must be 0, 1, or 2)",
    joint.name.c_str(), operating_modes_[i]);
  return hardware_interface::CallbackReturn::ERROR;
}
</code></pre>
<h2>Efficient Batched Communication</h2>
<p>The <code>SCServo_Linux</code> SDK handles the low-level serial communication with the motors. My favourite feature is <code>SyncWrite</code>, which batches commands to multiple motors in a single packet instead of sending them one by one.</p>
<p>But why stop there? During initialization, I pre-compute motor groupings by operating mode and pre-allocate all <code>SyncWrite</code> buffers. All hail <a href="https://claude.ai/">Claude</a> for identifying this optimization. This now happens once in <code>on_init()</code>, not every control cycle:</p>
<pre><code class="language-cpp">// Pre-compute motor groupings by operating mode (static after init)
for (size_t i = 0; i &lt; num_joints; ++i) {
  switch (operating_modes_[i]) {
    case MODE_SERVO:
      servo_motor_indices_.push_back(i);
      servo_sync_ids_.push_back(static_cast&lt;u8&gt;(motor_ids_[i]));
      break;
    case MODE_VELOCITY:
      velocity_motor_indices_.push_back(i);
      velocity_sync_ids_.push_back(static_cast&lt;u8&gt;(motor_ids_[i]));
      break;
    case MODE_PWM:
      pwm_motor_indices_.push_back(i);
      pwm_sync_ids_.push_back(static_cast&lt;u8&gt;(motor_ids_[i]));
      break;
  }
}

// Pre-allocate SyncWrite data buffers (IDs are fixed, data overwritten each cycle)
servo_sync_positions_.resize(servo_motor_indices_.size());
servo_sync_speeds_.resize(servo_motor_indices_.size());
servo_sync_accelerations_.resize(servo_motor_indices_.size());
velocity_sync_velocities_.resize(velocity_motor_indices_.size());
velocity_sync_accelerations_.resize(velocity_motor_indices_.size());
pwm_sync_pwm_values_.resize(pwm_motor_indices_.size());

RCLCPP_INFO(logger_, "Motor groupings: %zu servo, %zu velocity, %zu PWM",
  servo_motor_indices_.size(), velocity_motor_indices_.size(), pwm_motor_indices_.size());
</code></pre>
<h2>Interfaces and Unit Conversions</h2>
<p>STS motors speak their own language (steps, raw PWM values), while ROS 2 expects proper SI units (radians, rad/s) and standardized coordinate frames (according to <a href="https://www.ros.org/reps/rep-0103.html">REP-103</a>). The hardware interface translates between these worlds:</p>
<h3>Position (State/Command) Interface</h3>
<ul>
<li><p><strong>Motor:</strong> 0-4095 steps (one full revolution)</p>
</li>
<li><p><strong>ROS 2:</strong> 0-2π radians</p>
</li>
</ul>
<pre><code class="language-cpp">static constexpr double STEPS_PER_REVOLUTION = 4096.0;
static constexpr double STEPS_TO_RAD = (2.0 * M_PI) / STEPS_PER_REVOLUTION;

double raw_position_to_radians(int raw_position) const {
  int inverted_raw = STS_MAX_POSITION - raw_position;  // Position inversion
  return static_cast&lt;double&gt;(inverted_raw) * STEPS_TO_RAD;
}
</code></pre>
<p>See that <code>inverted_raw</code> line? <strong>That's not optional.</strong> The motors increment clockwise while ROS increments counterclockwise. I spent an entire Saturday evening watching the motors spin backwards before the lightbulb moment hit.</p>
<h3>Velocity (State/Command) Interface</h3>
<ul>
<li><p><strong>STS3215:</strong> <code>[-max_velocity_steps, max_velocity_steps]</code> steps/s</p>
</li>
<li><p><strong>ROS 2:</strong> <code>[-max_velocity_steps, max_velocity_steps]</code> * 2π/4096 rad/s</p>
</li>
<li><p><strong>Note:</strong> The <code>max_velocity_steps</code> parameter is configurable per joint and defaults to 3400 steps/s for the STS3215. However, the parameter must be updated for other STS series motors, as they have different max speeds.</p>
</li>
</ul>
<h3>Effort/PWM (State/Command) Interface</h3>
<ul>
<li><p><strong>Motor:</strong> <code>[-1000, 1000]</code> (representing -100% to +100% duty cycle, where the sign represents the direction)</p>
</li>
<li><p><strong>ROS 2 (command):</strong> <code>[-max_effort, max_effort]</code> (<code>max_value</code> defaults to 1.0, i.e. 100% duty cycle)</p>
</li>
<li><p><strong>ROS 2 (state):</strong> <code>[-1.0, 1.0]</code> (normalized measured value, completely independent of <code>max_effort</code>)</p>
</li>
<li><p><strong>Note:</strong> The <code>max_effort</code> parameter is configurable per joint via URDF. It acts as a safety limiter, restricting the command range without scaling. For example, if <code>max_effort = 0.5</code>, commands are limited to <code>[-0.5, 0.5]</code>, which maps to PWM <code>[-500, 500]</code>.</p>
</li>
</ul>
<p>⚠️ <strong>Note:</strong> PWM mode is currently untested, either in mock mode or with real hardware. Test thoroughly before using.</p>
<h3>Acceleration (Command) Interface</h3>
<ul>
<li><p><strong>Motor:</strong> <code>[0, 254]</code> (protocol constant, each unit apparently representing 100 steps/s² of acceleration)</p>
</li>
<li><p><strong>ROS 2:</strong> Pass raw values directly - no conversion needed</p>
</li>
</ul>
<p>The hardware interface simply clamps acceleration commands to the valid <code>[0, 254]</code> range. Higher values produce faster acceleration. Set to 0 to disable acceleration limiting (max acceleration).</p>
<h3>Additional State Interfaces</h3>
<p>The motors also report voltage, current, and temperature. These are straightforward scaling conversions</p>
<ul>
<li><p><strong>Voltage:</strong> <code>Raw units × 0.1 = volts</code> (raw 120 = 12.0V)</p>
</li>
<li><p><strong>Current:</strong> <code>Raw units × 0.0065 = amperes</code> (raw 100 = 0.65A)</p>
</li>
<li><p><strong>Temperature:</strong> Direct Celsius value, no conversion needed</p>
</li>
</ul>
<p>The bottom line is that the controllers work in clean SI units (except the PWM and acceleration values, of course) regardless of what chaos is happening at the motor level. The conversions are isolated in the hardware interface where they belong.</p>
<h2>ros2_control Lifecycle</h2>
<p>ROS 2's <a href="https://design.ros2.org/articles/node_lifecycle.html">managed/lifecycle node</a> system is one of those things that looks quite complex on paper, but just makes sense once you see it work. Instead of cramming all initialization steps into a single massive constructor, you get a clean progression through well-defined states. The <code>ros2_control</code> framework builds on this state machine, mapping each state transition to hardware initialization stages.</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1770429593468/82bac339-d3d0-43fe-bc49-52133e8a15ba.png" alt="" style="display:block;margin:0 auto" />

<p>Here's the journey from power-on to motion:</p>
<ul>
<li><p><code>on_init</code> - Here, we parse and validate everything upfront - motor IDs, command interfaces, baud rates, parameter ranges. This is where all the configuration errors are caught, with clear error messages, and not during runtime. The motor groupings and the pre-allocations I mentioned earlier? This happens here as well. All of this before ever touching the hardware.</p>
</li>
<li><p><code>on_configure</code> - This is where we talk to the real hardware. The serial port is opened, and each motor is pinged to verify that it is actually alive and responding.</p>
</li>
<li><p><code>on_activate</code> - Now, we are ready for motion. Each motor is initialized with its correct operating mode, torques are enabled, and crucially, the position and velocity states are zeroed for clean odometry. Skipping this reset will result in the robot teleporting to a random location, which is not fun to debug. This can be disabled by setting the <code>reset_states_on_activate</code> parameter to <code>false</code>, but this is for very specific use-cases.</p>
</li>
<li><p><code>on_deactivate</code> - Here, we gracefully shut down the system. All the motors are stopped using mode-appropriate commands, and their torques are disabled. Now, the wheels/joints can safely be moved by hand.</p>
</li>
<li><p><code>on_cleanup</code> - Finally, the serial port is closed, SDK resources are released, and everything is returned to a clean state. The state machine is now ready for another cycle if needed.</p>
</li>
<li><p><code>on_error</code> - During emergencies, like communication errors, all motors are stopped, torques are disabled, and the system transitions into an <code>ERROR</code> state, which means that manual intervention is needed. This is only meant for emergencies, and thankfully, I haven’t faced any yet. This also means that this state is currently untested.</p>
</li>
</ul>
<h2>The Read-Write Loop</h2>
<p>Every control cycle, <code>controller_manager</code> calls the <code>read()</code> and <code>write()</code> functions. This is where the actual motor communication magic happens.</p>
<h3><code>read()</code></h3>
<p>This function queries each motor, converts raw measurements to standard ROS 2 units, and publishes them for monitoring:</p>
<pre><code class="language-cpp">for (size_t i = 0; i &lt; motor_ids_.size(); ++i) {
  if (servo_-&gt;FeedBack(motor_ids_[i]) != 1) {
    consecutive_read_errors_++;
    // ... error recovery
  }
  hw_state_position_[i] = raw_position_to_radians(servo_-&gt;ReadPos(-1));
  hw_state_velocity_[i] = raw_velocity_to_rad_s(servo_-&gt;ReadSpeed(-1));
  // ... other states
}
</code></pre>
<p>Data flows from <strong>motors →</strong> <code>controller_manager</code> <strong>→ controllers →</strong> <code>/joint_states</code> topic.</p>
<h3><code>write()</code></h3>
<p>This function applies limits, converts ROS 2 units to the motor’s units, and sends commands to the serial bus. For motors grouped by operating mode, if <code>SyncWrite</code> is enabled, the pre-allocated buffers are used. Here’s an example of the velocity mode motors:</p>
<pre><code class="language-cpp">// Update pre-allocated SyncWrite buffers for velocity mode motors
if (!velocity_motor_indices_.empty() &amp;&amp; use_sync_write_ &amp;&amp; velocity_motor_indices_.size() &gt; 1) {
  for (size_t j = 0; j &lt; velocity_motor_indices_.size(); ++j) {
    size_t idx = velocity_motor_indices_[j];
    double target_velocity = apply_limit(hw_cmd_velocity_[idx],
                                          -velocity_max_[idx],
                                          velocity_max_[idx],
                                          has_velocity_limits_[idx]);
    velocity_sync_velocities_[j] = rad_s_to_raw_velocity(target_velocity);
    velocity_sync_accelerations_[j] = static_cast&lt;u8&gt;(clamp_acceleration(idx));
  }

  // Send batched command to all velocity mode motors in single packet
  servo_-&gt;SyncWriteSpe(
    velocity_sync_ids_.data(), velocity_sync_ids_.size(),
    velocity_sync_velocities_.data(), velocity_sync_accelerations_.data());
}
</code></pre>
<p>Commands flow from <strong>controllers → enforced safety limits → unit conversion → motors</strong>.</p>
<h2>Emergency Stop</h2>
<p>Ever watch a robot go crazy while you frantically search for the power switch? Yeah, me too. That's why I added an emergency stop into the hardware interface as a first-class feature, not an afterthought.</p>
<p>Another amazing feature of the STS protocol - <code>Broadcast</code> - lets you command all motors at once. One packet controls the entire serial bus without looping through motor IDs, which is perfect for an emergency stop. This can be triggered via a ROS 2 service:</p>
<pre><code class="language-bash"># Stop everything NOW
ros2 service call /emergency_stop std_srvs/srv/SetBool "{data: true}"

# Resume normal operation
ros2 service call /emergency_stop std_srvs/srv/SetBool "{data: false}"
</code></pre>
<p>The hardware interface creates a ROS 2 service server (during <code>on_configure()</code>) that accepts <code>std_srvs/SetBool</code> requests. Incoming service calls are processed every <code>read()</code> cycle via <code>spin_some()</code>. On receiving a request, the callback sets the flag, and the next <code>write()</code> cycle broadcasts the stop. The service returns <code>success: true</code> and a confirmation message on both activation and release. Just like in the lifecycle states, when the emergency stop is enabled, all motors are stopped, and their torques are disabled. The torque is enabled when the emergency stop is released.</p>
<h2>Mock Mode</h2>
<p>I’ll be honest, I don't strictly need <a href="https://control.ros.org/rolling/doc/ros2_control/hardware_interface/doc/mock_components_userdoc.html">mock mode</a> for LeKiwi's omni-wheels. The motors are in wheel mode, with no position limits, so testing on real hardware is pretty safe. I’ve also included a raised platform to make sure that the wheels aren’t touching the floor.</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1770573505454/17b7c001-7bb1-49a2-bd55-60a38dae71ca.jpeg" alt="" style="display:block;margin:0 auto" />

<p>But I implemented it anyway, mainly because of the SO-101 arm that's coming, which has servo joints with hard position limits. Send the wrong command, and you're physically damaging the robot. And since <code>ros2_control</code> offers this feature, why not make use of it?</p>
<p>I can now test test the entire control stack with zero hardware at risk. And I’m mocking not only the command interfaces but also the state interfaces - the voltage drops under load, temperature rises with activity, and the current draw matches the effort. This means that when you monitor <code>/dynamic_joint_states</code>, you see believable numbers instead of zeroes.</p>
<p>Is it a perfect simulation? Absolutely not. But at least it is good enough to catch bugs before it ends up breaking hardware. The mocked emergency stop behavior matches the real hardware as well. The mocked voltage, current, and temperature interfaces are just a fun little addition.</p>
<p>You can enable it in the URDF:</p>
<pre><code class="language-xml">&lt;param name="enable_mock_mode"&gt;true&lt;/param&gt;
</code></pre>
<p>Or via launch arguments (recommended):</p>
<pre><code class="language-bash">ros2 launch sts_hardware_interface single_motor.launch.py use_mock:=true
</code></pre>
<p>The payoff? Switching between simulation and real hardware is one parameter flip. Zero code changes needed.</p>
<h2>Try it out</h2>
<p>If you're running STS servos with ROS 2, the <a href="https://github.com/adityakamath/sts_hardware_interface"><code>sts_hardware_interface</code></a> handles the hard parts: unit conversions, mode switching, error recovery, mock simulation. Code lives on my <a href="https://github.com/adityakamath/sts_hardware_interface">GitHub</a>. Give it a shot.</p>
<p><code>ros2_control</code> and <code>sts_hardware_interface</code> have been powering my LeKiwi mobile base for a month now. Velocity control for the omni-wheels works perfectly. Position mode for the upcoming SO-101 arm is almost there - it needs some testing and possibly tweaks. PWM mode is implemented but hasn’t been tested. It's probably a little over-engineered, and not a perfect implementation - I've prioritized features I actually need - but it works, and it's a solid baseline for current and future projects.</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1770566082390/327e333c-3127-4028-959d-c8d9a5ee60b9.jpeg" alt="" style="display:block;margin:0 auto" />

<p>Note that configurations other than the LeKiwi mobile base haven't been tested and won't be for a while. If you give it a shot with different motors or configurations, let me know - feedback is appreciated, contributions even more so.</p>
<h3>What’s Next</h3>
<p>I’ve already updated the LeKiwi URDF to work with <code>ros2_control</code> and implemented the built-in <a href="https://control.ros.org/master/doc/ros2_controllers/omni_wheel_drive_controller/doc/userdoc.html"><code>omni_wheel_drive_controller</code></a>, which works like a charm out of the box. I’m planning to document it soon, but I promise it won’t be as long as this one, as I didn’t have to write new code. I also need to address some minor tuning issues. This weekend, I intend to calibrate the camera on the LeKiwi, which I must get back to…</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1770573039390/09ad90be-9b37-4d32-9e39-26e5f7b6c868.jpeg" alt="" style="display:block;margin:0 auto" />

<p>PS: Turns out hardback Asterix/Tintin books are perfect backing for a calibration checkerboard printout 😀</p>
]]></content:encoded></item><item><title><![CDATA[Upgrading the LeKiwi into a LiDAR-equipped explorer]]></title><description><![CDATA[tl;dr: This article details my process of upgrading a LeKiwi robot - a low-cost, open-source mobile manipulator - with a 2D LiDAR sensor and ROS 2 to enable SLAM and autonomous navigation. The process]]></description><link>https://kamathrobotics.com/upgrading-the-lekiwi-into-a-lidar-equipped-explorer</link><guid isPermaLink="true">https://kamathrobotics.com/upgrading-the-lekiwi-into-a-lidar-equipped-explorer</guid><dc:creator><![CDATA[Aditya Kamath]]></dc:creator><pubDate>Sun, 08 Feb 2026 09:30:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/d62f8d45-ce41-4d3e-af9a-ddf065240c58.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><strong>tl;dr:</strong> This article details my process of upgrading a LeKiwi robot - a low-cost, open-source mobile manipulator - with a 2D LiDAR sensor and ROS 2 to enable SLAM and autonomous navigation. The process involves the hardware integration of the LiDAR, creation of the URDF, a ros2_control package, and teleoperation, all of which were tested using Foxglove. The result is a functional ROS 2-powered, LiDAR-equipped mobile base, currently driven by a game controller, but fully ready for autonomous exploration.</p>
<p>Originally written for and published on the <a href="https://foxglove.dev/blog/upgrading-the-lekiwi-into-a-lidar-equipped-explorer">Foxglove blog</a>.</p>
<hr />
<p>A few months ago, I purchased a <a href="https://github.com/SIGRobotics-UIUC/LeKiwi">LeKiwi</a> robot kit with the intention of exploring the LeRobot framework and creating embodied AI applications with it. However, <a href="https://kamathrobotics.com/roscon-2025-singapore">my experience at ROSCon 2025</a> in Singapore was a turning point, inspiring me to build with ROS once again, so I decided to repurpose the LeKiwi platform for this journey. With my sights set on using Nav2, the navigation stack for ROS 2, I decided to add a 2D LiDAR to the mix. In this article, I will guide you through my workflow and explain how Foxglove was a crucial tool at various stages of my design process.</p>
<h2>What's LeKiwi?</h2>
<p>Launched in early 2025 by <a href="https://sigrobotics.acm.illinois.edu/">SIGRobotics</a>, a student group from the University of Illinois Urbana-Champaign, LeKiwi is a low-cost, open-source mobile manipulator. Designed to pair seamlessly with <a href="https://huggingface.co/lerobot">LeRobot</a>, HuggingFace's robotics framework for physical/embodied AI, it features an omni-wheeled base, a camera, a Raspberry Pi 5, and mounting points for the popular <a href="https://github.com/TheRobotStudio/SO-ARM100">SO-101</a> robot arm. The base and arm share the same <a href="https://kamathsblog.com/serial-bus-servo-motors-in-2025">STS3215 serial bus servo motors</a>, making integration with existing LeRobot setups a breeze.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/1c2bb1ea-4b09-4dcc-875c-56a4e51d5dc0.png" alt="" style="display:block;margin:0 auto" />

<p>With the growing popularity of LeRobot, it became an instant hit, especially among people who already had an SO-arm. Several companies also started offering fully assembled and DIY kits with everything you need. I was already on the hunt for a new robot platform, and since I already had experience with the SO-100 and the STS3215 motors, I couldn’t resist. So, I grabbed a kit to see what the buzz was about.</p>
<h2>Why add the LiDAR?</h2>
<p>After I changed directions and decided to work with <a href="https://www.ros.org/">ROS 2</a> and <a href="https://docs.nav2.org/">Nav2</a>, which need localization and mapping data for autonomous navigation, adding a LiDAR was a no-brainer. Packages such as <a href="https://github.com/SteveMacenski/slam_toolbox"><code>slam_toolbox</code></a> can then be used to create a map using the laser scans, and then localize the robot within this map. First, I set the arm aside temporarily and zeroed in on the mobile base.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/80f2fbad-55f0-47b5-ae45-c07f5e6b3c21.png" alt="" style="display:block;margin:0 auto" />

<p>With or without ROS 2, adding the LiDAR was a smart move. The LeKiwi base has a front-facing camera, and even with an SO-101 arm with a wrist-mounted camera attached, the rear is still a blind spot. A simple 2D laser scanner offers a budget-friendly way to achieve 360-degree obstacle detection. Even when an arm is attached, and parts of the laser scan are obstructed, the robot's blind spots are still covered.</p>
<h2>Integrating the LiDAR</h2>
<p>I used my spare <a href="https://www.ldrobot.com/images/2023/05/23/LDROBOT_LD19_Datasheet_EN_v2.6_Q1JXIRVq.pdf">LD19 LiDAR</a> and quickly designed and 3D printed a mounting plate. The sensor was now secured, with the USB adapter glued underneath. I then used four 35mm spacers to attach it above the Raspberry Pi. I eventually want to reattach the SO-101 arm, so I made sure to leave the mounting spot for it untouched. Finally, I connected the adapter to the Raspberry Pi with a USB cable.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/af68dbdf-8748-4265-898b-76dfae67c9c8.png" alt="" style="display:block;margin:0 auto" />

<p>Next, I tested the setup using the LDLidar ROS 2 package provided by the manufacturer, rather than other available alternatives. It wasn't the best choice, as the package had several bugs. But with AI tools at my disposal, fixing them was a breeze. I have pushed these fixes to <a href="https://github.com/adityakamath/ldlidar_ros2">my working fork of the LDLidar ROS 2 package</a>.</p>
<p>Finally, it was time to visualize and test the LiDAR using Foxglove. I ran the following commands on separate terminals on the robot's Raspberry Pi.</p>
<p><code>ros2 launch ldlidar_ros2 ld19.launch.py</code></p>
<p><code>ros2 launch foxglove_bridge foxglove_bridge_launch.xml</code></p>
<p>On my laptop, which is on the same network as the Raspberry Pi, I simply launched the <a href="https://foxglove.dev/download">Foxglove app</a>, connected to the <a href="https://docs.foxglove.dev/docs/visualization/ros-foxglove-bridge?utm_term=foxglove%20vs%20rviz&amp;utm_campaign=Foxglove_Competitors_Search_EU&amp;utm_source=adwords&amp;utm_medium=ppc&amp;hsa_acc=7660688999&amp;hsa_cam=23046618600&amp;hsa_grp=188985726387&amp;hsa_ad=775648193736&amp;hsa_src=g&amp;hsa_tgt=kwd-2364572777163&amp;hsa_kw=foxglove%20vs%20rviz&amp;hsa_mt=b&amp;hsa_net=adwords&amp;hsa_ver=3&amp;gad_source=1&amp;gad_campaignid=23046618600&amp;gbraid=0AAAAABaqO8MJ4taZpCKpRnD-uKTC5S__B&amp;gclid=Cj0KCQiAg63LBhDtARIsAJygHZ4a5j2sDvEiwfowi33axNlBIfgLvwTIbpGcK17flRFX6guxdflOVkQaAgfJEALw_wcB">Foxglove Bridge</a> on port 8765, and the laser scan visualization appeared on the <a href="https://docs.foxglove.dev/docs/visualization/panels/3d">3D panel</a> right away.</p>
<p><a class="embed-card" href="https://www.youtube.com/watch?v=QbdykAOCLRo">https://www.youtube.com/watch?v=QbdykAOCLRo</a></p>

<h2>URDF and ROS 2 Control</h2>
<p>Aided by AI, I set off to create my own LeKiwi ROS 2 package, following these steps:</p>
<p>First, I tackled the robot description file (URDF). It defines the robot's links and joints and maps them to the correct mesh files for visualization. The URDF also allows for adding <a href="https://control.ros.org/rolling/doc/getting_started/getting_started.html#hardware-description-in-urdf"><code>ros2_control</code> tags</a> to specify command and state interfaces, along with actuator limits. Since the LeKiwi repository contains an older version of the <a href="https://github.com/SIGRobotics-UIUC/LeKiwi/tree/main/URDF">URDF</a>, I created a new one for my robot and made it public in the <a href="https://github.com/adityakamath/lekiwi_ros2/tree/main/lekiwi_description"><code>lekiwi_description</code></a> package.</p>
<p>Foxglove was quite helpful in visualizing the URDF. The 3D panel provides an option to add a <a href="https://docs.foxglove.dev/docs/visualization/panels/3d#urdf-custom-layer">custom URDF layer</a>, which requires a link to a URDF file (URL, ROS 2 parameter/topic, or local path).</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/f413692b-559a-4104-b055-afef358b8200.png" alt="" style="display:block;margin:0 auto" />

<p>I used the URL to the <a href="https://github.com/adityakamath/lekiwi_ros2/blob/main/lekiwi_description/urdf/base.urdf.xacro">raw URDF</a> on my GitHub repository, which you can try out for yourself without any local ROS 2 installation.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/981a8475-1b19-4fe1-bc77-27ea329fa89d.png" alt="" style="display:block;margin:0 auto" />

<p>As shown in the image, I’ve also included the links and meshes for the LiDAR and the Camera, which I plan to implement later. Note that the warning signs in the above images are due to my visualizing a static URDF from a URL and not having a live connection to the robot at the time of testing.</p>
<p>With the robot's links, joints, and meshes visualized correctly, the next step was to control the motors and move the robot. For this, I used <code>ros2_control</code>. I created a <a href="https://control.ros.org/kilted/doc/ros2_control/hardware_interface/doc/writing_new_hardware_component.html">hardware interface</a> for the STS3215 motors and then configured the <a href="https://control.ros.org/master/doc/ros2_controllers/omni_wheel_drive_controller/doc/userdoc.html">omni_wheel_drive_controller</a>, which is provided in ros2_control.</p>
<p>You can find my implementation of the hardware interface and control configuration in the <a href="https://github.com/adityakamath/sts_hardware_interface">sts_hardware_interface</a> and <a href="https://github.com/adityakamath/lekiwi_ros2/tree/main/lekiwi_control"><code>lekiwi_control</code></a> packages, respectively. Note that the hardware interface was extensively tested only with the STS3215 motors. Please proceed with caution when using other STS series motors or untested modes (such as servo or PWM mode).</p>
<p>I tested everything using Foxglove, making adjustments to the control configuration and URDF along the way. On the Raspberry Pi, I used three separate terminals to run the controller, the Foxglove bridge, and a TwistStamped publisher (the controller requires twist messages with a timestamp) using the following commands:</p>
<p><code>ros2 launch lekiwi_control control.launch.py</code></p>
<p><code>ros2 launch foxglove_bridge foxglove_bridge_launch.xml</code></p>
<p><code>ros2 topic pub -r 10 /base_controller/cmd_vel geometry_msgs/msg/TwistStamped "{header: {frame_id: 'base_link'}, twist: {linear: {x: 0.1, y: 0.0, z: 0.0}, angular: {x: 0.0, y: 0.0, z: 0.0}}}"</code></p>
<p>And here are the results:</p>
<p><a class="embed-card" href="https://www.youtube.com/watch?v=ZMS3ILDSjJs">https://www.youtube.com/watch?v=ZMS3ILDSjJs</a></p>

<p>In this video, the robot is placed on a platform, so the motors are moving, but the wheels are not touching the floor. This means that the odometry, computed from the measured velocities of each motor, is quite accurate. With this test, I validated the URDF and the default control setup before integrating with the LiDAR.</p>
<h2>Integration and Testing</h2>
<p>To integrate the control package with the LiDAR, I set up a combined launch file in <a href="https://github.com/adityakamath/lekiwi_ros2/tree/main/lekiwi_bringup"><code>lekiwi_bringup</code></a>. This launch file starts both the ros2_control and LiDAR nodes with a single command. I also included <a href="https://index.ros.org/r/teleop_twist_joy/"><code>teleop_twist_joy</code></a> in the launch file to convert joystick inputs into <a href="https://docs.ros2.org/foxy/api/geometry_msgs/msg/TwistStamped.html"><code>TwistStamped</code> messages</a> for the control node.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/20eace5d-52f3-4b56-b3e2-af7ed66d084d.png" alt="" style="display:block;margin:0 auto" />

<p>For the joystick inputs, I used the standard <a href="https://index.ros.org/p/joy/">joy package</a> to read data from a game controller and format it into a <a href="https://docs.ros2.org/foxy/api/sensor_msgs/msg/Joy.html">Joy message</a>. I wanted to control the robot from my laptop, so I connected my game controller to it and ran the joy node there.</p>
<p><code>ros2 run joy joy_node</code></p>
<p>For communication between my laptop and the Raspberry Pi, I was already using <a href="https://tailscale.com/">Tailscale</a> and decided to work with <a href="https://docs.ros.org/en/kilted/index.html">ROS 2 Kilted</a> and <a href="https://docs.ros.org/en/humble/Installation/RMW-Implementations/Non-DDS-Implementations/Working-with-Zenoh.html">Zenoh RMW</a> as the middleware. However, this can be done in several different ways. For example, you could connect both devices to the same Wi-Fi network and use the different configurations provided by DDS alternatives like <a href="https://fast-dds.docs.eprosima.com/">FastDDS</a> or <a href="https://cyclonedds.io/">CycloneDDS</a>. The integrated system looks like this:</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/6f4ec59f-7eab-4375-b213-5b308123aafa.png" alt="" style="display:block;margin:0 auto" />

<p>Finally, I fired up the Foxglove Bridge and used the Foxglove app to visualize the robot in action as I navigated it with my controller. On the Foxglove app, I used the <a href="https://docs.foxglove.dev/docs/visualization/panels/plot">Plot panels</a> to compare the measured velocities from the <a href="https://docs.ros2.org/foxy/api/nav_msgs/msg/Odometry.html">Odometry messages</a> with the command velocities from the teleop_twist_joy node. This helped me determine realistic limits for the twist commands. Plus, the <a href="https://docs.foxglove.dev/docs/visualization/panels/diagnostics">Diagnostics panels</a> were really handy for keeping tabs on the ros2_control diagnostics data. I’m definitely using these tools for fine-tuning the robot's odometry (which is surprisingly accurate right out of the box, just a bit noisy) and for my adventures with Nav2.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/037e0ab7-db1d-4f38-8f8c-e434f78d088f.png" alt="" style="display:block;margin:0 auto" />

<h2>Summary</h2>
<p>With the addition of a LiDAR and the <code>ros2_control</code> implementation tested using a game controller, I'm well on my way to modifying LeKiwi into a ROS 2-powered autonomous mobile robot. Foxglove has been an invaluable tool throughout this journey, from creating the URDF to fine-tuning teleoperation and testing the integrated system, and all on a MacBook (which famously has limited support for native ROS 2 installations). While the foundation for this project seems solid, there’s still work to be done before starting with autonomy.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63a0f156a59cad9bdbba046e/0a2ebca9-7470-4315-9f5b-6c91e4423544.png" alt="" style="display:block;margin:0 auto" />

<p>For starters, the <code>ros2_control</code> configuration works with the default settings, but it needs to be fine-tuned. This includes the odometry, which has been surprisingly accurate, but it is too noisy and could use a filter. I also want to calibrate the camera and work with ROS 2 camera packages.</p>
<p>I am sure that Foxglove will continue being a key utility for what’s to come. Looking ahead, I’m excited to see this robot navigate around autonomously, even though I do not have a specific application for it. But I am certainly learning a lot during this journey and can’t wait to see where it leads!</p>
]]></content:encoded></item><item><title><![CDATA[FOSDEM 2026 - Brussels 🇧🇪]]></title><description><![CDATA[This is going to be a quick one, unlike my much more detailed report from ROSCon 2025. I had originally planned for a full weekend in Brussels, but because of some hectic work days before and after th]]></description><link>https://kamathrobotics.com/fosdem-2026-brussels</link><guid isPermaLink="true">https://kamathrobotics.com/fosdem-2026-brussels</guid><dc:creator><![CDATA[Aditya Kamath]]></dc:creator><pubDate>Wed, 04 Feb 2026 16:29:24 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1770145197429/f657bc3e-cfcb-4899-aae6-01db39828d99.jpeg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>This is going to be a quick one, unlike my much more detailed report from <a href="https://kamathrobotics.com/roscon-2025-singapore">ROSCon 2025</a>. I had originally planned for a full weekend in Brussels, but because of some hectic work days before and after the FOSDEM weekend, I decided on a day trip on Saturday and have Sunday for myself, at home.</p>
<h2>What’s FOSDEM?</h2>
<p>If FOSDEM sounds unfamiliar, you're not alone. I only discovered it when the Robotics and Simulation devroom debuted in 2025. After hearing rave reviews, I decided to check it out this year. <a href="https://fosdem.org/2026/">FOSDEM</a> is a free annual event at the <a href="https://www.ulb.be/en/maps-directions/solbosch">ULB Solbosch</a> campus celebrating all things free and open source. With thousands of talks and several devrooms, each focusing on specific topics, it's a techie's paradise.</p>
<p>I initially planned to stick to the <a href="https://fosdem.org/2026/schedule/track/robotics-and-simulation/">Robotics and Simulation devroom</a>, but it was way more popular than anyone expected. Leaving for lunch, which I did, meant a long wait in line to get back in. With limited time, I opted to explore other FOSDEM areas and wander around Brussels instead of queuing up.</p>
<p>If you know me, I am more interested in hardware than software, so I was naturally attracted to the stands showcasing open-source hardware projects. Here are a few interesting projects I came across:</p>
<h3><a href="https://openflexure.org/projects/microscope/">OpenFlexure Microscope</a></h3>
<p>An open-source, 3D printable, Raspberry Pi-powered microscope. Not a hobby/education tool like the ESPressoscope, but real research equipment actually deployed in the medical and research sector in 70+ countries. One of the creators, present at the stand, told me about how they deployed a few in rural Rwanda, where more expensive tooling is not accessible. And looking at the live images from their demo microscopes, it looked really high quality, accurate, and in focus. All from a Raspberry Pi HQ camera, and off-the-shelf stepper motors with PLA parts for positioning. Very cool.</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1770144534237/75949484-b2b8-402a-9aca-7fb5de86256d.jpeg" alt="" style="display:block;margin:0 auto" />

<h3>Open Micro-Manipulator</h3>
<p>At the <a href="https://www.freecad.org/">FreeCAD</a> and <a href="https://www.kicad.org/">KiCAD</a> stand, they demonstrated products created using these open-source CAD and PCB design tools, one of them being the <a href="https://github.com/0x23/MicroManipulatorStepper">Open Micro-Manipulator</a>. This is an XYZ-manipulator and motion control platform - made using NEMA stepper motors, 3D printed parts, and commercially available hardware components - achieving sub-micron precision. This was another project that blew my mind. To top that, the entire control system is powered by a Raspberry Pi Pico. I really need to dig into this project when I get the chance. I have the parts to build one, maybe I’ll make a weekend project out of it someday.</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1770144543364/05148b33-b3c8-4277-a79d-94d98970046d.jpeg" alt="" style="display:block;margin:0 auto" />

<h3>OpenPrinting</h3>
<p>They provide open-source drivers for paper printers, like a Klipper for 2D printing. Unfortunately, there was nobody at the stand when I was there; they had probably gone for lunch. But if I ever decide to get a paper printer, I’m definitely getting one that’s compatible with <a href="https://openprinting.github.io/">OpenPrinting</a>.</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1770144558856/53a13cf1-b1ba-4220-939c-e5d95d64c0cf.jpeg" alt="" style="display:block;margin:0 auto" />

<h1>Robotics and Simulation Devroom</h1>
<p>I reached the venue about 15 minutes before the day started, and was lucky enough to get a choice of seats. I said hi to some people, and after trying out a few spots, found the perfect place to sit. But soon, as more people started coming in, the room was full.</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1770144637608/80dd9ab3-da09-4b23-b064-8bebbc499c29.jpeg" alt="" style="display:block;margin:0 auto" />

<p>I left to get a coffee and some food, and that’s when I realized how crazy it was outside - there were about 20 people in line, waiting to get in. By the time I returned, the line had gotten even bigger. I decided to first explore other areas of FOSDem and then watch the livestreams for the rest of the talks. I’ve clubbed the interesting ones into two main categories - Robots and Rust. There were a few other memorable ones, which I have another section for below.</p>
<h2>Robots</h2>
<h3>HackerBot</h3>
<p>First, <a href="https://fosdem.org/2026/schedule/speaker/stef_dillo/">Stef Dillo</a>, the creator of the <a href="https://x.com/rovertenacity">Tenacity Rover</a>, talked about another project of his - <a href="https://fosdem.org/2026/schedule/event/PDWNCJ-map-hacking-a_cheap-robot-vac-with-open-source-sw/">converting a cheap robot vacuum into a ROS-powered robot base</a>, with a hacked mapping solution that converts the vacuum’s internal map into a ROS-compatible costmap. He talked about his challenges, how he fixed them, and shared some general tips and tricks.</p>
<p><a class="embed-card" href="https://www.youtube.com/watch?v=ykVGcZc0c0A">https://www.youtube.com/watch?v=ykVGcZc0c0A</a></p>

<p>It is a shame he didn’t share the actual make and model of the robot vacuum, but he did mention <a href="https://www.hackerbot.co/">HackerBot Industries</a>, a company that sells complete kits (but also does not mention the original manufacturer). This is something I’ve been wanting for years - a hackable robot vacuum cleaner. There’s the <a href="https://iroboteducation.github.io/create3_docs/">iRobot Create</a> platform, but it does not include any vacuuming features. I guess I will have to build my own.</p>
<h3>Just1</h3>
<p>In a lightning talk, <a href="https://fosdem.org/2026/schedule/speaker/nicolas_rodriguez/">Nicolas Rodriguez</a> showed (and demoed live) his mecanum-wheeled robot called <a href="https://fosdem.org/2026/schedule/event/KCPTX7-just1/">Just1</a>. It reminded me of my time working on <a href="https://kamathrobotics.com/series/akros">AKROS</a>, but this one was much more affordable. Just1 costs only 250 Euros to build, and this includes a 70 Euro estimate for the <a href="https://www.waveshare.com/wiki/DTOF_LIDAR_LD19?srsltid=AfmBOooRFtdIZVZMFMJkXW4CuEA6qHCJWrk7tTzcUQXDSZryPYrRWHO4">LD19</a> LiDAR, which these days costs 20-30 Euros on Chinese websites. Perfect solution for students/hobbyists. Combine the Just1 with <a href="https://github.com/linorobot/linorobot2">Linorobot</a>, and you’ve got a powerful robot on your hands, all for about 200 Euros.</p>
<p><a class="embed-card" href="https://www.youtube.com/watch?v=m_tZ3eg-KsA">https://www.youtube.com/watch?v=m_tZ3eg-KsA</a></p>

<h3>iXi</h3>
<p><a href="https://www.botronics.be/">Botronics</a>, a Belgian startup, did an <a href="https://fosdem.org/2026/schedule/event/M38A3V-botronics-robotics-tech-stack/">extensive deep-dive</a> into the technology stack of <a href="https://www.ixi.golf/?utm_source=website&amp;utm_campaign=botronics">iXi</a>, their autonomous golf-bag carrying robot. I was definitely impressed by their commitment to open-source, and they’re probably the only company I know of that is building a product without re-inventing the wheel. They use off-the-shelf parts, existing ROS 2 packages where possible, and build on top of that, contributing back to the community. And the things they’ve built on top are quite impressive as well. Do check out <a href="https://mirror.as35701.net/video.fosdem.org/2026/ub2147/M38A3V-botronics-robotics-tech-stack.av1.webm">their talk</a> to learn more.</p>
<p><a class="embed-card" href="https://www.youtube.com/watch?v=Wpu0oe_uBDM">https://www.youtube.com/watch?v=Wpu0oe_uBDM</a></p>

<h3>RustyRover</h3>
<p>In the second lightning talk session, engineers from Botronics showcased <a href="https://fosdem.org/2026/schedule/event/3PBHXY-simple_safe_open_building_your_first_ros_2_rover_with_rust_and_pixi/">RustyRover</a>, a DIY differential-drive robot built with Rust. They built it to experiment with Rust and assess new technologies. Botronics seems to have cultivated a really cool company culture, and it is very impressive. And a cool project as well, I’m definitely going to read through this as I slowly get started with Rust.</p>
<h2>Rust</h2>
<h3>Copper-rs</h3>
<p><a href="https://fosdem.org/2026/schedule/speaker/guillaume_binet/">Guillaume Binet</a> talked about his project, <a href="https://fosdem.org/2026/schedule/event/SK8EGJ-copper-rust-robotics-runtime/">Copper-rs</a>, a Rust-based, game-engine-like, deterministic framework for robotics. Very cool project and impressive results, but I am a bit too biased towards ROS, and while I do want to learn Rust, I would rather use it with ROS 2 rather than a completely new framework. But it was good to learn about Copper-rs, I’ll read more when I get the chance.</p>
<h3>rclrs / ros2_rust</h3>
<p><a href="https://fosdem.org/2026/schedule/speaker/esteve_fernandez/">Esteve Fernandez</a> talked about <a href="https://fosdem.org/2026/schedule/event/J8ZLKG-introducing_rclrs_the_official_ros_2_client_library_for_rust/">rclrs</a> - the ROS 2 client library for Rust. I’ll be honest, I was in line at a food truck at the time, and I completely missed this. But I was at <a href="https://vimeo.com/1136376117">Esteve’s talk at ROSCon</a> last year, so I had an idea of what he spoke about. I’m adding it to this post because this is one of the projects I am curious about, and I plan to try it out as a part of my Rust learning journey</p>
<h3>ROS-Z</h3>
<p>This was another project I had heard about at ROSCon, but I believe it was a lightning talk. This was a much longer presentation about it. <a href="https://fosdem.org/2026/schedule/event/BQ8DVM-ros-z/">ROS-Z</a> is simply ROS 2, but with parts of the ROS 2 stack replaced by pure Rust-based components, and using <a href="https://zenoh.io/">Zenoh</a> as the middleware. So you could have a Rust-native stack, but still have your C++/Python apps. I wasn’t that interested when I first learned about it at ROSCon, but after using Zenoh, I don’t think I will move back to DDS again. Now it makes sense to move to ROS-Z.</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1770144883122/75892a22-bbfc-4c7a-995e-1d11a726c8b7.png" alt="" style="display:block;margin:0 auto" />

<p>I am a bit conflicted between working with ROS-Z or ros2_rust as a potential side-project. But I have some time before I pick a lane. But at least after these talks, I am even more motivated to learn Rust. Now to find the free time…</p>
<h2>Memorable talks</h2>
<p>Besides this, there were a few other memorable talks. <a href="https://fosdem.org/2026/schedule/speaker/sam_pfeiffer/">Sam Pfeiffer</a> and <a href="https://fosdem.org/2026/schedule/speaker/roland_meertens/">Roland Meertens</a> talked about <a href="https://fosdem.org/2026/schedule/event/DJK8WL-calibrate-good-times/">getting good-quality robot data</a> from different types of sensors, and shared their personal experiences along with some tips and tricks. There was also a talk about <a href="https://fosdem.org/2026/schedule/event/VD7GN8-easynav/">EasyNav</a>, a lightweight navigation stack with some architectural decisions that make it quite different from <a href="https://docs.nav2.org/">Nav2</a>. Finally, <a href="https://x.com/facontidavide?lang=en">Davide Faconti</a> talked about the future roadmap of <a href="https://fosdem.org/2026/schedule/event/MNT7XJ-plotjuggler_the_log_visualization_tool_loved_by_roboticists/">PlotJuggler</a>. I watched the live-stream while on the bus back, and laughed so hard that I could have been kicked off the bus. I’m not going to say anything else; watch this talk for yourselves.</p>
<p><a class="embed-card" href="https://www.youtube.com/watch?v=cy--EC-YKCQ">https://www.youtube.com/watch?v=cy--EC-YKCQ</a></p>

<h2>Summary</h2>
<p>Overall, my first FOSDEM was a bit of a hectic experience, and a little overwhelming, but in a positive way. But now I can prepare for next year, and I hope they have a bigger space for the Robotics devroom at FOSDEM 2027. I’m definitely coming back, and given the projects I am working on this year, I have things I would like to present as well.</p>
<p>Speaking of presenting, the talk from Botronics included a surprise at the end - the announcement of ROSCon Belgium in 2026. The spoken language is English, which means I’m definitely attending and submitting a talk when the time comes.</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1770219964823/3952d6bc-f763-47e1-a735-a0b09f2d404d.png" alt="" style="display:block;margin:0 auto" />

<p>This image is from the Botronics talk. I really like this logo, especially the Moon Rocket from Tintin; I hope they confirm it. I also managed to visit a comic book store in Brussels on my way back - got myself the newest Asterix book, and a couple of Tintin comics in Dutch (I’m getting better at Dutch, so I’m going to give this a shot). Turns out, while it is <em>Tintin</em> in both English and French, the Dutch character is called <em>Kuifje</em>.</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1770221886061/fac5d43c-9f5e-432c-abff-14f3b5cfe98b.jpeg" alt="" style="display:block;margin:0 auto" />

<h1>What’s next?</h1>
<p>Thanks for FOSDEM, I have even more project ideas now. But this year, I want to focus on only a handful of projects, especially after registering a business - I want to continue doing projects with the LeKiwi robot, I’ve got a long-term product idea I’m working on (more on this soon), and I want to learn Rust. Luckily, the talks were quite relatable to the things I’m working on, and I’m glad. Now, back to working on LeKiwi’s <a href="https://github.com/adityakamath/sts_hardware_interface">ros2_control implementation</a>…</p>
]]></content:encoded></item></channel></rss>