Rust Client/Server Comms on the Pi Pico W with TCP & UDP

Murray Todd Williams
13 min readJul 12, 2024

--

Rusty the crab creates his first client/server application

In this seventh article in my blog series, it’s time to finally do some networking with our Raspberry Pi Pico W boards.

If you’re new to this blog series, here‘s a list of all the articles to date:

Murray's (Raspberry Pi Pico W) Embedded Rust Series

7 stories

In the last article, we did everything needed to enable networking—connecting to the wifi network, getting the IP stack configured, and looking up the IP address of the server to which we want to ultimately publish telemetry data. There was a lot that we had to do, especially since we needed to leverage the programmable IO (PIO) subsystem of the RP2040 chip to manage the communication between the RP2040 and the CYW43 wireless chip. (On the bright side, we finally got access to the onboard LED!)

We did everything except send and receive data between the Pico board and another system, and that’s what we’ll pick up now.

TCP vs UDP

I am going to approach the two fundamental networking routes for this: TCP and UDP. For those of you who might be new to these networking protocols, let me describe and contrast these.

  • TCP is the most widely known and understood protocol. The client initiates a connection with the server and, once established, both client and server can send each other data. Web page requests and REST API calls all use TCP because it represents a typical request/response behavior. TCP also has a lot of failsafe redundancy built in to guarantee that messages actually get delivered with resending fault tolerance built in. This may seem great on the surface, but these guarantees require a lot of cross-chatter between systems that add overhead. It also makes TCP communication necessarily synchronous in nature. Lots of blocking potential!
  • UDP is more lightweight. The client and server can send messages to each other, but there’s no guarantee that 100% of the messages will be successfully delivered, and if either the client or server isn’t actively “listening” for UDP packets, they will simply be ignored: the sender will not know that the receiver missed something. UDP is often used for things like video streaming where the server might need to be sending out a lot of data and cannot possibly afford to manage delivery guarantees. If the client, say a video player, starts missing a bunch of packets, it may need to devise a strategy to pause and ask the server to resend video data from a previous point in time.

There are also a variety of higher-level messaging protocols like MQTT (e.g. Mosquito) or AQMP (e.g. RabbitMQ) that we might want to use for our Raspberry Pi Pico W to send telemetry data to a server. These are built on top of TCP and have a few nice features, but at the moment I haven’t been able to find a good Rust “no-std” library that would be compatible with our embedded frameworks. (If or when that changes, you can rest assured I’ll be adding an example to this blog series!)

For now, we’re going to see that both TCP and UDP are pretty easy to use.

To see the code we’re working with, look at the networking branch of my GitHub repository.

Getting started with TCP

As I’ve mentioned in previous articles, we are going to be really flexible with your choice of server. I’ve got a Raspberry Pi 2B named “pi2b” that I use for my examples. If you’ve got that or some other Linux-based system, great! If not, you should just use your own development laptop. You either need to be able to reach it via a simple network name (like “pi2b” for my Raspberry Pi) or you need to know it’s IP address. Essentially, you need to be able to pull up a terminal window from another computer and type either ping pi2b (or whatever its name is) or ping 192.168.50.34 (or whatever its IP address is) and you should see ping packets.

On this machine, you’ll need to setup a server application that will listen to a port, accepts connections, and send some responses. For this blog series, I’ve written a basic Python script to do this. Why Python and not Rust? Because Python is ubiquitous and simple and we want to “press the easy button” here! Getting another Rust environment setup somewhere and writing standard library networking code would bog us down. As long as you have a text editor and your system has a “python” command, you should be golden. Here’s my hello_tcp.py code:

import socket

HOST = '0.0.0.0' # Listen on all interfaces
PORT = 9932

with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind((HOST, PORT))
s.listen()
while True:
conn, addr = s.accept()
print(f'Connected by {addr}')
with conn:
while True:
# Receive data from client (maximum 1024 bytes)
data = conn.recv(1024).decode(errors="ignore")
if not data:
print("Connection Ended")
break
print(f'Received {data.strip()}')
# Respond with "hello, " + message
response = f"hello, {data}"
conn.sendall(response.encode())
print(f'Response {response.strip()} sent')

