Robotics and Go
Initial Idea š¤
I was playing around on an Android handheld called the Logitech G Cloud when it struck me that it would make a really good control interface for some kind of FPV robot. It has a 7ā screen and built-in game controls, like an Android Switch lite. Unlike a Switch lite, it also has Chrome, giving me access to a Gamepad API and Websockets.
I have a lot of passing ideas, but when I saw I could buy a suitable robot for $50-70, I was sold. I had recently decommissioned a Raspberry Pi 4 as a gaming machine in favor of an old office PC, so I had the only other part I needed. My hardware experience is pretty slim, so I knew a kit was the right way to go.
I read The Go Programming Language last year and loved everything I understood of it. Unfortunately, I didnāt have a project to apply it to, so I forgot most of it. I decided to use this project to just dive into Go on something practical.
Go sounded like a good fit because
- Iād be writing all the software on my desktop, and I wanted easy cross-compilation with arm linux
- In the little embedded C programming Iāve done, making things work concurrently was a pain
- I wanted the interface to be served by the robot, and making a Go program a webserver is trivial
My overall goals were to:
- Control the robot from a web interface
- Have a video feed from the robot
Getting things running š¦æ
Firstly I had to get the Pi running. I had first tried to image the SD using what Iād used before, balenaEtcher. I soon found out that this doesnāt work well for headless installs. By default, Raspberry Pi OS doesnāt support ssh or password login. If you want to set up something easily and headlessly, it is best to use Raspberry Pi Imager, which allows setting up ssh, a password, wifi network, and even a .local
local area address.
I didnāt know about .local addresses and was pleased I could scp, ssh, and even browse webpages on my network from the .local address.
In order to put the robot together, I had to run a Python program on the Pi to hold the servos in position while I pushed them into place. This was also a good chance to see how the included Python code did things. The robot used the PCA9685 PWM driver for controlling the motors, and searching for that and Go led me to gobot. Gobot is a really cool library that supports lots of platforms, with each platform supporting lots of devices.
Running those provided Python programs was another reminder of why I was excited to use Go, after messing with dependencies, Python version number, and bit rot. The outdated how-to video is here: youtube
š¾ How I got the python scripts running
To run the example code for the robot:
git clone https://github.com/Freenove/Freenove_4WD_Smart_Car_Kit_for_Raspberry_Pi/
Remove python2, and symlink python3 in its place:
cd /usr/bin
sudo rm python
sudo ln -s python3 python
Enable camera and i2c:
sudo raspi-config
Interface Options > enable i2c and enable legacy camera
Install i2c-tools:
sudo apt install i2c-tools
Install python smbus:
sudo apt install python3-smbus
Check i2cdetect -y 1
:
0 1 2 3 4 5 6 7 8 9 a b c d e f
00: -- -- -- -- -- -- -- --
10: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
20: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
30: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
40: 40 -- -- -- -- -- -- -- 48 -- -- -- -- -- -- --
50: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
60: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
70: 70 -- -- -- -- -- -- --
Run setup:
cd ~\Freenove_4WD_Smart_Car_Kit_for_Raspberry_Pi\Code
sudo apt install pip
installs python3-pip
sudo pip3 install rpi_ws281x
sudo python3 setup.py
Get āNow the installation is successfulā
Once the robot was together, I was eager to get Golang moving anything.
š¾ Soon I had this minimal example
package main
import (
"time"
"gobot.io/x/gobot/drivers/i2c"
"gobot.io/x/gobot/platforms/raspi"
)
const (
maxDuty = 4095
minDuty = -4095
PCA9685Address = 0x40
)
func setMotor(pwm *i2c.PCA9685Driver, channelA, channelB int, duty int16) {
if duty > maxDuty {
duty = maxDuty
} else if duty < minDuty {
duty = minDuty
}
if duty > 0 {
pwm.SetPWM(channelA, 0, 0) // Off
pwm.SetPWM(channelB, 0, uint16(duty))
} else if duty < 0 {
pwm.SetPWM(channelB, 0, 0) // Off
pwm.SetPWM(channelA, 0, uint16(-duty))
} else {
// Set both channels to Full On to brake
pwm.SetPWM(channelA, 0, maxDuty)
pwm.SetPWM(channelB, 0, maxDuty)
}
}
func main() {
r := raspi.NewAdaptor()
pwm := i2c.NewPCA9685Driver(r, i2c.WithAddress(PCA9685Address))
if err := pwm.Start(); err != nil {
panic(err)
}
pwm.SetPWMFreq(50)
// Forward for 2 seconds
setMotor(pwm, 0, 1, 2000) // Left Upper Wheel
setMotor(pwm, 3, 2, 2000) // Left Lower Wheel
setMotor(pwm, 6, 7, 2000) // Right Upper Wheel
setMotor(pwm, 4, 5, 2000) // Right Lower Wheel
time.Sleep(2 * time.Second)
// Backwards for 2 seconds
setMotor(pwm, 0, 1, -2000) // Left Upper Wheel
setMotor(pwm, 3, 2, -2000) // Left Lower Wheel
setMotor(pwm, 6, 7, -2000) // Right Upper Wheel
setMotor(pwm, 4, 5, -2000) // Right Lower Wheel
time.Sleep(2 * time.Second)
// Stop
setMotor(pwm, 0, 1, 0)
setMotor(pwm, 3, 2, 0)
setMotor(pwm, 6, 7, 0)
setMotor(pwm, 4, 5, 0)
}
This code worked as expected for me, driving the robot forward for one second, then back for one second. I was able to cross-compile it from a Windows 10 machine easily with
set GOOS=linux
set GOARCH=arm
set GOARM=7
go build
A simple scp to my home folder, and a ./brains
later, and the robot was moving across the floor.
The Web Interface š¤³
Making programs into webservers in Go is trivial. I know how grating the word ātrivialā is to programmers, but it really is. I had a web interface up in very little time after the initial motor movements. I put the single index.HTML page in a /client folder that is served with FileServer. gorilla provided the websocket support, and I was able to get a simple interface up and running quickly.
š¾ Here is the example with all necessary webserver components
package main
import (
"log"
"net/http"
"encoding/json"
"github.com/gorilla/websocket"
"gobot.io/x/gobot/drivers/i2c"
"gobot.io/x/gobot/platforms/raspi"
)
const (
maxDuty = 4095
minDuty = -4095
PCA9685Address = 0x40
)
type DPad struct {
Up bool `json:"up"`
Down bool `json:"down"`
}
func setMotor(pwm *i2c.PCA9685Driver, channelA, channelB int, duty int16) {
if duty > maxDuty {
duty = maxDuty
} else if duty < minDuty {
duty = minDuty
}
if duty > 0 {
pwm.SetPWM(channelA, 0, 0) // Off
pwm.SetPWM(channelB, 0, uint16(duty))
} else if duty < 0 {
pwm.SetPWM(channelB, 0, 0) // Off
pwm.SetPWM(channelA, 0, uint16(-duty))
} else {
pwm.SetPWM(channelA, 0, maxDuty) // Full on
pwm.SetPWM(channelB, 0, maxDuty) // Full on
}
}
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true
},
}
var oldDpad = DPad{false, false}
var pwm *i2c.PCA9685Driver
func handleGamepadInput(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Println("Error during websocket handshake:", err)
return
}
defer conn.Close()
for {
messageType, p, err := conn.ReadMessage()
if err != nil {
log.Println("Error reading from websocket:", err)
return
}
if messageType == websocket.TextMessage {
log.Println("Received:", string(p))
var dpad DPad
err := json.Unmarshal([]byte(p), &dpad)
if err != nil {
panic(err)
}
if dpad.Up != oldDpad.Up {
if dpad.Up {
setMotor(pwm, 0, 1, 2000) // Left Upper Wheel
setMotor(pwm, 3, 2, 2000) // Left Lower Wheel
setMotor(pwm, 6, 7, 2000) // Right Upper Wheel
setMotor(pwm, 4, 5, 2000) // Right Lower Wheel
}
}
if dpad.Down != oldDpad.Down {
if dpad.Down {
setMotor(pwm, 0, 1, -2000) // Left Upper Wheel
setMotor(pwm, 3, 2, -2000) // Left Lower Wheel
setMotor(pwm, 6, 7, -2000) // Right Upper Wheel
setMotor(pwm, 4, 5, -2000) // Right Lower Wheel
}
}
if !dpad.Up && !dpad.Down {
setMotor(pwm, 0, 1, 0)
setMotor(pwm, 3, 2, 0)
setMotor(pwm, 6, 7, 0)
setMotor(pwm, 4, 5, 0)
}
oldDpad = dpad
}
}
}
func main() {
r := raspi.NewAdaptor()
pwm = i2c.NewPCA9685Driver(r, i2c.WithAddress(PCA9685Address))
if err := pwm.Start(); err != nil {
panic(err)
}
pwm.SetPWMFreq(50)
fs := http.FileServer(http.Dir("./client"))
http.Handle("/", fs)
http.HandleFunc("/gamepad-input", handleGamepadInput)
log.Println("Serving on :8080...")
err := http.ListenAndServe(":8080", nil)
if err != nil {
log.Fatal(err)
}
}
š¾ And here is the client/index.html file
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Gamepad Logger</title>
<style>
textarea {
width: 100%;
height: 30px;
}
</style>
</head>
<body>
<button onclick="goFullscreen()">Go Fullscreen</button>
<br>
<!-- Video element to display remote stream -->
<img src="http://itche.local:9000/stream/video.mjpeg" alt="image">
<!-- Control buttons -->
<button id="start">Start</button>
<button id="stop" disabled>Stop</button>
<br>
<textarea id="logArea" readonly></textarea>
<script>
let textarea = document.getElementById('logArea');
function goFullscreen() {
if (document.documentElement.requestFullscreen) {
document.documentElement.requestFullscreen();
} else if (document.documentElement.mozRequestFullScreen) {
document.documentElement.mozRequestFullScreen();
} else if (document.documentElement.webkitRequestFullscreen) {
document.documentElement.webkitRequestFullscreen();
} else if (document.documentElement.msRequestFullscreen) {
document.documentElement.msRequestFullscreen();
}
}
// Check for gamepad connection
window.addEventListener("gamepadconnected", function (event) {
let gamepad = event.gamepad;
appendToLog(`Gamepad connected: ${gamepad.id}`);
});
window.addEventListener("gamepaddisconnected", function (event) {
let gamepad = event.gamepad;
appendToLog(`Gamepad disconnected: ${gamepad.id}`);
});
function appendToLog(message) {
textarea.value += message + '\n';
textarea.scrollTop = textarea.scrollHeight;
}
let ws = new WebSocket('ws://itche.local:8080/gamepad-input');
ws.onopen = function () {
console.log('WebSocket connection opened');
pollGamepad();
};
ws.onerror = function (error) {
console.log('WebSocket Error:', error);
};
let dpad = {
up: false,
down: false,
left: false,
right: false,
};
function pollGamepad() {
let gamepads = navigator.getGamepads();
let newDpad = {
up: false,
down: false,
};
for (let i = 0; i < gamepads.length; i++) {
if (gamepads[i]) {
let gamepad = gamepads[i];
for (let j = 0; j < gamepad.buttons.length; j++) {
if (gamepad.buttons[j].pressed) {
const messages = {
12: true,
13: true,
};
if (messages[j]) {
appendToLog(j + ' pressed');
if(j === 12) {
newDpad.up = true;
} else if(j === 13) {
newDpad.down = true;
}
}
appendToLog(j + ' pressed');
}
}
}
}
if(JSON.stringify(dpad) !== JSON.stringify(newDpad)) {
dpad = newDpad;
let message = JSON.stringify(dpad);
console.log(message);
ws.send(message);
}
requestAnimationFrame(pollGamepad);
}
</script>
</body>
</html>
š¹ Video
Getting the video feed from the Raspberry Pi Camera (knock-off) to the handheld worked out, but Iām not really satisfied with it yet. My first approach was to run an HLS server with Go, but HLS latency was simply too high with any method I tried.
After that, I got the built-in WebRTC server from uv4l running. That is very low latency, and it worked great in their example client page. I didnāt have as much luck, some detail I overlooked, and after it repeatedly crashed after a single frame displayed on my own client page, I backed off to see what else I could try.
In the meantime, I am receiving the video feed on the handheld with a simple mjpeg stream, also from uv4l. This is a bit higher latency, but it works well enough for now. A single <img> tag does a lot of heavy lifting!
Cool stuff
ā²ļø Goroutines are great on paper, but it was during this project that I really got to geek over them first-hand. I was able to write long movement scripts with Sleep()s in them, then invoke them as Goroutines. The single Go program continues the movement script while handling more server requests. It was magic, simple, and had serious better-C energy.
š® Websocket Gamepad latency was imperceptible. Local networks are so cool when they work, though I couldnāt get my last Civ LAN party to connect for the life of me.
š§ The cross-compilation was as easy as I could have wanted.
šØ The Go developer experience is second to none. I was returning to Go after a long hiatus, but using libraries was completely frictionless. Gobot has been awesome, and it definitely has me curious about TinyGo.
šļø I was able to add a little polish and drive around the house with only a couple days of effort. It was a very high-reward project.
Possible Improvements
I would love to get rid of the uv4l requirement. Right now I need to keep that running as a service, which works, but the whole project being deployable as a single binary would be cool.
I have been playing with older TTS libraries, as I would love to have the robot babbling in a robot voice. It doesnāt seem feasible to run any LLM on the Pi. That said, I am considering providing a big script for the Pi to look up phrases. It could transcribe voice prompts, then feed those to a non-generative ML model like MobileBERT to query the script.
I would love it if the robot was creating a diablo-like minimap of its surroundings projected over its vision. I donāt know anything about how to approach that besides I should search about SLAM.
More data displayed on the handheld would be cool, like battery level, CPU %, etc.
Things I Wish I Knew
Get the mecanum wheels. The robot really canāt turn well at all, as-is. You have to perform many multi-point turns to rotate. I will be experimenting with mecanum wheels shortly.
Prototype on a spare Pi with wall power. I couldnāt connect over SSH, and tried to many steps to debug before simply recharging.
Buy spare parts, because these 9-gram servos are cheap. I had a glitching servo motor that I think is just bad, but I donāt have enough experience to be sure.
Future Concerns
The Gamepad API is going to quit working outside of āsecure contextsā. These contexts do not include .local addresses on a local network. Concerns about use cases like mine were brought up, but it seems like the decision is made. I will have to find a way to make this work, or I will have to find a new way to control the robot.
Conclusions
Overall, I had a really good time putting this project together. The biggest challenges were:
- Making a WebRTC connection with JS is not trivial, still need to learn more here
- Servo moving like crazy or not at all, hardware is hard
- Headless setup of Raspberry Pi OS is not the same as it used to be
- The web platform is becoming a drag with security measures making it harder to do cool things
I was surprised how fun it all was. sshāing into a little car on your desk is cool. I am glad to have more ideas I want to try and a new set of problems to keep me interested.