Robot Car with Android Handheld

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.