Compare commits

...
7 Commits
Author SHA1 Message Date
Mattias Walström b04b978d40 infix-hardware: fix operational with virtual radios
* Show a vendor of the radio (hwsim)
* Allow to show alot of supported AP interfaces, hwsim
  supports 2048
2026-07-02 15:25:55 +02:00
Mattias Walström 1d1afecdc3 Keep the hwsim netdev as an internal interface
It must exist, and will only handle frames when in
state running, therefore we keep it, but mark it internal.
2026-07-02 09:20:38 +02:00
Mattias Walström 2cfddb448c make run: Add support for virtual Wi-Fi interfaces 2026-07-02 09:20:38 +02:00
Mattias Walström f25a3189ed Wi-Fi: For station add connected BSSID to operational 2026-07-02 09:20:38 +02:00
Mattias Walström 1422a0ebdd test: Add Wi-Fi tests
Should map on physical as well, but only tested on virtual.
2026-07-02 09:20:38 +02:00
Mattias Walström ce1892a60b test: factory_config: Update generated files 2026-07-02 09:20:38 +02:00
Mattias Walström 4b340a38b3 statd: wifi: Limit bands to the supported ones
Infix only supports 2.4/5/6 Ghz, ignore all else.
2026-07-02 09:20:38 +02:00
55 changed files with 2639 additions and 211 deletions
+16
View File
@@ -253,3 +253,19 @@ config QEMU_NET_PORTS
int "Number of Rocker switch ports"
depends on QEMU_NET_ROCKER
default 10
comment "Wireless"
config QEMU_WIFI_RADIOS
int "Number of virtual WiFi radios (mac80211_hwsim)"
default 0
help
Number of simulated 802.11 radios to create with mac80211_hwsim.
The count is passed to the guest via the opt/wifi fw_cfg and loaded
by the 00-hwsim boot script.
0 (default) means no radios, so nothing wireless clutters the guest.
Set a small number (e.g. 2) for local AP/station experiments.
Note: 'make run' is a single guest with no inter-guest wireless
medium, so these radios only reach each other within this one guest.
+11
View File
@@ -310,6 +310,16 @@ EOF
echo -n "-fw_cfg name=opt/vpd,file=$vpd_file"
}
wifi_args()
{
# Number of mac80211_hwsim radios for the guest, read at boot by 00-hwsim
# (fw_cfg opt/wifi). Default 0 -> no radios, no wifi clutter.
radios=${CONFIG_QEMU_WIFI_RADIOS:-0}
wifi="${qdir}/wifi"
echo "$radios" > "$wifi"
echo -n "-fw_cfg name=opt/wifi,file=$wifi "
}
wdt_args()
{
echo -n "-device i6300esb "
@@ -387,6 +397,7 @@ run_qemu()
$(usb_args) \
$(host_args) \
$(net_args) \
$(wifi_args) \
$(wdt_args) \
$(rtc_args) \
$(vpd_args) \
@@ -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
@@ -43,5 +43,12 @@ for iface in $ports; do
ip link set "$iface" group port
done
# The mac80211_hwsim monitor tap (hwsim test images only) is a debug
# observer, not a user-facing interface. It cannot be deleted (no rtnl
# link ops), so classify it as internal like the DSA CPU interfaces.
if [ -d /sys/class/net/hwsim0 ]; then
ip link set dev hwsim0 group internal
fi
# At least loopback in iface group
ip link set lo group iface
+21 -13
View File
@@ -176,19 +176,25 @@ def parse_phy_info(phy_name):
if current_band and current_band.get('frequencies'):
result['bands'].append(current_band)
# Determine band names and assign band numbers
# Keep only the bands Infix supports (2.4/5/6 GHz), naming them by their
# first frequency. Hardware may expose others (e.g. S1G on hwsim) that we
# neither configure nor report.
bands = []
for band in result['bands']:
if band['frequencies']:
freq = band['frequencies'][0]
if 2400 <= freq <= 2500:
band['name'] = '2.4GHz'
band['band'] = 1
elif 5150 <= freq <= 5900:
band['name'] = '5GHz'
band['band'] = 2
elif 5955 <= freq <= 7115:
band['name'] = '6GHz'
band['band'] = 3
freq = band['frequencies'][0]
if 2400 <= freq <= 2500:
band['name'] = '2.4GHz'
band['band'] = 1
elif 5150 <= freq <= 5900:
band['name'] = '5GHz'
band['band'] = 2
elif 5955 <= freq <= 7115:
band['name'] = '6GHz'
band['band'] = 3
else:
continue
bands.append(band)
result['bands'] = bands
# Set max TX power
if max_power is not None:
@@ -207,7 +213,9 @@ def parse_phy_info(phy_name):
# Map driver to manufacturer
driver_lower = driver_name.lower()
if 'mt' in driver_lower or 'mediatek' in driver_lower:
if 'hwsim' in driver_lower:
result['manufacturer'] = 'Virtual (hwsim)'
elif 'mt' in driver_lower or 'mediatek' in driver_lower:
result['manufacturer'] = 'MediaTek Inc.'
elif 'rtw' in driver_lower or 'realtek' in driver_lower:
result['manufacturer'] = 'Realtek Semiconductor Corp.'
+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/
+7
View File
@@ -304,6 +304,7 @@ in-octets : 148388
out-octets : 24555
mode : station
ssid : MyNetwork
bssid : b4:fb:e4:17:b6:a7
signal : -45 dBm (good)
rx bitrate : 72.2 Mbps
tx bitrate : 86.6 Mbps
@@ -319,6 +320,11 @@ In the CLI, signal strength is reported as: excellent, good, fair or bad.
For precise signal strength values in dBm, use NETCONF or RESTCONF to access
the `signal-strength` leaf in the operational datastore.
When connected, `bssid` shows the MAC address of the access point the
station is associated to. It appears only while connected. When several
access points share one SSID (a roaming network), the `bssid` is what
tells them apart, and it changes as the station roams between them.
## Passphrase Requirements
To ensure your connection is secure and compatible with all network
@@ -378,6 +384,7 @@ operational status : up
physical address : f0:09:0d:36:5f:86
mode : station
ssid : MyHomeNetwork
bssid : b4:fb:e4:17:b6:a7
signal : -52 dBm (good)
</code></pre>
+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.
+1 -1
View File
@@ -27,7 +27,7 @@ MODULES=(
"infix-syslog@2025-11-17.yang"
"iana-hardware@2018-03-13.yang"
"ietf-hardware@2018-03-13.yang -e hardware-state -e hardware-sensor"
"infix-hardware@2026-03-01.yang"
"infix-hardware@2026-07-02.yang"
"ieee802-dot1q-types@2022-10-29.yang"
"infix-ip@2026-04-28.yang"
"infix-if-type@2026-01-07.yang"
+8 -2
View File
@@ -21,6 +21,12 @@ module infix-hardware {
contact "kernelkit@googlegroups.com";
description "Vital Product Data augmentation of ieee-hardware and deviations.";
revision 2026-07-02 {
description "Widen wifi max-interfaces ap/station to uint16, virtual
radios (mac80211_hwsim) report combinations up to 2048.";
reference "internal";
}
revision 2026-03-01 {
description "Add channel-width leaf for WiFi radio container.";
reference "internal";
@@ -457,13 +463,13 @@ module infix-hardware {
"Maximum number of virtual interfaces supported by this radio.";
leaf ap {
type uint8;
type uint16;
description
"Maximum number of AP interfaces.";
}
leaf station {
type uint8;
type uint16;
description
"Maximum number of station interfaces.";
}
+19
View File
@@ -48,6 +48,13 @@ submodule infix-if-wifi {
- Security: WPA2/WPA3 with keystore integration
- Operational state: Connection status, RSSI, client lists";
revision 2026-07-01 {
description
"Add station 'bssid' operational leaf: the BSSID the station is
currently associated to, to tell roaming-ESS access points apart.";
reference "internal";
}
revision 2026-03-06 {
description
"Adding mesh support and roaming.
@@ -239,6 +246,18 @@ submodule infix-if-wifi {
/* Operational state */
leaf bssid {
config false;
type yang:mac-address;
description
"BSSID (MAC address) of the access point the station is
currently associated to.
Only present while connected. With several access points
sharing one SSID (a roaming ESS), this identifies which one
the station is on.";
}
leaf signal-strength {
config false;
type int16;
@@ -1762,8 +1762,11 @@ class Iface:
station = self.wifi.get('station', {})
signal = station.get('signal-strength')
ssid = station.get('ssid', "----")
bssid = station.get('bssid')
print(f"{'mode':<{19}}: {mode}")
print(f"{'ssid':<{19}}: {ssid}")
if bssid is not None:
print(f"{'bssid':<{19}}: {bssid}")
if signal is not None:
signal_status = signal_to_status(signal)
print(f"{'signal':<{19}}: {signal} dBm ({signal_status})")
+2 -1
View File
@@ -591,8 +591,9 @@ def convert_iw_phy_info_for_yanger(phy_info):
for band in phy_info.get("bands", []):
band_data = {
"band": str(band.get("band", 0)),
"name": band.get("name", "Unknown")
}
if band.get("name"):
band_data["name"] = band["name"]
# Add capability flags (iw.py uses snake_case: ht_capable, vht_capable, he_capable)
if band.get("ht_capable"):
@@ -83,6 +83,8 @@ def wifi_station(ifname):
if link.get('connected'):
if link.get('ssid'):
station_data['ssid'] = link['ssid']
if link.get('bssid'):
station_data['bssid'] = link['bssid']
if link.get('signal-strength') is not None:
station_data['signal-strength'] = link['signal-strength']
if link.get('rx-speed') is not None:
+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

@@ -1,34 +0,0 @@
=== Boot From Factory Config
ifdef::topdoc[:imagesdir: {topdoc}../../test/case/system/factory_config]
==== Description
Verify that the device's factory-default configuration boots cleanly and
that the device remains usable afterwards -- i.e. it does not fall back to
the fail-secure failure-config.
This exercises the device's own first-boot bootstrap path: with no
startup-config present, confd initialises running from the factory-config.
That is exactly what a factory-fresh (or factory-reset) device does, and
it avoids applying a full config swap over the live management session.
The test is image-generic: it uses whatever factory-config the running
image was built with, so it covers both the stock Infix factory config and
any spin factory config.
A single-node topology is used on purpose: a factory config is not written
with a lab full of peers in mind, so applying it across a multi-node
topology could trigger broadcast storms or similar.
==== Topology
image::topology.svg[Boot From Factory Config topology, align=center, scaledwidth=75%]
==== Sequence
. Set up topology and attach to target DUT
. Determine factory-config hostname
. Clear startup-config so the device boots from factory
. Reboot onto the factory config
. Verify device is usable and not in failure-config
+1
View File
@@ -0,0 +1 @@
test.adoc
+24
View File
@@ -0,0 +1,24 @@
=== Boot From Factory Config
ifdef::topdoc[:imagesdir: {topdoc}../../test/case/system/factory_config]
==== Description
Verify that the device boots cleanly from its factory-config and stays
usable, i.e. it does not fall back to the fail-secure failure-config.
Clearing the startup-config makes confd bootstrap running from the
factory-config on the next boot, as on a factory-fresh device.
==== Topology
image::topology.svg[Boot From Factory Config topology, align=center, scaledwidth=75%]
==== Sequence
. Set up topology and attach to target DUT
. Determine factory-config hostname
. Clear startup-config so the device boots from factory
. Reboot onto the factory config
. Verify device is usable and not in failure-config
+1 -2
View File
@@ -1,8 +1,7 @@
<?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">
<!-- Generated by graphviz version 2.43.0 (0)
-->
<!-- Title: 1x1 Pages: 1 -->
<svg width="424pt" height="45pt"
viewBox="0.00 0.00 424.03 45.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.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