#PicoPi

#Python

#LEGO

Building a LEGO-car, controlled by a PicoPi

62 days ago

43

I've build a LEGO-car with my nephew, that can be remotely controlled using a PicoPi (2) W as a WebServer and controller.

Hardware Requirements

  • PicoPi W, PicoPi 2 W or other mycropython compatible microcontroller
  • min 2 x Servo Motors, I used M5Stack's LEGO-compatible servos - 1x 360° for the drive shaft and 1 x 180° for the steering
  • Sensors
    • for now, I'm just using one HC-SR04 ultrasonic distance sensor (be aware of 3,3V vs 5V versions and the need for a voltage devider for the ECHO-Pin for the 5V version)
  • LEGO - I'd recommend investing in a few more special parts / or other building material like wood
    • Gears
    • optional, but recommanded Differential 6510296 or 4525184
    • Tires
    • a few frames 6265646, 6265643, 6016154
    • a rack for the steering 6302830 or 6327430
  • optional battery-pack for wireless control



Software

You can get the complete code at my GitHub

So let's first talk about what we want/need. The PicoPi should control the servos and the sensors, so we need some kind of Controller.py. The PicoPi should also make that Controller.py accessible thorugh through Wifi and serve a WebServer.py. The Controller.py and WebServer.py should use asyncio, so the car can act autonomously using the sensors while also receiving input from the user.




Controller

The Controller.py code is rather simple and self-explanatory. First set up the servos and sensors.


from Servo import Servo
from UltrasonicDistanceSensor import UltrasonicDistanceSensor
class Controller:
    def __init__(self):
        self.servo1 = Servo(28,deg_range = 180)
        self.servo1.set_deg(90)
        self.servo2 = Servo(7,deg_range = 360)
        self.servo2.set_servo_speed(0)
        self.min_distance = 10.0 # in cm
        self.distanceSensor = UltrasonicDistanceSensor(17,16)
        self.state='manual'
        pass

Then add some basic functions to control the servos and sensors. Also add some more special functions, one that's called repeatedly to let the PicoPi control the car autonomously, and one that takes the joystick-input to control the car:


    # called asyncronously
    async def auto(self):
        await self.get_distance()
        if self.state == 'auto':
            # drive autonomously using the sensors
            pass
        
    async def get_distance(self):
        return await self.distanceSensor.get_distance(20)
    
    # x/y - coordinates, +-200
    # speed - distance from center, 0-100
    # angle - 0-360, 90 front, 0 right, 270 bottom,  180 left
    async def joystick(self,x,y,speed,angle):
        distance = await self.distanceSensor.get_distance(20)
        # only drive forward, when distance < min_distance
        if distance > self.min_distance or y < 0:
            speed2 = y/(-4) # speed based on y
            self.servo2.set_servo_speed(speed2)
            self.servo1.set_deg(90+(x/10)) # map to 90+-20
        else:
            self.servo2.set_servo_speed(0)

Wifi

The WiFi.py class takes a Pin to indicate the status with an LED and the SSID and PW to connect to an network. I added some failsafes here. So first I deactivated the wifi, if it was active before, to help the chip forget previous connections, after a reboot.


import time
import machine
import network

class WiFi:
    def __init__(self, status_led, ssid, pw):
        self.status_led = status_led
        self.status_led.value(False)
        self.ssid = ssid
        self.password = pw
        self.wlan = network.WLAN(network.STA_IF)
        if self.wlan.active():
            print('wifi was active before..')
            self.wlan.active(False)
            time.sleep(1)
        self.wlan.active(True)

Then try to connect a few times. If the connection is reset (status == -1) keep trying. That seems to be a save way to connect to an network, in my experience.


    def connect(self):    
        self.wlan.connect(self.ssid, self.password)

        # Wait for connect or fail
        max_wait = 20
        while max_wait > 0:
            status = self.wlan.status()
            if status < -1 or status >= 3:
                break
            print('waiting for connection...'+str(status))
            if status == -1:
                self.wlan.connect(self.ssid, self.password)
            max_wait -= 1
            time.sleep(1)
        # Handle connection error
        if self.wlan.status() != 3:
            self.status_led.value(False)
            print(self.wlan.status())
            print(self.wlan.isconnected())
            raise RuntimeError('network connection failed')
        else:
            print('connected')
            self.status_led.value(True)
            status = self.wlan.ifconfig()
            print( 'ip = ' + status[0] )
            return status[0]

Webserver

