Intro

Giving Wi-Fi connectivity to your Linux Embedded device can sometimes be cumbersome and costly. One of the most known, low-cost Wi-Fi-enabled microcontroller is the ESP32. A great project from Espressif to allow your ESP32 to be a communication co-processor is ESP-Hosted which comes in two flavours: ESP-Hosted-NG (New Generation) and ESP-Hosted-FG (First Generation). As you can see on the schematic down below, the ESP-Hosted-NG project contains two components: the ESP32 firmware and a driver that basically allows to make a bridge between the SDIO/SPI/UART interfaces and linux tools. Note that ESP-Hosted-FG works very similarly.

ESP-Hosted-NG

UART and SPI hardware connection between the ESP32 and the Linux host are able to transport IP packets, but as shown in the Feature Matrix, neither the ESP-Hosted-NG nor the ESP-Hosted-FG project provides Wi-Fi support if you only have UART connection available on your Linux Embedded device. Indeed, ESP-Hosted needs SPI to provide Wi-Fi support. We therefore need to add a protocol to transport IP over UART, and the best candidate is PPP. Let’s add PPP to the ESP32.

blog-article-1

This blog article aims to show you how to set up a solution such as just described. We will use an ESP32 devboard and a Raspberry Pi as depicted on the picture below. We will do this in three steps:

  1. We will setup a Wi-Fi network on the ESP32, this will allow you to have the first half of the path of the solution (from the client to the ESP32).
  2. We will then see how to run a PPP connection between the host processor and the ESP32, this will allow you to have the other part of the path.
  3. Finally, we will see how to connect those two half path together in order to be able to talk directly from the Wi-Fi client to the host processor.

This blog post will also contain code snipets with explanation. The entire code and project can be also found on Mind’s Gitlab.

blog-article-1

Wi-Fi Soft Access Point

A Wi-Fi Soft Access Point (Soft AP) is a software-enabled hotspot that allows a device to act as a wireless access point, sharing its connection with other devices.

Let’s run a Wi-Fi Soft AP on the ESP32 by using an example provided by Espressif. You need to install the ESP-IDF. This is quite straight forward, the documentation is on Expressif’s site.

In a nutshell, run the following commands. (The careful embedded SW developer would install the ESP-IDF in a container, but we skip the container in this blog post, for the sake of clarity.)

git clone -b v5.0.2 --recursive https://github.com/espressif/esp-idf.git ~/esp-idf
cd ~/esp-idf
./install.sh
. ./export.sh

Let’s now create a project based on the Wi-Fi Soft AP example

cp -rp ~/esp-idf/examples/examples/wifi/getting_started/softAP/ ~
cd ~/softap_sta

In this blog article, we will work with the target ESP32, which is the default, but let’s set it explicitly:

idf.py set-target esp32

You can now set the SSID and Password of your Wi-fi: run idf.py menuconfig and select the Example Configuration section.

Connect your ESP board via USB and run your freshly new code:

idf.py -p PORT flash monitor

You should now be able to connect a smartphone or laptop to your ESP32 via your newly created Wi-Fi Soft AP!

Now that the Wi-Fi Soft AP is in place, let’s change its netmask. We will explain why we change this in the IP Forwarding section! Let’s use a 255.255.255.128 netmask instead of 255.255.255.0.

We need to include additional header files to “main.c”:

#include "lwip/inet.h"
#include "lwip/netif.h"

and a new function:

struct netif* find_netif_from_esp_netif(esp_netif_t *esp_netif) {
    // Get the MAC address of the esp_netif
    uint8_t esp_mac_addr[6];
    esp_err_t ret = esp_netif_get_mac(esp_netif, esp_mac_addr);
    if (ret != ESP_OK) {
        ESP_LOGE(TAG, "Failed to get MAC address from esp_netif: %d", ret);
        return NULL;
    }

    // Iterate over the netif_list to find the matching netif structure
    struct netif *netif = netif_list;
    while (netif != NULL) {
        if (memcmp(netif->hwaddr, esp_mac_addr, sizeof(esp_mac_addr)) == 0) {
            return netif;
        }
        netif = netif->next;
    }
    return NULL;
}

