This blog talks about what OFDMA is, how it applies to Wi-Fi, how we can trigger it, and how we can capture it.

OFDMA and Wi-Fi

Prior to Wi-Fi 6, otherwise known as 802.11ax, the Wi-Fi used Orthogonal Frequency Division Multiplexing (OFDM) to allow different devices to take turns transmitting on the same channel for a given Wi-Fi network for a fixed period of time.  Other Wi-Fi networks would typically use other channels, but for a single Wi-Fi network, only one channel is used by one device at a time. 

With 802.11ax, Orthogonal Frequency Division Multiple Access (OFDMA) was introduced.  This allows devices to transmit at the same time on different subcarriers of the Wi-Fi channel.  This allows more efficient use of the wireless spectrum, which is why the packet type that utilizes OFDMA are referred to as High Efficiency (HE).  The figure below illustrates the difference between OFDM and OFDMA.

The above image came from https://blogs.cisco.com/networking/wi-fi-6-ofdma-resource-unit-ru-allocations-and-mappings. This site is also a good reference for how OFDMA works in Wi-Fi.

To enable OFDMA, the 802.11ax introduced two key things. The first thing it introduced was four new High Efficiency (HE) Physical-layer Protocol Data Unit (PPDU) formats. Prior to 802.11ax there were a total of 4 so this addition doubles the total to eight. Each of these are listed below. However, for the purposes of OFDMA, our interest is with HE MU PPDU and HE TB PPDU.

  • HE SU PPDU – single user
  • HE MU PPDU – multiple user
  • HE ER SU PPDU – extended range single user
  • HE TB PPDU – trigger based

The second thing 802.11ax introduced was trigger frames. Trigger frames are a MAC layer frame format that the Access Point (AP) transmits to other devices on the network. There are 8 different trigger types in the 802.11ax standard, which are listed below. However, for the purposes of OFDMA, our interest is with the MU-RTS and Basic triggers.

  • 0 – Basic
  • 1 – Beamforming Report Poll (BRP)
  • 2 – Multi-User Block ACK Request (MU-BAR)
  • 3 – Multi-User Request To Send (MU-RTS)
  • 4 – Buffer Status Report Poll (BSRP)
  • 5 – Group Cast Retries (GCR MU-BAR)
  • 6 – Bandwidth Query Report Poll (BQRP)
  • 7 – Null Data PPDU (NDP) Feedback Report Poll (NFRP)
  • 8-15 – Reserved

Trigger frames can either specify that the AP wants to transmit data to multiple devices simultaneously (DL OFDMA), or they can specify that the AP wants multiple devices to transmit data to the AP simultaneously (UL OFDMA).  Each trigger frame specifies some parameters that are common to all devices on the network as well as a set of parameters that are specific to each device.  One of the common parameters is the length of packet that each device must transmit.  If the device has less data than the length specified by the AP, it must add padding bytes.  Parameters specific to each device include the subcarrier to transmit on (referred to as a Resource Unit, or RU). 

When an AP wants to send data to multiple stations, it does the following:

  1. AP broadcasts a MU-RTS (Multi User Request To Send) trigger frame to multiple devices. Note this is not one of the new HE PPDUs but a legacy PPDU format.
  2. Each device responds with a CTS (Clear To Send) packet. This is also a legacy PPDU format.
  3. AP sends HE MU PPDU packet(s) to each device simultaneously.
  4. Each device acknowledges with an HE TB PPDU packet.

See below snippet from the 802.11ax standard.

When an AP wants multiple stations to send data to it, it does the following:

  1. AP broadcasts a MU-RTS (Multi User Request To Send) trigger frame to multiple devices. Note this is not one of the new HE PPDUs but a legacy PPDU format.
  2. Each device responds with a CTS (Clear To Send) packet. This is also a legacy PPDU format.
  3. AP broadcasts a basic trigger packet, destined for the relevant devices. This contains User Info fields for each station as to what RU to use.
  4. Each device sends HE TB PPDU packet(s) with the data.
  5. The AP broadcasts a block acknowledge, destined for the relevant devices

See below snippet from the 802.11ax standard.

Generating Trigger Frames for OFDMA

Now that we have the basics of OFDMA laid out, let’s move on to how to get an AP to generate trigger frames for OFDMA. Note that this describes the method I used to generate trigger frames for OFDMA and is by no means the only method or only AP that can do the job. Also, I have to give credit to konikofi, who’s blog got me a great start on this. See https://konikofi.wordpress.com/2021/03/21/chasing-trigger-frames/