So here comes the fun part. We need a simple WebServer.py that can run asynchronously and can trigger some actions with the Controller.py. await asyncio.start_server(self.server.request_callback,"0.0.0.0",80) makes the async part easy, so we just need to implement an RequestHandler. But for the sake of training I opted to implement a WebServer, that's providing an clean and easy way to write a HTTP-API. But you can also skip that part, just build custom RequestHandlers with the code used in TestApi.joystick(), TestApi.joypad() and TestApi.index2() and add them to the WebServer.


HTTP-API

So I want to be able to just write something like this, run it and have a working API.


from API import API
from API import ApiPath
from API import Api

@Api("TestApi2")
class TestApi2(API):
    @ApiPath("/te.*","GET")
    async def test_path2(self,httpRequest: HttpRequest):
        print("Handling Request:",httpRequest.to_string())
        print("hello")
        return "hello test2"

To achieve that, I needed to implement the annotation or 'decorators' @Api and @ApiPath, that use an global API_Handler-object to register the API-Mappings.


from RequestHandler import RequestHandler

# Handler to manage the APIs
# controls a list of handlers, gathered by the @ApiPath-Declarotor
# handlers can be matched by the api_name
class API_Handler:
    def __init__(self):
        self.api_name = ""
        self.method_names = []
        self.handlers = []
        pass
    # api_name should be unique
    def set_api_name(self,_api_name):
        self.api_name = _api_name       
handler : API_Handler = API_Handler()

# @ApiPath Decorator to define a ApiPath-Function
def ApiPath(request_pattern,request_type = 'GET'):
    global handler
    def decorate_apiPath(func):
        print("New API-Mapping:  ["+str(request_type)+"]",str(request_pattern)+" -> '"+str(handler.api_name)+"."+str(func.__name__)+"()'")
        handler.method_names += [str(func.__name__)]
        def wrapper(self, *args, **kwargs):
            result = func(self, *args, **kwargs)
            return result
        rh = RequestHandler(request_pattern, request_type)
        rh.api_name = handler.api_name
        rh.handle_request = wrapper
        handler.handlers += [rh]
        return wrapper
    return decorate_apiPath
    
# @Api Decorator to define a API-Object
# api_name should be unique
def Api(api_name = ""):
    global handler
    print("Defining New API: ["+str(api_name)+"]")
    handler.set_api_name(api_name)
    def decorate_api(func):
        def wrapper(*args, **kwargs):
            print("API created",api_name)
            result = func(*args, **kwargs)
            result.set_api_name(api_name)
            return result
        return wrapper
    return decorate_api

Those decorators might look a bit weird. They basically create functions, that take the original function and return a wrapper of the original function.

So for example the Api(api_name = "")-function is called when an class annotated/decorated with it is imported in a python program. When the parser reaches the annotation, the decorate_api(func) that was returned, is then called with the annotated/decorated class/function. That's where the @ApiPath-decorator registers the RequestHandler-Mapping in decorate_apiPath(func). In the wrapper of the @Api-decorator I also adjusted the resulting API-object to set the api_name.

The API-class simply maps and exposes the RequestHandlers from the global API_Handler, which can be used to add the API's RequestHandlers to the WebServer.py.


class API:
    def __init__(self):
        self.api_name = ""
    # called by the @Api decorator.
    # api_name should be unique
    def set_api_name(self,_api_name):
        self.api_name = _api_name
        
    # match handlers by api_name and set api_context
    def get_handlers(self):
        global handler
        handlers = []
        for h in handler.handlers:
            if h.api_name == self.api_name:
                h.api_context = self
                handlers += [h]
        return handlers

So now we can write our API


    @ApiPath("/joyControl","POST")
    async def joystick(self,httpRequest: HttpRequest):
        print("Handling Joystick Request:",httpRequest.to_string())
        jsonContent = json.loads(httpRequest.content)
        await self.controller.joystick(jsonContent['x'],jsonContent['y'],jsonContent['speed'],jsonContent['angle'])
        return 'HTTP/1.1 200 OK'
        
    @ApiPath("/","POST")
    async def joypad(self,httpRequest: HttpRequest):
        print("Handling Action Request:",httpRequest.to_string())
        if httpRequest.content == 'action=forward':
            self.controller.forward()
        elif httpRequest.content == 'action=backward':
            self.controller.backward()
        elif httpRequest.content == 'action=left':
            self.controller.left()
        elif httpRequest.content == 'action=center':
            self.controller.center()
        elif httpRequest.content == 'action=right':
            self.controller.right()
        elif httpRequest.content == 'action=toggle_state':
            self.controller.toggle_state()
        file = open('www/index.html')
        responseContent = file.read()
        file.close()
        #update and return html 
        distance = await self.controller.get_distance()
        responseContent = responseContent.format(self.controller.state,str(distance))
        return responseContent