and then apply some modification to the wifi_init_softap() function:

// esp_netif_create_default_wifi_ap(); becomes:
esp_netif_t *netif_ap = esp_netif_create_default_wifi_ap();

// add the following at the end of the function:
struct netif *lwip_netif = find_netif_from_esp_netif(netif_ap);
if (lwip_netif == NULL) {
    ESP_LOGE(TAG, "Failed to find lwIP netif for given esp_netif");
}
ip4_addr_t   	nm;
ip4addr_aton("255.255.255.128", &nm);
netif_set_netmask(lwip_netif, &nm);
ESP_LOGI(TAG, "lwip_netif itf netmask set to %s", ip4addr_ntoa(&nm));

Test your code again, you should still be able to connect to your Soft AP.

PPP

Like Ethernet, PPP (Point-to-Point Protocol) is a data link layer protocol (OSI Layer 2) that provides IP connectivity over direct point-to-point links. Initially created for dial-up connections, PPP encapsulates network layer protocols (OSI Layer 3) over serial links while providing error detection, authentication, and dynamic negotiation of link parameters. PPP also features robust security measures, including authentication methods such as PAP and CHAP, to verify users and secure data transmission. As PPP is a Point-to-Point Protocol, we will set it up on the ESP32 first, then on the host.

PPP needs to be enabled in the ESP-IDF configs, by enabling the CONFIG_LWIP_PPP_SUPPORT config. If you’re not used to this, there are multiple ways:

  • Either run idf.py menuconfig, search for LWIP_PPP_SUPPORT and set it to y. Save on exit. This will save it to the sdkconfig file. This file is aimed to be temporary, meaning that you should not include this later to a git project for example. To make the change permanent, run idf.py save-defconfig that will save this setting to sdkconfig.defaults.
  • Or edit sdkconfig.defaults directly, delete the sdkconfig file and rebuild your project.

Now that the CONFIG_LWIP_PPP_SUPPORT config is set, let’s go through this simple code that allows you to setup a PPP connection on your ESP32:

#include "netif/ppp/pppos.h"
#include "driver/uart.h"
#include "hal/uart_hal.h"
#include "freertos/event_groups.h"

#define UART_PORT_NUM           UART_NUM_2
#define UART_BAUD_RATE          1000000
#define UART_TX_PIN             (17)
#define UART_RX_PIN             (16)
#define UART_RX_BUFFER_SIZE     (1024 * 2)
#define UART_TX_BUFFER_SIZE     (1024 * 30)
#define UART_QUEUE_SIZE         100

static ppp_pcb *ppp;
static struct netif pppos_netif;
static QueueHandle_t uart_queue;

static void uart_event_task(void *pvParameters) {
    uart_event_t event;
    uint8_t *dtmp = (uint8_t *)malloc(UART_RX_BUFFER_SIZE);

    while (true) {
        if (xQueueReceive(uart_queue, (void * )&event, (TickType_t)portMAX_DELAY)) {
            bzero(dtmp, UART_RX_BUFFER_SIZE);
            switch (event.type) {
                case UART_DATA:
                    uart_read_bytes(UART_PORT_NUM, dtmp, event.size, portMAX_DELAY);
                    pppos_input_tcpip(ppp, dtmp, event.size);
                    break;
                default:
                    ESP_LOGI(TAG, "uart event type: %d - %d", event.type, uxQueueSpacesAvailable( uart_queue ) );
                    break;
            }
        }
    }

    free(dtmp);
    vTaskDelete(NULL);
}