I’ve arbitrarily decided we are going to listen on port 9932. (You may need elevated permissions to bind to the lower ports, and as I keep saying, I want to make this as frictionless as possible.

In a terminal window, just type python hello_tcp.py and your server should be running. If you want to verify that it is up, you could use the old “telnet” command to connect from another machine:

Thor:embassy-rp-blinky murray$ telnet pi2b 9932                                
Trying 192.168.50.135...
Connected to pi2b.
Escape character is '^]'.
bob
hello, bob
tina
hello, tina
^]

I type the names “bob” and “tina” and the server sends back simply “hello, bob” and “hello, tina”. Hitting control-] then typing quit gets me to disconnect, and from my python prompt, I see this:

murray@pi2b:~/Development/tcp $ python hello_tcp.py 
Connected by ('192.168.50.138', 55916)
Received bob
Response hello, bob sent
Received tina
Response hello, tina sent
Connection Ended

Yea! You now have a rudimentary TCP server. Let’s connect to it now with the Pico W!

Sending data to the TCP Server

We are going to start by sending and receiving a single message to our Python TCP “hello / echo” server, so I’m going to put most of this code right before our main blinky loop.

Let’s start with our use statements at the top of main.rs. We’re going to add a few types to our existing core::str and embassy_net inclusions. Instead of:

use core::str::FromStr;
...
use embassy_net::{Config as NetConfig, DhcpConfig, Stack, StackResources}

…we are going to add the function from_utf8 to the first line and IpEndpoint to the last one, plus we’ll add a few more names from embassy_net::tcp and embassy_net::udp, thus:

use core::str::{from_utf8, FromStr};
...
use embassy_net::tcp::TcpSocket;
use embassy_net::udp::{PacketMetadata, UdpSocket};
use embassy_net::{Config as NetConfig, DhcpConfig, IpEndpoint, Stack, StackResources}

(The embassy_net::udp line is actually for the next section, but let’s just throw it in while we’re here!) Also, since I’ve arbitrarily decided that I’m going to use port 9932 for our application, I’ll document it as a constant, right under the lines where I’d defined SERVER_NAME and CLIENT_NAME:

const SERVER_NAME: &str = "pi2b";
const CLIENT_NAME: &str = "picow";
const COMMS_PORT: u16 = 9932;

Then right before the primary loop in the main function, I’ll add the actual communication code:

let mut rx_buffer = [0; 1024];
let mut tx_buffer = [0; 1024];
let mut msg_buffer = [0; 128];

let mut socket = TcpSocket::new(&stack, &mut rx_buffer, &mut tx_buffer);
socket
.connect(IpEndpoint::new(dest, TCP_COMMS_PORT))
.await
.unwrap();

let tx_size = socket.write("test".as_bytes()).await.unwrap();
info!("Wrote {} byes to the server", tx_size);
let rx_size = socket.read(&mut msg_buffer).await.unwrap();
let response = from_utf8(&msg_buffer[..rx_size]).unwrap();
info!("Server replied with {}", response);

socket.close();

The explanation is relatively straightforward. We start by allocating some data buffers where our networking stack will be able to store incoming and outgoing messages. I’ve seen examples where people have allocated 16K of space—and if you know your application is going to be small, there’s nothing wrong with being more generous with your RP2040 chip’s 260K of RAM. I know these messages are going to be small, so I think it’s safe to stay within 1024 bytes.

I also need to keep a separate buffer that I’ll copy the pi2b’s responses into as I “read” the messages from the socket. It’s worth visualizing this a bit more: I’m providing the Embassy networking stack these rx_buffer and tx_buffer zones as a sort of “holding area” for incoming and outgoing data. If the server were to send us data before we were ready to read it, that data has to get stored somewhere, right? And similarly, given TCP’s fault-tolerant delivery guarantees, our networking stack has to have a place to hold transmission data until its delivery can be acknowledged.

The msg_buffer is going to be where we copy data out of the read buffer when we execute our read command. Essentially, that will place it somewhere where we’re allowed to safely manipulate it, and then the space in the rx_buffer will be freed-up to hold more incoming data.

Establishing the TCP socket with the server is straightforward enough: we’re just supplying these buffers and the underlying networking stack and specifying the IP address and port where we hope to connect.

We then write out the string “test” and pause to read the (expected) response. Note that we don’t create a separate buffer for the outgoing message. Since I know I’m just sending the static string “test”, I can just call .as_bytes() to pass the &[u8] memory space address of the string itself. If, however, I’m formatting and writing a more involved message—such as the telemetry readings of my sensors—I might end up need to declare an outgoing message buffer just like I did with msg_buffer.

TCP is full-duplex

Note that although this example demonstrates a “call-and-response” pattern, there’s no rule that says that I have to receive something from the server before sending another message. TCP is completely “full-duplex”. However, the way I’ve written the code, I’m using socket.read(…).await method that will block until a message has been send back to me. So in a sense, my code is forcing that “call-and-response” pattern.

Suppose I wanted to mostly send sensor reading telemetry to the server but sometimes check for incoming messages—maybe I want a mechanism for the server to instruct my Raspberry Pi Pico W to change a configuration or to sleep for a few hours. Or I could have certain special “commands” that the server could listen for that would expect a response, such as a command to ask for the current timestamp so I can resynchronize the onboard clock.

In either of those cases, after sending a message, I could check to see if there’s any incoming message first and then only perform a read operation if something already came through, like this:

if socket.can_recv() {
let rx_size = socket.read(&mut msg_buffer).await.unwrap();
let response = from_utf8(&msg_buffer[..rx_size]).unwrap();
info!("Server replied with {}", response);
}

Be aware these unwraps are very brittle!

In the last blog article, we talked about strategic value in writing code that intentionally panics when an error occurs, indicating there’s already an inherent problem that will keep your application from running. This makes sense for code where you need to setup your hardware and/or the underlying network.

Once you get into the realm of actual I/O, this is incredibly stupid. After all, my whole point for learning Rust is to understand its amazing fault tolerance, right? I promise, down the road we’ll looking at writing an application that has all the necessary recovery logic elegantly built in.

Getting started with UDP

We’re going to use a similar looking Python program to do the same “hello, world” behavior but with UDP. You can save this as hello_udp.py on your server:

import socket

HOST = '0.0.0.0' # Listen on all interfaces
PORT = 9932

# Create a UDP socket
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)

# Bind the socket to the port
sock.bind((HOST, PORT))
print(f"Server listening for messages on port {PORT}")

# Listen for incoming messages
while True:
data, address = sock.recvfrom(1024)
message = data.decode()

# Print the message and address
print(f"Received message {message} from {address}")
reply = f"hello, {message}"
# Echo a response to the sender
sock.sendto(reply.encode(), address)
print(f"Sent response '{reply}' back in response.")

Unlike with the TCP example, there’s no equivalent to telnet that I can use to test and verify this is working. Since I’m adding the UDP functionality to our Raspberry Pi Pico project, I’m going to need to run both the hello_tcp.py and hello_udp.py programs simultaneously. This is pretty easy: I’ve personally just opened another terminal window, gone to the same directory, and run python hello_tcp.py in one window and python hello_udp.py in another.

One thing may appear weird: my server is listening to port 9932 for TCP and UDP traffic at the same time! Won’t one server process fail and complain that the port is already bound? Well, as it happens to be, TCP ports and UDP ports are completely separate things. We just have conventions like HTTP traffic using TCP port 80 and nobody decides to do anything with UDP port 80, but there’s no reason why they couldn’t! (I actually didn’t know this before writing this article.)

Coding the TCP and UDP comms in Rust

We’re going to start, as always, with the use statements in the top of our program. Building on the code from the previous blog article, I’m going to add core::str::from_utf8, making the first inclusion:

use core::str::{from_utf8, FromStr}

and then my embassy_net inclusions will expand to:

use embassy_net::tcp::TcpSocket;
use embassy_net::udp::{PacketMetadata, UdpSocket};
use embassy_net::{Config as NetConfig, DhcpConfig, IpEndpoint, Stack, StackResources};

Now, this is a little artificial, but I’m going to pretend like my application needs some guaranteed back-and-forth request/response communication with the server in the very beginning—pretend that there’s some configuration information we need, or maybe we need to get some authentication token—for which the TCP protocol would be the most appropriate. Then in the main loop section, we’re going to use UDP to send sensor telemetry to the server. By choosing UDP, we’ve decided that missing or lost sensor readings are fine. We’d much rather have that happen than have a non-responsive server cause our Pico to stop working!

TCP Transmission

For the TCP Transmission, we’ll put our code right below the section where had used DNS to save our server’s destination address to the variable dest.

let mut rx_buffer = [0; 1024];
let mut tx_buffer = [0; 1024];
let mut msg_buffer = [0; 128];

let mut socket = TcpSocket::new(&stack, &mut rx_buffer, &mut tx_buffer);
socket
.connect(IpEndpoint::new(dest, COMMS_PORT))
.await
.unwrap();

let tx_size = socket.write("test".as_bytes()).await.unwrap();
info!("Wrote {} byes to the server", tx_size);
let rx_size = socket.read(&mut msg_buffer).await.unwrap();
let response = from_utf8(&msg_buffer[..rx_size]).unwrap();
info!("Server replied with {}", response);

socket.close();

In the top lines, we need to create a few buffers. rx_buffer and tx_buffer are going to be spaces where that TCP socket will be able to manage ingoing and outgoing communications. We also will need to create a space for the server’s response to get copied into so we can work with it, thus the msg_buffer. (Ah, such is the life of working in an embedded world where you don’t have a Heap for on-demand memory!)

Once we’ve setup the socket, it’s pretty trivial to send the message “test” to the server and then wait for the response. Note how we needed the rx_size value to know what slice of our msg_buffer to use for the string.

UDP Transmission

Right before the main loop section, we’ll set up our resources for the UDP socket and create the socket itself:

let mut udp_rx_meta = [PacketMetadata::EMPTY; 16];
let mut udp_rx_buffer = [0; 1024];
let mut udp_tx_meta = [PacketMetadata::EMPTY; 16];
let mut udp_tx_buffer = [0; 1024];
// I'll reuse the earlier msg_buffer since we're done with the TCP part

let mut udp_socket = UdpSocket::new(
&stack,
&mut udp_rx_meta,
&mut udp_rx_buffer,
&mut udp_tx_meta,
&mut udp_tx_buffer,
);

udp_socket.bind(0).unwrap();

I’ll admit, I’ve got no idea what the “RX Meta” and “TX Meta” structures are, but the UDP socket needs them to manage its resources. The one interesting thing to note is the last line here. When you setup a UDP socket, you have to establish a UDP port where the server can send any responses to. You don’t actually have to listen to it. Remember, the nature of UDP is that messages that aren’t being listened for will get unceremoniously ignored! Also, in a sensor telemetry application like ours, we really just care about one way delivery of our data.

Anyway, by putting a value of zero in our bind command, we are instructing our device to high-numbered port at random as the place for any responses. If we had wanted to hard-code the return message port, we would have replace the zero with our desired port number.

Next we’ll put our UDP transmission code inside the main loop section. I’m including the original code where we were alternatively blinking the onboard LED and the external LED lights.

loop {
info!("external LED on, onboard LED off!");
led.set_high();
control.gpio_set(0, false).await;
info!("sending UDP packet");
udp_socket
.send_to("test".as_bytes(), IpEndpoint::new(dest, COMMS_PORT))
.await
.unwrap();
Timer::after(Duration::from_secs(1)).await;

info!("external LED off, onboard LED on!");
led.set_low();
control.gpio_set(0, true).await;
if udp_socket.may_recv() {
let (rx_size, from_addr) = udp_socket.recv_from(&mut msg_buffer).await.unwrap();
let response = from_utf8(&msg_buffer[..rx_size]).unwrap();
info!("Server replied with {} from {}", response, from_addr);
}
Timer::after(Duration::from_secs(1)).await;
}

Sending the data is straightforward, and it looks a lot like our TCP example above. Note that on the receiving side, I did something a little different. I didn’t want to block on waiting for a response (if an expected response got lost, as can be the case with UDP, that could hang my application!) so I called the .may_recv() function, which returns true if there is already data in the receive buffer for me to read. In that “if” statement, I only try to read the message if I know one is there, so I know my .recv_from(...).await wont really do any blocking.

So that’s it! If I open two terminal windows on my server and run the hello_tcp.py and hello_udp.py scripts in each window and then run my Rust app, I’ll see this in the TCP server window:

Connected by ('192.168.50.219', 9835)
Received test
Response hello, test sent
Connection Ended

And in the UDP server window:

Received message test from ('192.168.50.219', 9836)
Sent response 'hello, test' back in response.
Received message test from ('192.168.50.219', 9836)
Sent response 'hello, test' back in response.
Received message test from ('192.168.50.219', 9836)
Sent response 'hello, test' back in response.

And in my Rust debug logging, I’ll see this from the initial TCP communication:

4.522047 INFO  Our server named pi2b resolved to the address 192.168.50.135
└─ embassy_rp_blinky::____embassy_main_task::{async_fn#0} @ src/main.rs:134
5.522691 DEBUG address 192.168.50.135 not in neighbor cache, sending ARP request
└─ smoltcp::iface::interface::{impl#2}::lookup_hardware_addr @ /Users/murray/.cargo/registry/src/index.crates.io-6f17d22bba15001f/smoltcp-0.11.0/src/macros.rs:18
5.557074 INFO Wrote 4 byes to the server
└─ embassy_rp_blinky::____embassy_main_task::{async_fn#0} @ src/main.rs:150
5.561265 INFO Server replied with hello, test

followed immediately by:

7.564793 INFO  external LED on, onboard LED off!
└─ embassy_rp_blinky::____embassy_main_task::{async_fn#0} @ src/main.rs:174
7.564855 DEBUG set gpioout = [01, 00, 00, 00, 00, 00, 00, 00]
└─ cyw43::control::{impl#0}::set_iovar_v::{async_fn#0} @ /Users/murray/.cargo/registry/src/index.crates.io-6f17d22bba15001f/cyw43-0.1.0/src/fmt.rs:130
7.565580 INFO sending UDP packet
└─ embassy_rp_blinky::____embassy_main_task::{async_fn#0} @ src/main.rs:177
8.565790 INFO external LED off, onboard LED on!
└─ embassy_rp_blinky::____embassy_main_task::{async_fn#0} @ src/main.rs:184
8.565869 DEBUG set gpioout = [01, 00, 00, 00, 01, 00, 00, 00]
└─ cyw43::control::{impl#0}::set_iovar_v::{async_fn#0} @ /Users/murray/.cargo/registry/src/index.crates.io-6f17d22bba15001f/cyw43-0.1.0/src/fmt.rs:130
8.566752 INFO Server replied with hello, test from 192.168.50.135:9932
└─ embassy_rp_blinky::____embassy_main_task::{async_fn#0} @ src/main.rs:190

And there you have it! We’ve successfully configured our networking on the Pico W and mastered the use of its onboard LED in the previous article, and finally we’ve shown a couple ways to send and receive data using the Wifi capabilities of the Pico’s CYW43 wireless chip.

The next task will be for me to merge these sensor and networking branches and move beyond “hello, test” messages to actually sending my sensor telemetry. I’ll also want to start fortifying my code a bit more so that I’m not depending on .unwrap statements that will lead my Pico to panic (die) if anything goes wrong.

Stay tuned!

--

--

Murray Todd Williams

Life-long learner, foodie and wine enthusiast, living in Austin, Texas.