Use custom instead of default FileRequestHandlers for index.html to write distance to the HTML


    # use custom instead of default FileRequestHandlers for index to write distance to html
    @ApiPath("/")
    @ApiPath("/index.html")
    async def index2(self,httpRequest: HttpRequest):
        print("Handling Request:",httpRequest.to_string())
        file = open('www/index.html')
        responseContent = file.read()
        file.close()
        #update and return html
        distance = await self.controller.get_distance()
        responseContent = responseContent.format(self.controller.state,str(distance))
        return responseContent

finally, the WebServer

Thank's to asyncio that part is also rather simple. We need to maintain a list of RequestHandlers, that map the API and iterate that list, to match incoming requests. For RequestHandlers that were defined with the @ApiPath-decorator, we also need to add the context of the API-class.


    async def request_callback(self,sr: asyncio.stream.StreamReader, sw: asyncio.StreamWriter):
        try:
            #parse to HttpRequest
            data = await sr.read(1024)
            request = HttpRequest(data.decode('UTF-8'))
            responseContent = await self.handle_request(request)
            #print("writing response:",responseContent)
            sw.write(responseContent)
            await asyncio.wait_for(sw.drain(),10)
            #print("handled callback",responseContent)
        finally:
            sw.close()
            await sw.wait_closed()
    async def handle_request(self,httpRequest: HttpRequest) -> str:
        
        for rh in self.request_handler:
            if rh.type == httpRequest.type and re.match("^"+rh.url+"$",httpRequest.url):
                print("New HttpRequest",str(rh.to_string()))
                if rh.api_context == None:
                    return await rh.handle_request(httpRequest)
                else:
                    return await rh.handle_request(rh.api_context,httpRequest)
        
        print("No RequestHandler found!",httpRequest.to_string())
        responseContent = 'HTTP/1.1 404 Not Found'
        return responseContent

To serve files from the ./www-folder, I also added a FileRequestHandler for each file in the folder. Add those handlers last to the WebServer, in case you need to overwrite one of them with a custom handler, like I did for the ./index.html


from RequestHandler import RequestHandler

class FileRequestHandler(RequestHandler):
    def __init__(self,_url: str,_type: str):
        super().__init__(_url,'GET')
        if _url.startswith('..'):
            raise Exception('invalid url path')
    async def handle_request(self,httpRequest: HttpRequest) -> str:
        super().handle_request(httpRequest)
        u = self.url.replace('/','',1)
        if u == "":
            u = "index.html"
        file = open('www/'+u)
        responseContent = file.read()
        file.close()
        return responseContent

HTML

Now that I also have a FileRequestHandler, I can write some HTML/JS to be served. I want two sets of control pages:

  • one simple joypad with individual buttons for all controlls and all readouts
  • one joystick, that lets you seemlesly control the car but with some safety build in

index.html

The first one can be a simple HTML-form. In that case, the actions need to return the updated HTML. Keep in mind, you might execute an action on reload.


        <div class="container">
            <div>
            </div>
            <div>
                <form action="./" method="post">
                    <input type="submit" value="&#11205;" />
                    <input type="hidden" name="action" value="forward" />
                </form>
            </div>
            <div>
            </div>
            <div>
                <form action="./" method="post">
                    <input type="submit" value="&#11207;" />
                    <input type="hidden" name="action" value="left" />
                </form>
            </div>
            <div>
                <form action="./" method="post">
                    <input type="submit" value="&#9208;" />
                    <input type="hidden" name="action" value="center" />
                </form>
            </div>
            <div>
                <form action="./" method="post">
                    <input type="submit" value="&#11208;" />
                    <input type="hidden" name="action" value="right" />
                </form>
            </div>
            <div>
            </div>
            <div>
                <form action="./" method="post">
                    <input type="submit" value="&#11206;" />
                    <input type="hidden" name="action" value="backward" />
                </form>
            </div>
            <div>
            </div>
        </div>
        <div>
            <form action="./" method="post">
                <input type="submit" value="Toggle State" />
                <input type="hidden" name="action" value="toggle_state" />
            </form>
        </div>
        <p>Distance is {1}</p>
        <p>State is {0}</p>

joy.html

