Bring up a two-node 802.11 link — one node as an access point (hostapd), one as a client (wpa_supplicant) — and measure it. The same experiment is presented three ways, from the simplest to the most capable:
ansible-playbook wifi/wifi.yml.All three read the same parameters and are parsed by the same library, so they produce the same result — and a one-command harness proves it (see Same experiment, same result below). Start with the CLI to learn the moving parts, move to Ansible when you want it repeatable, and reach for the notebook when you want to explore and visualise.
| Difficulty | Beginner → Intermediate |
| Estimated time | ~30 min (most of it imaging) |
| Domain / sandbox | any WiFi-equipped domain — grid, sb7 |
| Band / channel | 2.4 GHz, channel 1, 802.11g, WPA2-PSK |
| WiFi subnet | 192.168.100.0/24 (AP .1, client .2) |
| Transmits? | Yes — 2.4 GHz, inside your reservation |
| Last verified | 2026-08-28 on grid (node1-1 AP / node1-2 client, baseline.ndz) |
hostapd and a client with wpa_supplicant. console.<domain>.cosmos-lab.org
| (wired control plane, ssh root@node)
+--------------------------+
| |
node A ((( 802.11g ch1 ))) node B
hostapd <---- cosmos-wifi ----> wpa_supplicant
192.168.100.1 192.168.100.2
The control plane stays on the wired NIC; the experiment lives entirely on the radios. Bind measurements to the radio (ping -I <iface>) so they cannot silently succeed over the wired network.
Log into the console and unpack the shared bundle into ~/tutorials:
ssh <username>@console.<domain>.cosmos-lab.org
cd ~ && wget -q https://www.cosmos-lab.org/files/cosmos-tutorials-wifi.zip
unzip -o cosmos-tutorials-wifi.zip # extracts into ~/tutorials/
cd tutorials
The wifi/ directory holds everything for this tutorial:
| File | What it is |
|---|---|
wifi_params.yml |
the single source of truth — SSID, channel, PSK, addresses. Every mode reads these. |
wifi_manual.sh |
Manual mode — the hand commands, scripted. |
wifi.yml + ../roles/wifi_* |
Ansible mode — the playbook and its roles. |
wifi_tutorial.ipynb |
Jupyter mode — the notebook. |
wifi_lib.py |
shared parser + result contract + parity comparison. |
wifi_parity.sh |
one-command harness that runs all three and checks they agree. |
wifi_teardown.yml |
clean teardown (kills by pidfile, flushes the radio ifaces). |
../README.md |
quickstart + troubleshooting for the whole bundle. |
Reserve two WiFi-capable nodes and image them once (all three modes share this clean start):
export OMF_NODES="node1-1,node1-2" # your two reserved nodes: first = AP, second = client
omf load -i baseline.ndz -t node1-1,node1-2
omf tell on -t node1-1,node1-2
OMF_NODESis required. The inventory refuses to run without it (so a tutorial can
never touch nodes belonging to someone else's experiment), and a preflight play checks
your nodes are reachable and booted a real image (not the PXE stage image) before
anything else runs — failing fast with the exact fix if not.
Radio names differ per node. Grid nodes carry the Atheros radio in different PCI slots, so the interface is (for example)
wlp5s8on one node andwlp6s8on the other. Always discover the interface by driver on each node — assuming one name for both is the classic mistake, and it is exactly what the parity check below catches.
The clearest way to understand the experiment: run every step yourself. Substitute your two nodes for node1-1 (AP) and node1-2 (client).
# Discover each node's Atheros interface BY DRIVER (names differ per node)
IFACE_DISCOVER='for pref in ath5k ath9k; do for d in /sys/class/net/wl*; do \
[ -e "$d/device/driver" ]||continue; drv=$(basename "$(readlink -f "$d/device/driver")"); \
[ "$drv" = "$pref" ] && { basename "$d"; exit 0; }; done; done'
AP_IF=$(ssh root@node1-1 "$IFACE_DISCOVER"); STA_IF=$(ssh root@node1-2 "$IFACE_DISCOVER")
echo "ap=$AP_IF sta=$STA_IF"
# Access point: hostapd (WPA2-PSK) on node1-1
ssh root@node1-1 "apt-get install -y hostapd iperf3 iw rfkill; iw reg set US; \
printf 'interface=$AP_IF\ndriver=nl80211\nssid=cosmos-wifi\nhw_mode=g\nchannel=1\nwpa=2\n\
wpa_key_mgmt=WPA-PSK\nrsn_pairwise=CCMP\nwpa_passphrase=cosmos-wifi-2026\n' > /etc/hostapd/cosmos.conf; \
ip link set $AP_IF up; hostapd -B /etc/hostapd/cosmos.conf; sleep 3; \
ip addr add 192.168.100.1/24 dev $AP_IF; iperf3 -s -D"
# Client: wpa_supplicant on node1-2, then verify
ssh root@node1-2 "apt-get install -y wpasupplicant iperf3 iw rfkill; iw reg set US; \
printf 'network={\n ssid=\"cosmos-wifi\"\n key_mgmt=WPA-PSK\n psk=\"cosmos-wifi-2026\"\n}\n' \
> /etc/wpa_supplicant/cosmos.conf; ip link set $STA_IF up; \
wpa_supplicant -B -i $STA_IF -c /etc/wpa_supplicant/cosmos.conf; sleep 8; \
ip addr add 192.168.100.2/24 dev $STA_IF; iw dev $STA_IF link; \
ping -c 5 -I $STA_IF 192.168.100.1; iperf3 -c 192.168.100.1 -t 5"
Or run the same steps as a script, which also builds the result contract:
bash wifi/wifi_manual.sh node1-1 node1-2
The same experiment as a repeatable playbook. Node selection comes from OMF_NODES; parameters from wifi_params.yml; the four roles (wifi_common, wifi_ap, wifi_sta, wifi_verify) do driver load, per-node interface discovery, AP, client, and verification.
export OMF_NODES="node1-1,node1-2" # first node = AP, second = client
ansible-playbook wifi/wifi.yml
The verify role prints the ping/iperf tails and writes the result contract to $WIFI_RUN_DIR/wifi_contract_ansible.json (default /tmp/wifi-$USER/). PLAY RECAP shows failed=0 on both nodes. To change SSID, channel, PSK or addresses for every mode at once, edit wifi/wifi_params.yml — nothing else.
The notebook wifi/wifi_tutorial.ipynb drives the same experiment from Python cells and plots the link inline. Its kernel runs on the console (available on the grid console today, rolling out fleet-wide), so ssh root@<node> and omf work directly from the notebook — and you only ever control nodes you have reserved.
Open it from your own laptop, one of two ways:
console.<domain>.cosmos-lab.org, open ~/tutorials/wifi/, and select the console's Jupyter environment as the kernel.# on your laptop
ssh -L 8888:localhost:8888 <username>@console.<domain>.cosmos-lab.org
# then on the console
/opt/cosmos-jupyter/venv/bin/jupyter lab --no-browser --ip 127.0.0.1 --port 8888
# open the printed http://127.0.0.1:8888/?token=… locally
Set your nodes with export OMF_NODES="node1-1,node1-2" before launching (or edit the first cell), then run the cells top to bottom. The last cell plots the link signal and throughput. To run it unattended (this is how the harness and CI run it):
export OMF_NODES="node1-1,node1-2"
/opt/cosmos-jupyter/venv/bin/papermill wifi/wifi_tutorial.ipynb wifi/wifi_tutorial-out.ipynb
Because all three modes read wifi_params.yml and parse results through wifi_lib.py, they produce the same result contract — a small JSON summarising the run:
{
"mode": "ansible", "ap": "node1-1", "sta": "node1-2", "iface": "wlp6s8",
"ssid": "cosmos-wifi", "associated": true, "signal_dbm": -39,
"ping_tx": 5, "ping_rx": 5, "ping_loss_pct": 0.0, "iperf_mbps": 16.3
}
Run all three back-to-back on the same nodes and check they agree:
bash wifi/wifi_parity.sh node1-1 node1-2 # add --image to (re)image first
=== three-mode parity ===
mode assoc sig loss% iperf ap->sta
--------------------------------------------------------
manual True -39 0.0 13.6 node1-1->node1-2
ansible True -39 0.0 16.3 node1-1->node1-2
jupyter True -32 0.0 16.1 node1-1->node1-2
PARITY: PASS (all three modes associated, ~0 loss, throughput consistent)
The pass criteria are deterministic — every mode associates, packet loss is ~0, and throughput sits in a sanity band and within tolerance of the others. Over-the-air throughput naturally varies run-to-run (here ±20%); on a fixed-attenuation domain such as sb4's matrix the numbers are far more repeatable. This harness doubles as a regression test: if the tutorial ever rots, parity fails loudly instead of silently.
Stop the experiment cleanly (kills hostapd/wpa_supplicant by pidfile — never a blanket
pkill), then release the nodes:
ansible-playbook wifi/wifi_teardown.yml
omf tell offh -t node1-1,node1-2
| Symptom | Likely cause | Fix |
|---|---|---|
Client fails, No such device for the interface |
The two nodes name the radio differently (e.g. wlp5s8 vs wlp6s8) |
Discover the interface by driver, on each node — see the note above |
no wireless interface on a freshly imaged node |
The radio comes up ~36 s after boot, later than sshd | Wait and re-run iw dev |
root/password root logs into the node |
You are in the PXE stage image, not baseline.ndz |
Power-cycle: omf tell offh → wait → omf tell on; verify with cat /.omf-image-name |
| hostapd exits immediately | Channel illegal for the regulatory domain | iw reg set US before starting it (all modes do this) |
ping succeeds but the radio is down |
The ping went over the wired control network | Bind to the radio with -I <iface> |
| Parity fails on throughput only | Normal OTA variance, or one mode left stale state | Re-run; use a matrix domain (sb4) for repeatable numbers |
Orchestration: Ansible (ansible-playbook) + Jupyter (per-console kernel); imaging: omf. Bundle: cosmos-tutorials-wifi.zip. Last verified: 2026-08-28 on grid (node1-1 / node1-2, baseline.ndz), three-mode parity PASS (spread 1.03). Tags: wifi, hostapd, wpa_supplicant, ansible, jupyter.