RP2040 MicroPython: Making a Web Server with WebSockets

17 minute read

Where I expand on creating a web server application on a Pi Pico W by incorporating web sockets.

Repository

Introduction

In the first part of this entry, I introduced the ability to develop an HTTP-based web server using solely HTML to control the Pi Pico pins. I focused on HTTP protocol as you can write everything in the relatively simple language, HTML and it works. I used checkbox or radio button inputs to a form to control pins on the Pico. This is a far more simple approach than using JavaScript. It does introduce some significant delays as each form submission must make a complete round trip (i.e. a page reload).

This round-trip can be difficult for the user, as the delay may lead to double-clicking or the feeling of a non-responsive interface. Another description of this protocol, is request/response. The browser makes a request and the server fulfills the request with a response. The communication is half-duplex where communication is only one-way at a time and each communication requires a new series of handshaking.

For many embedded projects, this type of protocol is acceptable or even desired. For example, if a browser is used to setup a board and the board then runs on its own for a period of time. A time and temperature logging application would be a good example. The setup page on version 7 in the previous entry is a great example as well. (I recommend you install it and look at the first page in comparison to the second. The former does quite well with a half-duplex communication link, while the second page does not.)

Another approach would be to keep the communication open between the browser and the server and allow full-duplex communication. This would be ideal for an application where the user requires immediate feedback as to what they have done. The protocol for this approach is called web sockets.

Web Sockets

A web socket allows for real-time communication between the browser and the server. You click on a button in the browser and it is transmitted to board (no form, no submit button). This is very useful where the user desires to have an immediate response. To use web sockets, you need to use JavaScript, which makes the software, a bit more complicated.

I’m going to go through this slowly, for some it might be too slow, however, I believe it will ensure the best understanding of how to implement this versatile protocol. I’ll also add the capability to communicate with the board via a desktop Python program. This allows for greater automation between the desktop computer and the Pi Pico W board.

Websocket Development Tools

MicroPython Code Editor

I prefer and strongly recommend Sublime Text as I also have some great automation tools for using CoolTerm and MicroPython. Or use what you normally use for editing code.

Web Development Browser

It is very important to have the ability to view traffic between the browser and the Pico. Doing so, allows you to more quickly fix errors or at least understand why they are occurring. Google’s Chrome browser has excellent web development tools and in my case, I’ll be using the Arc, which is Chrome-based, however, has superior privacy controls.

MicroPython Utilities

There are two utilities which I discuss on the microserver repository. The MicroPython utility mpremote and my build application, mpbuild. I discuss how to use them on the repository and I strongly recommend you spend some time with them, particularly the commands mpremote littlefs_rp2 and mpbuild buildfile to erase and install a microserver application. You will need to use the command line for both of these utilities, I use Warp on macOS. Alternatives are iTerm2 or Terminal on macOS, Terminal on Windows and konsole on Linux.

CoolTerm

CoolTerm is my recommended serial application for interacting with the Pico. Make sure it is open, the computer is connected to the Pico and CoolTerm is connected to the right serial port.

Websocket Application: Echo

This is a very simple application, which the microdot developer uses to demonstrate a web socket. It accepts a line of text and echo’s the line of text back to the browser using a web socket. Use mpremote littlefs_rp2 to delete the files on the Pico, then install this application with mpbuild mpbuild/files_v1ws.txt.

Once the file is loaded on the board, you reset the board and go to the IP address displayed in your CoolTerm window. It will be the IP address, for example 10.0.1.6 followed by a :port address as in :5000 (the default port for Microdot). The page will look like this:

echo page showing web socket interaction

echo page showing web socket interaction

Large Version to see detail

However, I recommend you press Alt-Cmd-Ior View->Developer->Developer Tools to view the output in your browser. Which in this case, it will look like this:

echo page with Dev tools

echo page with Dev tools

Large Version to see detail

To get to this specific view, do the following:

  1. Click on View->Developer->Developer Tools to show the window
  2. Click on Network tab at the top
  3. Ensure All is clicked showing all data (second line from top)
  4. Do a refresh of the browser to display all activity, you will now see all of the files sent under Name
  5. Click on echo for the web socket URL, which will display Headers Messages Initiator Timing in the adjacent window, click on Messages to show the JSON data being exchanged.
  6. Click on a specific line if you wish to see more details

Web Application Demo v2

In the following applications, I’ll begin to discuss in greater detail as to what the code is doing. In this first example, we have a very simple on/off demonstration. Click the bulb in yellow, an LED lights, click the “empty” or blank bulb, the led will turn off.