static void
ppp_link_status_cb(ppp_pcb *pcb, int err_code, void *ctx)
{
    struct netif *pppif = ppp_netif(pcb);
    LWIP_UNUSED_ARG(ctx);

    switch(err_code) {
    case PPPERR_NONE:
        {

        ip4_addr_t   	nm;
        ip4addr_aton("255.255.255.128", &nm);
        netif_set_netmask(pppif, &nm);
        ESP_LOGI(TAG, "PPP itf netmask set to %s", ip4addr_ntoa(&nm));

        ESP_LOGI(TAG, "PPP link OK");

        fprintf(stderr, "ppp_link_status_cb: PPPERR_NONE\n\r");
#if LWIP_IPV4
        fprintf(stderr, "   our_ip4addr = %s\n\r", ip4addr_ntoa(netif_ip4_addr(pppif)));
        fprintf(stderr, "   gw_ipaddr  = %s\n\r", ip4addr_ntoa(netif_ip4_gw(pppif)));
        fprintf(stderr, "   netmask     = %s\n\r", ip4addr_ntoa(netif_ip4_netmask(pppif)));
#endif /* LWIP_IPV4 */
        }

        break;

    case PPPERR_PARAM:
        ESP_LOGE(TAG, "PPPERR_PARAM: Invalid parameter");
        break;
    case PPPERR_OPEN:
        ESP_LOGE(TAG, "PPPERR_OPEN: Unable to open PPP session");
        break;
    case PPPERR_DEVICE:
        ESP_LOGE(TAG, "PPPERR_DEVICE: Invalid I/O device for PPP");
        break;
    case PPPERR_ALLOC:
        ESP_LOGE(TAG, "PPPERR_ALLOC: Unable to allocate resources");
        break;
    case PPPERR_USER:
        ESP_LOGE(TAG, "PPPERR_USER: User interrupt");
        break;
    case PPPERR_CONNECT:
        ESP_LOGE(TAG, "PPPERR_CONNECT: Connection lost");
        break;
    case PPPERR_AUTHFAIL:
        ESP_LOGE(TAG, "PPPERR_AUTHFAIL: Failed authentication challenge");
        break;
    case PPPERR_PROTOCOL:
        ESP_LOGE(TAG, "PPPERR_PROTOCOL: Failed to meet protocol");
        break;
    case PPPERR_PEERDEAD:
        ESP_LOGE(TAG, "PPPERR_PEERDEAD: Connection timeout");
        break;
    case PPPERR_IDLETIMEOUT:
        ESP_LOGE(TAG, "PPPERR_IDLETIMEOUT: Idle Timeout");
        break;
    case PPPERR_CONNECTTIME:
        ESP_LOGE(TAG, "PPPERR_CONNECTTIME");
        break;
    case PPPERR_LOOPBACK:
        ESP_LOGE(TAG, "PPPERR_LOOPBACK");
        break;
    default:
        ESP_LOGE(TAG, "Unknown Error");
        break;
    }
}

static u32_t
ppp_output_cb(ppp_pcb *pcb, u8_t *data, u32_t len, void *ctx)
{
    LWIP_UNUSED_ARG(pcb);
    LWIP_UNUSED_ARG(ctx);
    return uart_write_bytes(UART_PORT_NUM, (const char *) data, len);
}

static void setup_uart()
{
    // Configure a UART interrupt threshold and timeout
    uart_intr_config_t uart_intr = {
        .intr_enable_mask = UART_INTR_RXFIFO_FULL | UART_INTR_RXFIFO_TOUT,
        .rxfifo_full_thresh = 100,
        .rx_timeout_thresh = 10,
    };

    const uart_config_t uart_config = {
        .baud_rate = UART_BAUD_RATE,
        .data_bits = UART_DATA_8_BITS,
        .parity    = UART_PARITY_DISABLE,
        .stop_bits = UART_STOP_BITS_1,
        .flow_ctrl = UART_HW_FLOWCTRL_DISABLE,
        .source_clk = UART_SCLK_DEFAULT,
    };
    uart_driver_install(UART_PORT_NUM, UART_RX_BUFFER_SIZE, UART_TX_BUFFER_SIZE, UART_QUEUE_SIZE, &uart_queue, 0);
    uart_param_config(UART_PORT_NUM, &uart_config);
    
    // Enable UART RX FIFO full threshold and timeout interrupts
    ESP_ERROR_CHECK(uart_intr_config(UART_PORT_NUM, &uart_intr));
    ESP_ERROR_CHECK(uart_enable_rx_intr(UART_PORT_NUM));

    uart_set_pin(UART_PORT_NUM, UART_TX_PIN, UART_RX_PIN, UART_PIN_NO_CHANGE, UART_PIN_NO_CHANGE);
}