Materials needed

  • Asus GT-AX16000 AP
  • Laptop for packet capture and AP control
    • MacBook Pro (Windows won’t capture the packets correctly, Linux should work though)
    • Ethernet cable to connect AP to MacBook
    • USB-C to Ethernet adapter (since MacBook does not come with an Ethernet port)
  • Raspberry Pi setups (quantity of 5 is good, but 3 will do)
    • Raspberry Pi 5 board
    • Wifi 6/802.11ax USB adapter (because the WiFi built into Raspberry Pi 5 is 802.11ac and we need 802.11ax) – I used BrosTrend WiFi 6 AX1800Mbps, but others should work as well
    • SD card (32GB is enough)
    • USB-C power cable
    • USB-C power source
  • Device(s) under test

Setup the test network

A diagram of the test network is shown below. In this test network, the AP is connected to the Raspberry Pis, a MacBook, and the device under test (DUT).  The Raspberry Pis are used to generate the traffic necessary to force the AP to transmit the trigger frames that cause the TB PPDUs that we need to test.  The MacBook serves two purposes.  The first is to capture Wi-Fi packets via Wireshark, which require the laptop not be connected to the Wi-Fi network due to restrictions imposed by capture mode on Mac OS.  The second purpose is to control the AP and use it to initiate traffic from the Raspberry Pis as well as the DUT.

To connect and configure this test network, follow the steps below.

  1. Connect the MacBook to the AP with the Ethernet cable and USB to Ethernet adapter.
  2. Configure the AP
    1. In the MacBook, point your browser to asusrouter.com or the AP’s IP address to configure it.
    2. Turn off smart connect and set a separate SSID for each band (2.4GHz, 5GHz-1, 5GHz-2, 6GHz) so you can control which band devices connect to (we want them all on the same band)
    3. Set the AP IP address to 192.168.50.1
    4. Enable ssh on the AP
    5. Either set the AP control channel to a fixed channel number or take note of what it auto-selected to
  3. Configure and connect each of the Raspberry Pis. Note that the integrated WiFi adapter will be used to connect to an internet connected WiFi while the USB WiFi 6 adapter will be used to connect to the test network.
    1. Create an OS image on a microSD card using Raspberry Pi Imager.
      1. Before starting, click edit settings
      2. Under general, set a unique hostname for the board
      3. Under general, set a username and password to log into the board.
      4. Under general, enter the WiFi SSID and password to your local WiFi with Internet access.
      5. Under general, set the Wireless LAN country to US (or wherever you live)
      6. Under services, make sure SSH is enabled. I set it to password authentication, but you could use public key authentication if you want.
      7. Most of these settings should be saved so you don’t have to retype them each time, but you may need to re-enter the WiFi password each time. You also need to change the hostname for each board.
    2. Put the microSD card in the Raspberry Pi board and power it up by connecting it to a USB-C power source
    3. After giving the board a minute or two to power up, ssh into it with the following command on Mac or Windows:
      1. ssh admin@s7rpi04
      2. replace s7rpi04 with whatever hostname you specified and replace admin with whatever username you specified
      3. You will be prompted to enter a password. Enter the one you set in Raspberry Pi Imager
    4. Update packages by executing the following commands:
      1. sudo apt upgrade
      2. sudo reboot
      3. the first command will take a few minutes
    5. The reboot command will cause the ssh session to disconnect so reconnect as described in step 3.
    6. Connect the WiFi 6 adapter into one of the USB 3 ports
      1. Execute the lsusb command to list all devices connected via USB. You should see the WiFi adapter listed. If not, try reconnecting it.
    7. Install the driver for the WiFi adapter by executing the following command
      1. sh -c 'wget linux.brostrend.com/install -O /tmp/install && sh /tmp/install'
      2. This command will take a few minutes and appear to stall at times. Give it time.
      3. Verify this step worked by executing the iwconfig command and checking for wlan1. Try this a few times as it sometimes takes a couple of minutes to come up.
      4. If wlan1 doesn’t ever show up in iwconfig, restart this step to re-install the drivers. I had to redo this step for 2 of the 5 devices.
    8. Connect the new WiFi adapter to the test network by entering the following command
      1. sudo nmcli device wifi connect S7-test-net password S7-test-net-pwd ifname wlan1
      2. Replace S7-test-net with the SSID you assigned the AP and S7-test-net-pwd with the password
      3. Make sure the Raspberry Pi connects to the AP. You can do this with the ifconfig command on the Raspberry Pi or check the network map page of the asusrouter configuration page.
    9. Install iperf3 on Raspberry Pi with this command:
      1. sudo apt install iperf3
      2. when it asks if you want to run iperf3 automatically as a daemon, say YES
      3. if you accidentally hit no, you can set it to yet by executing: sudo dpkg-reconfigure iperf3
    10. Repeat all of these steps for each additional Raspberry Pi
  4. Configure the MacBook
    • Install Wireshark on the MacBook
    • Leave WiFi enabled but disconnected from WiFi. This is required so that we can do Wireshark captures on Wifi.
  5. Do not connect the DUT to the network yet. Wait until the “Connect the DUT and Capture the Association ID and IP of the DUT” section for that.