The Joystick needs some more JavaScript. But first, create a basic setup


    <p style="text-align: center;">
        X: <span id="x_coordinate"> </span>
        Y: <span id="y_coordinate"> </span>
        Speed: <span id="speed"> </span> %
        Angle: <span id="angle"> </span>
    </p>
    <canvas id="canvas" name="joycanvas"></canvas>
    <script>
        var canvas, ctx;
        window.addEventListener('load', () => {
            canvas = document.getElementById('canvas');
            ctx = canvas.getContext('2d');          
            resize(); 

            document.addEventListener('mousedown', startDrawing);
            document.addEventListener('mouseup', stopDrawing);
            document.addEventListener('mousemove', Draw);

            document.addEventListener('touchstart', startDrawing);
            document.addEventListener('touchend', stopDrawing);
            document.addEventListener('touchcancel', stopDrawing);
            document.addEventListener('touchmove', Draw);
            window.addEventListener('resize', resize);

            document.getElementById("x_coordinate").innerText = 0;
            document.getElementById("y_coordinate").innerText = 0;
            document.getElementById("speed").innerText = 0;
            document.getElementById("angle").innerText = 0;
        });
        var width, height, radius, x_orig, y_orig;
        function resize() {
            width = window.innerWidth;
            radius = 200;
            height = radius * 6.5;
            ctx.canvas.width = width+100;
            ctx.canvas.height = height+100;
            background();
            joystick(width / 2, height / 3);
        }
        function background() {
            x_orig = width / 2;
            y_orig = height / 3;

            ctx.beginPath();
            ctx.arc(x_orig, y_orig, radius + 20, 0, Math.PI * 2, true);
            ctx.fillStyle = '#ECE5E5';
            ctx.fill();
        }
        function joystick(width, height) {
            ctx.beginPath();
            ctx.arc(width, height, radius, 0, Math.PI * 2, true);
            ctx.fillStyle = '#F08080';
            ctx.fill();
            ctx.strokeStyle = '#F6ABAB';
            ctx.lineWidth = 8;
            ctx.stroke();
        }
    </script>

...draw based on input and send the request back to the PicoPi.


        let coord = { x: 0, y: 0 };
        let paint = false;
        function getPosition(event) {
            var mouse_x = event.clientX || event.touches[0].clientX;
            var mouse_y = event.clientY || event.touches[0].clientY;
            coord.x = mouse_x - canvas.offsetLeft;
            coord.y = mouse_y - canvas.offsetTop;
        }
        function is_it_in_the_circle() {
            var current_radius = Math.sqrt(Math.pow(coord.x - x_orig, 2) + Math.pow(coord.y - y_orig, 2));
            if (radius >= current_radius) return true
            else return false
        }
        function startDrawing(event) {
            paint = true;
            getPosition(event);
            if (is_it_in_the_circle()) {
                Draw(event);
            }
        }
        function stopDrawing() {
            paint = false;
            ctx.clearRect(0, 0, canvas.width, canvas.height);
            background();
            joystick(width / 2, height / 3);
            document.getElementById("x_coordinate").innerText = 0;
            document.getElementById("y_coordinate").innerText = 0;
            document.getElementById("speed").innerText = 0;
            document.getElementById("angle").innerText = 0;
            send(0,0,0,0);
        }
        function Draw(event) {
            if (paint) {
                ctx.clearRect(0, 0, canvas.width, canvas.height);
                background();
                var angle_in_degrees,x, y, speed;
                var angle = Math.atan2((coord.y - y_orig), (coord.x - x_orig));

                if (Math.sign(angle) == -1) {
                    angle_in_degrees = Math.round(-angle * 180 / Math.PI);
                } else {
                    angle_in_degrees =Math.round( 360 - angle * 180 / Math.PI);
                }
                if (is_it_in_the_circle()) {
                    joystick(coord.x, coord.y);
                    x = coord.x;
                    y = coord.y;
                } else {
                    x = radius * Math.cos(angle) + x_orig;
                    y = radius * Math.sin(angle) + y_orig;
                    joystick(x, y);
                }
                getPosition(event);
                var speed =  Math.round(100 * Math.sqrt(Math.pow(x - x_orig, 2) + Math.pow(y - y_orig, 2)) / radius);
                var x_relative = Math.round(x - x_orig);
                var y_relative = Math.round(y - y_orig);
                
                document.getElementById("x_coordinate").innerText =  x_relative;
                document.getElementById("y_coordinate").innerText =y_relative ;
                document.getElementById("speed").innerText = speed;
                document.getElementById("angle").innerText = angle_in_degrees;

                send( x_relative,y_relative,speed,angle_in_degrees);
            }
        } 
        var last_update = 0;
        function send(x, y, speed, angle) {
            var now = Date.now();
            if(((now-last_update)>500) || (x==0 && y==0)){
                last_update = now;
                var data = {"x":x,"y":y,"speed":speed,"angle":angle};
                
                const xhr = new XMLHttpRequest();
                xhr.open("POST", "./joyControl");
                xhr.setRequestHeader("Content-Type", "application/json; charset=UTF-8");
                var body = JSON.stringify(data);
                console.log(body);
                xhr.send(body);
            }
        }