test: Add Wi-Fi tests

Should map on physical as well, but only tested on virtual.
This commit is contained in:
Mattias Walström
2026-07-02 09:20:38 +02:00
parent ce1892a60b
commit 1422a0ebdd
39 changed files with 2516 additions and 158 deletions
@@ -654,10 +654,17 @@ def main():
err = 1
os.umask(0o337)
# Write atomically: confd reads system.json as soon as ixinit signals
# done, so a truncated in-place write could be read half-written. Write
# to a temp file in the same dir, then rename (atomic on the same fs).
# pylint: disable=invalid-name
with open(SYSTEM_JSON, "w", encoding="ascii") as f:
tmp = SYSTEM_JSON + ".tmp"
with open(tmp, "w", encoding="ascii") as f:
json.dump(out, f)
shutil.chown(SYSTEM_JSON, user="root", group="wheel")
f.flush()
os.fsync(f.fileno())
shutil.chown(tmp, user="root", group="wheel")
os.replace(tmp, SYSTEM_JSON)
return err
+1
View File
@@ -146,6 +146,7 @@ INFIX_DOC="https://www.kernelkit.org/infix/"
INFIX_SUPPORT="mailto:kernelkit@googlegroups.com"
BR2_PACKAGE_FEATURE_GPS=y
BR2_PACKAGE_FEATURE_WIFI=y
BR2_PACKAGE_FEATURE_WIFI_HWSIM=y
BR2_PACKAGE_FEATURE_WIFI_MEDIATEK=y
BR2_PACKAGE_FEATURE_WIFI_QUALCOMM=y
BR2_PACKAGE_FEATURE_WIFI_REALTEK=y
+30
View File
@@ -297,6 +297,36 @@ image. The device will boot into test mode on first power-on.
> unless `startup_override()` is called first (or the marker is removed
> and a normal startup config is saved).
Simulated Wi-Fi
---------------
The virtual topology has no real radios, so Wi-Fi tests run against
`mac80211_hwsim` devices and the "air" between guests is faked. A small
relay, `wifimedium` (`package/feature-wifi/wifimedium`), takes every frame
a radio transmits, forwards it to the other guests, and injects what
arrives back into the local radios.
Each radio (`radio0`, `radio1`, ...) is paired with a carrier NIC of the
same name, and that carrier joins the multicast group (a *cell*) the
topology wires it to. Two radios hear each other only when their carriers
share a cell. Radios on the same cell that tune to different channels are
still kept apart, as on real hardware.
By convention the two general cells carry `radio0` and `radio1` across all
DUTs, so most tests put communicating radios on the same index: a station
joins an AP on the same `radioN`. The mesh-roaming test, for instance,
uses `radio1` for both the gateway APs and the client. That is only a
convention, though. A cell can carry any set of radios, including two of
the *same* DUT. The band-steering test relies on that: it wires one DUT's
`radio2` (2.4GHz) and `radio3` (5GHz) onto a dedicated cell so a single
client radio hears the same SSID on both bands.
How many radios each DUT gets depends on the topology. The number of
`radioN` ports wired to a node is passed in via the `opt/wifi` fw_cfg and
loaded at boot by the `00-hwsim` script, so a node with no wifi links has
no radios at all. The wiring for the standard suite lives in
`test/virt/quad`.
[9PM]: https://github.com/rical/9pm
[Qeneth]: https://github.com/wkz/qeneth
[TAP]: https://testanything.org/
+47
View File
@@ -0,0 +1,47 @@
#!/bin/sh
# Load mac80211_hwsim virtual radios sized to this DUT's topology, then wait
# for udev to rename the phys (phyN -> radioN) before returning.
#
# The radio count comes from a dedicated fw_cfg file (opt/wifi) that qeneth
# writes per node from the number of radioN carrier ports the topology wired
# to it. A node with no wifi links gets 0 (or no file), so hwsim is not loaded
# at all -- no phys, nothing clutters its interface view.
#
# Runs early in ixinit, before 00-probe, so the system probe records the radios
# by their final radioN names. ixinit is past the <pid/syslogd> / udev-settle
# barrier, so 60-rename-wifi-phy.rules (phyN -> radioN) and
# 70-remove-virtual-wifi-interfaces.rules (drop the stray wlanN) are loaded and
# fire on the phy add events. On success it sets the <usr/hwsim> condition so
# wifimedium starts only where radios exist. Installed only with
# BR2_PACKAGE_FEATURE_WIFI_HWSIM.
wifi=/sys/firmware/qemu_fw_cfg/by_name/opt/wifi/raw
ieee=/sys/class/ieee80211
# Number of radios to create, from the topology (via fw_cfg).
radios=0
[ -r "$wifi" ] && radios=$(cat "$wifi" 2>/dev/null)
# No radios wanted -> don't load hwsim, keep the view wifi-free.
[ "$radios" -gt 0 ] 2>/dev/null || exit 0
modprobe mac80211_hwsim radios="$radios" || exit 0
# Wait (bounded ~10s) for the phyN -> radioN rename to finish: the class has
# entries and none are still named phyN. The rename is a udev RUN, so it lags
# modprobe's return. On timeout the radios never settled -- fail (and log) so
# it is visible why 00-probe may record stale phyN names.
TIMEOUT=100
i=0
while [ "$i" -lt "$TIMEOUT" ]; do
names=$(ls "$ieee" 2>/dev/null)
if [ -n "$names" ] && ! echo "$names" | grep -q '^phy'; then
initctl -bq cond set hwsim
exit 0
fi
i=$((i + 1))
sleep 0.1
done
logger -p user.err -t 00-hwsim "timed out waiting for hwsim phys to be renamed radioN"
exit 1
+12
View File
@@ -18,6 +18,18 @@ config BR2_PACKAGE_FEATURE_WIFI
help
Enables WiFi in Infix. Enables all requried applications.
config BR2_PACKAGE_FEATURE_WIFI_HWSIM
bool "Virtual Wi-Fi radios (mac80211_hwsim)"
depends on BR2_PACKAGE_FEATURE_WIFI
help
Build the mac80211_hwsim virtual radio driver (as a module) for the
QEMU test target, to exercise AP, station, and mesh modes without
physical WiFi adapters. The module is autoloaded at boot (see the
x86_64 /etc/modules-load.d) and frames are bridged between guests by
the wifimedium relay.
Leave disabled on real hardware; it only provides simulated radios.
config BR2_PACKAGE_FEATURE_WIFI_MEDIATEK
bool "Mediatek WiFi Devices"
depends on BR2_PACKAGE_FEATURE_WIFI
+24
View File
@@ -14,6 +14,15 @@ define FEATURE_WIFI_LINUX_CONFIG_FIXUPS
$(call KCONFIG_SET_OPT,CONFIG_CFG80211,m)
$(call KCONFIG_ENABLE_OPT,CONFIG_MAC80211_MESH)
# Virtual radio for automated testing (mac80211_hwsim), built as a
# module and gated behind BR2_PACKAGE_FEATURE_WIFI_HWSIM so it is not
# built on real boards. When selected, the module is loaded early in
# ixinit by 00-hwsim -- sized to the DUT's topology, so radio-less nodes
# load nothing -- and bridged between QEMU guests by the wifimedium relay
# (both installed by FEATURE_WIFI_INSTALL_HWSIM below).
$(if $(BR2_PACKAGE_FEATURE_WIFI_HWSIM),
$(call KCONFIG_SET_OPT,CONFIG_MAC80211_HWSIM,m))
$(if $(filter y,$(BR2_PACKAGE_FEATURE_WIFI_MEDIATEK)),
$(call KCONFIG_ENABLE_OPT,CONFIG_MT7601U)
$(call KCONFIG_ENABLE_OPT,CONFIG_MT76x0U)
@@ -76,5 +85,20 @@ define FEATURE_WIFI_INSTALL_IN_ROMFS
endef
FEATURE_WIFI_POST_INSTALL_TARGET_HOOKS += FEATURE_WIFI_INSTALL_IN_ROMFS
# Virtual-radio (mac80211_hwsim) runtime, installed only when the option is
# selected: the 00-hwsim ixinit script loads the module sized to the opt/wifi
# fw_cfg qeneth writes per node (no radios -> not loaded), and the wifimedium
# relay + its Finit service bridge hwsim frames between QEMU guests so they can
# associate "wirelessly".
ifeq ($(BR2_PACKAGE_FEATURE_WIFI_HWSIM),y)
define FEATURE_WIFI_INSTALL_HWSIM
$(INSTALL) -D -m 0755 $(FEATURE_WIFI_PKGDIR)/00-hwsim $(TARGET_DIR)/usr/libexec/infix/init.d/00-hwsim
$(INSTALL) -D -m 0755 $(FEATURE_WIFI_PKGDIR)/wifimedium $(TARGET_DIR)/usr/libexec/infix/wifimedium
$(INSTALL) -D -m 0644 $(FEATURE_WIFI_PKGDIR)/wifimedium.conf $(TARGET_DIR)/etc/finit.d/available/wifimedium.conf
ln -sf ../available/wifimedium.conf $(TARGET_DIR)/etc/finit.d/enabled/wifimedium.conf
endef
FEATURE_WIFI_POST_INSTALL_TARGET_HOOKS += FEATURE_WIFI_INSTALL_HWSIM
endif
$(eval $(generic-package))
+401
View File
@@ -0,0 +1,401 @@
#!/usr/bin/env python3
"""wifimedium - a virtual WiFi medium for mac80211_hwsim, extended over Ethernet.
mac80211_hwsim simulates 802.11 radios in software. By default all radios in
one kernel share an in-kernel medium; radios in *different* kernels (i.e.
different QEMU guests) cannot hear each other. The moment a userspace process
registers on the hwsim generic-netlink family, the kernel stops delivering
frames itself and hands every transmitted frame to that process, which becomes
responsible for delivery.
This daemon is that process. On each DUT it:
* registers on the "mac80211_hwsim" genl family and receives every frame the
local radios transmit (HWSIM_CMD_FRAME),
* acknowledges each transmit back to the kernel (HWSIM_CMD_TX_INFO_FRAME) so
mac80211's TX path completes, and
* relays the frame per radio: each radio (phy radioN) is paired by name with a
carrier NIC (netdev radioN) that joins one multicast "cell" -- a QEMU socket
multicast group shared by every DUT's radioN (see test/virt/quad and
test/qeneth). A radio's frame is sent only onto its own carrier, and a
carrier's frames are injected only into its own radio
(HWSIM_CMD_FRAME with HWSIM_ATTR_ADDR_RECEIVER).
Each cell is a shared medium: every DUT's radioN is "in range" of every other
radioN. Radios on different indices are on different cells, so two radios
communicate only if they share an index -- the convention the test topologies
follow (a station uses the same radio index as the AP it joins). Channel
filtering in mac80211 still separates radios that share a cell but tune to
different frequencies.
This is intended for the QEMU/qeneth test topology only. On real hardware the
radios use the air, there are no radioN carrier netdevs, and this daemon idles.
See doc/wifi.md.
"""
import os
import socket
import struct
import time
import subprocess
import sys
import select
# ---- generic netlink / hwsim ABI (drivers/net/wireless/virtual/mac80211_hwsim.h)
NETLINK_GENERIC = 16
GENL_ID_CTRL = 16
CTRL_CMD_GETFAMILY = 3
CTRL_ATTR_FAMILY_ID = 1
CTRL_ATTR_FAMILY_NAME = 2
CTRL_ATTR_MCAST_GROUPS = 7
CTRL_ATTR_MCAST_GRP_NAME = 1
CTRL_ATTR_MCAST_GRP_ID = 2
SOL_NETLINK = 270
NETLINK_ADD_MEMBERSHIP = 1
HWSIM_CMD_REGISTER = 1
HWSIM_CMD_FRAME = 2
HWSIM_CMD_TX_INFO_FRAME = 3
HWSIM_ATTR_ADDR_RECEIVER = 1
HWSIM_ATTR_ADDR_TRANSMITTER = 2
HWSIM_ATTR_FRAME = 3
HWSIM_ATTR_FLAGS = 4
HWSIM_ATTR_RX_RATE = 5
HWSIM_ATTR_SIGNAL = 6
HWSIM_ATTR_TX_INFO = 7
HWSIM_ATTR_COOKIE = 8
HWSIM_ATTR_FREQ = 19
HWSIM_ATTR_TX_INFO_FLAGS = 21
HWSIM_TX_STAT_ACK = 1 << 2
# Signal/rate reported for injected (received) frames. A strong, fixed signal
# keeps mac80211 happy; the exact value is not significant for a virtual cell.
RX_SIGNAL = -30
RX_RATE = 0
# The medium is carried as raw Ethernet frames with a custom EtherType on the
# configured interface -- no IP addressing needed (the carrier link has none).
# A broadcast destination means every DUT on the segment is "in range".
ETH_P_WIFIMEDIUM = 0x88B5 # local-experimental EtherType
ETH_BROADCAST = b"\xff\xff\xff\xff\xff\xff"
ETYPE = struct.pack("!H", ETH_P_WIFIMEDIUM)
SOL_PACKET = 263
PACKET_IGNORE_OUTGOING = 23 # don't deliver our own TX back to the socket
NLMSG_HDR = struct.Struct("=IHHII") # len, type, flags, seq, pid
GENL_HDR = struct.Struct("=BBH") # cmd, version, reserved
DEBUG = bool(os.environ.get("WIFIMEDIUM_DEBUG"))
def log(msg):
sys.stderr.write(f"wifimedium: {msg}\n")
sys.stderr.flush()
def dbg(msg):
if DEBUG:
log(msg)
def nla_align(n):
return (n + 3) & ~3
def put_attr(atype, payload):
hdr = struct.pack("=HH", len(payload) + 4, atype)
return hdr + payload + b"\0" * (nla_align(len(payload) + 4) - (len(payload) + 4))
def parse_attrs(buf):
attrs = {}
off = 0
while off + 4 <= len(buf):
alen, atype = struct.unpack_from("=HH", buf, off)
if alen < 4:
break
attrs[atype & 0x7fff] = buf[off + 4:off + alen]
off += nla_align(alen)
return attrs
class Netlink:
"""Minimal generic-netlink client for the hwsim family."""
def __init__(self):
self.sock = socket.socket(socket.AF_NETLINK, socket.SOCK_RAW, NETLINK_GENERIC)
self.sock.bind((0, 0))
self.seq = 0
# NB: the genl family is "MAC80211_HWSIM" (uppercase); the lowercase
# "mac80211_hwsim" is the platform driver name, not the family.
self.family_id, self.mcast_id = self._resolve("MAC80211_HWSIM", "config")
def fileno(self):
return self.sock.fileno()
def _send(self, family_id, cmd, attrs=b"", flags=0):
self.seq += 1
payload = GENL_HDR.pack(cmd, 1, 0) + attrs
msg = NLMSG_HDR.pack(NLMSG_HDR.size + len(payload), family_id,
flags | 1, self.seq, 0) + payload # NLM_F_REQUEST
self.sock.send(msg)
def _resolve(self, name, grpname):
self._send(GENL_ID_CTRL, CTRL_CMD_GETFAMILY,
put_attr(CTRL_ATTR_FAMILY_NAME, name.encode() + b"\0"))
data = self.sock.recv(65536)
_, mtype, _, _, _ = NLMSG_HDR.unpack_from(data, 0)
if mtype == 2: # NLMSG_ERROR (also used for ACKs; err<0 is a failure)
err = struct.unpack_from("=i", data, NLMSG_HDR.size)[0]
raise RuntimeError(f"genl GETFAMILY({name}) failed: "
f"{os.strerror(-err) if err else 'empty reply'}")
attrs = parse_attrs(data[NLMSG_HDR.size + GENL_HDR.size:])
family_id = struct.unpack("=H", attrs[CTRL_ATTR_FAMILY_ID][:2])[0]
mcast_id = None
for _, grp in _parse_nested(attrs.get(CTRL_ATTR_MCAST_GROUPS, b"")):
gattrs = parse_attrs(grp)
gname = gattrs.get(CTRL_ATTR_MCAST_GRP_NAME, b"").rstrip(b"\0").decode()
if gname == grpname:
mcast_id = struct.unpack("=I", gattrs[CTRL_ATTR_MCAST_GRP_ID])[0]
if mcast_id is None:
raise RuntimeError(f"hwsim multicast group '{grpname}' not found")
return family_id, mcast_id
def register(self):
self.sock.setsockopt(SOL_NETLINK, NETLINK_ADD_MEMBERSHIP, self.mcast_id)
self._send(self.family_id, HWSIM_CMD_REGISTER)
def recv(self):
return self.sock.recv(65536)
def inject(self, receiver, frame, freq):
attrs = put_attr(HWSIM_ATTR_ADDR_RECEIVER, receiver)
attrs += put_attr(HWSIM_ATTR_FRAME, frame)
attrs += put_attr(HWSIM_ATTR_RX_RATE, struct.pack("=I", RX_RATE))
attrs += put_attr(HWSIM_ATTR_SIGNAL, struct.pack("=i", RX_SIGNAL))
if freq:
attrs += put_attr(HWSIM_ATTR_FREQ, struct.pack("=I", freq))
self._send(self.family_id, HWSIM_CMD_FRAME, attrs)
def tx_ack(self, transmitter, flags, tx_info, tx_info_flags, cookie):
attrs = put_attr(HWSIM_ATTR_ADDR_TRANSMITTER, transmitter)
attrs += put_attr(HWSIM_ATTR_FLAGS, struct.pack("=I", flags | HWSIM_TX_STAT_ACK))
attrs += put_attr(HWSIM_ATTR_SIGNAL, struct.pack("=i", RX_SIGNAL))
if tx_info:
attrs += put_attr(HWSIM_ATTR_TX_INFO, tx_info)
if tx_info_flags:
attrs += put_attr(HWSIM_ATTR_TX_INFO_FLAGS, tx_info_flags)
if cookie:
attrs += put_attr(HWSIM_ATTR_COOKIE, cookie)
self._send(self.family_id, HWSIM_CMD_TX_INFO_FRAME, attrs)
def _parse_nested(buf):
off = 0
while off + 4 <= len(buf):
alen, atype = struct.unpack_from("=HH", buf, off)
if alen < 4:
break
yield atype & 0x7fff, buf[off + 4:off + alen]
off += nla_align(alen)
def radio_addr1(phy):
"""The address mac80211_hwsim identifies a radio by on the medium.
A hwsim radio has three addresses (kernel new_radio()). sysfs 'macaddress'
is addresses[0]; the address the kernel stamps as HWSIM_ATTR_ADDR_TRANSMITTER
and matches HWSIM_ATTR_ADDR_RECEIVER against (its rhashtable key) is
addresses[1] -- addresses[0] with bit 0x40 set in the first octet, for the
default no-perm_addr radios we use. So both the transmitter we observe and
the receiver we must inject to are this 0x40 variant, not the sysfs value.
(Note custom-phys-address changes the vif/netdev MAC, not these.)
"""
with open(f"/sys/class/ieee80211/{phy}/macaddress") as fh:
addr = bytearray.fromhex(fh.read().strip().replace(":", ""))
addr[0] |= 0x40
return bytes(addr)
def open_medium(ifname):
"""Raw-Ethernet socket on the medium interface, or (None, None).
Returns (sock, mac); mac is the interface's own (unique) address, used as
the Ethernet source and to drop frames we transmitted ourselves -- the
multicast cell loops our own frames back to us."""
if not ifname:
return None, None
# The carrier NIC is unconfigured (it is not a real DUT port), so Infix
# leaves it administratively down -- a raw packet socket on a down link
# carries no frames. Bring it up; wifimedium owns the medium, like hwsim0.
ifup(ifname)
sock = socket.socket(socket.AF_PACKET, socket.SOCK_RAW,
socket.htons(ETH_P_WIFIMEDIUM))
sock.bind((ifname, ETH_P_WIFIMEDIUM))
# Don't receive our own transmitted frames (avoids a self-feedback path
# and any reliance on the source MAC, which can collide between guests).
try:
sock.setsockopt(SOL_PACKET, PACKET_IGNORE_OUTGOING, 1)
except OSError:
pass # pre-4.20 kernels; the length check below still applies
with open(f"/sys/class/net/{ifname}/address") as fh:
mac = bytes.fromhex(fh.read().strip().replace(":", ""))
return sock, mac
# Wire format on the medium: 6-byte transmitter MAC, 4-byte LE freq, then the
# raw 802.11 frame.
WIRE = struct.Struct("=6sI")
def wait_radios_renamed(timeout=20):
"""Wait until the hwsim phys have been renamed phyN -> radioN.
60-rename-wifi-phy.rules renames each phy *asynchronously* (a udev RUN),
and the carrier NICs are already named radioN (mactab/nameif, early boot).
We pair phy and carrier by name, so discovering while a phy is still 'phyN'
would miss it -- and we discover only once. This raced our start (gated
merely on the module load) and left some boots relaying nothing until a
restart. Block until every phy is renamed (no 'phyN' left) and the set has
settled, so discovery sees radioN phys that match the radioN carriers.
"""
base = "/sys/class/ieee80211"
prev, stable = None, 0
for _ in range(timeout * 4):
phys = sorted(os.listdir(base)) if os.path.isdir(base) else []
if phys and not any(p.startswith("phy") for p in phys):
if phys == prev:
stable += 1
if stable >= 2: # unchanged for ~0.5s
return
else:
stable = 0
prev = phys
time.sleep(0.25)
def discover_radios():
"""Pair each local hwsim radio with its multicast carrier interface.
A radio's phy is named radioN (60-rename-wifi-phy.rules) under
/sys/class/ieee80211/. In the qeneth test topology each radio also has a
carrier NIC of the SAME name under /sys/class/net/ -- the topology names the
port radioN, so the mactab/nameif rename the carrier to match -- joined to
one multicast cell shared by every DUT's radioN. Pairing them by name gives
a per-radio relay: a radio's frames go onto its own carrier (its cell) and a
carrier's frames are injected only into its own radio.
Returns a list of {name, addr1, sock, mac}. A phy with no matching carrier
netdev (e.g. real hardware) is skipped, so the daemon simply idles there.
"""
wait_radios_renamed()
radios = []
base = "/sys/class/ieee80211"
if not os.path.isdir(base):
return radios
for phy in sorted(os.listdir(base)):
if not os.path.isdir(f"/sys/class/net/{phy}"):
continue # no carrier of this name -> not a relayed radio
sock, mac = open_medium(phy)
if sock:
radios.append({"name": phy, "addr1": radio_addr1(phy),
"sock": sock, "mac": mac})
return radios
def ifup(ifname):
"""Bring an interface up (best effort)."""
subprocess.run(["ip", "link", "set", ifname, "up"], check=False)
def main():
# The hwsim monitor interface is down after boot; bring it up so the
# simulated medium is observable (tcpdump -i hwsim0) and active.
ifup("hwsim0")
# Discover carriers first. With none -- e.g. 'make run', which creates
# radios but no inter-guest medium -- registering on the hwsim family would
# make the kernel hand us every frame with nowhere to relay it, breaking
# delivery between this guest's own radios. So leave the medium to the
# kernel and idle instead of registering.
radios = discover_radios()
if not radios:
log("no radio carriers found; leaving local hwsim medium to the kernel")
while True:
time.sleep(3600)
nl = Netlink()
nl.register()
log(f"registered on hwsim family {nl.family_id}; "
f"radios={' '.join(r['name'] for r in radios)}")
# TX routing: a frame's transmitter (addresses[1]) -> that radio's carrier.
by_addr1 = {r["addr1"]: r for r in radios}
dbg(f"netlink portid={nl.sock.getsockname()[0]}; "
+ "; ".join(f"{r['name']} addr1={r['addr1'].hex()} "
f"carrier={r['mac'].hex()}" for r in radios))
socks = [nl] + [r["sock"] for r in radios]
while True:
ready, _, _ = select.select(socks, [], [])
if nl in ready:
data = nl.recv()
off = 0
while off + NLMSG_HDR.size <= len(data):
mlen, mtype, _, _, _ = NLMSG_HDR.unpack_from(data, off)
if mlen < NLMSG_HDR.size:
break
if mtype == nl.family_id and \
data[off + NLMSG_HDR.size] == HWSIM_CMD_FRAME:
a = parse_attrs(data[off + NLMSG_HDR.size + GENL_HDR.size:
off + mlen])
tx = a.get(HWSIM_ATTR_ADDR_TRANSMITTER)
frame = a.get(HWSIM_ATTR_FRAME)
if tx and frame:
freq = struct.unpack("=I", a[HWSIM_ATTR_FREQ])[0] \
if HWSIM_ATTR_FREQ in a else 0
flags = struct.unpack("=I", a[HWSIM_ATTR_FLAGS])[0] \
if HWSIM_ATTR_FLAGS in a else 0
# Complete the kernel TX path regardless of delivery.
nl.tx_ack(tx, flags, a.get(HWSIM_ATTR_TX_INFO),
a.get(HWSIM_ATTR_TX_INFO_FLAGS),
a.get(HWSIM_ATTR_COOKIE))
# Send only onto the transmitting radio's own carrier.
r = by_addr1.get(tx)
if r:
r["sock"].send(ETH_BROADCAST + r["mac"] + ETYPE +
WIRE.pack(tx, freq) + frame)
dbg(f"tx {r['name']} freq={freq} len={len(frame)}")
else:
dbg(f"tx from unknown radio {tx.hex()} -- dropped")
off += nla_align(mlen)
for r in radios:
if r["sock"] not in ready:
continue
pkt = r["sock"].recv(65536)
if len(pkt) < 14 + WIRE.size:
continue
# The cell multicasts our own frames back to us; PACKET_IGNORE_-
# OUTGOING does not catch that looped copy, so drop anything whose
# Ethernet source is our own carrier (carrier MACs are unique per
# DUT; transmitter addrs are not, so we must key on the carrier).
if pkt[6:12] == r["mac"]:
continue
tx, freq = WIRE.unpack_from(pkt, 14)
frame = pkt[14 + WIRE.size:]
# Inject into THIS radio only -- its carrier is its cell.
nl.inject(r["addr1"], frame, freq)
dbg(f"rx {r['name']} tx={tx.hex()} freq={freq} len={len(frame)}")
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
pass
+14
View File
@@ -0,0 +1,14 @@
# Virtual WiFi medium for mac80211_hwsim (QEMU test target).
#
# Installed only when BR2_PACKAGE_FEATURE_WIFI_HWSIM is set. Bridges hwsim
# frames between the local radios and -- when a medium interface is configured
# (WIFIMEDIUM_IFACE env or 'wifimedium.iface=' on the kernel cmdline) -- to
# peer DUTs over that Ethernet segment, so two QEMU guests can associate
# "wirelessly". hwsim is loaded by the 00-hwsim ixinit script, which sets the
# <usr/hwsim> condition once radios exist and are renamed -- so this service
# starts only on nodes that actually have radios (registering on the hwsim genl
# family would fail otherwise). On real hardware neither is shipped and the
# radios use the air.
service env:-/etc/default/wifimedium name:wifimedium \
[2345] <usr/hwsim> \
/usr/libexec/infix/wifimedium -- Virtual WiFi medium (hwsim)
+4 -9
View File
@@ -571,14 +571,6 @@ static int change_cb(sr_session_ctx_t *session, uint32_t sub_id, const char *mod
}
max_dep--;
}
#if 0
/* Debug: print diff to file */
FILE *f = fopen("/tmp/confd-diff.json", "w");
if (f) {
lyd_print_file(f, diff, LYD_JSON, LYD_PRINT_SIBLINGS);
fclose(f);
}
#endif
}
/* ietf-hardware */
@@ -728,8 +720,11 @@ int sr_plugin_init_cb(sr_session_ctx_t *session, void **priv)
goto err;
confd.root = json_load_file("/run/system.json", 0, NULL);
if (!confd.root)
if (!confd.root) {
ERROR("failed loading /run/system.json, system probe incomplete");
rc = SR_ERR_SYS;
goto err;
}
/* An optional file that contains hardware specific quirks for
* the network interfaces on running board.
+3
View File
@@ -52,3 +52,6 @@
- name: Layer-2/3 Tunnel Tests
suite: tunnels.yaml
- name: WiFi Tests
suite: wifi.yaml
+12
View File
@@ -0,0 +1,12 @@
---
- name: WiFi AP and Station Association across two DUTs
case: wifi_ap_station_2dut/test.py
- name: WiFi Access Point serving three Stations
case: wifi_ap_multi_station/test.py
- name: WiFi Mesh backhaul with roaming Access Points
case: wifi_mesh_roaming/test.py
- name: WiFi Band Steering across a dual-band Access Point
case: wifi_band_steering/test.py
@@ -0,0 +1 @@
test.adoc
@@ -0,0 +1,46 @@
=== WiFi Access Point serving three Stations
ifdef::topdoc[:imagesdir: {topdoc}../../test/case/interfaces/wifi_ap_multi_station]
==== Description
One AP and three Stations, each on its own DUT: the ap runs a
WPA2/WPA3-personal Access Point on radio0, and station1, station2 and
station3 each associate to it. The radios are in separate kernels, so the
"air" between them is realised differently per environment:
* On real hardware the links are real RF -- the stations just associate.
* On QEMU the radios are mac80211_hwsim, and the `wifimedium` relay on each
DUT bridges hwsim frames over the Ethernet segments between the guests
(the topology's wifi-links). See doc/wifi.md.
This is the multi-client case: a single AP cell must serve more than one
station at once. Each station associating with WPA2/WPA3-personal is a
strong end-to-end check -- authentication, association and the WPA 4-way
handshake all require frames to cross the medium in *both* directions -- so
three successful associations prove the AP keeps several stations
associated simultaneously over the (relayed) radio links. It then confirms
data-plane reach: the ap serves DHCP and each station leases an address
from the pool over the radio link.
Topology:
....
((( station1 ==(mgmt)== host
host ==(mgmt)== ap ((( station2 ==(mgmt)== host
((( station3 ==(mgmt)== host
....
==== Topology
image::topology.svg[WiFi Access Point serving three Stations topology, align=center, scaledwidth=75%]
==== Sequence
. Set up topology and attach to the ap and three stations
. Configure the ap as an Access Point on radio0
. Configure the station on radio0
. Verify the station associates to the ap over the wifi link
. Verify the station's wifi0 operational status is up
. Verify the station leases an address from the ap over wifi
+181
View File
@@ -0,0 +1,181 @@
#!/usr/bin/env python3
r"""
WiFi Access Point serving multiple Stations
One AP and three Stations, each on its own DUT: the ap runs a
WPA2/WPA3-personal Access Point on radio0, and station1, station2 and
station3 each associate to it. The radios are in separate kernels, so the
"air" between them is realised differently per environment:
* On real hardware the links are real RF -- the stations just associate.
* On QEMU the radios are mac80211_hwsim, and the `wifimedium` relay on each
DUT bridges hwsim frames over the Ethernet segments between the guests
(the topology's wifi-links). See doc/wifi.md.
This is the multi-client case: a single AP cell must serve more than one
station at once. Each station associating with WPA2/WPA3-personal is a
strong end-to-end check -- authentication, association and the WPA 4-way
handshake all require frames to cross the medium in *both* directions -- so
three successful associations prove the AP keeps several stations
associated simultaneously over the (relayed) radio links. It then confirms
data-plane reach: the ap serves DHCP and each station leases an address
from the pool over the radio link.
Topology:
....
((( station1 ==(mgmt)== host
host ==(mgmt)== ap ((( station2 ==(mgmt)== host
((( station3 ==(mgmt)== host
....
"""
import base64
import infamy
import infamy.iface as iface
from infamy.util import until, parallel
SSID = "infix-test"
PSK = "infixinfix" # 8-63 printable ASCII, see doc/wifi.md
PSK_B64 = base64.b64encode(PSK.encode()).decode()
COUNTRY = "SE"
# hwsim defaults every radio0 to 02:00:00:00:00:00, so the AP and all the
# stations would otherwise share one MAC -- give each DUT a unique address.
STATIONS = [
("station1", "02:00:00:00:00:02"),
("station2", "02:00:00:00:00:03"),
("station3", "02:00:00:00:00:04"),
]
AP_MAC = "02:00:00:00:00:01"
# The ap runs a DHCP server on wifi0; every station leases an address from
# the pool. A completed lease is real bidirectional IP traffic over the
# radio link, so a lease per station proves all three reach the ap at L3.
SUBNET = "192.168.20.0/24"
AP_IP = "192.168.20.1"
POOL_START = "192.168.20.100"
POOL_END = "192.168.20.102" # three addresses, one per station
def radio(name, band=None, channel=None):
"""ietf-hardware component for a radio."""
wifi_radio = {"country-code": COUNTRY}
if band:
wifi_radio["band"] = band
if channel is not None:
wifi_radio["channel"] = channel
return {
"name": name,
"class": "infix-hardware:wifi",
"infix-hardware:wifi-radio": wifi_radio,
}
def keystore():
return {
"keystore": {
"symmetric-keys": {
"symmetric-key": [{
"name": "wifi",
"key-format": "infix-crypto-types:passphrase-key-format",
"cleartext-symmetric-key": PSK_B64,
}]
}
}
}
def wifi_iface(mac, wifi, ipv4=None):
ifc = {
"name": "wifi0",
"type": "infix-if-type:wifi",
"infix-interfaces:custom-phys-address": {"static": mac},
"infix-interfaces:wifi": wifi,
}
if ipv4:
ifc["ietf-ip:ipv4"] = ipv4
return {"interfaces": {"interface": [ifc]}}
def leased(dut):
"""True once wifi0 holds an address handed out by the ap's DHCP server."""
for addr in iface.get_ipv4_address(dut, "wifi0") or []:
if addr.get("origin") == "dhcp" and \
POOL_START <= addr.get("ip", "") <= POOL_END:
return True
return False
with infamy.Test() as test:
with test.step("Set up topology and attach to the ap and three stations"):
env = infamy.Env()
# Connect to the AP and all stations concurrently to cut setup time.
ap, *station_duts = parallel(
lambda: env.attach("ap", "mgmt"),
*((lambda n=name: env.attach(n, "mgmt")) for name, _ in STATIONS),
)
stations = [(name, mac, dut)
for (name, mac), dut in zip(STATIONS, station_duts)]
for dut in [ap] + [s for _, _, s in stations]:
if not dut.has_feature("infix-interfaces", "wifi"):
print("DUT does not advertise the 'wifi' feature -- skipping")
test.skip()
with test.step("Configure the ap as an Access Point on radio0"):
ap.put_config_dicts({
"ietf-hardware": {"hardware": {"component": [
radio("radio0", band="2.4GHz", channel=1)]}},
"ietf-keystore": keystore(),
"ietf-interfaces": wifi_iface(AP_MAC, {
"radio": "radio0",
"access-point": {
"ssid": SSID,
"security": {"mode": "wpa2-wpa3-personal", "secret": "wifi"},
},
}, ipv4={"address": [{"ip": AP_IP, "prefix-length": 24}]}),
"infix-dhcp-server": {"dhcp-server": {"subnet": [{
"subnet": SUBNET,
"pool": {"start-address": POOL_START, "end-address": POOL_END},
}]}},
})
for name, mac, dut in stations:
with test.step("Configure the station on radio0"):
print(f"Configuring {name}")
dut.put_config_dicts({
"ietf-hardware": {"hardware": {"component": [radio("radio0")]}},
"ietf-keystore": keystore(),
"ietf-interfaces": wifi_iface(mac, {
"radio": "radio0",
"station": {
"ssid": SSID,
"security": {"mode": "auto", "secret": "wifi"},
},
}, ipv4={"infix-dhcp-client:dhcp": {}}),
})
for name, _mac, dut in stations:
with test.step("Verify the station associates to the ap over the wifi link"):
print(f"Verifying {name}")
def associated(dut=dut):
ifc = dut.get_iface("wifi0")
if not ifc:
return False
wifi = ifc.get("infix-interfaces:wifi") or ifc.get("wifi") or {}
station = wifi.get("station", {})
return station.get("ssid") == SSID and \
station.get("signal-strength") is not None
until(associated, attempts=60, interval=2)
for name, _mac, dut in stations:
with test.step("Verify the station's wifi0 operational status is up"):
print(f"Verifying {name}")
until(lambda dut=dut: iface.is_oper_up(dut, "wifi0"), attempts=30)
for name, _mac, dut in stations:
with test.step("Verify the station leases an address from the ap over wifi"):
print(f"Verifying {name}")
until(lambda dut=dut: leased(dut), attempts=60, interval=2)
test.succeed()
@@ -0,0 +1,61 @@
graph "wifi-ap-multi-station" {
layout="neato";
overlap="false";
esep="+40";
node [shape=record, fontname="DejaVu Sans Mono, Book"];
edge [color="cornflowerblue", penwidth="2", fontname="DejaVu Serif, Book"];
host [
label="host | { <mgmt1> mgmt1 | <mgmt2> mgmt2 | <mgmt3> mgmt3 | <mgmt4> mgmt4 }",
pos="0,0!",
requires="controller",
];
ap [
label="{ <mgmt> mgmt | <wifi> wifi } | ap",
pos="6,0!",
requires="infix",
];
station1 [
label="{ <mgmt> mgmt | <wifi> wifi } | station1",
pos="11,3!",
requires="infix",
];
station2 [
label="{ <mgmt> mgmt | <wifi> wifi } | station2",
pos="11,0!",
requires="infix",
];
station3 [
label="{ <mgmt> mgmt | <wifi> wifi } | station3",
pos="11,-3!",
requires="infix",
];
// One AP cell shared by the ap and all three stations: a single medium
// node every radio joins (same index -> same cell). The ap's one radio
// must be in range of every station, which the mapper guarantees by
// placing all four on this shared medium:
// * physical: maps onto one over-the-air RF cell;
// * virtual (qeneth): maps onto one multicast cell the wifimedium
// relay bridges mac80211_hwsim frames over.
cell [
label="cell",
pos="8.5,0!",
requires="wifi-radio",
];
host:mgmt1 -- ap:mgmt [requires="mgmt", color="lightgray"]
host:mgmt2 -- station1:mgmt [requires="mgmt", color="lightgray"]
host:mgmt3 -- station2:mgmt [requires="mgmt", color="lightgray"]
host:mgmt4 -- station3:mgmt [requires="mgmt", color="lightgray"]
ap:wifi -- cell [requires="wifi2.4GHz", style="dashed"]
station1:wifi -- cell [requires="wifi2.4GHz", style="dashed"]
station2:wifi -- cell [requires="wifi2.4GHz", style="dashed"]
station3:wifi -- cell [requires="wifi2.4GHz", style="dashed"]
}
@@ -0,0 +1,112 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Title: wifi&#45;ap&#45;multi&#45;station Pages: 1 -->
<svg width="980pt" height="519pt"
viewBox="0.00 0.00 979.99 519.45" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 515.45)">
<title>wifi&#45;ap&#45;multi&#45;station</title>
<polygon fill="white" stroke="transparent" points="-4,4 -4,-515.45 975.99,-515.45 975.99,4 -4,4"/>
<!-- host -->
<g id="node1" class="node">
<title>host</title>
<polygon fill="none" stroke="black" points="0,-209.72 0,-301.72 108,-301.72 108,-209.72 0,-209.72"/>
<text text-anchor="middle" x="25" y="-252.02" font-family="DejaVu Sans Mono, Book" font-size="14.00">host</text>
<polyline fill="none" stroke="black" points="50,-209.72 50,-301.72 "/>
<text text-anchor="middle" x="79" y="-286.52" font-family="DejaVu Sans Mono, Book" font-size="14.00">mgmt1</text>
<polyline fill="none" stroke="black" points="50,-278.72 108,-278.72 "/>
<text text-anchor="middle" x="79" y="-263.52" font-family="DejaVu Sans Mono, Book" font-size="14.00">mgmt2</text>
<polyline fill="none" stroke="black" points="50,-255.72 108,-255.72 "/>
<text text-anchor="middle" x="79" y="-240.52" font-family="DejaVu Sans Mono, Book" font-size="14.00">mgmt3</text>
<polyline fill="none" stroke="black" points="50,-232.72 108,-232.72 "/>
<text text-anchor="middle" x="79" y="-217.52" font-family="DejaVu Sans Mono, Book" font-size="14.00">mgmt4</text>
</g>
<!-- ap -->
<g id="node2" class="node">
<title>ap</title>
<polygon fill="none" stroke="black" points="476.95,-232.72 476.95,-278.72 559.95,-278.72 559.95,-232.72 476.95,-232.72"/>
<text text-anchor="middle" x="501.95" y="-263.52" font-family="DejaVu Sans Mono, Book" font-size="14.00">mgmt</text>
<polyline fill="none" stroke="black" points="476.95,-255.72 526.95,-255.72 "/>
<text text-anchor="middle" x="501.95" y="-240.52" font-family="DejaVu Sans Mono, Book" font-size="14.00">wifi</text>
<polyline fill="none" stroke="black" points="526.95,-232.72 526.95,-278.72 "/>
<text text-anchor="middle" x="543.45" y="-252.02" font-family="DejaVu Sans Mono, Book" font-size="14.00">ap</text>
</g>
<!-- host&#45;&#45;ap -->
<g id="edge1" class="edge">
<title>host:mgmt1&#45;&#45;ap:mgmt</title>
<path fill="none" stroke="lightgray" stroke-width="2" d="M108,-290.72C108,-290.72 476.45,-267.72 476.45,-267.72"/>
</g>
<!-- station1 -->
<g id="node3" class="node">
<title>station1</title>
<polygon fill="none" stroke="black" points="838.99,-464.95 838.99,-510.95 971.99,-510.95 971.99,-464.95 838.99,-464.95"/>
<text text-anchor="middle" x="863.99" y="-495.75" font-family="DejaVu Sans Mono, Book" font-size="14.00">mgmt</text>
<polyline fill="none" stroke="black" points="838.99,-487.95 888.99,-487.95 "/>
<text text-anchor="middle" x="863.99" y="-472.75" font-family="DejaVu Sans Mono, Book" font-size="14.00">wifi</text>
<polyline fill="none" stroke="black" points="888.99,-464.95 888.99,-510.95 "/>
<text text-anchor="middle" x="930.49" y="-484.25" font-family="DejaVu Sans Mono, Book" font-size="14.00">station1</text>
</g>
<!-- host&#45;&#45;station1 -->
<g id="edge2" class="edge">
<title>host:mgmt2&#45;&#45;station1:mgmt</title>
<path fill="none" stroke="lightgray" stroke-width="2" d="M108,-267.72C108,-267.72 838.49,-499.95 838.49,-499.95"/>
</g>
<!-- station2 -->
<g id="node4" class="node">
<title>station2</title>
<polygon fill="none" stroke="black" points="838.99,-232.72 838.99,-278.72 971.99,-278.72 971.99,-232.72 838.99,-232.72"/>
<text text-anchor="middle" x="863.99" y="-263.52" font-family="DejaVu Sans Mono, Book" font-size="14.00">mgmt</text>
<polyline fill="none" stroke="black" points="838.99,-255.72 888.99,-255.72 "/>
<text text-anchor="middle" x="863.99" y="-240.52" font-family="DejaVu Sans Mono, Book" font-size="14.00">wifi</text>
<polyline fill="none" stroke="black" points="888.99,-232.72 888.99,-278.72 "/>
<text text-anchor="middle" x="930.49" y="-252.02" font-family="DejaVu Sans Mono, Book" font-size="14.00">station2</text>
</g>
<!-- host&#45;&#45;station2 -->
<g id="edge3" class="edge">
<title>host:mgmt3&#45;&#45;station2:mgmt</title>
<path fill="none" stroke="lightgray" stroke-width="2" d="M108,-243.72C108,-243.72 838.49,-267.72 838.49,-267.72"/>
</g>
<!-- station3 -->
<g id="node5" class="node">
<title>station3</title>
<polygon fill="none" stroke="black" points="838.99,-0.5 838.99,-46.5 971.99,-46.5 971.99,-0.5 838.99,-0.5"/>
<text text-anchor="middle" x="863.99" y="-31.3" font-family="DejaVu Sans Mono, Book" font-size="14.00">mgmt</text>
<polyline fill="none" stroke="black" points="838.99,-23.5 888.99,-23.5 "/>
<text text-anchor="middle" x="863.99" y="-8.3" font-family="DejaVu Sans Mono, Book" font-size="14.00">wifi</text>
<polyline fill="none" stroke="black" points="888.99,-0.5 888.99,-46.5 "/>
<text text-anchor="middle" x="930.49" y="-19.8" font-family="DejaVu Sans Mono, Book" font-size="14.00">station3</text>
</g>
<!-- host&#45;&#45;station3 -->
<g id="edge4" class="edge">
<title>host:mgmt4&#45;&#45;station3:mgmt</title>
<path fill="none" stroke="lightgray" stroke-width="2" d="M108,-220.72C108,-220.72 838.49,-35.5 838.49,-35.5"/>
</g>
<!-- cell -->
<g id="node6" class="node">
<title>cell</title>
<polygon fill="none" stroke="black" points="684.97,-237.72 684.97,-273.72 738.97,-273.72 738.97,-237.72 684.97,-237.72"/>
<text text-anchor="middle" x="711.97" y="-252.02" font-family="DejaVu Sans Mono, Book" font-size="14.00">cell</text>
</g>
<!-- ap&#45;&#45;cell -->
<g id="edge5" class="edge">
<title>ap:wifi&#45;&#45;cell</title>
<path fill="none" stroke="cornflowerblue" stroke-width="2" stroke-dasharray="5,2" d="M501.45,-232.72C501.45,-232.72 628.02,-246.55 684.88,-252.76"/>
</g>
<!-- station1&#45;&#45;cell -->
<g id="edge6" class="edge">
<title>station1:wifi&#45;&#45;cell</title>
<path fill="none" stroke="cornflowerblue" stroke-width="2" stroke-dasharray="5,2" d="M838.49,-475.95C838.49,-475.95 752.54,-326.34 722.32,-273.75"/>
</g>
<!-- station2&#45;&#45;cell -->
<g id="edge7" class="edge">
<title>station2:wifi&#45;&#45;cell</title>
<path fill="none" stroke="cornflowerblue" stroke-width="2" stroke-dasharray="5,2" d="M838.49,-243.72C838.49,-243.72 776.28,-249.62 739.11,-253.15"/>
</g>
<!-- station3&#45;&#45;cell -->
<g id="edge8" class="edge">
<title>station3:wifi&#45;&#45;cell</title>
<path fill="none" stroke="cornflowerblue" stroke-width="2" stroke-dasharray="5,2" d="M838.49,-11.5C838.49,-11.5 750.9,-180.57 721.48,-237.37"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 6.5 KiB

@@ -0,0 +1 @@
test.adoc
@@ -0,0 +1,43 @@
=== WiFi AP and Station Association across two DUTs
ifdef::topdoc[:imagesdir: {topdoc}../../test/case/interfaces/wifi_ap_station_2dut]
==== Description
Two DUTs, each with its own radio: the ap runs a WPA2/WPA3-personal Access
Point, the station associates to it. The radios are in
separate kernels, so the "air" between them is realised differently per
environment:
* On real hardware the link is real RF -- the radios just associate.
* On QEMU the radios are mac80211_hwsim, and the `wifimedium` relay on
each DUT bridges hwsim frames over the Ethernet segment between the
guests (the topology's wifi-link). See doc/wifi.md.
The test asserts that the station associates with WPA2/WPA3-personal. That
is a strong end-to-end check: authentication, association and the WPA
4-way handshake all require frames to cross the medium in *both*
directions, so a successful association proves real bidirectional
communication over the (relayed) radio link -- not just that an interface
came up. It then confirms data-plane reach: the ap serves DHCP and the
station leases its address over the radio link.
Topology:
....
host ==(mgmt)== ap ))) ~ wifi-link ~ ((( station ==(mgmt)== host
....
==== Topology
image::topology.svg[WiFi AP and Station Association across two DUTs topology, align=center, scaledwidth=75%]
==== Sequence
. Set up topology and attach to the ap and the station
. Configure the ap as an Access Point on radio0
. Configure the station on radio0
. Verify the station associates to the ap over the wifi link
. Verify the station's wifi0 operational status is up
. Verify the station leases its address from the ap over wifi
+153
View File
@@ -0,0 +1,153 @@
#!/usr/bin/env python3
r"""
WiFi Access Point and Station association across two DUTs
Two DUTs, each with its own radio: the ap runs a WPA2/WPA3-personal Access
Point, the station associates to it. The radios are in
separate kernels, so the "air" between them is realised differently per
environment:
* On real hardware the link is real RF -- the radios just associate.
* On QEMU the radios are mac80211_hwsim, and the `wifimedium` relay on
each DUT bridges hwsim frames over the Ethernet segment between the
guests (the topology's wifi-link). See doc/wifi.md.
The test asserts that the station associates with WPA2/WPA3-personal. That
is a strong end-to-end check: authentication, association and the WPA
4-way handshake all require frames to cross the medium in *both*
directions, so a successful association proves real bidirectional
communication over the (relayed) radio link -- not just that an interface
came up. It then confirms data-plane reach: the ap serves DHCP and the
station leases its address over the radio link.
Topology:
....
host ==(mgmt)== ap ))) ~ wifi-link ~ ((( station ==(mgmt)== host
....
"""
import base64
import infamy
import infamy.iface as iface
from infamy.util import until, parallel
SSID = "infix-test"
PSK = "infixinfix" # 8-63 printable ASCII, see doc/wifi.md
PSK_B64 = base64.b64encode(PSK.encode()).decode()
COUNTRY = "SE"
# The ap runs a DHCP server on wifi0; the station leases its address. A
# completed lease is real bidirectional IP traffic over the radio link.
SUBNET = "192.168.20.0/24"
AP_IP = "192.168.20.1"
LEASE = "192.168.20.100" # single-address pool -> deterministic lease
def radio(name, band=None, channel=None):
"""ietf-hardware component for a radio."""
wifi_radio = {"country-code": COUNTRY}
if band:
wifi_radio["band"] = band
if channel is not None:
wifi_radio["channel"] = channel
return {
"name": name,
"class": "infix-hardware:wifi",
"infix-hardware:wifi-radio": wifi_radio,
}
def keystore():
return {
"keystore": {
"symmetric-keys": {
"symmetric-key": [{
"name": "wifi",
"key-format": "infix-crypto-types:passphrase-key-format",
"cleartext-symmetric-key": PSK_B64,
}]
}
}
}
with infamy.Test() as test:
with test.step("Set up topology and attach to the ap and the station"):
env = infamy.Env()
# Connect to both nodes concurrently to cut setup time.
ap, station = parallel(
lambda: env.attach("ap", "mgmt"),
lambda: env.attach("station", "mgmt"),
)
for dut in (ap, station):
if not dut.has_feature("infix-interfaces", "wifi"):
print("DUT does not advertise the 'wifi' feature -- skipping")
test.skip()
with test.step("Configure the ap as an Access Point on radio0"):
ap.put_config_dicts({
"ietf-hardware": {"hardware": {"component": [
radio("radio0", band="2.4GHz", channel=1)]}},
"ietf-keystore": keystore(),
"ietf-interfaces": {"interfaces": {"interface": [{
"name": "wifi0",
"type": "infix-if-type:wifi",
# hwsim defaults every radio0 to 02:00:00:00:00:00, so the AP
# and the station would otherwise share a MAC -- give each a
# unique address.
"infix-interfaces:custom-phys-address": {"static": "02:00:00:00:00:01"},
"infix-interfaces:wifi": {
"radio": "radio0",
"access-point": {
"ssid": SSID,
"security": {"mode": "wpa2-wpa3-personal", "secret": "wifi"},
},
},
"ietf-ip:ipv4": {"address": [
{"ip": AP_IP, "prefix-length": 24}]},
}]}},
"infix-dhcp-server": {"dhcp-server": {"subnet": [{
"subnet": SUBNET,
"pool": {"start-address": LEASE, "end-address": LEASE},
}]}},
})
with test.step("Configure the station on radio0"):
station.put_config_dicts({
"ietf-hardware": {"hardware": {"component": [radio("radio0")]}},
"ietf-keystore": keystore(),
"ietf-interfaces": {"interfaces": {"interface": [{
"name": "wifi0",
"type": "infix-if-type:wifi",
"infix-interfaces:custom-phys-address": {"static": "02:00:00:00:00:02"},
"infix-interfaces:wifi": {
"radio": "radio0",
"station": {
"ssid": SSID,
"security": {"mode": "auto", "secret": "wifi"},
},
},
"ietf-ip:ipv4": {"infix-dhcp-client:dhcp": {}},
}]}},
})
with test.step("Verify the station associates to the ap over the wifi link"):
def associated():
ifc = station.get_iface("wifi0")
if not ifc:
return False
wifi = ifc.get("infix-interfaces:wifi") or ifc.get("wifi") or {}
sta = wifi.get("station", {})
return sta.get("ssid") == SSID and \
sta.get("signal-strength") is not None
until(associated, attempts=60, interval=2)
with test.step("Verify the station's wifi0 operational status is up"):
until(lambda: iface.is_oper_up(station, "wifi0"), attempts=30)
with test.step("Verify the station leases its address from the ap over wifi"):
until(lambda: iface.address_exist(station, "wifi0", LEASE),
attempts=60, interval=2)
test.succeed()
@@ -0,0 +1,44 @@
graph "wifi-ap-station-2dut" {
layout="neato";
overlap="false";
esep="+40";
node [shape=record, fontname="DejaVu Sans Mono, Book"];
edge [color="cornflowerblue", penwidth="2", fontname="DejaVu Serif, Book"];
host [
label="host | { <mgmt1> mgmt1 | <mgmt2> mgmt2 }",
pos="0,0!",
requires="controller",
];
ap [
label="{ <mgmt> mgmt | <wifi> wifi } | ap",
pos="6,2!",
requires="infix",
];
station [
label="{ <mgmt> mgmt | <wifi> wifi } | station",
pos="6,-2!",
requires="infix",
];
// The wireless cell the ap and the station share, modelled as a medium
// node both join (one radio each, same index -> same cell):
// * physical: maps onto a real over-the-air RF cell;
// * virtual (qeneth): maps onto one of the multicast cells over which
// the wifimedium relay bridges mac80211_hwsim frames.
// The mapper guarantees the two DUTs actually share this medium.
cell [
label="cell",
pos="9,0!",
requires="wifi-radio",
];
host:mgmt1 -- ap:mgmt [requires="mgmt", color="lightgray"]
host:mgmt2 -- station:mgmt [requires="mgmt", color="lightgray"]
ap:wifi -- cell [requires="wifi2.4GHz", style="dashed"]
station:wifi -- cell [requires="wifi2.4GHz", style="dashed"]
}
@@ -0,0 +1,68 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Title: wifi&#45;ap&#45;station&#45;2dut Pages: 1 -->
<svg width="656pt" height="307pt"
viewBox="0.00 0.00 656.06 307.03" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 303.03)">
<title>wifi&#45;ap&#45;station&#45;2dut</title>
<polygon fill="white" stroke="transparent" points="-4,4 -4,-303.03 652.06,-303.03 652.06,4 -4,4"/>
<!-- host -->
<g id="node1" class="node">
<title>host</title>
<polygon fill="none" stroke="black" points="0,-126.51 0,-172.51 108,-172.51 108,-126.51 0,-126.51"/>
<text text-anchor="middle" x="25" y="-145.81" font-family="DejaVu Sans Mono, Book" font-size="14.00">host</text>
<polyline fill="none" stroke="black" points="50,-126.51 50,-172.51 "/>
<text text-anchor="middle" x="79" y="-157.31" font-family="DejaVu Sans Mono, Book" font-size="14.00">mgmt1</text>
<polyline fill="none" stroke="black" points="50,-149.51 108,-149.51 "/>
<text text-anchor="middle" x="79" y="-134.31" font-family="DejaVu Sans Mono, Book" font-size="14.00">mgmt2</text>
</g>
<!-- ap -->
<g id="node2" class="node">
<title>ap</title>
<polygon fill="none" stroke="black" points="390.54,-252.53 390.54,-298.53 473.54,-298.53 473.54,-252.53 390.54,-252.53"/>
<text text-anchor="middle" x="415.54" y="-283.33" font-family="DejaVu Sans Mono, Book" font-size="14.00">mgmt</text>
<polyline fill="none" stroke="black" points="390.54,-275.53 440.54,-275.53 "/>
<text text-anchor="middle" x="415.54" y="-260.33" font-family="DejaVu Sans Mono, Book" font-size="14.00">wifi</text>
<polyline fill="none" stroke="black" points="440.54,-252.53 440.54,-298.53 "/>
<text text-anchor="middle" x="457.04" y="-271.83" font-family="DejaVu Sans Mono, Book" font-size="14.00">ap</text>
</g>
<!-- host&#45;&#45;ap -->
<g id="edge1" class="edge">
<title>host:mgmt1&#45;&#45;ap:mgmt</title>
<path fill="none" stroke="lightgray" stroke-width="2" d="M108,-161.51C108,-161.51 390.04,-287.53 390.04,-287.53"/>
</g>
<!-- station -->
<g id="node3" class="node">
<title>station</title>
<polygon fill="none" stroke="black" points="370.04,-0.5 370.04,-46.5 494.04,-46.5 494.04,-0.5 370.04,-0.5"/>
<text text-anchor="middle" x="395.04" y="-31.3" font-family="DejaVu Sans Mono, Book" font-size="14.00">mgmt</text>
<polyline fill="none" stroke="black" points="370.04,-23.5 420.04,-23.5 "/>
<text text-anchor="middle" x="395.04" y="-8.3" font-family="DejaVu Sans Mono, Book" font-size="14.00">wifi</text>
<polyline fill="none" stroke="black" points="420.04,-0.5 420.04,-46.5 "/>
<text text-anchor="middle" x="457.04" y="-19.8" font-family="DejaVu Sans Mono, Book" font-size="14.00">station</text>
</g>
<!-- host&#45;&#45;station -->
<g id="edge2" class="edge">
<title>host:mgmt2&#45;&#45;station:mgmt</title>
<path fill="none" stroke="lightgray" stroke-width="2" d="M108,-137.51C108,-137.51 370.04,-35.5 370.04,-35.5"/>
</g>
<!-- cell -->
<g id="node4" class="node">
<title>cell</title>
<polygon fill="none" stroke="black" points="594.06,-131.51 594.06,-167.51 648.06,-167.51 648.06,-131.51 594.06,-131.51"/>
<text text-anchor="middle" x="621.06" y="-145.81" font-family="DejaVu Sans Mono, Book" font-size="14.00">cell</text>
</g>
<!-- ap&#45;&#45;cell -->
<g id="edge3" class="edge">
<title>ap:wifi&#45;&#45;cell</title>
<path fill="none" stroke="cornflowerblue" stroke-width="2" stroke-dasharray="5,2" d="M415.04,-252.53C415.04,-252.53 537.66,-191.21 593.7,-163.19"/>
</g>
<!-- station&#45;&#45;cell -->
<g id="edge4" class="edge">
<title>station:wifi&#45;&#45;cell</title>
<path fill="none" stroke="cornflowerblue" stroke-width="2" stroke-dasharray="5,2" d="M395.04,-0.5C395.04,-0.5 533.68,-91.91 593.79,-131.54"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.8 KiB

+1
View File
@@ -0,0 +1 @@
test.adoc
@@ -0,0 +1,50 @@
=== WiFi Band Steering across a dual-band Access Point
ifdef::topdoc[:imagesdir: {topdoc}../../test/case/interfaces/wifi_band_steering]
==== Description
One DUT is a dual-band Access Point whose two radios sit on the *same*
wireless cell: one radio serves the SSID "campus" on 2.4GHz and the other
serves the same SSID on 5GHz. Both BSSes are bridged into br0 with a single DHCP
server, so the two bands are one network. Infix runs all AP radios in a
single hostapd process, which is what lets the cross-radio band-steering
directive resolve (see src/confd/src/hardware.c).
The other DUT is a client with a *single* dual-band radio. Because both
BSSes share one cell, that one radio hears "campus" on both bands -- the
device band steering acts on.
Enabling 802.11v (`roaming dot11v`) on both BSSes turns on MBO band steering
by default: hostapd tracks which band it has seen a client on and, on the
2.4GHz BSS, suppresses probe responses to a client it has seen on the 5GHz
BSS (`no_probe_resp_if_seen_on`). A dual-band client is therefore answered
only on 5GHz and associates there; a 2.4-only client, never seen on 5GHz, is
still answered and works normally. See doc/wifi.md.
The test asserts the client associates to "campus", that band steering lands
it on the 5GHz BSS (not the 2.4GHz one), and that it leases an address -- a
completed association plus DHCP lease is real bidirectional traffic over the
(relayed) radio link.
Topology:
....
(( 2.4GHz ))
host ==(mgmt)== ap ~ one cell ~ client ==(mgmt)== host
(( 5GHz )) (single dual-band radio)
....
==== Topology
image::topology.svg[WiFi Band Steering across a dual-band Access Point topology, align=center, scaledwidth=75%]
==== Sequence
. Set up topology and attach to the ap and the client
. Configure the dual-band AP: one BSS on 2.4GHz, one on 5GHz
. Configure the client with a single dual-band station radio
. Verify the client associates to the 'campus' SSID
. Verify band steering put the client on the 5GHz BSS
. Verify the client leases an address over 5GHz
+201
View File
@@ -0,0 +1,201 @@
#!/usr/bin/env python3
r"""
WiFi Band Steering: a dual-band AP nudges a client onto 5GHz
One DUT is a dual-band Access Point whose two radios sit on the *same*
wireless cell: one radio serves the SSID "campus" on 2.4GHz and the other
serves the same SSID on 5GHz. Both BSSes are bridged into br0 with a single DHCP
server, so the two bands are one network. Infix runs all AP radios in a
single hostapd process, which is what lets the cross-radio band-steering
directive resolve (see src/confd/src/hardware.c).
The other DUT is a client with a *single* dual-band radio. Because both
BSSes share one cell, that one radio hears "campus" on both bands -- the
device band steering acts on.
Enabling 802.11v (`roaming dot11v`) on both BSSes turns on MBO band steering
by default: hostapd tracks which band it has seen a client on and, on the
2.4GHz BSS, suppresses probe responses to a client it has seen on the 5GHz
BSS (`no_probe_resp_if_seen_on`). A dual-band client is therefore answered
only on 5GHz and associates there; a 2.4-only client, never seen on 5GHz, is
still answered and works normally. See doc/wifi.md.
The test asserts the client associates to "campus", that band steering lands
it on the 5GHz BSS (not the 2.4GHz one), and that it leases an address -- a
completed association plus DHCP lease is real bidirectional traffic over the
(relayed) radio link.
Topology:
....
(( 2.4GHz ))
host ==(mgmt)== ap ~ one cell ~ client ==(mgmt)== host
(( 5GHz )) (single dual-band radio)
....
"""
import base64
import infamy
import infamy.iface as iface
from infamy.util import until, parallel
SSID = "campus"
PSK = "infixinfix" # 8-63 printable ASCII, see doc/wifi.md
PSK_B64 = base64.b64encode(PSK.encode()).decode()
COUNTRY = "SE"
# Both BSSes are bridged into br0 behind one DHCP server: the two bands are
# a single subnet, so a lease proves the client reached the network. A
# two-address pool keeps the lease deterministic.
SUBNET = "192.168.30.0/24"
AP_IP = "192.168.30.1"
POOL_START = "192.168.30.100"
POOL_END = "192.168.30.101"
# hwsim defaults every radioN to the same address across guests, so the AP
# BSSIDs and the client's radio would collide without explicit addresses.
AP_BSSID_24 = "02:00:00:00:0a:01"
AP_BSSID_5 = "02:00:00:00:0b:01"
CLIENT_MAC = "02:00:00:00:00:0c"
def radio(name, band=None, channel=None):
"""ietf-hardware component for a radio."""
wifi_radio = {"country-code": COUNTRY}
if band:
wifi_radio["band"] = band
if channel is not None:
wifi_radio["channel"] = channel
return {
"name": name,
"class": "infix-hardware:wifi",
"infix-hardware:wifi-radio": wifi_radio,
}
def keystore():
return {
"keystore": {
"symmetric-keys": {
"symmetric-key": [{
"name": "wifi",
"key-format": "infix-crypto-types:passphrase-key-format",
"cleartext-symmetric-key": PSK_B64,
}]
}
}
}
def ap_bss(name, radio_name, bssid):
"""A band-steering AP BSS on the given radio, bridged into br0."""
return {
"name": name,
"type": "infix-if-type:wifi",
"enabled": True,
"infix-interfaces:custom-phys-address": {"static": bssid},
"infix-interfaces:wifi": {
"radio": radio_name,
"access-point": {
"ssid": SSID,
"security": {"mode": "wpa2-wpa3-personal", "secret": "wifi"},
# dot11v enables MBO band steering by default; the two BSSes
# share the SSID so band steering has another band to point at.
"roaming": {"dot11v": {}},
},
},
"infix-interfaces:bridge-port": {"bridge": "br0"},
}
def wifi_of(ifc):
return (ifc or {}).get("infix-interfaces:wifi") or (ifc or {}).get("wifi") or {}
def associated(dut, ifname):
sta = wifi_of(dut.get_iface(ifname)).get("station", {})
return sta.get("ssid") == SSID and sta.get("signal-strength") is not None
def ap_clients(dut, ifname):
"""MACs of the stations currently associated to this BSS."""
ap = wifi_of(dut.get_iface(ifname)).get("access-point") or {}
stations = (ap.get("stations") or {}).get("station") or []
return {s.get("mac-address", "").lower() for s in stations}
with infamy.Test() as test:
with test.step("Set up topology and attach to the ap and the client"):
env = infamy.Env()
ap, client = parallel(
lambda: env.attach("ap", "mgmt"),
lambda: env.attach("client", "mgmt"),
)
for dut in (ap, client):
if not dut.has_feature("infix-interfaces", "wifi"):
print("DUT does not advertise the 'wifi' feature -- skipping")
test.skip()
with test.step("Configure the dual-band AP: one BSS on 2.4GHz, one on 5GHz"):
# radio2 and radio3 are dut1's two extra radios, both wired to the
# dedicated band-steering cell (cell2) in test/virt/quad.
ap.put_config_dicts({
"ietf-hardware": {"hardware": {"component": [
radio("radio2", band="2.4GHz", channel=1),
radio("radio3", band="5GHz", channel=36),
]}},
"ietf-keystore": keystore(),
"ietf-interfaces": {"interfaces": {"interface": [
{"name": "br0", "type": "infix-if-type:bridge", "enabled": True,
"ietf-ip:ipv4": {"address": [
{"ip": AP_IP, "prefix-length": 24}]}},
ap_bss("wifi0", "radio2", AP_BSSID_24),
ap_bss("wifi1", "radio3", AP_BSSID_5),
]}},
"infix-dhcp-server": {"dhcp-server": {"subnet": [{
"subnet": SUBNET,
"pool": {"start-address": POOL_START, "end-address": POOL_END},
}]}},
})
with test.step("Configure the client with a single dual-band station radio"):
# radio2 is the client DUT's extra radio on the band-steering cell
# (cell2). No band/channel pinned: the one radio scans both bands and
# lets band steering decide where it lands.
client.put_config_dicts({
"ietf-hardware": {"hardware": {"component": [radio("radio2")]}},
"ietf-keystore": keystore(),
"ietf-interfaces": {"interfaces": {"interface": [{
"name": "wifi0",
"type": "infix-if-type:wifi",
"enabled": True,
"infix-interfaces:custom-phys-address": {"static": CLIENT_MAC},
"infix-interfaces:wifi": {
"radio": "radio2",
"station": {
"ssid": SSID,
"security": {"mode": "auto", "secret": "wifi"},
},
},
"ietf-ip:ipv4": {"infix-dhcp-client:dhcp": {}},
}]}},
})
with test.step("Verify the client associates to the 'campus' SSID"):
until(lambda: associated(client, "wifi0"), attempts=60, interval=2)
with test.step("Verify band steering put the client on the 5GHz BSS"):
# Steered: the client shows up on the 5GHz BSS (wifi1) and not on the
# 2.4GHz BSS (wifi0) -- the 2.4GHz BSS suppressed its probe responses
# once it had been seen on 5GHz.
until(lambda: CLIENT_MAC in ap_clients(ap, "wifi1"), attempts=90, interval=2)
assert CLIENT_MAC not in ap_clients(ap, "wifi0"), \
"client associated on 2.4GHz; band steering did not steer it to 5GHz"
with test.step("Verify the client leases an address over 5GHz"):
until(lambda: iface.is_oper_up(client, "wifi0"), attempts=30)
until(lambda: iface.address_exist(client, "wifi0", POOL_START) or
iface.address_exist(client, "wifi0", POOL_END),
attempts=60, interval=2)
test.succeed()
@@ -0,0 +1,54 @@
graph "wifi-band-steering" {
layout="neato";
overlap="false";
esep="+40";
node [shape=record, fontname="DejaVu Sans Mono, Book"];
edge [color="cornflowerblue", penwidth="2", fontname="DejaVu Serif, Book"];
host [
label="host | { <mgmt1> mgmt1 | <mgmt2> mgmt2 }",
pos="0,0!",
requires="controller",
];
// One dual-band Access Point with two radios on the *same* cell: one
// serves the SSID on 2.4GHz, the other serves the same SSID on 5GHz.
// "wifi-dualband" pins it to the quad DUT that wires two extra radios to
// the dedicated band-steering cell, the only node where both BSSes share
// one cell (see test/virt/quad).
ap [
label="{ <mgmt> mgmt | <lo> lo | <hi> hi } | ap",
pos="6,0!",
requires="infix wifi-dualband",
];
// One client with a single dual-band radio. Because both AP BSSes live
// on the one cell, this single radio hears the SSID on both bands and is
// the device band steering nudges onto 5GHz.
client [
label="{ <mgmt> mgmt | <wifi> wifi } | client",
pos="12,0!",
requires="infix",
];
// A single wireless cell carrying both bands. The medium is
// frequency-aware (the wifimedium relay preserves each frame's frequency
// and mac80211 channel-filters), so 2.4GHz and 5GHz coexist on one cell:
// * physical: one over-the-air neighbourhood the client scans;
// * virtual (qeneth): one multicast cell the AP's two radios and the
// client's radio all join.
cell [
label="cell\n(2.4GHz + 5GHz)",
pos="9,0!",
requires="wifi-radio",
];
host:mgmt1 -- ap:mgmt [requires="mgmt", color="lightgray"]
host:mgmt2 -- client:mgmt [requires="mgmt", color="lightgray"]
// Both AP radios and the client's single radio share the one cell.
ap:lo -- cell [requires="wifi2.4GHz", style="dashed"]
ap:hi -- cell [requires="wifi5GHz", style="dashed"]
client:wifi -- cell [requires="wifi2.4GHz wifi5GHz", style="dashed"]
}
@@ -0,0 +1,76 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Title: wifi&#45;band&#45;steering Pages: 1 -->
<svg width="1032pt" height="78pt"
viewBox="0.00 0.00 1032.09 78.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 74)">
<title>wifi&#45;band&#45;steering</title>
<polygon fill="white" stroke="transparent" points="-4,4 -4,-74 1028.09,-74 1028.09,4 -4,4"/>
<!-- host -->
<g id="node1" class="node">
<title>host</title>
<polygon fill="none" stroke="black" points="0,-12 0,-58 108,-58 108,-12 0,-12"/>
<text text-anchor="middle" x="25" y="-31.3" font-family="DejaVu Sans Mono, Book" font-size="14.00">host</text>
<polyline fill="none" stroke="black" points="50,-12 50,-58 "/>
<text text-anchor="middle" x="79" y="-42.8" font-family="DejaVu Sans Mono, Book" font-size="14.00">mgmt1</text>
<polyline fill="none" stroke="black" points="50,-35 108,-35 "/>
<text text-anchor="middle" x="79" y="-19.8" font-family="DejaVu Sans Mono, Book" font-size="14.00">mgmt2</text>
</g>
<!-- ap -->
<g id="node2" class="node">
<title>ap</title>
<polygon fill="none" stroke="black" points="468.55,-0.5 468.55,-69.5 551.55,-69.5 551.55,-0.5 468.55,-0.5"/>
<text text-anchor="middle" x="493.55" y="-54.3" font-family="DejaVu Sans Mono, Book" font-size="14.00">mgmt</text>
<polyline fill="none" stroke="black" points="468.55,-46.5 518.55,-46.5 "/>
<text text-anchor="middle" x="493.55" y="-31.3" font-family="DejaVu Sans Mono, Book" font-size="14.00">lo</text>
<polyline fill="none" stroke="black" points="468.55,-23.5 518.55,-23.5 "/>
<text text-anchor="middle" x="493.55" y="-8.3" font-family="DejaVu Sans Mono, Book" font-size="14.00">hi</text>
<polyline fill="none" stroke="black" points="518.55,-0.5 518.55,-69.5 "/>
<text text-anchor="middle" x="535.05" y="-31.3" font-family="DejaVu Sans Mono, Book" font-size="14.00">ap</text>
</g>
<!-- host&#45;&#45;ap -->
<g id="edge1" class="edge">
<title>host:mgmt1&#45;&#45;ap:mgmt</title>
<path fill="none" stroke="lightgray" stroke-width="2" d="M108,-47C108,-47 468.05,-58 468.05,-58"/>
</g>
<!-- client -->
<g id="node3" class="node">
<title>client</title>
<polygon fill="none" stroke="black" points="908.09,-12 908.09,-58 1024.09,-58 1024.09,-12 908.09,-12"/>
<text text-anchor="middle" x="933.09" y="-42.8" font-family="DejaVu Sans Mono, Book" font-size="14.00">mgmt</text>
<polyline fill="none" stroke="black" points="908.09,-35 958.09,-35 "/>
<text text-anchor="middle" x="933.09" y="-19.8" font-family="DejaVu Sans Mono, Book" font-size="14.00">wifi</text>
<polyline fill="none" stroke="black" points="958.09,-12 958.09,-58 "/>
<text text-anchor="middle" x="991.09" y="-31.3" font-family="DejaVu Sans Mono, Book" font-size="14.00">client</text>
</g>
<!-- host&#45;&#45;client -->
<g id="edge2" class="edge">
<title>host:mgmt2&#45;&#45;client:mgmt</title>
<path fill="none" stroke="lightgray" stroke-width="2" d="M108,-23C108,-23 908.09,-47 908.09,-47"/>
</g>
<!-- cell -->
<g id="node4" class="node">
<title>cell</title>
<polygon fill="none" stroke="black" points="668.07,-16 668.07,-54 808.07,-54 808.07,-16 668.07,-16"/>
<text text-anchor="middle" x="738.07" y="-38.8" font-family="DejaVu Sans Mono, Book" font-size="14.00">cell</text>
<text text-anchor="middle" x="738.07" y="-23.8" font-family="DejaVu Sans Mono, Book" font-size="14.00">(2.4GHz + 5GHz)</text>
</g>
<!-- ap&#45;&#45;cell -->
<g id="edge3" class="edge">
<title>ap:lo&#45;&#45;cell</title>
<path fill="none" stroke="cornflowerblue" stroke-width="2" stroke-dasharray="5,2" d="M468.05,-35C468.05,-35 587.82,-35 667.83,-35"/>
</g>
<!-- ap&#45;&#45;cell -->
<g id="edge4" class="edge">
<title>ap:hi&#45;&#45;cell</title>
<path fill="none" stroke="cornflowerblue" stroke-width="2" stroke-dasharray="5,2" d="M493.05,0C493.05,0 595.45,-14.63 667.85,-24.97"/>
</g>
<!-- client&#45;&#45;cell -->
<g id="edge5" class="edge">
<title>client:wifi&#45;&#45;cell</title>
<path fill="none" stroke="cornflowerblue" stroke-width="2" stroke-dasharray="5,2" d="M908.09,-23C908.09,-23 855.04,-26.74 808.2,-30.05"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.1 KiB

+1
View File
@@ -0,0 +1 @@
test.adoc
@@ -0,0 +1,62 @@
=== WiFi Mesh backhaul with roaming Access Points
ifdef::topdoc[:imagesdir: {topdoc}../../test/case/interfaces/wifi_mesh_roaming]
==== Description
The worked example from doc/whitepaper-wifi-mesh-roaming.md, as a test.
Three gateway nodes (gw1, gw2, gw3) each do two jobs on two radios:
* radio0 -- an 802.11s mesh point. The three join one mesh ("backhaul")
on 5GHz; this carries traffic between the nodes so only gw1 needs a wire
to the rest of the LAN.
* radio1 -- a WPA2/WPA3-personal Access Point on 2.4GHz. All three share
the same SSID ("campus") and 802.11r mobility domain, so a client roams
between them as one network.
Each node bridges its mesh and AP (and, on gw1, the wired uplink) into br0,
so the mesh is a transparent layer-2 backhaul. A fourth node is the client.
The test checks the claims the whitepaper makes:
1. the three nodes form a mesh (each sees its two peers);
2. the client associates to the "campus" SSID;
3. traffic reaches the client across the mesh backhaul (host behind gw1
pings the client, which is attached to some gw's AP);
4. roaming: the client reports the BSSID it is connected to, which is one
of the three gw APs; when that AP is taken down the client moves to
another node -- same SSID, same mobility domain -- so the reported
BSSID changes to a different gw, and traffic recovers.
In simulation every radio hears every other at one fixed strength, so there
is no signal gradient to drift the client between APs. Step 4 forces the
move instead, which is a stronger check: it proves a second AP accepts the
client and the backhaul re-converges.
Topology:
....
host ==(mgmt)== gw1 (mesh+AP) )) ~ mesh ~ (( gw2 (mesh+AP)
\\__(lan, wired)__/ \ /
) ~ mesh ~ (( gw3 (mesh+AP)
client (((roams between the gw APs)))
....
==== Topology
image::topology.svg[WiFi Mesh backhaul with roaming Access Points topology, align=center, scaledwidth=75%]
==== Sequence
. Set up topology and attach to gw1, gw2, gw3 and the client
. Configure gw1, gw2, gw3 as mesh nodes with a roaming AP
. Configure the client as a station for the 'campus' SSID
. Verify the three nodes form the mesh backhaul
. Verify the client associates to the 'campus' SSID
. Verify the client is connected to one of the campus APs
. Verify the client is reachable across the mesh
. Take down the client's current AP to force a roam
. Verify the client roams to another node's AP
. Verify connectivity is restored after roaming
+247
View File
@@ -0,0 +1,247 @@
#!/usr/bin/env python3
r"""
WiFi Mesh backhaul with roaming Access Points
The worked example from doc/whitepaper-wifi-mesh-roaming.md, as a test.
Three gateway nodes (gw1, gw2, gw3) each do two jobs on two radios:
* radio0 -- an 802.11s mesh point. The three join one mesh ("backhaul")
on 5GHz; this carries traffic between the nodes so only gw1 needs a wire
to the rest of the LAN.
* radio1 -- a WPA2/WPA3-personal Access Point on 2.4GHz. All three share
the same SSID ("campus") and 802.11r mobility domain, so a client roams
between them as one network.
Each node bridges its mesh and AP (and, on gw1, the wired uplink) into br0,
so the mesh is a transparent layer-2 backhaul. A fourth node is the client.
The test checks the claims the whitepaper makes:
1. the three nodes form a mesh (each sees its two peers);
2. the client associates to the "campus" SSID;
3. traffic reaches the client across the mesh backhaul (host behind gw1
pings the client, which is attached to some gw's AP);
4. roaming: the client reports the BSSID it is connected to, which is one
of the three gw APs; when that AP is taken down the client moves to
another node -- same SSID, same mobility domain -- so the reported
BSSID changes to a different gw, and traffic recovers.
In simulation every radio hears every other at one fixed strength, so there
is no signal gradient to drift the client between APs. Step 4 forces the
move instead, which is a stronger check: it proves a second AP accepts the
client and the backhaul re-converges.
Topology:
....
host ==(mgmt)== gw1 (mesh+AP) )) ~ mesh ~ (( gw2 (mesh+AP)
\\__(lan, wired)__/ \ /
) ~ mesh ~ (( gw3 (mesh+AP)
client (((roams between the gw APs)))
....
"""
import base64
import infamy
from infamy.util import until, parallel
SSID = "campus"
MESH_ID = "backhaul"
MESH_PSK = base64.b64encode(b"meshmeshmesh").decode()
WIFI_PSK = base64.b64encode(b"infixinfix").decode()
COUNTRY = "SE"
CLIENT_IP = "10.0.0.9"
HOST_IP = "10.0.0.1"
CLIENT_MAC = "02:00:00:00:00:09"
# Unique MACs: hwsim defaults every radioN to the same address across guests,
# so the mesh peers and the AP BSSIDs would collide without these. The AP MAC
# is the BSSID the client reports when associated to that node.
GWS = [
# name, mesh radio0 MAC, AP radio1 MAC (BSSID)
("gw1", "02:00:00:00:00:01", "02:00:00:00:0a:01"),
("gw2", "02:00:00:00:00:02", "02:00:00:00:0a:02"),
("gw3", "02:00:00:00:00:03", "02:00:00:00:0a:03"),
]
def radio(name, band, channel):
return {
"name": name,
"class": "infix-hardware:wifi",
"infix-hardware:wifi-radio": {
"country-code": COUNTRY, "band": band, "channel": channel,
},
}
def keystore():
return {"keystore": {"symmetric-keys": {"symmetric-key": [
{"name": "mesh-secret",
"key-format": "infix-crypto-types:passphrase-key-format",
"cleartext-symmetric-key": MESH_PSK},
{"name": "wifi-secret",
"key-format": "infix-crypto-types:passphrase-key-format",
"cleartext-symmetric-key": WIFI_PSK},
]}}}
def gw_config(mesh_mac, ap_mac, uplink=None):
interfaces = [
{"name": "br0", "type": "infix-if-type:bridge", "enabled": True},
{
"name": "wifi0", "type": "infix-if-type:wifi", "enabled": True,
"infix-interfaces:custom-phys-address": {"static": mesh_mac},
"infix-interfaces:wifi": {
"radio": "radio0",
"mesh-point": {
"mesh-id": MESH_ID,
"security": {"secret": "mesh-secret"},
},
},
"infix-interfaces:bridge-port": {"bridge": "br0"},
},
{
"name": "wifi1", "type": "infix-if-type:wifi", "enabled": True,
"infix-interfaces:custom-phys-address": {"static": ap_mac},
"infix-interfaces:wifi": {
"radio": "radio1",
"access-point": {
"ssid": SSID,
"security": {"mode": "wpa2-wpa3-personal", "secret": "wifi-secret"},
"roaming": {
"dot11r": {"mobility-domain": "hash"},
"dot11k": {},
"dot11v": {},
},
},
},
"infix-interfaces:bridge-port": {"bridge": "br0"},
},
]
if uplink:
interfaces.append({
"name": uplink, "enabled": True,
"infix-interfaces:bridge-port": {"bridge": "br0"},
})
return {
"ietf-hardware": {"hardware": {"component": [
radio("radio0", "5GHz", 36),
radio("radio1", "2.4GHz", 1),
]}},
"ietf-keystore": keystore(),
"ietf-interfaces": {"interfaces": {"interface": interfaces}},
}
def wifi_of(ifc):
return (ifc or {}).get("infix-interfaces:wifi") or (ifc or {}).get("wifi") or {}
def mesh_peers(dut):
mp = wifi_of(dut.get_iface("wifi0")).get("mesh-point") or {}
return (mp.get("peers") or {}).get("peer") or []
def station(dut, ifname="wifi0"):
return wifi_of(dut.get_iface(ifname)).get("station", {})
def station_bssid(dut):
"""The BSSID the client's station is currently associated to."""
return (station(dut).get("bssid") or "").lower()
with infamy.Test() as test:
with test.step("Set up topology and attach to gw1, gw2, gw3 and the client"):
env = infamy.Env()
# Connect to all four nodes concurrently -- each attach probes the
# node and downloads its YANG models, so doing them in parallel cuts
# the setup time roughly four-fold.
gw1, gw2, gw3, client = parallel(
lambda: env.attach("gw1", "mgmt"),
lambda: env.attach("gw2", "mgmt"),
lambda: env.attach("gw3", "mgmt"),
lambda: env.attach("client", "mgmt"),
)
gw_duts = [gw1, gw2, gw3]
gws = [(name, dut, mesh, ap) for (name, mesh, ap), dut in zip(GWS, gw_duts)]
for dut in gw_duts + [client]:
if not dut.has_feature("infix-interfaces", "wifi"):
print("DUT does not advertise the 'wifi' feature -- skipping")
test.skip()
with test.step("Configure gw1, gw2, gw3 as mesh nodes with a roaming AP"):
_, gw1_uplink = env.ltop.xlate("gw1", "uplink")
for name, dut, mesh_mac, ap_mac in gws:
dut.put_config_dicts(gw_config(mesh_mac, ap_mac,
uplink=gw1_uplink if name == "gw1" else None))
with test.step("Configure the client as a station for the 'campus' SSID"):
# The client joins the gw APs, which run on radio1 (2.4GHz). In the
# virtual topology a wireless cell is shared per radio index, so the
# client's station must use radio1 too -- a station and the AP it
# associates to live in the same cell only when they share an index.
# See doc/wifi.md and test/virt/quad.
client.put_config_dicts({
"ietf-hardware": {"hardware": {"component": [radio("radio1", "2.4GHz", 1)]}},
"ietf-keystore": keystore(),
"ietf-interfaces": {"interfaces": {"interface": [{
"name": "wifi0", "type": "infix-if-type:wifi", "enabled": True,
"infix-interfaces:custom-phys-address": {"static": CLIENT_MAC},
"infix-interfaces:wifi": {
"radio": "radio1",
"station": {
"ssid": SSID,
"security": {"mode": "auto", "secret": "wifi-secret"},
},
},
"ietf-ip:ipv4": {"address": [{"ip": CLIENT_IP, "prefix-length": 24}]},
}]}},
})
with test.step("Verify the three nodes form the mesh backhaul"):
for name, dut, _, _ in gws:
until(lambda dut=dut: len(mesh_peers(dut)) >= 2, attempts=60, interval=2)
with test.step("Verify the client associates to the 'campus' SSID"):
def associated():
sta = station(client)
return sta.get("ssid") == SSID and sta.get("signal-strength") is not None
until(associated, attempts=60, interval=2)
# The client reports the BSSID it is on; with all three APs sharing the
# SSID, that BSSID is what tells them apart.
aps = {ap_mac.lower(): (name, dut) for name, dut, _, ap_mac in gws}
with test.step("Verify the client is connected to one of the campus APs"):
until(lambda: station_bssid(client) in aps, attempts=60, interval=2)
first_bssid = station_bssid(client)
first_ap, first_dut = aps[first_bssid]
print(f"client is on {first_ap} ({first_bssid})")
# The host sits on the wired LAN behind gw1; reaching the client proves
# the frames cross the mesh backhaul (the client may be on any gw's AP).
_, hlan = env.ltop.xlate("host", "lan")
with infamy.IsolatedMacVlan(hlan) as ns:
ns.addip(HOST_IP)
with test.step("Verify the client is reachable across the mesh"):
ns.must_reach(CLIENT_IP)
with test.step("Take down the client's current AP to force a roam"):
first_dut.put_config_dicts({"ietf-interfaces": {"interfaces": {
"interface": [{"name": "wifi1", "enabled": False}]}}})
with test.step("Verify the client roams to another node's AP"):
until(lambda: station_bssid(client) in aps and station_bssid(client) != first_bssid,
attempts=90, interval=2)
new_ap, _ = aps[station_bssid(client)]
print(f"client roamed from {first_ap} to {new_ap}")
with test.step("Verify connectivity is restored after roaming"):
ns.must_reach(CLIENT_IP)
test.succeed()
@@ -0,0 +1,79 @@
graph "wifi-mesh-roaming" {
layout="neato";
overlap="false";
esep="+40";
node [shape=record, fontname="DejaVu Sans Mono, Book"];
edge [color="cornflowerblue", penwidth="2", fontname="DejaVu Serif, Book"];
host [
label="host | { <mgmt1> mgmt1 | <mgmt2> mgmt2 | <mgmt3> mgmt3 | <mgmt4> mgmt4 | <lan> lan }",
pos="0,0!",
requires="controller",
];
gw1 [
label="{ <mgmt> mgmt | <uplink> uplink | <mesh> mesh | <ap> ap } | gw1",
pos="6,4!",
requires="infix",
];
gw2 [
label="{ <mgmt> mgmt | <mesh> mesh | <ap> ap } | gw2",
pos="9,0!",
requires="infix",
];
gw3 [
label="{ <mgmt> mgmt | <mesh> mesh | <ap> ap } | gw3",
pos="6,-4!",
requires="infix",
];
client [
label="{ <mgmt> mgmt | <wifi> wifi } | client",
pos="13,0!",
requires="infix",
];
// Two wireless cells, each a medium node the relevant radios join. The
// three gw nodes form the 802.11s mesh backhaul on their radio0 (the
// "mesh" cell); their roaming AP and the client all live on radio1 (the
// "ap" cell), so the client is in range of every gw's AP and can roam.
// Communicating radios share an index -> the same cell (see doc/wifi.md):
// * physical: maps onto two over-the-air RF cells;
// * virtual (qeneth): maps onto two multicast cells the wifimedium
// relay bridges mac80211_hwsim frames over.
mesh [
label="mesh-cell",
pos="6,0!",
requires="wifi-radio",
];
ap [
label="ap-cell",
pos="11,0!",
requires="wifi-radio",
];
host:mgmt1 -- gw1:mgmt [requires="mgmt", color="lightgray"]
host:mgmt2 -- gw2:mgmt [requires="mgmt", color="lightgray"]
host:mgmt3 -- gw3:mgmt [requires="mgmt", color="lightgray"]
host:mgmt4 -- client:mgmt [requires="mgmt", color="lightgray"]
// Wired uplink: the rest of the LAN sits behind gw1. gw2/gw3 reach it
// only over the mesh backhaul, so traffic to a client on their APs must
// cross the mesh.
host:lan -- gw1:uplink [color="black"]
// Mesh backhaul (radio0, 5GHz)
gw1:mesh -- mesh [requires="wifi5GHz", style="dashed"]
gw2:mesh -- mesh [requires="wifi5GHz", style="dashed"]
gw3:mesh -- mesh [requires="wifi5GHz", style="dashed"]
// Roaming AP cell (radio1, 2.4GHz): the gw APs and the client
gw1:ap -- ap [requires="wifi2.4GHz", style="dashed"]
gw2:ap -- ap [requires="wifi2.4GHz", style="dashed"]
gw3:ap -- ap [requires="wifi2.4GHz", style="dashed"]
client:wifi -- ap [requires="wifi2.4GHz", style="dashed"]
}
@@ -0,0 +1,148 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Title: wifi&#45;mesh&#45;roaming Pages: 1 -->
<svg width="1388pt" height="870pt"
viewBox="0.00 0.00 1387.63 869.58" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 865.58)">
<title>wifi&#45;mesh&#45;roaming</title>
<polygon fill="white" stroke="transparent" points="-4,4 -4,-865.58 1383.63,-865.58 1383.63,4 -4,4"/>
<!-- host -->
<g id="node1" class="node">
<title>host</title>
<polygon fill="none" stroke="black" points="0,-367.54 0,-482.54 108,-482.54 108,-367.54 0,-367.54"/>
<text text-anchor="middle" x="25" y="-421.34" font-family="DejaVu Sans Mono, Book" font-size="14.00">host</text>
<polyline fill="none" stroke="black" points="50,-367.54 50,-482.54 "/>
<text text-anchor="middle" x="79" y="-467.34" font-family="DejaVu Sans Mono, Book" font-size="14.00">mgmt1</text>
<polyline fill="none" stroke="black" points="50,-459.54 108,-459.54 "/>
<text text-anchor="middle" x="79" y="-444.34" font-family="DejaVu Sans Mono, Book" font-size="14.00">mgmt2</text>
<polyline fill="none" stroke="black" points="50,-436.54 108,-436.54 "/>
<text text-anchor="middle" x="79" y="-421.34" font-family="DejaVu Sans Mono, Book" font-size="14.00">mgmt3</text>
<polyline fill="none" stroke="black" points="50,-413.54 108,-413.54 "/>
<text text-anchor="middle" x="79" y="-398.34" font-family="DejaVu Sans Mono, Book" font-size="14.00">mgmt4</text>
<polyline fill="none" stroke="black" points="50,-390.54 108,-390.54 "/>
<text text-anchor="middle" x="79" y="-375.34" font-family="DejaVu Sans Mono, Book" font-size="14.00">lan</text>
</g>
<!-- gw1 -->
<g id="node2" class="node">
<title>gw1</title>
<polygon fill="none" stroke="black" points="585.56,-769.08 585.56,-861.08 692.56,-861.08 692.56,-769.08 585.56,-769.08"/>
<text text-anchor="middle" x="618.56" y="-845.88" font-family="DejaVu Sans Mono, Book" font-size="14.00">mgmt</text>
<polyline fill="none" stroke="black" points="585.56,-838.08 651.56,-838.08 "/>
<text text-anchor="middle" x="618.56" y="-822.88" font-family="DejaVu Sans Mono, Book" font-size="14.00">uplink</text>
<polyline fill="none" stroke="black" points="585.56,-815.08 651.56,-815.08 "/>
<text text-anchor="middle" x="618.56" y="-799.88" font-family="DejaVu Sans Mono, Book" font-size="14.00">mesh</text>
<polyline fill="none" stroke="black" points="585.56,-792.08 651.56,-792.08 "/>
<text text-anchor="middle" x="618.56" y="-776.88" font-family="DejaVu Sans Mono, Book" font-size="14.00">ap</text>
<polyline fill="none" stroke="black" points="651.56,-769.08 651.56,-861.08 "/>
<text text-anchor="middle" x="672.06" y="-811.38" font-family="DejaVu Sans Mono, Book" font-size="14.00">gw1</text>
</g>
<!-- host&#45;&#45;gw1 -->
<g id="edge1" class="edge">
<title>host:mgmt1&#45;&#45;gw1:mgmt</title>
<path fill="none" stroke="lightgray" stroke-width="2" d="M108,-471.04C108,-471.04 585.06,-850.08 585.06,-850.08"/>
</g>
<!-- host&#45;&#45;gw1 -->
<g id="edge5" class="edge">
<title>host:lan&#45;&#45;gw1:uplink</title>
<path fill="none" stroke="black" stroke-width="2" d="M108,-379.04C108,-379.04 585.06,-827.08 585.06,-827.08"/>
</g>
<!-- gw2 -->
<g id="node3" class="node">
<title>gw2</title>
<polygon fill="none" stroke="black" points="886.09,-390.54 886.09,-459.54 977.09,-459.54 977.09,-390.54 886.09,-390.54"/>
<text text-anchor="middle" x="911.09" y="-444.34" font-family="DejaVu Sans Mono, Book" font-size="14.00">mgmt</text>
<polyline fill="none" stroke="black" points="886.09,-436.54 936.09,-436.54 "/>
<text text-anchor="middle" x="911.09" y="-421.34" font-family="DejaVu Sans Mono, Book" font-size="14.00">mesh</text>
<polyline fill="none" stroke="black" points="886.09,-413.54 936.09,-413.54 "/>
<text text-anchor="middle" x="911.09" y="-398.34" font-family="DejaVu Sans Mono, Book" font-size="14.00">ap</text>
<polyline fill="none" stroke="black" points="936.09,-390.54 936.09,-459.54 "/>
<text text-anchor="middle" x="956.59" y="-421.34" font-family="DejaVu Sans Mono, Book" font-size="14.00">gw2</text>
</g>
<!-- host&#45;&#45;gw2 -->
<g id="edge2" class="edge">
<title>host:mgmt2&#45;&#45;gw2:mgmt</title>
<path fill="none" stroke="lightgray" stroke-width="2" d="M108,-448.04C108,-448.04 885.59,-448.04 885.59,-448.04"/>
</g>
<!-- gw3 -->
<g id="node4" class="node">
<title>gw3</title>
<polygon fill="none" stroke="black" points="593.56,-0.5 593.56,-69.5 684.56,-69.5 684.56,-0.5 593.56,-0.5"/>
<text text-anchor="middle" x="618.56" y="-54.3" font-family="DejaVu Sans Mono, Book" font-size="14.00">mgmt</text>
<polyline fill="none" stroke="black" points="593.56,-46.5 643.56,-46.5 "/>
<text text-anchor="middle" x="618.56" y="-31.3" font-family="DejaVu Sans Mono, Book" font-size="14.00">mesh</text>
<polyline fill="none" stroke="black" points="593.56,-23.5 643.56,-23.5 "/>
<text text-anchor="middle" x="618.56" y="-8.3" font-family="DejaVu Sans Mono, Book" font-size="14.00">ap</text>
<polyline fill="none" stroke="black" points="643.56,-0.5 643.56,-69.5 "/>
<text text-anchor="middle" x="664.06" y="-31.3" font-family="DejaVu Sans Mono, Book" font-size="14.00">gw3</text>
</g>
<!-- host&#45;&#45;gw3 -->
<g id="edge3" class="edge">
<title>host:mgmt3&#45;&#45;gw3:mgmt</title>
<path fill="none" stroke="lightgray" stroke-width="2" d="M108,-425.04C108,-425.04 593.06,-58 593.06,-58"/>
</g>
<!-- client -->
<g id="node5" class="node">
<title>client</title>
<polygon fill="none" stroke="black" points="1263.63,-402.04 1263.63,-448.04 1379.63,-448.04 1379.63,-402.04 1263.63,-402.04"/>
<text text-anchor="middle" x="1288.63" y="-432.84" font-family="DejaVu Sans Mono, Book" font-size="14.00">mgmt</text>
<polyline fill="none" stroke="black" points="1263.63,-425.04 1313.63,-425.04 "/>
<text text-anchor="middle" x="1288.63" y="-409.84" font-family="DejaVu Sans Mono, Book" font-size="14.00">wifi</text>
<polyline fill="none" stroke="black" points="1313.63,-402.04 1313.63,-448.04 "/>
<text text-anchor="middle" x="1346.63" y="-421.34" font-family="DejaVu Sans Mono, Book" font-size="14.00">client</text>
</g>
<!-- host&#45;&#45;client -->
<g id="edge4" class="edge">
<title>host:mgmt4&#45;&#45;client:mgmt</title>
<path fill="none" stroke="lightgray" stroke-width="2" d="M108,-402.04C108,-402.04 1263.63,-437.04 1263.63,-437.04"/>
</g>
<!-- mesh -->
<g id="node6" class="node">
<title>mesh</title>
<polygon fill="none" stroke="black" points="593.56,-407.04 593.56,-443.04 684.56,-443.04 684.56,-407.04 593.56,-407.04"/>
<text text-anchor="middle" x="639.06" y="-421.34" font-family="DejaVu Sans Mono, Book" font-size="14.00">mesh&#45;cell</text>
</g>
<!-- gw1&#45;&#45;mesh -->
<g id="edge6" class="edge">
<title>gw1:mesh&#45;&#45;mesh</title>
<path fill="none" stroke="cornflowerblue" stroke-width="2" stroke-dasharray="5,2" d="M585.06,-803.08C585.06,-803.08 625.67,-518.79 636.45,-443.27"/>
</g>
<!-- ap -->
<g id="node7" class="node">
<title>ap</title>
<polygon fill="none" stroke="black" points="1089.61,-407.04 1089.61,-443.04 1163.61,-443.04 1163.61,-407.04 1089.61,-407.04"/>
<text text-anchor="middle" x="1126.61" y="-421.34" font-family="DejaVu Sans Mono, Book" font-size="14.00">ap&#45;cell</text>
</g>
<!-- gw1&#45;&#45;ap -->
<g id="edge9" class="edge">
<title>gw1:ap&#45;&#45;ap</title>
<path fill="none" stroke="cornflowerblue" stroke-width="2" stroke-dasharray="5,2" d="M618.06,-769.08C618.06,-769.08 995.34,-513.84 1099.98,-443.05"/>
</g>
<!-- gw2&#45;&#45;mesh -->
<g id="edge7" class="edge">
<title>gw2:mesh&#45;&#45;mesh</title>
<path fill="none" stroke="cornflowerblue" stroke-width="2" stroke-dasharray="5,2" d="M885.59,-425.04C885.59,-425.04 755.8,-425.04 684.56,-425.04"/>
</g>
<!-- gw2&#45;&#45;ap -->
<g id="edge10" class="edge">
<title>gw2:ap&#45;&#45;ap</title>
<path fill="none" stroke="cornflowerblue" stroke-width="2" stroke-dasharray="5,2" d="M910.59,-390.04C910.59,-390.04 1027.71,-409.02 1089.47,-419.02"/>
</g>
<!-- gw3&#45;&#45;mesh -->
<g id="edge8" class="edge">
<title>gw3:mesh&#45;&#45;mesh</title>
<path fill="none" stroke="cornflowerblue" stroke-width="2" stroke-dasharray="5,2" d="M593.06,-35C593.06,-35 627.89,-330.3 636.93,-407.01"/>
</g>
<!-- gw3&#45;&#45;ap -->
<g id="edge11" class="edge">
<title>gw3:ap&#45;&#45;ap</title>
<path fill="none" stroke="cornflowerblue" stroke-width="2" stroke-dasharray="5,2" d="M618.06,0C618.06,0 1007.42,-325.42 1104.76,-406.78"/>
</g>
<!-- client&#45;&#45;ap -->
<g id="edge12" class="edge">
<title>client:wifi&#45;&#45;ap</title>
<path fill="none" stroke="cornflowerblue" stroke-width="2" stroke-dasharray="5,2" d="M1263.63,-413.04C1263.63,-413.04 1204.27,-418.24 1163.69,-421.79"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 8.6 KiB

+7 -3
View File
@@ -44,10 +44,14 @@ class Topology:
self.g.add_node(name, **attrs)
for e in self.dotg.get_edges():
sn, sp = e.get_source().split(":")
dn, dp = e.get_destination().split(":")
attrs = {_qstrip(k): _qstrip(v) for k, v in e.get_attributes().items()}
# An endpoint may be port-less, e.g. a medium node that models a
# shared wireless cell ("dut1:radio0 -- cell0"): the cell is just a
# node every radio joins, it carries no port of its own.
sn, _, sp = e.get_source().partition(":")
dn, _, dp = e.get_destination().partition(":")
attrs[sn] = sp
attrs[dn] = dp
+10
View File
@@ -15,6 +15,15 @@ truncate -s 0 $imgdir/{{name}}.mactab
echo "{{qn_name}} {{qn_mac}}" >>$imgdir/{{name}}.mactab
{{/links}}
# Number of mac80211_hwsim radios for this node = radioN carrier ports the
# topology wired to it. Read at boot by 00-hwsim (fw_cfg opt/wifi); 0 means
# no radios, so the node stays wifi-free.
radios=0
{{#links}}
case "{{qn_name}}" in radio[0-9]*) radios=$((radios + 1));; esac
{{/links}}
echo "$radios" > $imgdir/{{name}}.wifi
{{#qn_gps}}
mkfifo /tmp/{{name}}-gps.in /tmp/{{name}}-gps.out 2>/dev/null
mkfifo /tmp/{{name}}-gps1.in /tmp/{{name}}-gps1.out 2>/dev/null
@@ -24,6 +33,7 @@ exec qemu-system-x86_64 -M pc,accel=kvm:tcg -cpu max \
-m {{#qn_mem}}{{qn_mem}}{{/qn_mem}}{{^qn_mem}}256M{{/qn_mem}} \
{{> ../qeneth/templates/inc/qemu-links}}
-fw_cfg name=opt/mactab,file=$imgdir/{{name}}.mactab \
-fw_cfg name=opt/wifi,file=$imgdir/{{name}}.wifi \
-bios $bios \
-drive file={{name}}.qcow2,if=virtio,format=qcow2,bus=0,unit=1 \
$usb_cmd \
@@ -18,6 +18,15 @@ truncate -s 0 $imgdir/{{name}}.mactab
echo "{{qn_name}} {{qn_mac}}" >>$imgdir/{{name}}.mactab
{{/links}}
# Number of mac80211_hwsim radios for this node = radioN carrier ports the
# topology wired to it. Read at boot by 00-hwsim (fw_cfg opt/wifi); 0 means
# no radios, so the node stays wifi-free.
radios=0
{{#links}}
case "{{qn_name}}" in radio[0-9]*) radios=$((radios + 1));; esac
{{/links}}
echo "$radios" > $imgdir/{{name}}.wifi
exec qemu-system-x86_64 -M pc,accel=kvm:tcg -cpu max \
-m {{#qn_mem}}{{qn_mem}}{{/qn_mem}}{{^qn_mem}}384M{{/qn_mem}} \
-kernel "bzImage" -initrd $img \
@@ -25,6 +34,7 @@ exec qemu-system-x86_64 -M pc,accel=kvm:tcg -cpu max \
-drive file={{name}}.disk,if=virtio,format=raw,bus=0,unit=1 \
{{> ../qeneth/templates/inc/qemu-links}}
-fw_cfg name=opt/mactab,file=$imgdir/{{name}}.mactab \
-fw_cfg name=opt/wifi,file=$imgdir/{{name}}.wifi \
$usb_cmd \
{{> ../qeneth/templates/inc/infix-common}}
{{> ../qeneth/templates/inc/qemu-console}}
+10
View File
@@ -18,6 +18,15 @@ truncate -s 0 $imgdir/{{name}}.mactab
echo "{{qn_name}} {{qn_mac}}" >>$imgdir/{{name}}.mactab
{{/links}}
# Number of mac80211_hwsim radios for this node = radioN carrier ports the
# topology wired to it. Read at boot by 00-hwsim (fw_cfg opt/wifi); 0 means
# no radios, so the node stays wifi-free.
radios=0
{{#links}}
case "{{qn_name}}" in radio[0-9]*) radios=$((radios + 1));; esac
{{/links}}
echo "$radios" > $imgdir/{{name}}.wifi
exec qemu-system-x86_64 -M pc,accel=kvm:tcg -cpu max \
-m {{#qn_mem}}{{qn_mem}}{{/qn_mem}}{{^qn_mem}}384M{{/qn_mem}} \
-kernel $imgdir/boot/bzImage -initrd $img \
@@ -25,5 +34,6 @@ exec qemu-system-x86_64 -M pc,accel=kvm:tcg -cpu max \
-drive file={{name}}.disk,if=virtio,format=raw,bus=0,unit=1 \
{{> inc/qemu-links}}
-fw_cfg name=opt/mactab,file=$imgdir/{{name}}.mactab \
-fw_cfg name=opt/wifi,file=$imgdir/{{name}}.wifi \
$usb_cmd \
{{> inc/infix-common}}
+1 -1
View File
@@ -18,7 +18,7 @@ xpaths_all_csv := $(test-dir)/.log/xpaths_all.csv
base := -b $(base-dir)
TEST_MODE ?= qeneth
mode-qeneth := -q $(test-dir)/virt/quad
mode-qeneth := -q $(or $(QTOPOLOGY),$(test-dir)/virt/quad)
mode-host := -t $(or $(TOPOLOGY),/etc/infamy.dot)
mode-run := -t $(BINARIES_DIR)/qemu.dot
mode := $(mode-$(TEST_MODE))
+60 -9
View File
@@ -21,28 +21,34 @@ graph "quad" {
];
dut1 [
label="{ <e1> e1 | <e2> e2 | <e3> e3 | <e4> e4 } | dut1 | { <e5> e5 | <e6> e6 | <e7> e7 | <e8> e8}",
label="{ <e1> e1 | <e2> e2 | <e3> e3 | <e4> e4 } | dut1 | { <e5> e5 | <e6> e6 | <e7> e7 | <e8> e8 } | { <radio0> radio0 | <radio1> radio1 | <radio2> radio2 | <radio3> radio3 }",
pos="10,30!",
provides="infix watchdog gps",
provides="infix watchdog gps wifi wifi-dualband",
expected_boot="primary",
qn_console=9001,
qn_mem="384M",
qn_usb="dut1.usb",
qn_gps="true"
// NB: dut1 has two extra radios, radio2 and radio3, wired to the
// dedicated cell2 (below). With both on one cell this DUT runs a
// dual-band AP whose 2.4GHz and 5GHz BSSes share a single cell -- the
// only way a single-radio client can hear both bands and be band-
// steered (wifi_band_steering). radio0/radio1 stay on the general
// cells. The hwsim radio count is raised in package/feature-wifi.
];
dut2 [
label="{ <e1> e1 | <e2> e2 | <e3> e3 | <e4> e4 } | dut2 | { <e5> e5 | <e6> e6 | <e7> e7 | <e8> e8}",
label="{ <e1> e1 | <e2> e2 | <e3> e3 | <e4> e4 } | dut2 | { <e5> e5 | <e6> e6 | <e7> e7 | <e8> e8 } | { <radio0> radio0 | <radio1> radio1 | <radio2> radio2 }",
pos="0,20!",
provides="infix watchdog",
provides="infix watchdog wifi",
expected_boot="primary",
qn_console=9002,
qn_mem="384M",
qn_usb="dut2.usb"
];
dut3 [
label="{ <e1> e1 | <e2> e2 | <e3> e3 | <e4> e4 } | dut3 | { <e5> e5 | <e6> e6 | <e7> e7 | <e8> e8}",
label="{ <e1> e1 | <e2> e2 | <e3> e3 | <e4> e4 } | dut3 | { <e5> e5 | <e6> e6 | <e7> e7 | <e8> e8 } | { <radio0> radio0 | <radio1> radio1 }",
pos="0,10!",
provides="infix watchdog",
provides="infix watchdog wifi",
expected_boot="primary",
qn_console=9003,
qn_mem="384M",
@@ -50,15 +56,30 @@ graph "quad" {
];
dut4 [
label="{ <e1> e1 | <e2> e2 | <e3> e3 | <e4> e4 } | dut4 | { <e5> e5 | <e6> e6 | <e7> e7 | <e8> e8}",
label="{ <e1> e1 | <e2> e2 | <e3> e3 | <e4> e4 } | dut4 | { <e5> e5 | <e6> e6 | <e7> e7 | <e8> e8 } | { <radio0> radio0 | <radio1> radio1 }",
pos="10,0!",
provides="infix watchdog",
provides="infix watchdog wifi",
expected_boot="primary",
qn_console=9004,
qn_mem="384M",
qn_usb="dut4.usb"
];
// Wireless cells. A node with qn_hub="true" is a shared multicast
// medium (one QEMU socket multicast group), not a guest: every radio
// wired to it is "in range" of every other, like a real RF cell.
cell0 [label="cell0\n(radio0)", provides="wifi-radio", qn_hub="true",
pos="-6,22!", color="cornflowerblue", fontcolor="cornflowerblue", style="dashed"];
cell1 [label="cell1\n(radio1)", provides="wifi-radio", qn_hub="true",
pos="-6,8!", color="cornflowerblue", fontcolor="cornflowerblue", style="dashed"];
// Dedicated band-steering cell, separate from the general cells above so
// the AP's shared SSID never leaks onto cell0/cell1. dut1 wires two
// radios here for a dual-band AP (radio2=2.4GHz, radio3=5GHz) and dut2
// joins with a single dual-band radio (radio2) as the client.
cell2 [label="cell2\n(band steering)", provides="wifi-radio", qn_hub="true",
pos="-6,15!", color="mediumseagreen", fontcolor="mediumseagreen", style="dashed"];
host:d1a -- dut1:e1 [provides="mgmt", color="lightgray"]
host:d1b -- dut1:e2 [provides="ieee-mc"]
host:d1c -- dut1:e3 [provides="ieee-mc"]
@@ -79,7 +100,10 @@ graph "quad" {
host:d4c -- dut4:e3 [provides="ieee-mc"]
host:d4d -- dut4:e4 [provides="ieee-mc"]
// Lag
// Wired inter-DUT fabric (full mesh): ring + cross-links + a two-link
// lag between dut2 and dut3. General-purpose links the non-wifi
// multi-DUT tests map onto; kept plain (wifi rides the cells above).
// Lag
dut2:e7 -- dut3:e6 [color="black", penwidth="3"]
dut2:e8 -- dut3:e5 [color="black", penwidth="3"]
@@ -91,4 +115,31 @@ graph "quad" {
// Cross-links
dut1:e6 -- dut3:e7 [color="red"]
dut2:e6 -- dut4:e7 [color="red"]
// Wireless adjacency: every radio joins its cell, so all radios on the
// same index hear each other (full mesh per cell). The carrier port is
// named after the radio, so it becomes the Linux netdev radioN that the
// wifimedium relay pairs with phy radioN.
// provides lists the bands each radio supports, one wifi<band> token per
// band (mac80211_hwsim is tri-band). On real hardware a band-specific
// radio advertises only its own bands, so a test requiring e.g. wifi5GHz
// maps only onto a 5GHz-capable radio.
dut1:radio0 -- cell0 [provides="wifi2.4GHz wifi5GHz wifi6GHz"]
dut2:radio0 -- cell0 [provides="wifi2.4GHz wifi5GHz wifi6GHz"]
dut3:radio0 -- cell0 [provides="wifi2.4GHz wifi5GHz wifi6GHz"]
dut4:radio0 -- cell0 [provides="wifi2.4GHz wifi5GHz wifi6GHz"]
dut1:radio1 -- cell1 [provides="wifi2.4GHz wifi5GHz wifi6GHz"]
dut2:radio1 -- cell1 [provides="wifi2.4GHz wifi5GHz wifi6GHz"]
dut3:radio1 -- cell1 [provides="wifi2.4GHz wifi5GHz wifi6GHz"]
dut4:radio1 -- cell1 [provides="wifi2.4GHz wifi5GHz wifi6GHz"]
// Band-steering cell: dut1's two extra radios form a dual-band AP
// (radio2=2.4GHz, radio3=5GHz) whose BSSes share this one cell, and
// dut2's extra radio (radio2) is the single-radio client that hears
// both bands. Only dut1 has two radios here, so it is the sole node the
// dual-band AP can map onto (wifi_band_steering).
dut1:radio2 -- cell2 [provides="wifi2.4GHz wifi5GHz wifi6GHz"]
dut1:radio3 -- cell2 [provides="wifi2.4GHz wifi5GHz wifi6GHz"]
dut2:radio2 -- cell2 [provides="wifi2.4GHz wifi5GHz wifi6GHz"]
}
+231 -133
View File
@@ -4,251 +4,349 @@
<!-- Generated by graphviz version 2.43.0 (0)
-->
<!-- Title: quad Pages: 1 -->
<svg width="542pt" height="560pt"
viewBox="0.00 0.00 541.97 560.05" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 556.05)">
<svg width="1001pt" height="1199pt"
viewBox="0.00 0.00 1000.71 1199.35" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 1195.35)">
<title>quad</title>
<polygon fill="white" stroke="transparent" points="-4,4 -4,-556.05 537.97,-556.05 537.97,4 -4,4"/>
<polygon fill="white" stroke="transparent" points="-4,4 -4,-1195.35 996.71,-1195.35 996.71,4 -4,4"/>
<!-- host -->
<g id="node1" class="node">
<title>host</title>
<polygon fill="none" stroke="grey" points="0,-92.02 0,-460.02 91,-460.02 91,-92.02 0,-92.02"/>
<text text-anchor="middle" x="25" y="-272.32" font-family="monospace" font-size="14.00" fill="grey">host</text>
<polyline fill="none" stroke="grey" points="50,-92.02 50,-460.02 "/>
<text text-anchor="middle" x="70.5" y="-444.82" font-family="monospace" font-size="14.00" fill="grey">d1a</text>
<polyline fill="none" stroke="grey" points="50,-437.02 91,-437.02 "/>
<text text-anchor="middle" x="70.5" y="-421.82" font-family="monospace" font-size="14.00" fill="grey">d1b</text>
<polyline fill="none" stroke="grey" points="50,-414.02 91,-414.02 "/>
<text text-anchor="middle" x="70.5" y="-398.82" font-family="monospace" font-size="14.00" fill="grey">d1c</text>
<polyline fill="none" stroke="grey" points="50,-391.02 91,-391.02 "/>
<text text-anchor="middle" x="70.5" y="-375.82" font-family="monospace" font-size="14.00" fill="grey">d1d</text>
<polyline fill="none" stroke="grey" points="50,-368.02 91,-368.02 "/>
<text text-anchor="middle" x="70.5" y="-352.82" font-family="monospace" font-size="14.00" fill="grey">d2a</text>
<polyline fill="none" stroke="grey" points="50,-345.02 91,-345.02 "/>
<text text-anchor="middle" x="70.5" y="-329.82" font-family="monospace" font-size="14.00" fill="grey">d2b</text>
<polyline fill="none" stroke="grey" points="50,-322.02 91,-322.02 "/>
<text text-anchor="middle" x="70.5" y="-306.82" font-family="monospace" font-size="14.00" fill="grey">d2c</text>
<polyline fill="none" stroke="grey" points="50,-299.02 91,-299.02 "/>
<text text-anchor="middle" x="70.5" y="-283.82" font-family="monospace" font-size="14.00" fill="grey">d2d</text>
<polyline fill="none" stroke="grey" points="50,-276.02 91,-276.02 "/>
<text text-anchor="middle" x="70.5" y="-260.82" font-family="monospace" font-size="14.00" fill="grey">d3a</text>
<polyline fill="none" stroke="grey" points="50,-253.02 91,-253.02 "/>
<text text-anchor="middle" x="70.5" y="-237.82" font-family="monospace" font-size="14.00" fill="grey">d3b</text>
<polyline fill="none" stroke="grey" points="50,-230.02 91,-230.02 "/>
<text text-anchor="middle" x="70.5" y="-214.82" font-family="monospace" font-size="14.00" fill="grey">d3c</text>
<polyline fill="none" stroke="grey" points="50,-207.02 91,-207.02 "/>
<text text-anchor="middle" x="70.5" y="-191.82" font-family="monospace" font-size="14.00" fill="grey">d3d</text>
<polyline fill="none" stroke="grey" points="50,-184.02 91,-184.02 "/>
<text text-anchor="middle" x="70.5" y="-168.82" font-family="monospace" font-size="14.00" fill="grey">d4a</text>
<polyline fill="none" stroke="grey" points="50,-161.02 91,-161.02 "/>
<text text-anchor="middle" x="70.5" y="-145.82" font-family="monospace" font-size="14.00" fill="grey">d4b</text>
<polyline fill="none" stroke="grey" points="50,-138.02 91,-138.02 "/>
<text text-anchor="middle" x="70.5" y="-122.82" font-family="monospace" font-size="14.00" fill="grey">d4c</text>
<polyline fill="none" stroke="grey" points="50,-115.02 91,-115.02 "/>
<text text-anchor="middle" x="70.5" y="-99.82" font-family="monospace" font-size="14.00" fill="grey">d4d</text>
<polygon fill="none" stroke="grey" points="0,-404.69 0,-772.69 91,-772.69 91,-404.69 0,-404.69"/>
<text text-anchor="middle" x="25" y="-584.99" font-family="monospace" font-size="14.00" fill="grey">host</text>
<polyline fill="none" stroke="grey" points="50,-404.69 50,-772.69 "/>
<text text-anchor="middle" x="70.5" y="-757.49" font-family="monospace" font-size="14.00" fill="grey">d1a</text>
<polyline fill="none" stroke="grey" points="50,-749.69 91,-749.69 "/>
<text text-anchor="middle" x="70.5" y="-734.49" font-family="monospace" font-size="14.00" fill="grey">d1b</text>
<polyline fill="none" stroke="grey" points="50,-726.69 91,-726.69 "/>
<text text-anchor="middle" x="70.5" y="-711.49" font-family="monospace" font-size="14.00" fill="grey">d1c</text>
<polyline fill="none" stroke="grey" points="50,-703.69 91,-703.69 "/>
<text text-anchor="middle" x="70.5" y="-688.49" font-family="monospace" font-size="14.00" fill="grey">d1d</text>
<polyline fill="none" stroke="grey" points="50,-680.69 91,-680.69 "/>
<text text-anchor="middle" x="70.5" y="-665.49" font-family="monospace" font-size="14.00" fill="grey">d2a</text>
<polyline fill="none" stroke="grey" points="50,-657.69 91,-657.69 "/>
<text text-anchor="middle" x="70.5" y="-642.49" font-family="monospace" font-size="14.00" fill="grey">d2b</text>
<polyline fill="none" stroke="grey" points="50,-634.69 91,-634.69 "/>
<text text-anchor="middle" x="70.5" y="-619.49" font-family="monospace" font-size="14.00" fill="grey">d2c</text>
<polyline fill="none" stroke="grey" points="50,-611.69 91,-611.69 "/>
<text text-anchor="middle" x="70.5" y="-596.49" font-family="monospace" font-size="14.00" fill="grey">d2d</text>
<polyline fill="none" stroke="grey" points="50,-588.69 91,-588.69 "/>
<text text-anchor="middle" x="70.5" y="-573.49" font-family="monospace" font-size="14.00" fill="grey">d3a</text>
<polyline fill="none" stroke="grey" points="50,-565.69 91,-565.69 "/>
<text text-anchor="middle" x="70.5" y="-550.49" font-family="monospace" font-size="14.00" fill="grey">d3b</text>
<polyline fill="none" stroke="grey" points="50,-542.69 91,-542.69 "/>
<text text-anchor="middle" x="70.5" y="-527.49" font-family="monospace" font-size="14.00" fill="grey">d3c</text>
<polyline fill="none" stroke="grey" points="50,-519.69 91,-519.69 "/>
<text text-anchor="middle" x="70.5" y="-504.49" font-family="monospace" font-size="14.00" fill="grey">d3d</text>
<polyline fill="none" stroke="grey" points="50,-496.69 91,-496.69 "/>
<text text-anchor="middle" x="70.5" y="-481.49" font-family="monospace" font-size="14.00" fill="grey">d4a</text>
<polyline fill="none" stroke="grey" points="50,-473.69 91,-473.69 "/>
<text text-anchor="middle" x="70.5" y="-458.49" font-family="monospace" font-size="14.00" fill="grey">d4b</text>
<polyline fill="none" stroke="grey" points="50,-450.69 91,-450.69 "/>
<text text-anchor="middle" x="70.5" y="-435.49" font-family="monospace" font-size="14.00" fill="grey">d4c</text>
<polyline fill="none" stroke="grey" points="50,-427.69 91,-427.69 "/>
<text text-anchor="middle" x="70.5" y="-412.49" font-family="monospace" font-size="14.00" fill="grey">d4d</text>
</g>
<!-- dut1 -->
<g id="node2" class="node">
<title>dut1</title>
<polygon fill="none" stroke="black" points="370.04,-459.55 370.04,-551.55 486.04,-551.55 486.04,-459.55 370.04,-459.55"/>
<text text-anchor="middle" x="386.54" y="-536.35" font-family="monospace" font-size="14.00">e1</text>
<polyline fill="none" stroke="black" points="370.04,-528.55 403.04,-528.55 "/>
<text text-anchor="middle" x="386.54" y="-513.35" font-family="monospace" font-size="14.00">e2</text>
<polyline fill="none" stroke="black" points="370.04,-505.55 403.04,-505.55 "/>
<text text-anchor="middle" x="386.54" y="-490.35" font-family="monospace" font-size="14.00">e3</text>
<polyline fill="none" stroke="black" points="370.04,-482.55 403.04,-482.55 "/>
<text text-anchor="middle" x="386.54" y="-467.35" font-family="monospace" font-size="14.00">e4</text>
<polyline fill="none" stroke="black" points="403.04,-459.55 403.04,-551.55 "/>
<text text-anchor="middle" x="428.04" y="-501.85" font-family="monospace" font-size="14.00">dut1</text>
<polyline fill="none" stroke="black" points="453.04,-459.55 453.04,-551.55 "/>
<text text-anchor="middle" x="469.54" y="-536.35" font-family="monospace" font-size="14.00">e5</text>
<polyline fill="none" stroke="black" points="453.04,-528.55 486.04,-528.55 "/>
<text text-anchor="middle" x="469.54" y="-513.35" font-family="monospace" font-size="14.00">e6</text>
<polyline fill="none" stroke="black" points="453.04,-505.55 486.04,-505.55 "/>
<text text-anchor="middle" x="469.54" y="-490.35" font-family="monospace" font-size="14.00">e7</text>
<polyline fill="none" stroke="black" points="453.04,-482.55 486.04,-482.55 "/>
<text text-anchor="middle" x="469.54" y="-467.35" font-family="monospace" font-size="14.00">e8</text>
<polygon fill="none" stroke="black" points="756.66,-1023.99 756.66,-1115.99 938.66,-1115.99 938.66,-1023.99 756.66,-1023.99"/>
<text text-anchor="middle" x="773.16" y="-1100.79" font-family="monospace" font-size="14.00">e1</text>
<polyline fill="none" stroke="black" points="756.66,-1092.99 789.66,-1092.99 "/>
<text text-anchor="middle" x="773.16" y="-1077.79" font-family="monospace" font-size="14.00">e2</text>
<polyline fill="none" stroke="black" points="756.66,-1069.99 789.66,-1069.99 "/>
<text text-anchor="middle" x="773.16" y="-1054.79" font-family="monospace" font-size="14.00">e3</text>
<polyline fill="none" stroke="black" points="756.66,-1046.99 789.66,-1046.99 "/>
<text text-anchor="middle" x="773.16" y="-1031.79" font-family="monospace" font-size="14.00">e4</text>
<polyline fill="none" stroke="black" points="789.66,-1023.99 789.66,-1115.99 "/>
<text text-anchor="middle" x="814.66" y="-1066.29" font-family="monospace" font-size="14.00">dut1</text>
<polyline fill="none" stroke="black" points="839.66,-1023.99 839.66,-1115.99 "/>
<text text-anchor="middle" x="856.16" y="-1100.79" font-family="monospace" font-size="14.00">e5</text>
<polyline fill="none" stroke="black" points="839.66,-1092.99 872.66,-1092.99 "/>
<text text-anchor="middle" x="856.16" y="-1077.79" font-family="monospace" font-size="14.00">e6</text>
<polyline fill="none" stroke="black" points="839.66,-1069.99 872.66,-1069.99 "/>
<text text-anchor="middle" x="856.16" y="-1054.79" font-family="monospace" font-size="14.00">e7</text>
<polyline fill="none" stroke="black" points="839.66,-1046.99 872.66,-1046.99 "/>
<text text-anchor="middle" x="856.16" y="-1031.79" font-family="monospace" font-size="14.00">e8</text>
<polyline fill="none" stroke="black" points="872.66,-1023.99 872.66,-1115.99 "/>
<text text-anchor="middle" x="905.66" y="-1100.79" font-family="monospace" font-size="14.00">radio0</text>
<polyline fill="none" stroke="black" points="872.66,-1092.99 938.66,-1092.99 "/>
<text text-anchor="middle" x="905.66" y="-1077.79" font-family="monospace" font-size="14.00">radio1</text>
<polyline fill="none" stroke="black" points="872.66,-1069.99 938.66,-1069.99 "/>
<text text-anchor="middle" x="905.66" y="-1054.79" font-family="monospace" font-size="14.00">radio2</text>
<polyline fill="none" stroke="black" points="872.66,-1046.99 938.66,-1046.99 "/>
<text text-anchor="middle" x="905.66" y="-1031.79" font-family="monospace" font-size="14.00">radio3</text>
</g>
<!-- host&#45;&#45;dut1 -->
<g id="edge1" class="edge">
<title>host:d1a&#45;&#45;dut1:e1</title>
<path fill="none" stroke="lightgray" stroke-width="2" d="M91.5,-449.02C91.5,-449.02 370.04,-540.55 370.04,-540.55"/>
<path fill="none" stroke="lightgray" stroke-width="2" d="M91.5,-761.69C91.5,-761.69 307.4,-665.13 405.78,-764.3 405.82,-764.33 405.78,-825.07 405.8,-825.12 449.08,-914.98 756.66,-1104.99 756.66,-1104.99"/>
</g>
<!-- host&#45;&#45;dut1 -->
<g id="edge2" class="edge">
<title>host:d1b&#45;&#45;dut1:e2</title>
<path fill="none" stroke="cornflowerblue" stroke-width="2" d="M91.5,-426.02C91.5,-426.02 370.04,-517.55 370.04,-517.55"/>
<path fill="none" stroke="cornflowerblue" stroke-width="2" d="M91.5,-738.69C91.5,-738.69 310.78,-661.27 405.78,-764.3 405.81,-764.33 405.77,-825.07 405.8,-825.12 493.25,-997.46 756.66,-1081.99 756.66,-1081.99"/>
</g>
<!-- host&#45;&#45;dut1 -->
<g id="edge3" class="edge">
<title>host:d1c&#45;&#45;dut1:e3</title>
<path fill="none" stroke="cornflowerblue" stroke-width="2" d="M91.5,-403.02C91.5,-403.02 370.04,-493.55 370.04,-493.55"/>
<path fill="none" stroke="cornflowerblue" stroke-width="2" d="M91.5,-715.69C91.5,-715.69 313.77,-657 405.78,-764.3 405.81,-764.34 405.77,-825.08 405.8,-825.12 494.31,-990.03 756.66,-1057.99 756.66,-1057.99"/>
</g>
<!-- host&#45;&#45;dut1 -->
<g id="edge4" class="edge">
<title>host:d1d&#45;&#45;dut1:e4</title>
<path fill="none" stroke="cornflowerblue" stroke-width="2" d="M91.5,-380.02C91.5,-380.02 142.26,-412.3 187.02,-428.53 265.48,-456.97 370.04,-470.55 370.04,-470.55"/>
<path fill="none" stroke="cornflowerblue" stroke-width="2" d="M91.5,-692.69C91.5,-692.69 316.42,-652.32 405.78,-764.3 405.81,-764.34 405.77,-825.08 405.8,-825.12 495.46,-983.17 756.66,-1034.99 756.66,-1034.99"/>
</g>
<!-- dut2 -->
<g id="node3" class="node">
<title>dut2</title>
<polygon fill="none" stroke="black" points="217.02,-306.53 217.02,-398.53 333.02,-398.53 333.02,-306.53 217.02,-306.53"/>
<text text-anchor="middle" x="233.52" y="-383.33" font-family="monospace" font-size="14.00">e1</text>
<polyline fill="none" stroke="black" points="217.02,-375.53 250.02,-375.53 "/>
<text text-anchor="middle" x="233.52" y="-360.33" font-family="monospace" font-size="14.00">e2</text>
<polyline fill="none" stroke="black" points="217.02,-352.53 250.02,-352.53 "/>
<text text-anchor="middle" x="233.52" y="-337.33" font-family="monospace" font-size="14.00">e3</text>
<polyline fill="none" stroke="black" points="217.02,-329.53 250.02,-329.53 "/>
<text text-anchor="middle" x="233.52" y="-314.33" font-family="monospace" font-size="14.00">e4</text>
<polyline fill="none" stroke="black" points="250.02,-306.53 250.02,-398.53 "/>
<text text-anchor="middle" x="275.02" y="-348.83" font-family="monospace" font-size="14.00">dut2</text>
<polyline fill="none" stroke="black" points="300.02,-306.53 300.02,-398.53 "/>
<text text-anchor="middle" x="316.52" y="-383.33" font-family="monospace" font-size="14.00">e5</text>
<polyline fill="none" stroke="black" points="300.02,-375.53 333.02,-375.53 "/>
<text text-anchor="middle" x="316.52" y="-360.33" font-family="monospace" font-size="14.00">e6</text>
<polyline fill="none" stroke="black" points="300.02,-352.53 333.02,-352.53 "/>
<text text-anchor="middle" x="316.52" y="-337.33" font-family="monospace" font-size="14.00">e7</text>
<polyline fill="none" stroke="black" points="300.02,-329.53 333.02,-329.53 "/>
<text text-anchor="middle" x="316.52" y="-314.33" font-family="monospace" font-size="14.00">e8</text>
<polygon fill="none" stroke="black" points="435.8,-703.12 435.8,-795.12 617.8,-795.12 617.8,-703.12 435.8,-703.12"/>
<text text-anchor="middle" x="452.3" y="-779.92" font-family="monospace" font-size="14.00">e1</text>
<polyline fill="none" stroke="black" points="435.8,-772.12 468.8,-772.12 "/>
<text text-anchor="middle" x="452.3" y="-756.92" font-family="monospace" font-size="14.00">e2</text>
<polyline fill="none" stroke="black" points="435.8,-749.12 468.8,-749.12 "/>
<text text-anchor="middle" x="452.3" y="-733.92" font-family="monospace" font-size="14.00">e3</text>
<polyline fill="none" stroke="black" points="435.8,-726.12 468.8,-726.12 "/>
<text text-anchor="middle" x="452.3" y="-710.92" font-family="monospace" font-size="14.00">e4</text>
<polyline fill="none" stroke="black" points="468.8,-703.12 468.8,-795.12 "/>
<text text-anchor="middle" x="493.8" y="-745.42" font-family="monospace" font-size="14.00">dut2</text>
<polyline fill="none" stroke="black" points="518.8,-703.12 518.8,-795.12 "/>
<text text-anchor="middle" x="535.3" y="-779.92" font-family="monospace" font-size="14.00">e5</text>
<polyline fill="none" stroke="black" points="518.8,-772.12 551.8,-772.12 "/>
<text text-anchor="middle" x="535.3" y="-756.92" font-family="monospace" font-size="14.00">e6</text>
<polyline fill="none" stroke="black" points="518.8,-749.12 551.8,-749.12 "/>
<text text-anchor="middle" x="535.3" y="-733.92" font-family="monospace" font-size="14.00">e7</text>
<polyline fill="none" stroke="black" points="518.8,-726.12 551.8,-726.12 "/>
<text text-anchor="middle" x="535.3" y="-710.92" font-family="monospace" font-size="14.00">e8</text>
<polyline fill="none" stroke="black" points="551.8,-703.12 551.8,-795.12 "/>
<text text-anchor="middle" x="584.8" y="-776.42" font-family="monospace" font-size="14.00">radio0</text>
<polyline fill="none" stroke="black" points="551.8,-765.12 617.8,-765.12 "/>
<text text-anchor="middle" x="584.8" y="-745.92" font-family="monospace" font-size="14.00">radio1</text>
<polyline fill="none" stroke="black" points="551.8,-734.12 617.8,-734.12 "/>
<text text-anchor="middle" x="584.8" y="-714.92" font-family="monospace" font-size="14.00">radio2</text>
</g>
<!-- host&#45;&#45;dut2 -->
<g id="edge5" class="edge">
<title>host:d2a&#45;&#45;dut2:e1</title>
<path fill="none" stroke="lightgray" stroke-width="2" d="M91.5,-357.02C91.5,-357.02 217.02,-387.53 217.02,-387.53"/>
<path fill="none" stroke="lightgray" stroke-width="2" d="M91.5,-669.69C91.5,-669.69 273.68,-702.42 405.78,-764.3 420.26,-771.08 435.8,-784.12 435.8,-784.12"/>
</g>
<!-- host&#45;&#45;dut2 -->
<g id="edge6" class="edge">
<title>host:d2b&#45;&#45;dut2:e2</title>
<path fill="none" stroke="cornflowerblue" stroke-width="2" d="M91.5,-334.02C91.5,-334.02 217.02,-364.53 217.02,-364.53"/>
<path fill="none" stroke="cornflowerblue" stroke-width="2" d="M91.5,-646.69C91.5,-646.69 435.8,-761.12 435.8,-761.12"/>
</g>
<!-- host&#45;&#45;dut2 -->
<g id="edge7" class="edge">
<title>host:d2c&#45;&#45;dut2:e3</title>
<path fill="none" stroke="cornflowerblue" stroke-width="2" d="M91.5,-311.02C91.5,-311.02 217.02,-340.53 217.02,-340.53"/>
<path fill="none" stroke="cornflowerblue" stroke-width="2" d="M91.5,-623.69C91.5,-623.69 435.8,-737.12 435.8,-737.12"/>
</g>
<!-- host&#45;&#45;dut2 -->
<g id="edge8" class="edge">
<title>host:d2d&#45;&#45;dut2:e4</title>
<path fill="none" stroke="cornflowerblue" stroke-width="2" d="M91.5,-288.02C91.5,-288.02 217.02,-317.53 217.02,-317.53"/>
<path fill="none" stroke="cornflowerblue" stroke-width="2" d="M91.5,-600.69C91.5,-600.69 435.8,-714.12 435.8,-714.12"/>
</g>
<!-- dut3 -->
<g id="node4" class="node">
<title>dut3</title>
<polygon fill="none" stroke="black" points="217.02,-153.52 217.02,-245.52 333.02,-245.52 333.02,-153.52 217.02,-153.52"/>
<text text-anchor="middle" x="233.52" y="-230.32" font-family="monospace" font-size="14.00">e1</text>
<polyline fill="none" stroke="black" points="217.02,-222.52 250.02,-222.52 "/>
<text text-anchor="middle" x="233.52" y="-207.32" font-family="monospace" font-size="14.00">e2</text>
<polyline fill="none" stroke="black" points="217.02,-199.52 250.02,-199.52 "/>
<text text-anchor="middle" x="233.52" y="-184.32" font-family="monospace" font-size="14.00">e3</text>
<polyline fill="none" stroke="black" points="217.02,-176.52 250.02,-176.52 "/>
<text text-anchor="middle" x="233.52" y="-161.32" font-family="monospace" font-size="14.00">e4</text>
<polyline fill="none" stroke="black" points="250.02,-153.52 250.02,-245.52 "/>
<text text-anchor="middle" x="275.02" y="-195.82" font-family="monospace" font-size="14.00">dut3</text>
<polyline fill="none" stroke="black" points="300.02,-153.52 300.02,-245.52 "/>
<text text-anchor="middle" x="316.52" y="-230.32" font-family="monospace" font-size="14.00">e5</text>
<polyline fill="none" stroke="black" points="300.02,-222.52 333.02,-222.52 "/>
<text text-anchor="middle" x="316.52" y="-207.32" font-family="monospace" font-size="14.00">e6</text>
<polyline fill="none" stroke="black" points="300.02,-199.52 333.02,-199.52 "/>
<text text-anchor="middle" x="316.52" y="-184.32" font-family="monospace" font-size="14.00">e7</text>
<polyline fill="none" stroke="black" points="300.02,-176.52 333.02,-176.52 "/>
<text text-anchor="middle" x="316.52" y="-161.32" font-family="monospace" font-size="14.00">e8</text>
<polygon fill="none" stroke="black" points="435.8,-382.26 435.8,-474.26 617.8,-474.26 617.8,-382.26 435.8,-382.26"/>
<text text-anchor="middle" x="452.3" y="-459.06" font-family="monospace" font-size="14.00">e1</text>
<polyline fill="none" stroke="black" points="435.8,-451.26 468.8,-451.26 "/>
<text text-anchor="middle" x="452.3" y="-436.06" font-family="monospace" font-size="14.00">e2</text>
<polyline fill="none" stroke="black" points="435.8,-428.26 468.8,-428.26 "/>
<text text-anchor="middle" x="452.3" y="-413.06" font-family="monospace" font-size="14.00">e3</text>
<polyline fill="none" stroke="black" points="435.8,-405.26 468.8,-405.26 "/>
<text text-anchor="middle" x="452.3" y="-390.06" font-family="monospace" font-size="14.00">e4</text>
<polyline fill="none" stroke="black" points="468.8,-382.26 468.8,-474.26 "/>
<text text-anchor="middle" x="493.8" y="-424.56" font-family="monospace" font-size="14.00">dut3</text>
<polyline fill="none" stroke="black" points="518.8,-382.26 518.8,-474.26 "/>
<text text-anchor="middle" x="535.3" y="-459.06" font-family="monospace" font-size="14.00">e5</text>
<polyline fill="none" stroke="black" points="518.8,-451.26 551.8,-451.26 "/>
<text text-anchor="middle" x="535.3" y="-436.06" font-family="monospace" font-size="14.00">e6</text>
<polyline fill="none" stroke="black" points="518.8,-428.26 551.8,-428.26 "/>
<text text-anchor="middle" x="535.3" y="-413.06" font-family="monospace" font-size="14.00">e7</text>
<polyline fill="none" stroke="black" points="518.8,-405.26 551.8,-405.26 "/>
<text text-anchor="middle" x="535.3" y="-390.06" font-family="monospace" font-size="14.00">e8</text>
<polyline fill="none" stroke="black" points="551.8,-382.26 551.8,-474.26 "/>
<text text-anchor="middle" x="584.8" y="-447.56" font-family="monospace" font-size="14.00">radio0</text>
<polyline fill="none" stroke="black" points="551.8,-428.26 617.8,-428.26 "/>
<text text-anchor="middle" x="584.8" y="-401.56" font-family="monospace" font-size="14.00">radio1</text>
</g>
<!-- host&#45;&#45;dut3 -->
<g id="edge9" class="edge">
<title>host:d3a&#45;&#45;dut3:e1</title>
<path fill="none" stroke="lightgray" stroke-width="2" d="M91.5,-264.02C91.5,-264.02 217.02,-234.52 217.02,-234.52"/>
<path fill="none" stroke="lightgray" stroke-width="2" d="M91.5,-576.69C91.5,-576.69 435.8,-463.26 435.8,-463.26"/>
</g>
<!-- host&#45;&#45;dut3 -->
<g id="edge10" class="edge">
<title>host:d3b&#45;&#45;dut3:e2</title>
<path fill="none" stroke="cornflowerblue" stroke-width="2" d="M91.5,-241.02C91.5,-241.02 217.02,-211.52 217.02,-211.52"/>
<path fill="none" stroke="cornflowerblue" stroke-width="2" d="M91.5,-553.69C91.5,-553.69 435.8,-440.26 435.8,-440.26"/>
</g>
<!-- host&#45;&#45;dut3 -->
<g id="edge11" class="edge">
<title>host:d3c&#45;&#45;dut3:e3</title>
<path fill="none" stroke="cornflowerblue" stroke-width="2" d="M91.5,-218.02C91.5,-218.02 217.02,-187.52 217.02,-187.52"/>
<path fill="none" stroke="cornflowerblue" stroke-width="2" d="M91.5,-530.69C91.5,-530.69 435.8,-416.26 435.8,-416.26"/>
</g>
<!-- host&#45;&#45;dut3 -->
<g id="edge12" class="edge">
<title>host:d3d&#45;&#45;dut3:e4</title>
<path fill="none" stroke="cornflowerblue" stroke-width="2" d="M91.5,-195.02C91.5,-195.02 217.02,-164.52 217.02,-164.52"/>
<path fill="none" stroke="cornflowerblue" stroke-width="2" d="M91.5,-507.69C91.5,-507.69 273.68,-474.96 405.78,-413.08 420.26,-406.3 435.8,-393.26 435.8,-393.26"/>
</g>
<!-- dut4 -->
<g id="node5" class="node">
<title>dut4</title>
<polygon fill="none" stroke="black" points="370.04,-0.5 370.04,-92.5 486.04,-92.5 486.04,-0.5 370.04,-0.5"/>
<text text-anchor="middle" x="386.54" y="-77.3" font-family="monospace" font-size="14.00">e1</text>
<polyline fill="none" stroke="black" points="370.04,-69.5 403.04,-69.5 "/>
<text text-anchor="middle" x="386.54" y="-54.3" font-family="monospace" font-size="14.00">e2</text>
<polyline fill="none" stroke="black" points="370.04,-46.5 403.04,-46.5 "/>
<text text-anchor="middle" x="386.54" y="-31.3" font-family="monospace" font-size="14.00">e3</text>
<polyline fill="none" stroke="black" points="370.04,-23.5 403.04,-23.5 "/>
<text text-anchor="middle" x="386.54" y="-8.3" font-family="monospace" font-size="14.00">e4</text>
<polyline fill="none" stroke="black" points="403.04,-0.5 403.04,-92.5 "/>
<text text-anchor="middle" x="428.04" y="-42.8" font-family="monospace" font-size="14.00">dut4</text>
<polyline fill="none" stroke="black" points="453.04,-0.5 453.04,-92.5 "/>
<text text-anchor="middle" x="469.54" y="-77.3" font-family="monospace" font-size="14.00">e5</text>
<polyline fill="none" stroke="black" points="453.04,-69.5 486.04,-69.5 "/>
<text text-anchor="middle" x="469.54" y="-54.3" font-family="monospace" font-size="14.00">e6</text>
<polyline fill="none" stroke="black" points="453.04,-46.5 486.04,-46.5 "/>
<text text-anchor="middle" x="469.54" y="-31.3" font-family="monospace" font-size="14.00">e7</text>
<polyline fill="none" stroke="black" points="453.04,-23.5 486.04,-23.5 "/>
<text text-anchor="middle" x="469.54" y="-8.3" font-family="monospace" font-size="14.00">e8</text>
<polygon fill="none" stroke="black" points="756.66,-61.39 756.66,-153.39 938.66,-153.39 938.66,-61.39 756.66,-61.39"/>
<text text-anchor="middle" x="773.16" y="-138.19" font-family="monospace" font-size="14.00">e1</text>
<polyline fill="none" stroke="black" points="756.66,-130.39 789.66,-130.39 "/>
<text text-anchor="middle" x="773.16" y="-115.19" font-family="monospace" font-size="14.00">e2</text>
<polyline fill="none" stroke="black" points="756.66,-107.39 789.66,-107.39 "/>
<text text-anchor="middle" x="773.16" y="-92.19" font-family="monospace" font-size="14.00">e3</text>
<polyline fill="none" stroke="black" points="756.66,-84.39 789.66,-84.39 "/>
<text text-anchor="middle" x="773.16" y="-69.19" font-family="monospace" font-size="14.00">e4</text>
<polyline fill="none" stroke="black" points="789.66,-61.39 789.66,-153.39 "/>
<text text-anchor="middle" x="814.66" y="-103.69" font-family="monospace" font-size="14.00">dut4</text>
<polyline fill="none" stroke="black" points="839.66,-61.39 839.66,-153.39 "/>
<text text-anchor="middle" x="856.16" y="-138.19" font-family="monospace" font-size="14.00">e5</text>
<polyline fill="none" stroke="black" points="839.66,-130.39 872.66,-130.39 "/>
<text text-anchor="middle" x="856.16" y="-115.19" font-family="monospace" font-size="14.00">e6</text>
<polyline fill="none" stroke="black" points="839.66,-107.39 872.66,-107.39 "/>
<text text-anchor="middle" x="856.16" y="-92.19" font-family="monospace" font-size="14.00">e7</text>
<polyline fill="none" stroke="black" points="839.66,-84.39 872.66,-84.39 "/>
<text text-anchor="middle" x="856.16" y="-69.19" font-family="monospace" font-size="14.00">e8</text>
<polyline fill="none" stroke="black" points="872.66,-61.39 872.66,-153.39 "/>
<text text-anchor="middle" x="905.66" y="-126.69" font-family="monospace" font-size="14.00">radio0</text>
<polyline fill="none" stroke="black" points="872.66,-107.39 938.66,-107.39 "/>
<text text-anchor="middle" x="905.66" y="-80.69" font-family="monospace" font-size="14.00">radio1</text>
</g>
<!-- host&#45;&#45;dut4 -->
<g id="edge13" class="edge">
<title>host:d4a&#45;&#45;dut4:e1</title>
<path fill="none" stroke="lightgray" stroke-width="2" d="M91.5,-172.02C91.5,-172.02 142.26,-139.74 187.02,-123.52 265.48,-95.07 370.04,-81.5 370.04,-81.5"/>
<path fill="none" stroke="lightgray" stroke-width="2" d="M91.5,-484.69C91.5,-484.69 316.42,-525.06 405.78,-413.08 405.81,-413.04 405.77,-352.3 405.8,-352.26 495.46,-194.21 756.66,-142.39 756.66,-142.39"/>
</g>
<!-- host&#45;&#45;dut4 -->
<g id="edge14" class="edge">
<title>host:d4b&#45;&#45;dut4:e2</title>
<path fill="none" stroke="cornflowerblue" stroke-width="2" d="M91.5,-149.02C91.5,-149.02 370.04,-58.5 370.04,-58.5"/>
<path fill="none" stroke="cornflowerblue" stroke-width="2" d="M91.5,-461.69C91.5,-461.69 313.77,-520.38 405.78,-413.08 405.81,-413.04 405.77,-352.3 405.8,-352.26 494.31,-187.35 756.66,-119.39 756.66,-119.39"/>
</g>
<!-- host&#45;&#45;dut4 -->
<g id="edge15" class="edge">
<title>host:d4c&#45;&#45;dut4:e3</title>
<path fill="none" stroke="cornflowerblue" stroke-width="2" d="M91.5,-126.02C91.5,-126.02 370.04,-34.5 370.04,-34.5"/>
<path fill="none" stroke="cornflowerblue" stroke-width="2" d="M91.5,-438.69C91.5,-438.69 310.78,-516.11 405.78,-413.08 405.81,-413.04 405.77,-352.3 405.8,-352.26 493.25,-179.91 756.66,-95.39 756.66,-95.39"/>
</g>
<!-- host&#45;&#45;dut4 -->
<g id="edge16" class="edge">
<title>host:d4d&#45;&#45;dut4:e4</title>
<path fill="none" stroke="cornflowerblue" stroke-width="2" d="M91.5,-103.02C91.5,-103.02 370.04,-11.5 370.04,-11.5"/>
<path fill="none" stroke="cornflowerblue" stroke-width="2" d="M91.5,-415.69C91.5,-415.69 307.4,-512.25 405.78,-413.08 405.82,-413.05 405.78,-352.3 405.8,-352.26 449.08,-262.4 756.66,-72.39 756.66,-72.39"/>
</g>
<!-- dut1&#45;&#45;dut2 -->
<g id="edge19" class="edge">
<title>dut1:e8&#45;&#45;dut2:e5</title>
<path fill="none" stroke="black" stroke-width="2" d="M470.04,-459.55C470.04,-459.55 333.02,-387.53 333.02,-387.53"/>
<path fill="none" stroke="black" stroke-width="2" d="M856.66,-1023.99C856.66,-1023.99 535.8,-795.12 535.8,-795.12"/>
</g>
<!-- dut1&#45;&#45;dut3 -->
<g id="edge22" class="edge">
<title>dut1:e6&#45;&#45;dut3:e7</title>
<path fill="none" stroke="red" stroke-width="2" d="M486.13,-517.51C486.13,-517.51 522.61,-470.31 516.04,-429.55 494.57,-296.4 333.02,-187.52 333.02,-187.52"/>
<path fill="none" stroke="red" stroke-width="2" d="M850.82,-1069.86C811.11,-987.48 581.28,-510.61 541.62,-428.34"/>
</g>
<!-- cell0 -->
<g id="node6" class="node">
<title>cell0</title>
<polygon fill="none" stroke="cornflowerblue" stroke-dasharray="5,2" points="292.78,-794.3 292.78,-832.3 375.78,-832.3 375.78,-794.3 292.78,-794.3"/>
<text text-anchor="middle" x="334.28" y="-817.1" font-family="monospace" font-size="14.00" fill="cornflowerblue">cell0</text>
<text text-anchor="middle" x="334.28" y="-802.1" font-family="monospace" font-size="14.00" fill="cornflowerblue">(radio0)</text>
</g>
<!-- dut1&#45;&#45;cell0 -->
<g id="edge24" class="edge">
<title>dut1:radio0&#45;&#45;cell0</title>
<path fill="none" stroke="cornflowerblue" stroke-width="2" d="M905.57,-1116.03C905.57,-1116.03 804.39,-1167.39 726.66,-1145.99 541.96,-1095.13 391.3,-895.93 347.31,-832.68"/>
</g>
<!-- cell1 -->
<g id="node7" class="node">
<title>cell1</title>
<polygon fill="none" stroke="cornflowerblue" stroke-dasharray="5,2" points="292.78,-345.08 292.78,-383.08 375.78,-383.08 375.78,-345.08 292.78,-345.08"/>
<text text-anchor="middle" x="334.28" y="-367.88" font-family="monospace" font-size="14.00" fill="cornflowerblue">cell1</text>
<text text-anchor="middle" x="334.28" y="-352.88" font-family="monospace" font-size="14.00" fill="cornflowerblue">(radio1)</text>
</g>
<!-- dut1&#45;&#45;cell1 -->
<g id="edge28" class="edge">
<title>dut1:radio1&#45;&#45;cell1</title>
<path fill="none" stroke="cornflowerblue" stroke-width="2" d="M938.76,-1081.95C938.76,-1081.95 985.53,-1119.49 968.66,-1145.99 939.78,-1191.35 778.43,-1160.56 726.66,-1145.99 494.04,-1080.49 394.02,-1065.22 262.78,-862.3 184.61,-741.43 218.38,-682.75 234.28,-539.69 240.65,-482.37 230.43,-460.84 262.78,-413.08 271.04,-400.89 283.44,-390.85 295.6,-383.1"/>
</g>
<!-- cell2 -->
<g id="node8" class="node">
<title>cell2</title>
<polygon fill="none" stroke="mediumseagreen" stroke-dasharray="5,2" points="264.28,-569.69 264.28,-607.69 404.28,-607.69 404.28,-569.69 264.28,-569.69"/>
<text text-anchor="middle" x="334.28" y="-592.49" font-family="monospace" font-size="14.00" fill="mediumseagreen">cell2</text>
<text text-anchor="middle" x="334.28" y="-577.49" font-family="monospace" font-size="14.00" fill="mediumseagreen">(band steering)</text>
</g>
<!-- dut1&#45;&#45;cell2 -->
<g id="edge32" class="edge">
<title>dut1:radio2&#45;&#45;cell2</title>
<path fill="none" stroke="cornflowerblue" stroke-width="2" d="M938.76,-1058.02C938.76,-1058.02 992.71,-1112.42 968.66,-1145.99 937.35,-1189.71 778.43,-1160.56 726.66,-1145.99 494.04,-1080.49 394.02,-1065.22 262.78,-862.3 208.13,-777.8 186.54,-726.27 234.28,-637.69 241.38,-624.51 253.34,-614.82 266.56,-607.71"/>
</g>
<!-- dut1&#45;&#45;cell2 -->
<g id="edge33" class="edge">
<title>dut1:radio3&#45;&#45;cell2</title>
<path fill="none" stroke="cornflowerblue" stroke-width="2" d="M905.66,-1023.99C905.66,-1023.99 472.93,-924.04 405.8,-825.12 405.77,-825.08 405.79,-764.35 405.78,-764.3 394.07,-704.53 361.94,-639.44 345.03,-607.93"/>
</g>
<!-- dut2&#45;&#45;dut3 -->
<g id="edge17" class="edge">
<title>dut2:e7&#45;&#45;dut3:e6</title>
<path fill="none" stroke="black" stroke-width="3" d="M333.12,-340.57C333.12,-340.57 369.92,-306.58 363.02,-275.52 356.22,-244.85 333.12,-211.48 333.12,-211.48"/>
<path fill="none" stroke="black" stroke-width="3" d="M535.8,-725.85C535.8,-678.82 535.8,-498.59 535.8,-451.54"/>
</g>
<!-- dut2&#45;&#45;dut3 -->
<g id="edge18" class="edge">
<title>dut2:e8&#45;&#45;dut3:e5</title>
<path fill="none" stroke="black" stroke-width="3" d="M317.02,-306.53C317.02,-306.53 317.02,-245.52 317.02,-245.52"/>
<path fill="none" stroke="black" stroke-width="3" d="M535.8,-703.12C535.8,-703.12 535.8,-474.26 535.8,-474.26"/>
</g>
<!-- dut2&#45;&#45;dut4 -->
<g id="edge23" class="edge">
<title>dut2:e6&#45;&#45;dut4:e7</title>
<path fill="none" stroke="red" stroke-width="2" d="M333.02,-364.53C333.02,-364.53 494.57,-255.64 516.04,-122.5 522.61,-81.73 486.13,-34.54 486.13,-34.54"/>
<path fill="none" stroke="red" stroke-width="2" d="M541.64,-749C581.35,-666.61 811.19,-189.75 850.84,-107.48"/>
</g>
<!-- dut2&#45;&#45;cell0 -->
<g id="edge25" class="edge">
<title>dut2:radio0&#45;&#45;cell0</title>
<path fill="none" stroke="cornflowerblue" stroke-width="2" d="M584.71,-795.17C584.71,-795.17 486.42,-825.04 405.8,-825.12 396.11,-825.13 385.78,-824.07 376.07,-822.57"/>
</g>
<!-- dut2&#45;&#45;cell1 -->
<g id="edge29" class="edge">
<title>dut2:radio1&#45;&#45;cell1</title>
<path fill="none" stroke="cornflowerblue" stroke-width="2" d="M617.9,-750.12C617.9,-750.12 658.49,-708.24 647.8,-673.12 609.59,-547.66 491.95,-603.14 405.8,-504.26 373.32,-466.97 351.01,-412.34 340.73,-383.53"/>
</g>
<!-- dut2&#45;&#45;cell2 -->
<g id="edge34" class="edge">
<title>dut2:radio2&#45;&#45;cell2</title>
<path fill="none" stroke="cornflowerblue" stroke-width="2" d="M584.8,-703.12C584.8,-703.12 500.51,-668.26 434.28,-637.69 413.57,-628.13 390.71,-617 372.08,-607.77"/>
</g>
<!-- dut3&#45;&#45;dut4 -->
<g id="edge20" class="edge">
<title>dut3:e8&#45;&#45;dut4:e5</title>
<path fill="none" stroke="black" stroke-width="2" d="M333.02,-164.52C333.02,-164.52 470.04,-92.5 470.04,-92.5"/>
<path fill="none" stroke="black" stroke-width="2" d="M535.8,-382.26C535.8,-382.26 856.66,-153.39 856.66,-153.39"/>
</g>
<!-- dut3&#45;&#45;cell0 -->
<g id="edge26" class="edge">
<title>dut3:radio0&#45;&#45;cell0</title>
<path fill="none" stroke="cornflowerblue" stroke-width="2" d="M584.8,-474.26C584.8,-474.26 422.64,-648.62 405.8,-673.12 377.94,-713.64 353.9,-766.48 342.08,-794.28"/>
</g>
<!-- dut3&#45;&#45;cell1 -->
<g id="edge30" class="edge">
<title>dut3:radio1&#45;&#45;cell1</title>
<path fill="none" stroke="cornflowerblue" stroke-width="2" d="M584.71,-382.21C584.71,-382.21 486.42,-352.34 405.8,-352.26 396.11,-352.25 385.78,-353.31 376.07,-354.81"/>
</g>
<!-- dut4&#45;&#45;dut1 -->
<g id="edge21" class="edge">
<title>dut4:e8&#45;&#45;dut1:e5</title>
<path fill="none" stroke="black" stroke-width="2" d="M486.1,-11.58C486.1,-11.58 509.33,-71.88 516.04,-122.5 533.97,-257.78 533.97,-294.26 516.04,-429.55 509.33,-480.17 486.1,-540.47 486.1,-540.47"/>
<path fill="none" stroke="black" stroke-width="2" d="M856.63,-61.3C856.63,-61.3 927.79,0 968.66,-31.39 968.66,-31.39 856.63,-1116.08 856.63,-1116.08"/>
</g>
<!-- dut4&#45;&#45;cell0 -->
<g id="edge27" class="edge">
<title>dut4:radio0&#45;&#45;cell0</title>
<path fill="none" stroke="cornflowerblue" stroke-width="2" d="M905.66,-153.39C905.66,-153.39 786.31,-369.1 647.8,-504.26 553.93,-595.85 491.95,-574.23 405.8,-673.12 373.32,-710.41 351.01,-765.04 340.73,-793.85"/>
</g>
<!-- dut4&#45;&#45;cell1 -->
<g id="edge31" class="edge">
<title>dut4:radio1&#45;&#45;cell1</title>
<path fill="none" stroke="cornflowerblue" stroke-width="2" d="M905.57,-61.35C905.57,-61.35 804.39,-9.99 726.66,-31.39 541.96,-82.25 391.3,-281.45 347.31,-344.7"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 23 KiB