Install Scripts on the AP

You’ll need a few scripts on the AP. They are listed below by name but see the appendix for the contents of each script. Install each of the following scripts on the AP.

  • run-wl
  • run-test-ping
  • run-test-iperf

To install scripts on the AP, follow these steps.

  1. From the MacBook, SSH into the AP, for example: ssh -p 2222 admin@192.168.50.1
  2. Use the vi text editor to edit/create the script (type vi run-wl, for example)
  3. Copy the script from the appendix to vi
  4. Exit vi
  5. Make the file an executable script (for example, chmod +x run-wl)
  6. If you are not familiar with how to use vi, see https://www.cs.colostate.edu/helpdocs/vi.html

You will use either run-test-ping or run-test-iperf, depending on whether you can use pings or iperf to generate traffic on your DUT. So you may not bother create both of those, but it might be useful to have them.

An important note about scripts on the AP: these scripts will be deleted when you power cycle the AP. After each power cycle, you’ll have to re-create them.

Install Script on the MacBook

The only script you need on the MacBook is one to change the WiFi channel that you want to capture traffic on. If your Mac OS is older than Oonoma 14.4, you can use the built in airport command. It is not in the default path so you’ll need to add it or create a link to it or specify the full path when you run it. The full path is /System/Library/PrivateFrameworks/Apple80211.framework/Versions/A/Resources/airport

To use airport to change channels, simply enter this command: airport -c40 where 40 is the channel you are changing to.

If you are using Mac OS Sonoma 14.4 or later, airport is deprecated and you’ll need a new script. There are probably several ways to implement this but I cobbled a script together based on what I found on the web. I used a python script (see appendix).

Connect the DUT and Capture it’s Association ID and IP address

Follow these steps to find the Association ID (AID) and the IP address of the DUT. You will need both of these to continue testing.

  1. Verify that all the raspberry pis are connected to the same band by going to the AP’s network map page.
  2. Go to the AP’s wireless page to see what the WiFi channel is for the band the raspberry pis are connected to.
  3. Use the Mac’s airport command or the set-channels.py script to set the Wifi channel to the channel that the raspberry pis are connected to
  4. Start a Wireshark capture with the WiFi interface in monitor mode (this is a checkbox in the capture options window).
  5. Connect the DUT to the test network on the same band as the raspberry pis
  6. Once the DUT is connected, stop the Wireshark capture.
  7. Use this Wireshark filter (wlan.fc.type_subtype == 1) to find the Association Response packet for the TV.
  8. Find the association ID by going to “IEEE 802.11 Wireless Management”->”Fixed parameters”->”Association ID”.
  9. Find the IP address of the DUT. This can typically be done with the DUT itself or can be done by going to the network map in the AP web page.

Capture traffic during the test

Traffic is generated from the Raspberry Pis using the iperf3 utility, with the AP running the client (controlled by the MacBook) and the server running on the Raspberry Pis. 

  1. SSH into the AP from the MacBook
    • Run the run-wl command.
    • Note the ofdma column this script outputs.
  2. Open another SSH session to the AP from the MacBook.
    • You will use this to run the run-test-* scripts
    • Get ready by typing either run-test-iperf <DUT-IP-ADDR> or run-test-ping <DUT-IP-ADDR> but don’t hit enter yet
    • Pings work on most devices but won’t generate as many OFDMA triggers as iperf
  3. Start a Wireshark capture on the MacBook. Again, make sure monitor mode is enabled in capture options.
  4. Immediately go to the second SSH window and hit enter to execute whichever command you typed.
    • The iperf and ping sessions will run in the background and send their output is sent to the test-out directory so you won’t see anything after running the script.
    • You can check on the progress of the script by entering this command: tail -f test-out/iperf-S7rpi04.txt
  5. After 60 seconds for the test to run, stop the Wireshark capture.