static void setup_ppp()
{
    ppp = pppos_create(&pppos_netif, ppp_output_cb, ppp_link_status_cb, NULL);
    if (!ppp) {
        ESP_LOGE(TAG, "PPPOS example: Could not create PPP control interface");
        return;
    }
  
    ppp_set_default(ppp);
    ppp_set_silent(ppp, 1);
    ppp_connect(ppp, 0);

    ip4_addr_t   	nm;
    ip4addr_aton("255.255.255.128", &nm);
    netif_set_netmask(&pppos_netif, &nm);
    ESP_LOGI(TAG, "PPP itf netmask set to %s", ip4addr_ntoa(&nm));
}

void app_main(void)
{
    // ...
    
    setup_uart();
    setup_ppp();
    xTaskCreate(uart_event_task, "uart_event_task", 2048, NULL, 12, NULL);
    
    // ...
}

Here is how it works.

As we have seen in the PPP introduction, PPP handles a stream of serialised IP packets. In our case, we use a UART port as interface and set it up. Once the PPP connection is setup, we create a task that sends the data coming from the UART interface to the PPP driver by calling pppos_input_tcpip()..

Regarding the PPP setup, the most important call is the call to pppos_create() that allows us to specify two callbacks functions:

  • ppp_output_cb allows you to write to the UART.
  • ppp_link_status_cb allows you to see and handle the PPP status.

You may have noticed we set the UART interrupt threshold to 100. By default, the interrupt threshold is set to the size of the hardware UART buffer size (128). This can be an issue when we use high serial baudrate. Indeed, when the threshold is reached, data comes so fast, that new data is already arriving before the ESP has the time to copy the hardware UART buffer to RAM. Therefore, this data is discarded. Decreasing the UART interrupt threshold to a lower value, such as 100, resolves this issue.

You might have also noticed we change the netmask to 255.255.255.128, like on the Wi-FI interface: we will explain this in the IP Forwarding section.

Let’s test this setup using our Raspberry Pi.

pppd is the user-space program that implements the PPP protocol stack on Unix-like systems. It plays a key role in establishing, configuring, and maintaining point-to-point connections over serial interfaces. When pppd initiates a connection, it first negotiates the link parameters using the Link Control Protocol (LCP). LCP is responsible for setting up, configuring, and testing the data link, ensuring that both ends agree on essential parameters such as maximum frame size, authentication methods, and compression options. This phase is critical because it verifies that the physical connection is stable and capable of supporting data exchange. After the link is established, pppd moves on to the negotiation of network-layer protocols in order to configure an IP connection. pppd configuration is quite simple. You just need to pass the following options to pppd:

noauth
nocrtscts
xonxoff
passive
local
maxfail 0
nodetach
192.168.4.202:192.168.4.201
persist
proxyarp
debug
dump
netmask 255.255.255.128

The pppd options are sourced from multiple places. First, from the command-line arguments (e.g., pppd 1000000 /dev/ttyUSB0 noauth nocrtscts ...). Second, from an interface-specific file, such as /etc/pppd/options.ttyUSB0. Finally, options may also be taken from the generic options file at /etc/pppd/options.

It’s important that the options described here are passed without any additional settings from the generic file (/etc/pppd/options). A good solution is thus to replace /etc/pppd/options’s content by the options listed above.

There is an additional file to edit: /etc/ppp/ip-up. Indeed, PPP is a Point-to-Point protocol, but in our case, we want to be able to reach the Wi-Fi Client, we therefore need to change the netmask of the PPP connection by adding the following lines at the end of this file.

route del -net 192.168.4.202 gw 0.0.0.0 netmask 255.255.255.255 dev ppp0
ip route add 192.168.4.0/25 dev ppp0

By now, you should be able to run a PPP connection from your Raspberry Pi to the ESP32 through UART and you should see the following on your Raspberry Pi:

