Websocket Communication

Requirements

  • Communication between Raspberry Pi (RPi) and PC shall be wireless
  • Communication should be bi-directional
  • RPi operates as server, PC as client

Options

HTTP communication between RPi and PC; requires PC to be client, requesting data from RPi must be initiated from PC.

The RPi should be able to advertise data to the interested PC. The PC may the decide to query the data . That is not easily done with HTTP.

Websockets offer some more flexibility in this respect.


Learning Websocket Programming on PC

Required skills

Some libraries use asynchronous programming. Some familiarity with the Python Asyncio library is needed.

Most of what I have learned about programming with websockets is taken from this resource:

https://websockets.readthedocs.io/en/stable/intro/index.html

Websocket programming requires some familiarity with Python-Asyncio. As I had never done asynchronous programming in Python I bought the PDF-booklet Python Asyncio Jump-Start  (author: Jason Brownlee) which is indeed very well written.

https://superfastpython.com/python-asyncio/

The following resources of the same author have been extremely helpful too:

https://superfastpython.com/

Other Tools

I use these development environments to write code (Python, HTML, CSS, Javascript):

  • Visual Studio Code (VsCode)
  • PyCharm Community Edition

At the time of writing PyCharm Community Edition does not support remote code development and debugging of Python programs running on the RPi. So I prefer to use VsCode for most of my work.

While learning programming with websockets all programs described below run exclusively on a PC. This made program development simple and debugging easy. Later applications will use websockets to communicate between Raspberry Pi and PC.

Example#1

A websocket server is started (and runs forever if not aborted…) from coroutine main().

The server is invoked via websockets.serve() method which accepts a handler function hello(). Function hello() requires a websocket object as argument. This argument is automatically provided when calling websockets.serve(). That this is the case is not really obvious. However consulting the documentation I got this information:

ws_handler (Union[Callable[[WebSocketServerProtocol], Awaitable[Any]], Callable[[WebSocketServerProtocol, str], Awaitable[Any]]])

So the handler function ws_handler (in program server_ex_1a.py this is function hello(websocket) ) expects at least one input parameter.

In this example we provide two server programs

server_ex_1a.py

server_ex_1b.py

These programs are almost identical with one exception:

The handler function async def hello() of server_ex_1a.py expects a single parameter websocket. In server server_ex_1b.py the handler function async def hello() expects as before parameter websocket plus an additional parameter path_str. For debugging purposes a print() statement has been added in the handler function. Thus it can be observed, what parameters are passed into the handler function.

To observe the print-out of the additional parameter path_str the client program client

Server (1a)

# server_ex_1a.py
# 08.03.2023
import asyncio
import websockets

async def hello(websocket):
    name = await websocket.recv()
    print(f"<<< {name}")
    greeting = f"Hello {name}!"
    await websocket.send(greeting)
    print(f">>> {greeting}")

async def main():
    async with websockets.serve(hello, "", 8765):
        await asyncio.Future()
if __name__ == "__main__":
    asyncio.run(main())

Server (1b)

(shows only the changes made to the code of server_ex_1a.py)

# server_ex_1b.py
# 08.03.2023
async def hello(websocket, path_str):
    print(f"websocket: {websocket}; path_str:{path_str}")

Client (1a)

# client_ex_1a.py
# 08.03.2023

import asyncio
import websockets

async def hello():
    uri = "ws://localhost:8765"
    async with websockets.connect(uri) as websocket:
        name = input("What's your name? ")
        await websocket.send(name)
        print(f">>> {name}")
        greeting = await websocket.recv()
        print(f"<<< {greeting}")

if __name__ == "__main__":
    asyncio.run(hello())

Client (1b)

(shows only the changes (added /some_path_string to variable uri) made to the code of client_ex_1a.py)

# client_ex_1b.py
# 08.03.2023

import asyncio
import websockets

async def hello():
    uri = "ws://localhost:8765/some_path_string"
Summary

It has been shown that the server invokes the handler function hello() with one (server_ex_1a.py, client_ex_1a.py) or two (server_ex_1b.py, client_ex_1b.py) input parameters.

But what if we need to add other application specific parameters to the handler function? 

The next example demonstrates how this can be achieved by wrapping the handler function using methods provided by Python module functools.


Example#2

Server

The handler function hello() now expects 3 additional parameters param_a, param_b, param_c. These parameters must be inserted before the mandatory parameters websocket and path_str. To see that these parameters are successfully passed into the handler function three print() statements have been added.

Handler function hello() can no longer be used directly since method websockets.serve() expects a handler function with two arguments. Using function partial() of Python module functools is used to wrap function hello() into a new function wrapped_hello() which now expects only two function parameters (websocket, path_str). The wrapping is done in coroutine main() .

# server_ex_2.py
# 09.03.2023

import asyncio
import websockets
from functools import partial

async def hello(param_a, param_b, param_c, websocket, path_str):
    print(f"websocket: {websocket}; path_str: {path_str}\n")
    print(f"param_a: {param_a}")
    print(f"param_b: {param_b}")
    print(f"param_c: {param_c}\n")

    name = await websocket.recv()

    print(f"<<< {name}")
    greeting = f"Hello {name}!"

    await websocket.send(greeting)
    print(f">>> {greeting}")

async def main(param_a, param_b, param_c):
    # wrapping handler function hello()
    wrapped_hello = partial(hello, param_a, param_b, param_c)

    async with websockets.serve(wrapped_hello, "", 8765):
        await asyncio.Future()

if __name__ == "__main__":
    # some params that go into the handler function
    param_a = {"pa_1": [1, 2, 3], "pa_2": "hello", "pa_c": 4711.23}
    param_b = 3543.3
    param_c = "parameter c"

    asyncio.run(main(param_a, param_b, param_c))

Client

The client program client_ex_2.py has only been renamed (identical to client_ex_1b.py).

# client_ex_2.py
# 09.03.2023
import asyncio
import websockets

async def hello():
    uri = "ws://localhost:8765/some_path_string"

    async with websockets.connect(uri) as websocket:
        name = input("What's your name? ")
        await websocket.send(name)
        print(f">>> {name}")
        greeting = await websocket.recv()
        print(f"<<< {greeting}")

if __name__ == "__main__":
    asyncio.run(hello())

How it works:

The server program server_ex_2.py is started first.

Then the client program client_ex_2.py is started.

The server invokes the handler function wrapped_hello() and responds with:

websocket: <websockets.legacy.server.WebSocketServerProtocol object at 0x000001A46FEECE80>; path_str: /some_path_string

param_a: {'pa_1': [1, 2, 3], 'pa_2': 'hello', 'pa_c': 4711.23}
param_b: 3543.3
param_c: parameter c

The client queries the name, prints it to the console and sends it via websocket.send() to the server:

What's your name? Hamlet
>>> Hamlet

The server receives the the name via  websocket.recv(), prints the name, creates a variable greeting, sends it to the client and prints greeting.

<<< Hamlet
>>> Hello Hamlet!

On reception of the greeting the clients prints it:

<<< Hello Hamlet!


Summary

Example#2 demonstrated how to pass additional parameters to the handler function by wrapping the handler function.

Alternately additional parameters could possibly be made available as global parameters which however is not recommended.


Resources

An archiv of the sample code is provided here: