Connecting to WiFi with the Pi Pico W

V. Hunter Adams


The Pico W WiFi stack

There are two libraries that facilitate WiFi on the Pi Pico W. There exists a very low-level library, cyw43_driver, that allows for interaction with the CYW43 chip via an SPI interface. For almost everyone, this library is probably a layer or two beneath that on which they will be doing most of their engineering. The pico SDK includes a slightly-higher-level library, pico_cyw43_arch, which implements some functions that use the low-level driver to do things like scan for access points, connect to access points, etc. This is the lowest-level library that most folks will probably use in their projects, and they'll use this library to setup WiFi rather than sending data over WiFi.

Above this sits the pico_lwip library, which is a wrapper for Adam Dunkels' Lightweight IP. This is a open-sourced TCP/IP stack designed for embedded systems, and ported to the Pi Pico W.

This webpage focuses only on how one uses the pico_cyw43_arch library to setup a WiFi connection with a Pico W. Subsequent webpages will discuss how to utilize the pico_lwip library to exchange data over WiFi via UDP and TCIP/IP. As a resource for this documentation, I am making extensive use of Master the Raspberry Pi Pico in C: WiFi with LWIP and mbedutils. I'm also making use of the LWIP examples, and the pi pico examples. Bruce also has some really nice examples from which to start a project.


What happens when we connect to WiFi?

Before describing this process for the pico in particular, it is helpful to understand the process in general. When we connect a device to a WiFi network, the following occurs:

Setting up a network

  1. Our router connects to the Internet via its modem, and receives a public IP address from the Internet Service Provider (ISP).
  2. During setup/configuration, the router will prompt for (or automatically assign) a country code. This tells the router which channels and power levels are legally allowed in that particular region.
  3. The router may also ask the user to choose a security protocol. One often chooses WPA2-Mixed, which supports both WPA2-AES for newer devices and WPA-TKIP for older ones.
  4. The router sets up the local network, and assigns itself a private IP address on that network. These local IP addresses are usually of the form 192.168.1.X, and it's often the case that the router will take 192.168.1.1.
  5. This router will also prepare a subnet mask (e.g. 255.255.255.0). The subnet mask tells all devices on the local network which part of the IP address represents the network, and which part represents the specific device on that network. For example, a device with IP address 192.168.1.20 on a network with subnet mask 255.255.255.0 uses the first three parts of the IP (192.168.1) to identify the network, and the last part of the IP (20) to identify the device on that network.
  6. The router also generally makes itself the gateway for the network. Any device that wants to send a message to another device with an IP that indicates (by way of the subnet mask) that it is not on the subnet, will instead forward the message to the gateway, which subsequently sends out out to the Internet. The gateway is the door from the local network to the broader Internet. If the IP indicates that the device is on the local network, then we don't bother the gateway.
  7. The router starts up a DHCP server (Dynamic Host Configuration Protocol). This will automatically assign local IP addresses to new devices which join the network. It has a collection from which it can choose, of a size dictated by the subnet mask.

Connecting to the network

  1. We connect to the network by way of that network's SSID (i.e., it's name). This SSID is broadcast by the router as part of the WiFi signal so that devices can see it. Connection often requires a password.
  2. When we connect, the router will use its DHCP server to assign us a local IP address, and it will send us the local network gateway and subnet mask. That's all the info that we need to join that network.

After connection, of course, there's a whole other protocol by which information is actually exchanged. But let's save that conversation, and focus only on getting a Pico W connected to a network.


Connecting to WiFi with the Pico W

Initializing the hardware

Before connecting to a WiFi network, we must initialize the CYW43 chip itself. As a part of this initialization, we must tell the chip which country its in, so that it knows which channels and power levels it should use. In order to do this, we have two options. We could either call the pico_cyw43_arch library function int cyw43_arch_init_with_country(uint32_t country) which, as an argument, takes the country code. Macros for these country codes are available at pico-sdk/lib/cyw43-driver/src/cyw43_country.h. Alternatively, we can simply call int cyw43_arch_init(void), which uses the country code set by PICO_CYW43_ARCH_DEFAULT_COUNTRY_CODE in cyw43_arch.h. By default, this is set to "worldwide." Everything that happens when we call this function is described to excruciating depth on this webpage.

Putting the device into station mode

Our Pico can act either as a client in station mode (in which it connects to a network) or as an access point (in which it generates a network to which other devices connect). We will consider each, but let us start with station mode, in which the Pico W will connect to a network. We enable station mode by way of the pico_cyw43_arch library function cy243_arch_enable_sta_mode().

Connecting to the network

After having initialized the hardware and putting it into station mode, we can now make a connection to an access point. There are three types of connection: blocking, timout, and async. As the names suggest, a blocking connection will block until a connection has been made, timeout will block until a specified time has passed, and async will attempt an asynchronous connection in a non-blocking fashion. Furthermore, each of these three types of connection comes in two versions, one that includes a MAC address and one that does not. All options are enumerated below. Note that the auth argument represents the security protocol. Macros for all options are available in pico-sdk/lib/cyw43-driver/src/cyw43_ll.h.

  1. int cyw43_arch_wifi_connect_blocking(const char *ssid, const char *pw, uint32_t auth)
  2. int cyw43_arch_wifi_connect_bssid_blocking(const char *ssid, const uint8_t *bssid, const char *pw, uint32_t auth)
  3. int cyw43_arch_wifi_connect_timeout_ms(const char *ssid, const char *pw, uint32_t auth, uint32_t timeout_ms)
  4. int cyw43_arch_wifi_connect_bssid_timeout_ms(const char *ssid, const uint8_t *bssid, const char *pw, uint32_t auth, uint32_t timeout_ms)
  5. int cyw43_arch_wifi_connect_async(const char *ssid, const char *pw, uint32_t auth)
  6. int cyw43_arch_wifi_connect_bssid_async(const char *ssid, const uint8_t *bssid, const char *pw, uint32_t auth)

Viewing and customizing the network interface

After having made the connection to a network, we are out of the lower-level pico_cyw43_arch library and up into the pico_lwip library. This library contains a number of different modules, but the module of relevance for viewing and customizing our network interface is NETIF. It is via this module that we can learn all the network information that the access point gave us upon connection: our IP, netmask, gateway, etc. In particular, all this information gets stored in a struct called netif_default. This struct has three fields: ip_attr_t ip_addr, ip_addr_t netmask, and ip_addr_t gw which represent our assigned IP, netmask, and gateway.

The SDK provides some macros for viewing and modifying these network settings. To print the fields of the global netif_default struct, we can use the following (taken from Fairhead and James:

  • printf("IP: %s\n", ip4addr_ntoa(netif_ip_addr(netif_default))) ;
  • printf("Mask: %s\n", ip4addr_ntoa(netif_ip_netmask4(netif_default))) ;
  • printf("Gateway: %s\n", ip4addr_ntoa(netif_ip_gw4(netif_default))) ;

But perhaps we want to assign our Pico W a fixed IP address! In order to do this, we could do the following (again borrowed from Fairhead and James):

// create an object of type ip_addr_t called ip
ip_addr_t ip ;
// Use an SDK macro to set the value of this ip object to our desired IP address
IP4_ADDR(&ip, 192, 168, 253, 210) ;
// Set the IP field of the netif_default struct to our desired IP address
netif_set_ipaddr(netif_default, &ip) ;
// Use an SDK macro to set the value of the ip object to our desired netmask
IP4_ADDR(&ip, 255, 255, 255, 0) ;
// Set the netmask field of the netif_default struct to our desired netmask
netif_set_netmask(netif_default, &ip) ;
// Use an SDK macro to set the value of this ip object to our desired gateway
IP4_ADDR(&ip, 192, 168, 253, 210) ;
// Set the gateway field of the netif_default struct to our desired gateway
netif_set_gw(netif_default, &ip)

A function for connecting to a network

We can consolidate all of the above steps into a single, reusable function. It probably makes sense to consolidate all of this into its own header file, perhaps called something like connect.h. Please note that this is a minimal example. You might imagine augmenting this such that the user can pass desired IP, gateway, and netmask values in as arguments. You might also use the async connection to provide a bit more feedback during the connection process.

#include <stdio.h>
#include "pico/stdlib.h"
#include "pico/cyw43_arch.h"

char ssid[] = "hostname" ;
char password[] = "password" ;
uint32_t country = CYW43_COUNTRY_USA ;
uint32_t auth = CYW43_AUTH_WPA2_MIXED_PSK ;


int connectWifi(uint32_t country, const char *ssid, const char *pass, uint32_t auth) {

    // Initialize the hardware
    if (cyw43_arch_init_with_country(country)) {
        printf("Failed to initialize hardware.\n") ;
        return 1 ;
    }

    // Make sure the LED is off
    cyw43_arch_gpio_put(CYW43_WL_GPIO_LED_PIN, 0) ;

    // Put the device into station mode
    cyw43_arch_enable_sta_mode() ;

    // Print a status message
    printf("Attempting connection . . . \n") ;

    // Connect to the network
    if (cyw43_arch_wifi_connect_blocking(ssid, pass, auth)) {
        return 2 ;
    }

    // Use the LED to indicate connection success
    cyw43_arch_gpio_put(CYW43_WL_GPIO_LED_PIN, 1) ;

    // Report the IP, netmask, and gateway that we've been assigned
    printf("IP: %s\n", ip4addr_ntoa(netif_ip_addr4(netif_default))) ;
    printf("Mask: %s\n", ip4addr_ntoa(netif_ip_netmask4(netif_default))) ;
    printf("Gateway: %s\n", ip4addr_ntoa(netif_ip_gw4(netif_default))) ;

    return 0 ;

}

CMake

We could now import the above header file into our application and use it to connect to a WiFi network. There's one last configuration that's required, however. The WiFi chip needs periodic attention, and we can give it that attention in a few different ways. If we choose to setup the system in polling mode, then it is our responsibility, in our program, to periodically call cyw43_arch_poll() (around every 50 ms). Alternatively, we could run the system in threadsafe background mode, which will automatically call the poll function in an interrupt. Finally, we could set this up in FreeRTOS mode, which incorporates lwip into the FreeRTOS infrastructure. We choose which of these three modes we'd like to use by linking the associated library in the project's CMakeLists.txt file. For the above file, the target_link_libraries line of the CMakeLists would look like the following if we wanted to use threadsafe background mode:

target_link_libraries(project_name pico_stdlib, pico_cyw43_arch_lwip_threadsafe_background)

If instead we wanted to poll, we'd link pico_cyw43_arch_lwip_poll. And if instead we wanted to use FreeRTOS, we'd use pico_cyw43_arch_lwip_sys_freertos.


Other odds and ends

Scanning networks

Perhaps we want to scan for available WiFi networks. The pico_cyw43_arch library includes a function which allows for us to discover available access points. That function looks like the following:

int cyw43_wifi_scan(cyw43_t *self, cyw43_wifi_scan_options_t *opts, void *env, int (*result_cb)(void *, const cyw43_ev_scan_result_t *))

The first argument is a pointer to a cyw43_t struct. During initalization, a global object of this type gets created, and is named cyw43_state. When we actually call this function, we'll pass a pointer to this global struct for this argument. The second argument, *opts, must be set to zero. The third argument gets NULL, and the final argument is a pointer to the scan result callback function. So, calling this function would look something like the following (from Fairhead and James):

cyw43_wifi_scan_options_t scan_options = {0} ;
int err = cyw43_wifi_scan(&cyw43_state, &scan_options, NULL, scan_result) ;

But what about that callback function, scan_result? This callback function should take two arguments. The first is a null pointer, and the second is a pointer to an object of type cyw43_ev_scan_result_t, as shown below:

static int scan_result(void *env, const cyw43_ev_scan_result_t *result)

That second argument is a pointer to the following struct:

typedef struct _cyw43_ev_scan_result_t {
    uint32_t _0[5];
    uint8_t bssid[6];   ///< access point mac address
    uint16_t _1[2];
    uint8_t ssid_len;   ///< length of wlan access point name
    uint8_t ssid[32];   ///< wlan access point name
    uint32_t _2[5];
    uint16_t channel;   ///< wifi channel
    uint16_t _3;
    uint8_t auth_mode;  ///< wifi auth mode \ref CYW43_AUTH_
    int16_t rssi;       ///< signal strength
} cyw43_ev_scan_result_t;

Our callback function could go through and print each field of the struct that it receives the pointer to as an argument to learn about each access point.

Getting signal strength

The Received Signal Strength Indicator (RSSI) provides a measure of an access point's signal strength. The pico_cyw43_arch library provides a mechanism for learning this value for the access point with which we've connected. The function for doing this is the following:

cyw43_wifi_get_rssi(&cyw43_state, &rssi)

The first argument is a pointer to the automatically-generated global struct of type cyw43_t that gets created during initialization. The second argument, &rssi, is simply a pointer to an int32_t. This function will dereferece this pointer and set the value at the pointer address to the RSSI of the connected access point.