Demo v2

Demo v2

Large Version to see detail

As you can see from the image, each time I click, I get a corresponding on or off in the web socket (ws) feed. And by clicking as fast as possible, I was able to get a roughly 20ms time delta (08:53:15.644 - 08:53:15.452) between clicks. How’s that for responsive!

MicroPython Review - light_leds_v2ws.py

The critical code for the web socket is this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
@app.route('/ws')
@with_websocket
async def ws(request, ws):
    while True:
        data = await ws.receive()
        if data == 'on':
            builtin.value(1)
        elif data == 'off':
            builtin.value(0)
        else:
            print("Error occured, value must be 'on' or 'off'")

A very simple check of the data being received. If the board gets an “on”, it turns the LED on, and “off”, it turns the LED, off. Note the object builtin is the on-board LED.

HTML Review - index_v2ws.html (html)

I use a radio button with a value of “on” or “off” to set the value being communicated. The html code is this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
<div class="leds">
<label>
  <input type="radio" name="state" value="on" class="visually-hidden">
  <img src="on.svg" class="state-image" height="100" width="100" alt="LED on" />
</label>

<label>
  <input type="radio" name="state" value="off" class="visually-hidden">
  <img src="off.svg" class="state-image" height="100" width="100" alt="LED off" />
</label>
</div>

Note that I use a class of “visually-hidden” to replace the radio buttons with images. This isn’t critical, however, it makes a nicer display.

Javascript Review index_v2ws.html (script)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
<script>
  // 1. Connect to a WebSocket server at the 'ws' endpoint
  const socket = new WebSocket('ws://' + location.host + '/ws');

  // 2. Find all radio buttons with the input name of 'state'
  const radios = document.querySelectorAll('input[name="state"]');

  // 3. Loop through all radio buttons adding an 'click' event listener
  // when button clicked, send radio 'value' back to socket
  radios.forEach(radio => {
    radio.addEventListener('click', () => {
      socket.send(radio.value); 
    });
  });
</script>
  1. Line 3 - creates the JS connection to the web socket. While the python program will request the connection with the @with_websocket decorator, the JS socket object will be used for communication.

  2. Line 6 - creates an array called radios, in which are all of the radio buttons identified on the page. In this case, there are two, one with the value on and the other with the value, off.

  3. Lines 10-13 - this code is a listener, which listens for a change in state of any of the radio buttons. When that happens, it sends the value of that radio button via the web socket.

This works, because grouped radio buttons on a web page, can only have one value or to put another way, “Only one radio button in a given group can be selected at the same time”. When working on code like this, I recommend keeping Mozilla’s web documentation open to particular element.

Refactor

If you wanted to simplify (refactor), the MicroPython code, you could have the webpage use the values “1” and “0”, instead of on/off. This would simplify the MicroPython code to be:

1
2
3
4
5
@app.route('/ws')
@with_websocket
async def ws(request, ws):
    while True:
        builtin.value(int(await ws.receive()))

The code above works well, however, you do lose the error checking. Either approach works, the refactored version might be valuable in a more complicated situation where you want a faster response. Both versions are now in the light_leds_v2ws.py file.

Web Application Demo v3

The third demo changes the user interface from one of two bulbs to click to a single link “click” and an image which changes based on the state of the bulb. When you observe the traffic in the Dev Tools, a load of an image is cancelled if it fails to load before the next click. You can see this with a “red circle with an x” to the left of the image as well as the word “(cancelled)” under status.

MicroPython Review - light_leds_v3ws.py

Almost no change from v2, with the exception the check changes from on/off to true/false. What is unfortunate, is that JS uses lower case true/false vs Python’s use of capital case, True/False, which ensures you have to do exact matching instead of using a boolean (if data:). It also prevents us from refactoring the code to be a single line, as we did in version 2.

HTML Review - index_v3ws.html (html)

Use a checkbox which returns a value of “true” or “false”. The checkbox remains hidden and the state of the checkbox is the active data element. The HTML code is this:

1
2
3
4
5
<div class="leds">
<input type="checkbox" id="stateCheckbox" class="visually-hidden"/>
<label class="state-image" for="stateCheckbox">Click</label>
<img src="off.svg" id="stateImage" height="100" width="100" alt="LED off" />
</div>

Javascript Review index_v3ws.html (script)