Post-process the Wireshark capture

  1. Save the Wireshark capture in case it crashes (and it does crash)
  2. Use this filter to see all the trigger packets intended for the DUT: (wlan.fc == 0x2400) && (wlan.trigger.he.trigger_type == 0) && (wlan.trigger.he.user_info.aid12 == 0x8)
  3. Replace the 0x8 at the end of the filter with the AID of the DUT found earlier.
  4. See screenshot below for what these packets look like. Note the user info fields, with 3 shown in this example. The RU allocation from the AP is given here.
  5. Not that while you can capture triggers, you can’t capture the actual OFDMA TB PPDUs. That is because they are on sub-channels of the channel that Wireshark and the WiFi adapter are listening on, not the full channel.

Appendix of Scripts

run-wl

Below is the run-wl script for the AP. This is used to monitor how much traffic each connected device is transferring and how much of it is in OFDMA, which is why this is useful.

while true; do
	wl -i eth7 bs_data
	sleep 1
done

Note that eth7 is the adapter for the 5GHz-1 band on the AP. If you use a different band, you’ll need to replace that with whichever one you use. Some trial and error can figure it out.

The output will look something like below, with it updating every second.

Station Address   PHY Mbps  Data Mbps    Air Use   Data Use    Retries    bw   mcs   Nss   ofdma mu-mimo
xx:xx:xx:38:18:AA     1134.2        0.0       0.4%       0.0%       0.0%    80    11     2  100.0%    0.0%
xx:xx:xx:A4:50:FD      873.9       38.6       9.4%      40.5%      46.7%  79.9   8.3   2.0    0.0%    0.0%
xx:xx:xx:A8:4B:35      730.1       32.8       8.9%      34.4%      41.8%  79.6   7.1   2.0    0.0%    0.0%
xx:xx:xxB4:5F:F5     1134.2        0.0       0.0%       0.0%       0.0%    80    11     2    0.0%    0.0%
xx:xx:xx:B8:61:8D      520.4       23.9       5.9%      25.1%       6.5%    80   9.8     1    0.0%    0.0%
xx:xx:xx:A8:4C:35      680.5        0.0       0.0%       0.0%       0.0%    80     7     2    0.0%    0.0%
        (overall)          -       95.3      24.6%         -         -

run-test-iperf

Below is the run-test-iperf script for the AP. This is used to generate traffic from the Raspberry Pis as well as the DUT, all using iperf.