> root@RP3:/home/pi# pppd -d /dev/serial0 1000000
> pppd options in effect:
> debug debug		# (from /etc/ppp/options.serial0)
> -d -d		# (from command line)
> nodetach		# (from /etc/ppp/options.serial0)
> persist		# (from /etc/ppp/options.serial0)
> maxfail 0		# (from /etc/ppp/options.serial0)
> dump		# (from /etc/ppp/options.serial0)
> noauth		# (from /etc/ppp/options.serial0)
> /dev/serial0		# (from command line)
> 1000000		# (from command line)
> lock		# (from /etc/ppp/options)
> xonxoff		# (from /etc/ppp/options.serial0)
> local		# (from /etc/ppp/options.serial0)
> asyncmap a0000		# (from /etc/ppp/options)
> passive		# (from /etc/ppp/options.serial0)
> lcp-echo-failure 4		# (from /etc/ppp/options)
> lcp-echo-interval 30		# (from /etc/ppp/options)
> hide-password		# (from /etc/ppp/options)
> proxyarp		# (from /etc/ppp/options.serial0)
> netmask 255.255.255.128		# (from /etc/ppp/options.serial0)
> 192.168.4.202:192.168.4.201		# (from /etc/ppp/options.serial0)
> noipx		# (from /etc/ppp/options)
> using channel 7
> Using interface ppp0
> Connect: ppp0 <--> /dev/serial0
> sent [LCP ConfReq id=0x1 <asyncmap 0xa0000> <magic 0x1d2c8aed> <pcomp> <accomp>]
> rcvd [LCP ConfReq id=0x1 <asyncmap 0x0> <magic 0xb2d05ead> <pcomp> <accomp>]
> sent [LCP ConfAck id=0x1 <asyncmap 0x0> <magic 0xb2d05ead> <pcomp> <accomp>]
> rcvd [LCP ConfAck id=0x1 <asyncmap 0xa0000> <magic 0x1d2c8aed> <pcomp> <accomp>]
> sent [LCP EchoReq id=0x0 magic=0x1d2c8aed]
> sent [CCP ConfReq id=0x1 <deflate 15> <deflate(old#) 15> <bsd v1 15>]
> sent [IPCP ConfReq id=0x1 <compress VJ 0f 01> <addr 192.168.4.202>]
> sent [IPV6CP ConfReq id=0x1 <addr fe80::1081:274e:2818:cadb>]
> rcvd [IPCP ConfReq id=0x1 <compress VJ 0f 01> <addr 0.0.0.0>]
> sent [IPCP ConfNak id=0x1 <addr 192.168.4.201>]
> rcvd [IPV6CP ConfReq id=0x1 <addr fe80::45cb:e94e:5238:2d92>]
> sent [IPV6CP ConfAck id=0x1 <addr fe80::45cb:e94e:5238:2d92>]
> rcvd [LCP EchoRep id=0x0 magic=0xb2d05ead]
> rcvd [LCP ProtRej id=0x2 80 fd 01 01 00 0f 1a 04 78 00 18 04 78 00 15 03 2f]
> Protocol-Reject for 'Compression Control Protocol' (0x80fd) received
> rcvd [IPCP ConfAck id=0x1 <compress VJ 0f 01> <addr 192.168.4.202>]
> rcvd [IPV6CP ConfAck id=0x1 <addr fe80::1081:274e:2818:cadb>]
> local  LL address fe80::1081:274e:2818:cadb
> remote LL address fe80::45cb:e94e:5238:2d92
> Script /etc/ppp/ipv6-up started (pid 2474)
> rcvd [IPCP ConfReq id=0x2 <compress VJ 0f 01> <addr 192.168.4.201>]
> sent [IPCP ConfAck id=0x2 <compress VJ 0f 01> <addr 192.168.4.201>]
> Script /etc/ppp/ip-pre-up started (pid 2475)
> Script /etc/ppp/ip-pre-up finished (pid 2475), status = 0x0
> Cannot determine ethernet address for proxy ARP
> local  IP address 192.168.4.202
> remote IP address 192.168.4.201
> Script /etc/ppp/ip-up started (pid 2479)
> Script /etc/ppp/ipv6-up finished (pid 2474), status = 0x0
> Script /etc/ppp/ip-up finished (pid 2479), status = 0x0

And you should see this output on ESP32:

> ppp_link_status_cb: PPPERR_NONE
>    our_ip4addr = 192.168.4.201
>    his_ipaddr  = 192.168.4.202
>       netmask     = 255.255.255.128

Note that you could test the PPP connection also from a computer using USB-to-serial adapter. In this case, make sure the adapter supports 1000000 baudrates or lower the baudrate on the ESP and the PPP in case of doubts.

IP forwarding

This is nice, we can now connect to the Wi-Fi Access Point, and we can connect to the PPP interface. However, how do we connect the two together? For now, if you try to ping the PPP interface from the Wi-Fi client, it will fail… This section explains how to connect the two ends together, with the use of…as you would have guessed by the title… IP Forwarding.

IP forwarding is the process by which a router or a device with multiple network interfaces transfers an IP packet from one network to another. It ensures that data packets reach the correct destination even if that destination is on a different network segment.

As you have noticed, in the Wi-Fi and in the PPP section, we used a /25 (255.255.255.128) subnet. The first Wi-Fi client IP is 192.1658.4.2 whereas the PPP client’s address is 192.168.4.202. They are therefore not on the same network! This is why we need to enable IP Forwarding such that packets from one subnet are able to go to the other.

Enabling IP Forwarding is straightforward: enable option CONFIG_LWIP_IP_FORWARD in the ESP-IDF config

GARP

I thought that just enabling IP forwarding would be sufficient to be able to ping from both sides, but that still does not work!

The reason can be found by sniffing the Wi-Fi interface of the client with Wireshark. You can notice on the first line that the Wi-Fi client receives the Echo request but doesn’t response. The second line depicts the Wi-Fi client asking for the MAC Address of 192.168.4.202 (Raspberry Pi IP address) but does not get any answer… The Wi-Fi client therefore can’t create correctly the Ethernet frame as it can’t set the destination MAC address. And this is the issue which can be resolved by… GARP! Who is this GARP ? Well GARP is Gratuitous ARP.

arp-wireshark

On top of many link layer protocols like Ethernet, we need the ARP protocol to let the IP stack know the target MAC addresses of IP packets. On a PPP link, ARP packets are not necessary because PPP is a point-to-point protocol, there is no need to resolve MAC addresses. Each end of the PPP link knows the other end directly, and there are no intermediary devices that require address resolution. PPP encapsulates network layer protocols directly.

But on the Wi-Fi side, ARP packets are necessary because from the Wi-Fi client point of view, packets leaving the interface should have a destination MAC address. Indeed, the standard requires all packets to have both a source MAC address and a destination MAC address. In our specific case, the destination MAC address is not necessary, as anyway it would be removed when going through the PPP tunnel. But the Wi-Fi client does not know that! Therefore, the Wi-Fi client wants to put a destination MAC address that it can not find unless we provide it one. Of course, the MAC Address doesn’t need to be the real MAC address of a real NIC, as PPP link doesn’t use MAC addresses. This can be manually set via the following command:

$ sudo ip neighbour replace to 192.168.4.202 lladdr b8:27:eb:31:34:fe dev <eth_itf> nud permanent

This would not be very practical however, as the client would need to do a manual operation… Hence, we prefer sending frequent Gratuitous ARP (GARP) packets with an arbitrary MAC address to the Client. For that purpose, we can use an already existing function in ESP-IDF: etharp_raw(), which allows sending raw ARP packets. Unfortunately, etharp_raw() is a static function that we can’t call. So we can either modify the ESP-IDF code to export this function, or we can copy-paste it in our source which is the preferred option here. We create a send_arp_task() task that calls etharp_raw() with the correct parameters every second.

#include "lwip/etharp.h"
#include "netif/etharp.h"
#include "lwip/prot/iana.h"

#define PPP_HOST_IP           LWIP_MAKEU32(192, 168, 4, 202)

/**
 * Send a raw ARP packet (opcode and all addresses can be modified)
 *
 * @param netif the lwip network interface on which to send the ARP packet
 * @param ethsrc_addr the source MAC address for the ethernet header
 * @param ethdst_addr the destination MAC address for the ethernet header
 * @param hwsrc_addr the source MAC address for the ARP protocol header
 * @param ipsrc_addr the source IP address for the ARP protocol header
 * @param hwdst_addr the destination MAC address for the ARP protocol header
 * @param ipdst_addr the destination IP address for the ARP protocol header
 * @param opcode the type of the ARP packet
 * @return ERR_OK if the ARP packet has been sent
 *         ERR_MEM if the ARP packet couldn't be allocated
 *         any other err_t on failure
 */
static err_t
etharp_raw(struct netif *netif, const struct eth_addr *ethsrc_addr,
           const struct eth_addr *ethdst_addr,
           const struct eth_addr *hwsrc_addr, const ip4_addr_t *ipsrc_addr,
           const struct eth_addr *hwdst_addr, const ip4_addr_t *ipdst_addr,
           const u16_t opcode)
{
  struct pbuf *p;
  err_t result = ERR_OK;
  struct etharp_hdr *hdr;

  LWIP_ASSERT("netif != NULL", netif != NULL);

  /* allocate a pbuf for the outgoing ARP request packet */
  p = pbuf_alloc(PBUF_LINK, SIZEOF_ETHARP_HDR, PBUF_RAM);
  /* could allocate a pbuf for an ARP request? */
  if (p == NULL) {
    LWIP_DEBUGF(ETHARP_DEBUG | LWIP_DBG_TRACE | LWIP_DBG_LEVEL_SERIOUS,
                ("etharp_raw: could not allocate pbuf for ARP request.\n"));
    ETHARP_STATS_INC(etharp.memerr);
    return ERR_MEM;
  }
  LWIP_ASSERT("check that first pbuf can hold struct etharp_hdr",
              (p->len >= SIZEOF_ETHARP_HDR));

  hdr = (struct etharp_hdr *)p->payload;
  LWIP_DEBUGF(ETHARP_DEBUG | LWIP_DBG_TRACE, ("etharp_raw: sending raw ARP packet.\n"));
  hdr->opcode = lwip_htons(opcode);

  LWIP_ASSERT("netif->hwaddr_len must be the same as ETH_HWADDR_LEN for etharp!",
              (netif->hwaddr_len == ETH_HWADDR_LEN));

  /* Write the ARP MAC-Addresses */
  SMEMCPY(&hdr->shwaddr, hwsrc_addr, ETH_HWADDR_LEN);
  SMEMCPY(&hdr->dhwaddr, hwdst_addr, ETH_HWADDR_LEN);
  /* Copy struct ip4_addr_wordaligned to aligned ip4_addr, to support compilers without
   * structure packing. */
  IPADDR_WORDALIGNED_COPY_FROM_IP4_ADDR_T(&hdr->sipaddr, ipsrc_addr);
  IPADDR_WORDALIGNED_COPY_FROM_IP4_ADDR_T(&hdr->dipaddr, ipdst_addr);

  hdr->hwtype = PP_HTONS(LWIP_IANA_HWTYPE_ETHERNET);
  hdr->proto = PP_HTONS(ETHTYPE_IP);
  /* set hwlen and protolen */
  hdr->hwlen = ETH_HWADDR_LEN;
  hdr->protolen = sizeof(ip4_addr_t);

  /* send ARP query */
#if LWIP_AUTOIP
  /* If we are using Link-Local, all ARP packets that contain a Link-Local
   * 'sender IP address' MUST be sent using link-layer broadcast instead of
   * link-layer unicast. (See RFC3927 Section 2.5, last paragraph) */
  if (ip4_addr_islinklocal(ipsrc_addr)) {
    ethernet_output(netif, p, ethsrc_addr, &ethbroadcast, ETHTYPE_ARP);
  } else
#endif /* LWIP_AUTOIP */
  {
    ethernet_output(netif, p, ethsrc_addr, ethdst_addr, ETHTYPE_ARP);
  }

  ETHARP_STATS_INC(etharp.xmit);
  /* free ARP query packet */
  pbuf_free(p);
  p = NULL;
  /* could not allocate pbuf for ARP request */

  return result;
}

static void send_arp_task(void *pvParameters) {
    esp_netif_t *netif = pvParameters;
    bool setup_success = false;

    const struct eth_addr broadcast_eth_addr = { .addr = {0xff, 0xff, 0xff, 0xff, 0xff, 0xff} };

    // MAC address of the host
    const struct eth_addr rpi_eth_addr = { .addr = {(esp_random() & 0xF0) | 0x02, 
                                                        esp_random(), 
                                                        esp_random(),
                                                        esp_random(),
                                                        esp_random(),
                                                        esp_random()} };

    uint8_t mac_addr[6];
    struct eth_addr *src_eth_addr;

    const ip4_addr_t ip_addr = { .addr = PP_HTONL(PPP_HOST_IP) };
    const ip4_addr_t ip_addr_dst = { .addr = PP_HTONL(LWIP_MAKEU32(192, 168, 4, 255)) };

    struct netif *lwip_netif;
    
    do {
        // Get MAC address of Wi-Fi interface
        lwip_netif = find_netif_from_esp_netif(netif);
        if (lwip_netif == NULL) {
            ESP_LOGE("GARP", "Failed to find lwIP netif for given esp_netif");
            vTaskDelay(1000 / portTICK_PERIOD_MS);
            continue;
        }
        
        esp_err_t ret = esp_netif_get_mac(netif, mac_addr);
        if (ret != ESP_OK) {
            ESP_LOGE("GARP", "Failed to get MAC address: %d", ret);
            vTaskDelay(1000 / portTICK_PERIOD_MS);
            continue;
        }
        src_eth_addr = (struct eth_addr *)mac_addr;
        
        setup_success = true;
    } while(!setup_success);

    while (true) {
        ESP_LOGI("GARP", "Sending ARP");

        etharp_raw(lwip_netif, 
                    src_eth_addr,
                    &broadcast_eth_addr, 
                    &rpi_eth_addr, 
                    &ip_addr, 
                    &broadcast_eth_addr, 
                    &ip_addr_dst,
                    ARP_REPLY );

        vTaskDelay(1000 / portTICK_PERIOD_MS);
    }
}

This section of code creates a task that sends a GARP every second. In order to run this task, add the following line such that GARP are sent when Wi-Fi connection is established.

xTaskCreate(send_arp_task, "send_arp_task", 2048*2, netif_ap, 12, NULL);

Tests and Improvements

Tests

The speed of the connection will highly depend on the UART speed. Most USB-to-serial adapter do not go over 1Mbps. So we will limit the speed to that baudrate here. Raspberry Pi UART can go up to 1Mbps without changing the setup. Requesting higher baud rates implies changing clock settings which is not the goal of this blog post. Therefore, tests will be done using 1000000 baudrate UART speed.

$ wget http://192.168.4.202/random100K.bin
--2025-02-24 10:14:38--  http://192.168.4.202/random100K.bin
Connecting to 192.168.4.202:80... connected.
HTTP request sent, awaiting response... 200 OK
Length: 102400 (100K) [application/octet-stream]
Saving to: ‘random100K.bin’

random100K.bin.3              100%[================================================>] 100.00K  92.4KB/s    in 1.1s    

2025-02-24 10:14:39 (92.4 KB/s) - ‘random100K.bin’ saved [102400/102400]

$ wget http://192.168.4.202/random10M.bin
--2025-02-24 10:14:41--  http://192.168.4.202/random10M.bin
Connecting to 192.168.4.202:80... connected.
HTTP request sent, awaiting response... 200 OK
Length: 10240000 (9.8M) [application/octet-stream]
Saving to: ‘random10M.bin’

random10M.bin.1               100%[================================================>]   9.77M  92.3KB/s    in 1m 51s  

2025-02-24 10:16:33 (89.7 KB/s) - ‘random10M.bin’ saved [10240000/10240000]

The ESP32 can achieve 5Mbps speed, so theoretically, speed could be multiplied by 5 giving a maxium transfer speed of 450KB/s

Conclusion

In conclusion, we’ve seen how to transform the ESP32 into a versatile Wi-Fi co-processor, when the main processor is limited by a single UART interface. By starting with a Soft AP to handle initial connectivity, progressing through establishing a robust PPP link, and fine-tuning the network with IP forwarding and GARP, we’ve pieced together a cost-effective and modular solution that bridges the gap between your embedded devices and Wi-Fi clients. This approach solves immediate connectivity challenge but also opens the door for further enhancements and customizations in your projects.