The JS is again a three part program using a checkbox, instead of two radio buttons:

  1. Create the web socket
  2. Setup the watch variables
  3. Execute code based on the state of the checkbox.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
<script>
// 1. Connect to a WebSocket server at the 'ws' endpoint
const socket = new WebSocket('ws://' + location.host + '/ws');

// 2. Setup checkbox with the name of 'stateCheckbox'
// image will reflect the state of the checkbox
// on load, set state to off
const checkbox = document.getElementById('stateCheckbox');
const stateImage = document.getElementById('stateImage');
setStateImage(); 

// 3. When checkbox state changes
// Change state image
// Send state to socket, 'true' for checked/'false' for unchecked
checkbox.addEventListener('change', () => {
  setStateImage();
  socket.send(checkbox.checked); 
});

// function changes image based on state of checkbox
function setStateImage() {
if(checkbox.checked) {
  stateImage.src = "on.svg"; 
} else {
  stateImage.src = "off.svg";
}
}
</script>
  1. Line 3 - create the web socket.
  2. Lines 8-10 - setup the checkbox, similar to the previous radio button. In this case, there is a checkbox, which is checked(true) or not(false). The variable, stateImage contains the image appropriate to the checkbox value. And setStateImage is a function which examines the value stateImage and sets the image accordingly.
  3. Lines 16-19 - the active code watching the checkbox, setting the image and sending the state.

Web Application Demo v4

This application makes considerable progress to having great value. It enables you to turn at least four LEDs on and off, using web sockets. Let’s take a look at the interface:

Demo v4

Demo v4

Notice the more complex data being transferred. The data has two values and now it is transferred as JSON data. This simplifies the python program required to understand the data. The interface is more colorful, with the color of the indicator matching the color of the LED. All told, this is a nice and usable UX.

Large Version to see detail

MicroPython Review - light_leds_v4ws.py

The MicroPython code remains extremely simple.

Early in the code, I used the following to setup the LEDs as well as create a list containing their object.

1
2
3
4
5
6
yellow = Pin(2, Pin.OUT)
green = Pin(15, Pin.OUT)
red = Pin(16, Pin.OUT)
blue = Pin(22, Pin.OUT)

leds = [yellow, green, red, blue]

By using JSON data, this enables us to use the web socket message directly in our code:

1
2
3
4
5
6
@with_websocket
async def ws(request, ws):
    while True:
        raw = await ws.receive()
        data = json.loads(raw)
        leds[data['i']].value(int(data['checkbox']))

We receive the data, we convert the JSON data into a dictionary then use the contents to set the appropriate LED and specified value. This approach is similar to the simplified approach in the enhancement to the v2 demo.

HTML Review - index_v4ws.html (html)

The HTML isn’t radically different than what was in v3, except it has been duplicated 4 times. I would consider using a template for this in the future to reduce code, code size and reduce bugs. Similar to the template changes in the HTML code for index_v7.html.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
	<div class="leds">
	<div class="led led_1">
	<input type="checkbox" id="stateCheckbox_1" class="visually-hidden"/>
	<label class="state-image" for="stateCheckbox_1">Yellow</label>
	<img src="off.svg" id="stateImage_1" height="100" width="100" alt="LED 1 off" />
	</div>
	<div class="led led_2">
	<input type="checkbox" id="stateCheckbox_2" class="visually-hidden"/>
	<label class="state-image" for="stateCheckbox_2">Green</label>
	<img src="off.svg" id="stateImage_2" height="100" width="100" alt="LED 2 off" />
	</div>
	<div class="led led_3">
	<input type="checkbox" id="stateCheckbox_3" class="visually-hidden"/>
	<label class="state-image" for="stateCheckbox_3">Red</label>
	<img src="off.svg" id="stateImage_3" height="100" width="100" alt="LED 3 off" />
	</div>
	<div class="led led_4">
	<input type="checkbox" id="stateCheckbox_4" class="visually-hidden"/>
	<label class="state-image" for="stateCheckbox_4">Blue</label>
	<img src="off.svg" id="stateImage_4" height="100" width="100" alt="LED 4 off" />
	</div>
	</div>

Javascript Review index_v4ws.html (script)

The JS in theory remains a 3-step process, however, it is a bit more complicated. I’ll breakdown each part separately.

Web socket connect

This section remains simple, though a couple of extra items are setup so that they are global in scope.

  1. Array for the checkboxes to track the state, image and color of each div
  2. Web socket
  3. Array allows us to simplify how to reference the “on” images or color for each LED div