#!/bin/bash
dps=250
pps=300
tt=60
npi=5
if [ $# -lt 1 ]; then
  echo "    Usage: $0 DUT_IP_addr [test time in seconds] [Pi payload size] [DUT payload size] [num Pis]"
  echo "           test time defaults to $tt seconds"
  echo "           pi payload size defaults to $pps bytes"
  echo "           DUT payload size defaults to $dps bytes"
  echo "           number of Pis defaults to $npi"
  exit 2
fi
if [ $# -ge 2 ]; then
  tt=$2
fi
if [ $# -ge 3 ]; then
  pps=$3
fi
if [ $# -ge 4 ]; then
  dps=$4
fi
if [ $# -ge 5 ]; then
  npi=$5
fi
echo "running $0 $1 time=$tt pi_size=$pps dut_size=$dps num_pis=$npi"
mkdir -p test-out

iperf3 -c $1-l $dps -R -u -b 0 -t $tt > test-out/iperf-dut.txt &

pi=4
last_pi=$(( $pi + $npi - 1 ))
while [ $pi -le $last_pi ]; do
  iperf3 -c S7rpi0$pi -l $pps -R -u -b 0 -t $tt > test-out/iperf-S7rpi0$pi.txt &
  pi=$(( $pi + 1 ))
done

Note that this assumes hostnames of S7rpi04 – S7rpi08 for the raspberry pis. Change the last while loop to handle whatever naming convention you used for your hostnames. Also note that the -R option on the iperf command means Reverse, so traffic will be sent from the raspberry pis to the AP, generating UL OFDMA. To generate DL OFDMA, simply remove the -R option.

This script requires you specify the IP address of the DUT, but other than that, there are optional parameters for the test run time, the iperf payload size for the raspberry pis, iperf payload size for the DUT, and the number of raspberry pis to use. The defaults tend to work well for most devices.

run-test-ping

Below is the run-test-ping script for the AP. This is used to generate traffic from the Raspberry Pis as well using iperf and for the DUT using ping.

#!/bin/bash
dps=250
pps=300
tt=60
npi=5
pings=100            
if [ $# -lt 1 ]; then                                                                                   
  echo "    Usage: $0 DUT_IP_addr [test time in seconds] [Pi payload size] [DUT payload size] [num Pis] [num pings]"
  echo "           test time defaults to $tt seconds"     
  echo "           pi payload size defaults to $pps bytes" 
  echo "           DUT payload size defaults to $dps bytes"
  echo "           number of Pis defaults to $npi"
  echo "           number of pings defaults to $pings"
  exit 2             
fi                   
if [ $# -ge 2 ]; then
  tt=$2              
fi                   
if [ $# -ge 3 ]; then
  pps=$3             
fi                   
if [ $# -ge 4 ]; then
  dps=$4             
fi                   
if [ $# -ge 5 ]; then
  npi=$5                                                             
fi                                                                   
if [ $# -ge 6 ]; then                                                                                                       
  pings=$6                                                                                                                  
fi                                                                                                                          
echo "running $0 $1 time=$tt pi_size=$pps dut_size=$dps num_pis=$npi num_pings=$pings"                                      
mkdir -p test-out
                                                                                                          
i=1                                                                                                                        
while [ $i -le $pings ]; do                                                                                                
  ping -c $tt -s $dps $1 > test-out/ping-dut-$i.txt &                                                                      
  i=$(( $i + 1 ))                                                                
done                                                                             

pi=4                                                                            
last_pi=$(( $pi + $npi - 1 ))                                                   
while [ $pi -le $last_pi ]; do                                                  
  iperf3 -c S7rpi0$pi -l $pps -R -u -b 0 -t $tt > test-out/iperf-S7rpi0$pi.txt &
  pi=$(( $pi + 1 ))                                                             
done

Note that this assumes hostnames of S7rpi04 – S7rpi08 for the raspberry pis. Change the last while loop to handle whatever naming convention you used for your hostnames. Also note that the -R option on the iperf command means Reverse, so traffic will be sent from the raspberry pis to the AP, generating UL OFDMA. To generate DL OFDMA, simply remove the -R option.

This script requires you specify the IP address of the DUT, but other than that, there are optional parameters for the test run time, the iperf payload size for the raspberry pis, ping payload size for the DUT, the number of raspberry pis to use, and the number of pings per second to send to the DUT. The defaults tend to work well for most devices, but sometimes more pings are required.

set-channel.py

This is only needed if you are using Mac OS Sonoma 14.4 or later. Anything before that and you can use the built in Mac OS airport command. There are probably several ways to implement this but this is what I cobbled it together based on what I found on the web. This is a python script, and the following two packages must be installed for the script to work.

pip3 install pyobjc-framework-CoreWLAN 
pip3 install -U pyobjc

And here is the script. Note that it is hard coded to 5GHz band, 80MHz width. That can obviously be changed easily if needed.

#!/usr/bin/env pythonw

import objc
import CoreWLAN
import sys

def main():
	print('Initializing')

	# see https://clburlison.com/macos-wifi-scanning/
	bundle_path = '/System/Library/Frameworks/CoreWLAN.framework'
	objc.loadBundle('CoreWLAN',
                bundle_path=bundle_path,
                module_globals=globals())

	iface = CWInterface.interface()
	chan = iface.wlanChannel()
	print("Current channel info")
	print(chan)

	# see https://gitlab.com/wireshark/wireshark/-/issues/19316
	chan_num = int(sys.argv[1])
	width = CoreWLAN.kCWChannelWidth80MHz
	band = CoreWLAN.kCWChannelBand5GHz
	print("Attempting to change channel to ", chan_num)

	supported_channels = iface.supportedWLANChannels()
	for channel_object in supported_channels:
	    if chan_num == channel_object.channelNumber() \
	            and band == channel_object.channelBand() \
	            and width == channel_object.channelWidth():
	        print("Setting channel to " + str(channel_object))
	        iface.disassociate()
	        iface.setWLANChannel_error_(channel_object, None)

	chan = iface.wlanChannel()
	print("New channel info")
	print(chan)

if __name__ == "__main__":
    main()

Below is some sample output of this script.

Initializing
Current channel info
<CWChannel: 0x600003818340> [channelNumber=44(5GHz), channelWidth={80MHz}]
Attempting to change channel to  48
Setting channel to <CWChannel: 0x6000038151e0> [channelNumber=48(5GHz), channelWidth={80MHz}, active]
New channel info
<CWChannel: 0x6000038183a0> [channelNumber=48(5GHz), channelWidth={80MHz}]

About the Author: Nick Kohout

Nick Kohout is an engineer and technical leader for over 20 years. He holds a Masters degree in Electrical Engineering from the University of Maryland. His technical expertise includes digital hardware design, embedded software, reverse engineering, wireless communication, systems engineering, product development, and technical leadership. Throughout his career, Nick has focused on everything from small, low power, custom communication devices to large space systems.