Weddingbot
Rolling my own telepresence robot for my best man. Or, a lesson on how to write insecure software.
Near-Miss Disaster and a Plan
We were both lucky that it happened where it did and unlucky that it happened at all, but a very serious medical emergency two weeks before my wedding resulted in my closest friend being unable to attend our wedding. There was no wedding party but had there been, he would have been my best man. The wedding was quite literally across the country, a full day of travel from home, and in the aftermath of his recovery the doctors kept him from boarding a plane despite some serious protests on his end. My immediate thought was to rent a telepresence robot and I even spoke with a sales rep from a company that ships them but only in CA. He was willing to ship outside of CA but the costs were prohibitive and the timeline would be incredibly tight, so any delay or snag would mean lots of money and effort wasted.
What is a guy to do when his best friend, who was arguably more excited about the trip and wedding than even the groom himself, can’t attend? The obvious answer is to convert an auntonmous robot in the basement to one that can be teleoperated including a video call, set up the infrastructure to make all of that happen in a remote location, streamline the setup and use so it’s foolproof (I didn’t tell him about this until the night of the wedding), design and build the hardware required to mount a cardboard cutout of the friend on the front of the robot, and figure out how to get the robot to the wedding venue 2000 miles away. I spoke with the rep from the rental company late on a Wednesday afternoon and we left for the wedding the following Tuesday morning, 5 days to make it happen.
Plan of Attack
Mobile Platform
The basic plan was fairly simple: replace the control inputs of an autonomous platform I built with manual ones from a user ✨ somewhere ✨. The platform is basic and sturdy, built from aluminum extrusion, an ODrive controller, a couple Ebay hoverboard BLDC motors, and an NVidia Orin for the brains. Over the years it has been used for some autonomous computer vision work that isn’t quite ready for the spotlight and removing all of the sensors meant that I felt reasonably confident that I could disassemble it to a point that it would fit in a knock-off Pelican case and be safely checked at the airport.
It isn’t pretty but it’s a modular workhorse that has survived 5 years of sensor testing and intregrations:
Command Server
I have been paying for a cheap VPS for I don’t know how long and have been using it over the years as a jump box to other machines and some light file hosting. Working under the assumption that the wedding venue would have some form of wifi I started working on the system architecture for the whole project. I cannot stress how little I actually thought about the implementation and went with whatever made sense. There was no time for security or optimizations; if it worked it worked and I would defensively code my way around any problems that I could think of. Once the platform was connected to the server the basic data flow was a one-way ride, with user inputs being sent to the server and forwarded to the robot.
graph TD
A[Server]-->B[Weddingbot]
C[User Inputs]-->A
Video Call
An old cell phone would act as the video call interface. I assumed that an ipad would be too unwieldy on the end of a long pole - the pole holding a lifesized cardboard cutout of my friend - and a cell phone gave me options in case wifi wasn’t available since it could host a hotspot for the robot to connect to if need be.
With the basic plan in place the next step was actually implementing the idea and what followed is nothing short of a miracle only made possible by modern technology, notably AI coding assistants.
Software
I will preface this section by saying that I wrote effectively zero code for this entire project. Utilizing Claude Sonnect 4 in Agent mode I laid out commands for what I wanted while the computer did almost all of the work. I had the basic motor control functions sitting in a utility file from another project, but the architectural implementation was done by the bot. Along the way I would test iterative steps and verify functionality, slowly adding to the codebase, but the bot did all of the heavy lifting. Working with the different coding assistants over the past year I have found that they work best when you describe a toy version of the problem you are trying to solve, have the bot implement the simplest version of it, and with that context still in its memory have it add the full feature to the larger codebase. Working in Agent mode a codebase can get unwiedly very fast and it’s best to put guardrails up both to keep the bot focused and to make debugging more tractable.
While I have the requisite experience and skill to write the code used to make this project happen, I sure didn’t have the time, and that’s where the AI really shined. At one point I was cleaning the house and packing for the wedding while the bot was churning away on a new feature. We really do live in a pretty incredible point in history.
Networking in the Wild
The first problem I needed to address was giving the mobile platform access to the outside world. The Orin has an onboard wireless card and I have it create a WAP at boot so I always have a route in to debug that doesn’t require a serial cable.
A systemd entry references a bash script placed in /opt
and waits for network-online.target
and multi-user.target
before executing. This has proven to be pretty bulletproof over the course of the last year or so.
#!/bin/bash
NIC=wlP1p1s0
SSID=botaccess
PASS=notmypassword
# Give the system more time to start up
sleep 3
sudo nmcli d wifi hotspot ifname $NIC ssid $SSID password $PASS
The issue is that using the onboard wireless NIC for debugging means I need another one for internet access. A spare USB adapter was the obvious answer but because the Orin, like all of Nvidia’s Tegra devices, runs a custom (old) version of the Linux kernel this wifi adapter was missing a kernel module. Shout out to morrownr on Github because their RTL8811AU repo worked flawlessly on the first try.
When I arrived at the venue I simply needed to attach to the botaccess
WAP, call sudo nmtui
and add an available wifi network to give the robot access to the outside world. This would be the only manual step in the entire setup process, allowing me to complete it days before the wedding.
Sticking with the mantra of “whatever is easy and works”, I wrote a startup script that is called by /etc/rc.local
because who has time to fiddle with sytemd files when optimizing for time (the systemd WAP stuff above was sitting on the shelf already). The remote server IP is passed as an argument and the robot pings Google to ensure it has internet access before calling the primary application.
#!/bin/bash
# Wedding Bot Remote Client Launcher
# This script checks internet connectivity before starting the remote client
echo "🌐 Checking internet connectivity..."
# Ping Google DNS with 3 packets, timeout after 5 seconds
if ping -c 3 -W 5 google.com > /dev/null 2>&1; then
echo "✅ Internet connection verified"
echo "🚀 Starting Wedding Bot Remote Client..."
echo ""
# Run the remote client
python3 /home/dev/odrive-remote-control/remote.py $1
else
echo "❌ No internet connection detected"
echo "Please check your network connection and try again"
exit 1
fi
You can tell the AI wrote this due to the liberal smattering of emojis.
The assumption here is that I would have already set up access and if the script fails to ping Google someone would reboot the whole thing, AKA yank the battery and try again. This is a terrible design but elegance wasn’t the name of the game; I needed something foolproof that could be “debugged” by a wedding guest with no idea how computers actually work since I wouldn’t be able to help.
Remote Control
This project introduced a fun problem I had not encounted before: How do I avoid hardcoding network-related things, IPs and ports, while also ensuring that I don’t hit any firewall issues? I was operating with zero knowledge of the venue’s network topology or security and I needed a way to establish durable connections between both the control server and the robot as well as the user and the control server. My VPS has a static IP so I knew I could rely on that, but everything else was up in the air. My solution was definitely wrong and the “security” was implemented on the wrong side but it worked once and that’s what mattered.
The server looks for inbound connections on a predefined port and the only “security” is on the client side. The client looks for a specific response before connecting. Again, this is a terrible idea and is not what I meant to do but that detail was lost in the chaos of building this in a few days. The “security” of a single if
statement notwithstanding, the check should be reversed. A server should never blindly accept connections and the client shouldn’t be the only one deciding if a connection should be established or not.
Server-side
self.log(f"Responding to pending client {client_id}")
# Send the expected response
response = {"title": "weddingbot", "payload": "yrconnected"}
response_json = json.dumps(response) + "\n"
client_socket.send(response_json.encode("utf-8"))
# Add to connected clients instead of closing
self.connected_clients[client_id] = {
'socket': client_socket,
'address': client_address,
'connected_at': datetime.now()
}
self.log(f"Sent response to {client_id}: {response}")
self.log(f"Client {client_id} added to connected clients")
Client-side
# Check if we got the expected response
if (response_json.get('title') == 'weddingbot' and
response_json.get('payload') == 'yrconnected'):
print("Connection successful! Starting command listener...")
# Start listening for commands in a separate thread
self.listening = True
self.listen_thread = threading.Thread(target=self.listen_for_commands, daemon=True)
self.listen_thread.start()
return {
'status': 'success',
'message': 'Connection successful! Listening for commands.',
'response': response_json
}
Once a connection is established the server waits for someone to connect on the front end and only then will it start sending commands to the remote app running on the robot. The most basic of checks is done on the inbound messages, looking only at the title of the JSON payload, so it would be trivial to spoof these and DDOS the robot. I didn’t even do any checksum calculations to look for malformed messages. I will reiterate that this is a terrible idea and no one should ever deploy something like this out into the wild save a one-off demo.
def handle_command(self, command):
"""Handle incoming movement commands"""
if not isinstance(command, dict):
print(f"Invalid command format: {command}")
return
title = command.get('title')
payload = command.get('payload')
if title != 'weddingbot':
print(f"Unexpected command title: {title}")
return
print(f"🎮 Received command: {payload}")
# If a custom movement callback is set, use it to handle the command
if self.movement_callback:
try:
self.movement_callback(payload)
except Exception as e:
print(f"Error in custom movement callback: {e}")
return
# Handle the movement commands
if payload == 'forward':
self.move_forward()
elif payload == 'backward':
self.move_backward()
elif payload == 'left':
self.move_left()
elif payload == 'right':
self.move_right()
elif payload == 'stop':
self.stop_movement()
# Don't trigger reconnection for regular stop commands
print("🛑 Movement stopped (connection maintained)")
elif payload == 'quit':
self.stop_movement()
print("👋 Quit command received - disconnecting...")
# Trigger reconnection after quit command if auto-reconnect is enabled
if self.auto_reconnect:
self.schedule_reconnection()
else:
print(f"Unknown command: {payload}")
Userland
My front end knowledge begins and ends circa 1999 when Geocities was at its height. However, I have been playing with Flask for the aforementioned autonomous robot project as a means to tune parameters from a mobile device without the need for a bespoke app. Flask felt like a good solution here as well, as I would need the back end to supply interative feedback to the user and issue commands to the remote platform. Having the AI write all of that in Python meant I could understand it and debug as necessary.
I first created a landing page on this domain for my friend to hit since I didn’t want him to have to type in the IP and port of my VPS running Flask. I had the AI make the most basic of pages using words like “colorful” and “fun”. I also had it add a picture of him and a click effect of spinning, because why not. You can’t tell in the image below but the background color is mapped to cursor position, allowing it to change color as you move the mouse around, a super fun effect with a simple implementation.
Foolproof was the name of the game, so the actual UI for the robot includes a checklist to go over before trying to drive it. We used Signal because it provides a generic way to video call across different mobile devices and doesn’t require any kind of account or setup.
When the checklist has been completed, the “connect to client” button establishes the connection with the robot and the user can either use the keyboard or click the buttons in the UI to control the robot. The reversal of who decides to establish the connection would ultimately be a semi-fatal bug that I failed to foresee. More on that later.
Hardware
Thankfully the bulk of the integration was already done. Over the years I have iterated on stronger motor mounts, longer signal wires, etc. and my primary task was to design and build hardware to secure a broom handle to the front where we would eventually fasten a life-sized cardboard cutout. The cell phone used for the video call would be secured ✨ somewhere ✨, completing the build.
I have learned over the years to make hardware as modular as possible even if it means a little more work on the front end. Hardware breaks, and partitioning the reponsbility of parts along some modularity boundary decouples the failure of one with the failure of another. To put it another way: I don’t want the failure of a motor mount to mean that my battery clip has to be replaced. This decision would pay dividends later. More on that later.
With that basic design principle in mind I revisited an idea I had used in the past and made a blank 8020/25 profile with a plate on top. This part allows me to fasten anything on top with some M5 screws, allowing the threads to tap into the plastic. If repeated use of screws isn’t necessary then neither is a heated brass insert. Let the screws tap the plastic, don’t strip them, and the holes are fine to be used a handful of times before they might start to fatigue.
On top of that would sit a pole holder with a set screw to secure the pole.
Valuable Testing
I printed the parts, found a broom handle, and fashioned an extremely rudamentary “cutout” to drive around the house. I tapped a friend to test drive both the UI and the actual robot and we learned some valuable lessons.
The software worked flawlessly, great success. The hardware, not so much. Despite the relatively slow speed of the platform, the cardboard still caught a good amount of air, resulting in enough torque on the pole mount to break it. This was due to both some play in the pole inside the mount and a lack of lateral support of the pole. My solution was to use a threaded mount as well as design some lateral supports.
Hardware v2.0
Standards are great when they’re adhered to and adopted by the market at large. In this case I was able to leverage the standard threading of a broom handle. I found a pre-existing design online and merged it with my generic mounting plate in Blender. If I could find the original Thingiverse link I would provide it but their search engine is a dumpster fire and I cannot locate it, so I’ve uploaded all of the parts to the Gitlab page with the code for this project.
I printed the mount and ran to Home Depot for a test fit, the plan being I would buy the pole once we arrived at our destination, obviating the need to pack or ship a long broom handle.
Next up was the lateral support. I have learned in woodworking that it is often better to mark a board against what you’re fitting and make the cut, rather than measuring the work piece assuming your original design measuremnts are accurate. The idea also holds here: I know the dimensions of the platform and started down the path of 3D trig calculations to determine the length and angles of arms needed to support the pole, but it was much easier to just hold a piece of cardboard against the actual pieces and mark the lengths and angles. Again sticking with modularity and ease of implementation I designed supports for the 8020 profile to which I would affix pieces of wood cut to length.
This will come as a surprise to a lot of 3D printer owners, but you don’t have to print everything all the time. In this case it was 1000x faster to cut some wood to length rather than wait for arms to print. Using hinged arms also allowed me to break down the supports for shipping and not worry about them breaking. The hinges also meant that my measurements didn’t need to be ultra-precise. As long as I cut the arms roughly to length I could adjust as necessary.
I first thought I would secure the pole with just the wooden arms but quickly decided I needed more surface area against the pole and a flat surface to hold zip ties in place.
The rest of my testing was done a spare pole mount and a non-threaded broom handle that I already had. Our departure date was quickly approaching and I didn’t have time to keep tinkering, so I packed everything up in a rugged case from Harbor Freight (including some spare parts - always carry spares) and hoped for the best.
Do It Live
A minor setback
We arrived at the wedding venue a couple days before the wedding. I assembled the robot, got it on their wifi, and everything was ready for the big day. I sent instructions to another friend to contact our absent friend and walk him through the steps to control the robot. A few hours before the wedding I did a quick checkout of the controls before affixing the cardboard cutout to the pole and securing the support arms in place. Everything was working great and I was excited that nothing had broken in transit and my plan to give my buddy a window into the weekend was going to work. Best laid plans.
My beautiful wife has picked up my penchant for playing pranks and had the idea to prank me with a fake first look. She instructed the wedding coordinator to get the carboard cutout and put it behind me while the photographer was waiting to catch my reaction. Honestly a great prank and I love her for it. The only issue is that she didn’t know I had threaded the broom handle into the robot or that it was secured with zip ties. In our testing at home the handle and cutout were kind of loose in the pole holder and we could pretty easily slide the pole out. She told the wedding coordinator to pull up on it and remove it which did work but it also broke the pole mount in the process. Without the cutout on the robot the whole plan was shot. He couldn’t drive around the reception, and if anyone wanted to talk to him they would have to walk up to the cutout where the phone was attached.
Thankfully I packed spare parts and I found a few minutes to swap the threaded insert with the original tap screw brace before the ceremony started. The arms supported the pole enough to keep it in place for the duration of the night.
A Foolproof Startup
When the battery is inserted the whole system powers up. The raw 40V line powers the ODrive and the voltage is stepped down to 12V for the Orin. The remote server is assumed up and running, and the startup script checks for access to the internet and calls the main application. Once everything boots and the main control loop starts it enables the hoverboard motors, lighting a blue ring on the outside of the wheels. The external indicator was crucial during both my testing and the main event. Every embedded device needs to indicate its status to a user in absence of a screen, and colored lights are the simplest way to do that. When the wheels turned blue we knew everything was ready.
Proof of online-ness was further indicated in the UI, with a status box that live-streamed all commands and statuses. Shout-out to the AI for including this without me asking.
A (kind of) Fatal Bug
The original implementation included a timeout on the robot that would reset if it tried to connect and no one clicked the “connect” button on the server within 5 minutes. This resulted in the queue of “pending clients” growing over time as the robot would disconnect and try again. I would have to click “connect to client” N
times for N
timeouts until the last one in the queue was connected, allowing me to gain control of the platform as nature intended.
def connect_and_wait(self, timeout=600): # 5 minute timeout
"""Connect to server, send payload, and wait for response"""
try:
# Create socket connection
self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.socket.settimeout(timeout)
I fixed this by setting the timout to 2 hours and adding functionality to decrement the “pending clients” every time the “connect to client” button was clicked. Little did I know that fixing that bug in combination with the server blindly accepting connections would render it seemingly dead in the water.
I don’t know exatly what happened on the day of the wedding, but I do know that there was some trouble getting my friend connected and in helping him troubleshoot the problems I think other friends went to the control page and clicked “connect”. My guess is that someone, at some point, could have driven the robot around but there were too many people trying at once and I never designed it to accept input from multiple users at once.
At one point I saw a couple friends carrying the robot around the cocktail hour with my best man on the video call, so at least he got to talk to people and see the venue, even if it wasn’t how I had hoped.
Redemption
Later that night toward the end of the reception after everyone had stopped trying to use the robot I checked the server and sure enough I saw Pending Clients: 5
in the status box. I clicked “Connect” a few times to decrement the queue, checking the controls for each one, and I think on the second to last client I got a response and the platform lurched forward. I drove it out on to the dance floor and over the course of the next few songs had it spin and move around with everyone dancing. Not exactly what I had hoped for, but still fun nonetheless.