// Array to store all checkboxes and create web socket
const checkboxes = [];
const socket = new WebSocket('ws://' + location.host + '/ws');

// Array of 'on' image files
const onImages = [
"on_yellow.svg",
"on_green.svg",
"on_red.svg",
"on_blue.svg",
];

Setup

Since there are multiple steps which have to happen when a box is checked, we’re going to watch the div holding the checkbox, instead of just the checkbox. This allows us to change the image and examine the checkbox value.

  1. To perform the setup required to listen for these events, we need to ensure the page is complete, ‘DOMContentLoaded’.
  2. We create a local array which tracks all of the div’s with a class of “led”
  3. Which means we use a previously declared array “checkboxes” as our tracking array, keeping track of the state of the checkbox, the image shown and what image we shown when on (essentially the color of the led)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
      // Loop through all elements with class "led_"
      // Wait until page has loaded before proceeding
      document.addEventListener('DOMContentLoaded', () => {
        const ledDivs = document.querySelectorAll(".led");  
      
        for (let i = 0; i < ledDivs.length; i++) {
          // Get checkbox and image elements
          const checkbox = ledDivs[i].querySelector("input[type='checkbox']");
          const img = ledDivs[i].querySelector("img");

          // Add checkbox div to array
          checkboxes.push({
            checkbox: checkbox,
            image: img,
            onImage: onImages[i]
          });

        }

Execute

Finally, we execute!

  1. We cycle through the checkboxes array, pulling three variables out to simplify processing:
    • checkbox - contains the state of checked, true/false
    • image - current image, off or color
    • onImage - the color pertaining to the index (uses array onImages)
  2. We send a JSON array of the state and index to the web socket
  3. Update the page with led color and state
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
        // Loop through checkboxes array
        for (let i = 0; i < checkboxes.length; i++) {

          // Alias current checkbox 
          const {checkbox, image, onImage} = checkboxes[i];

          // Set initial state
          setStateImage(checkbox, image);

          // Add event listeners
          checkbox.addEventListener('change', () => {
            const resp = JSON.stringify({ "i":i, "checkbox":checkbox.checked});
            socket.send(resp); 
            setStateImage(checkbox, image, onImage);
          });

        }
      });

      function setStateImage(checkbox, image, onImage) {
        if (checkbox.checked) {
          image.src = onImage; 
        } else {
          image.src = "off.svg";
        }
      }
    </script>

Web Application Demo v5 Time Test

This application changes direction, it is now intended to measure response time, using web sockets. Let’s take a look at the interface:

Demo v5

Demo v5

We’re back to a simple click and one bulb, however, the first click starts a timer with ’true’ and the second, stops the timer with ‘false’. If you look closely, you can see the browser’s time stamp vs. the calculated. For the first one, there is difference of .078 seconds or 78 milliseconds (4.5%) between what the browser reports and what the MicroPython program measured.

Large Version to see detail

MicroPython Review - light_leds_v5tt.py

The MicroPython code is almost the same as version 3. I add two lines, one to start the timer (line 7) and one to measure the elapsed time (line 9).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
async def ws(request, ws):
    while True:
        data = await ws.receive()
        print(f"{data=}")
        if data == 'true':
            blink_led.value(1)
            start = ticks_us()
        elif data == 'false':
            elapsed = ticks_diff(ticks_us(), start)
            blink_led.value(0)
            print(f"{elapsed=}")
            await ws.send(str(elapsed))
        else:
            print(f"{data} sent, value must be boolean")

Line 7 starts the microsecond timer and line 10, ends the timer. The blinking led adds a bit of overhead, which we will remove in the next version.

HTML Review - index_v5tt.html (html)

The HTML is to version 3, save for the specific style sheet and header.

Javascript Review index_v5tt.html (script)

The JS is also the same as version 3.

Response Times Note:

I thought it would be interesting to compare the elapsed time as measured by the MicroPython program to the times reported by the browser. Here are the three times shown in the image above (elapsed time is rounded to 3 digits to ease comparison):

Elapsed Browser
1.329 1.334
1.507 1.466
1.201 1.201

Why Response Times?

The previous version demonstrated measuring response times, why would we do that? I believe its helpful to understand the inherent speed of a system. How fast can it cycle through a process? To know a systems response time, is to know to for which applications it might be suitable.

The testing of the response times of this combination of MicroPython, Microdot, the Pi Pico W and web sockets is best performed using automation. In other words, don’t use a browser-based test, use a desktop Python test.

The tests doing just that reside in the desktop folder in the repository. The versions correspond to the same versions of MicroPython/HTML/JS code. To test simply load the desired application on to the Pico then use Python to run the test.

Response time/Variability with 100 ms

For example, after loading files_v5tt.txt via mpbuild, I can then run `python desktop/set_pico_v5tt.py. This will result in the following output:

(Based on pinging the Pico every 100ms (.1s), the elapsed times are below, along with the percent delta between elapse time and 100ms.):

python desktop/set_pico_v5tt.py
Testing with 0.1 s, press Ctrl+C to quit
Elapsed time:   135.97 ms, % delta:  26.45
Elapsed time:   105.39 ms, % delta:   5.11
Elapsed time:   157.27 ms, % delta:  36.42
Elapsed time:   106.03 ms, % delta:   5.68
Elapsed time:   192.46 ms, % delta:  48.04
Elapsed time:   105.30 ms, % delta:   5.04
Elapsed time:   105.29 ms, % delta:   5.03
Elapsed time:   101.31 ms, % delta:   1.29
Elapsed time:   118.99 ms, % delta:  15.96
Elapsed time:   105.18 ms, % delta:   4.93
Elapsed time:   102.14 ms, % delta:   2.09
Elapsed time:   105.58 ms, % delta:   5.29
Elapsed time:   102.99 ms, % delta:   2.91
Elapsed time:   106.34 ms, % delta:   5.96
Elapsed time:   102.94 ms, % delta:   2.86
Elapsed time:   114.16 ms, % delta:  12.40
Elapsed time:   105.01 ms, % delta:   4.77
Elapsed time:   116.03 ms, % delta:  13.82
^CStopped Test

Response time/Variability with 1000 ms (1s)

python desktop/set_pico_v5tt.py
Testing with 1.0 s, press Ctrl+C to quit
Elapsed time:  1005.20 ms, % delta:   0.52
Elapsed time:  1047.10 ms, % delta:   4.50
Elapsed time:  1004.84 ms, % delta:   0.48
Elapsed time:  1000.97 ms, % delta:   0.10
Elapsed time:  1001.99 ms, % delta:   0.20
Elapsed time:  1005.14 ms, % delta:   0.51
Elapsed time:   977.58 ms, % delta:  -2.29
Elapsed time:  1005.51 ms, % delta:   0.55
Elapsed time:  1371.13 ms, % delta:  27.07
Elapsed time:  1006.03 ms, % delta:   0.60
Elapsed time:  1017.56 ms, % delta:   1.73
Elapsed time:  1047.90 ms, % delta:   4.57
Elapsed time:  1007.48 ms, % delta:   0.74
Elapsed time:  1006.62 ms, % delta:   0.66
Elapsed time:  1008.23 ms, % delta:   0.82
Elapsed time:  1006.65 ms, % delta:   0.66
Elapsed time:  1147.20 ms, % delta:  12.83
Elapsed time:  1005.45 ms, % delta:   0.54
Elapsed time:  1005.25 ms, % delta:   0.52
^CStopped Test

Response Time/Variability with 10 ms

python desktop/set_pico_v5tt.py
Testing with 0.01 s, press Ctrl+C to quit
Elapsed time:    10.81 ms, % delta:   7.46
Elapsed time:    12.70 ms, % delta:  21.24
Elapsed time:    13.20 ms, % delta:  24.24
Elapsed time:    25.45 ms, % delta:  60.71
Elapsed time:   161.93 ms, % delta:  93.82
Elapsed time:    12.39 ms, % delta:  19.30
Elapsed time:    11.97 ms, % delta:  16.43
Elapsed time:    11.12 ms, % delta:  10.10
Elapsed time:    13.10 ms, % delta:  23.65
Elapsed time:    92.53 ms, % delta:  89.19
Elapsed time:    12.82 ms, % delta:  22.01
Elapsed time:   117.06 ms, % delta:  91.46
Elapsed time:    12.67 ms, % delta:  21.05
Elapsed time:    30.15 ms, % delta:  66.83
Elapsed time:    12.71 ms, % delta:  21.33
Elapsed time:    11.34 ms, % delta:  11.82
Elapsed time:    11.30 ms, % delta:  11.51
Elapsed time:    12.62 ms, % delta:  20.79
Elapsed time:    12.60 ms, % delta:  20.60
^CStopped Test

Comments powered by Talkyard.