Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Wireless Ethernet Dispatch, lets the PPE forward offloaded flows to the radios
options mt7915e wed_enable=1
2 changes: 1 addition & 1 deletion board/aarch64/bananapi-bpi-r3/uboot/mt7986-env.dtsi
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
fdt_addr_r = "0x43f00000";
kernel_addr_r = "0x44000000";
scriptaddr = "0x48000000";
ramdisk_addr_r = "0x4A000000";
ramdisk_addr_r = "0x50000000";

en8811h_fw_part = "0#en8811h_fw";
en8811h_fw_dm_dir = "EthMD32.dm.bin";
Expand Down
29 changes: 29 additions & 0 deletions board/common/rootfs/usr/libexec/infix/iw.py
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,29 @@ def parse_dev():
return result


def parse_wds_ports(ifname):
"""
List the WDS ports of an access point: the AP/VLAN interfaces on the
same PHY that carry its MAC address.
Returns: [ifname, ...]
"""
info = parse_interface_info(ifname)
mac = info.get('mac')
ports = []

for phy, ifaces in parse_dev().items():
if ifname not in ifaces:
continue
for dev in ifaces:
if dev == ifname:
continue
devinfo = parse_interface_info(dev)
if devinfo.get('iftype') == 'AP/VLAN' and devinfo.get('mac') == mac:
ports.append(dev)

return ports


def parse_link(ifname):
"""
Parse 'iw dev <name> link' output for station mode
Expand Down Expand Up @@ -629,6 +652,7 @@ def main():
'station': 'Get connected stations in AP mode (requires interface)',
'link': 'Get link info in station mode (requires interface)',
'mesh': 'Get mesh parameters in mesh point mode (requires interface)',
'wds': 'List the WDS ports of an access point (requires interface)',
'caps': 'Get HT/VHT capability bitmasks (requires PHY/radio)'
},
'examples': [
Expand Down Expand Up @@ -677,6 +701,11 @@ def main():
data = {'error': 'mesh command requires interface argument'}
else:
data = parse_mesh_param(sys.argv[2])
elif command == 'wds':
if len(sys.argv) < 3:
data = {'error': 'wds command requires interface argument'}
else:
data = parse_wds_ports(sys.argv[2])
elif command == 'survey':
if len(sys.argv) < 3:
data = {'error': 'survey command requires interface argument'}
Expand Down
20 changes: 20 additions & 0 deletions doc/ChangeLog.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,26 @@ All notable changes to the project are documented in this file.
- LLDP neighbors were listed without their system name, descriptions, and
capabilities, in the CLI, the WebUI, and the operational datastore

### Added

- WiFi 4-address (WDS) links: a station with `wds` enabled can be a bridge
port, and an access point gets a `wds-link` interface per remote station
to bridge it. Together they build wireless bridges and repeaters, see
[WDS Backhaul and Repeaters][wds]. The station leaf `peer-bssid` pins a
station to one access point
- MT7986 boards (Banana Pi BPI-R3, BPI-R3 Mini, Acer Connect Vero W6m):
WiFi hardware offloading is now active, which lowers the CPU load of
WiFi traffic

### Fixes

- Fix #1679: a WiFi station set up on an interface that was in scan-only
mode, as in the Raspberry Pi 4 factory configuration, did not connect
until the device was rebooted. Changes to a station's or mesh point's
settings now take effect on commit

[wds]: https://www.kernelkit.org/infix/latest/wifi/#wds-backhaul-and-repeaters

[v26.09.0][] - 2026-09-30
-------------------------

Expand Down
140 changes: 126 additions & 14 deletions doc/wifi.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
# Wi-Fi (Wireless LAN)

Infix includes comprehensive Wi-Fi support for both client (Station) and
Access Point modes. When a compatible Wi-Fi adapter is detected, the system
Infix supports Wi-Fi as a client (Station), as an Access Point, as an
802.11s mesh point, and as a 4-address (WDS) link for wireless bridges
and repeaters. When a compatible Wi-Fi adapter is detected, the system
automatically creates a WiFi radio (PHY) in factory-config, that can
host virtual interfaces.

Expand All @@ -16,7 +17,7 @@ Infix uses a two-layer WiFi architecture:

2. **WiFi Interface (Network layer)**: Virtual interface on a radio
- Configured via `infix-interfaces` module
- Can operate in Station (client) or Access Point mode
- Operates in Station (client), Access Point, Mesh Point or WDS link mode
- Each interface references a parent radio

## Naming Conventions
Expand Down Expand Up @@ -44,7 +45,9 @@ Where `N` is a number (0, 1, 2, ...).

- USB hotplug is not supported - adapters must be present at boot
- Interface naming may be inconsistent with multiple USB Wi-Fi adapters
- AP and Station modes cannot be mixed on the same radio
- A station and access points on the same radio must share a channel: the
station follows its access point, so pin the radio to that channel on
both ends. See [WDS Backhaul and Repeaters](#wds-backhaul-and-repeaters)

## Supported Wi-Fi Adapters

Expand Down Expand Up @@ -704,9 +707,8 @@ Repeat for all APs that should participate in the roaming group.

IEEE 802.11s is a wireless mesh networking standard operating at Layer 2.
Mesh nodes form peer links directly with each other and route traffic
using HWMP (Hybrid Wireless Mesh Protocol), which is built into the
Linux mac80211 subsystem. There is no central controller; nodes
discover peers and find paths on their own.
using HWMP (Hybrid Wireless Mesh Protocol). There is no central
controller; nodes discover peers and find paths on their own.

The standard defines two node roles:

Expand All @@ -718,11 +720,13 @@ The standard defines two node roles:
In practice, a node bridging the mesh interface to a LAN acts as a mesh
portal.

For a backhaul with a single root, where multi-hop and self-healing are
not needed, see [WDS Backhaul and Repeaters](#wds-backhaul-and-repeaters).
That variant can use WiFi hardware offloading, mesh cannot.

> [!NOTE]
> Not all WiFi hardware supports 802.11s mesh. The driver must implement
> mesh point mode in mac80211. Check your adapter's capabilities with
> `iw phy <phy> info` and look for "mesh point" under "Supported interface
> modes".
> Not all WiFi hardware supports 802.11s mesh, see the adapter list
> under [Supported Wi-Fi Adapters](#supported-wi-fi-adapters).

### 802.11s vs EasyMesh

Expand All @@ -733,10 +737,10 @@ portal.
| **Single point of failure** | None | Controller |
| **Multi-hop** | True N-hop | Limited (1-2 hops) |
| **Vendor lock-in** | None | Common |
| **Linux support** | Kernel-native (mac80211) | Requires proprietary firmware |
| **Vendor software** | None needed | Required |

Infix uses 802.11s because it runs entirely in the kernel with no
proprietary components.
Infix uses 802.11s because it is an open standard that works across
vendors without proprietary components.

### Mesh configuration

Expand Down Expand Up @@ -808,6 +812,114 @@ With 802.11r/k/v roaming enabled on the APs (same SSID, same
passphrase, same mobility domain), clients hand off between nodes while
the mesh carries backhaul traffic.

## WDS Backhaul and Repeaters

A WiFi station normally carries only its own traffic and cannot be a
bridge port. In 4-address mode, also called WDS, it can forward traffic
for the devices behind it. That is what makes a device with a station
and an access point a repeater, and a device with a station and wired
ports a wireless bridge.

Both ends take part. The satellite enables `wds` on its station. The
root accepts the station on one of its access points and gives it a
`wds-link` interface: a bridge port, one per satellite, created by
configuration and tied to the satellite's MAC address.

Compared to an [802.11s mesh](#80211s-mesh-point-mode), a WDS backhaul
is a star with one root, without multi-hop or self-healing. In return
it is a plain access point and station link, which WiFi hardware
offloading supports where mesh is not.

### Root: access point with WDS ports

On the root, two kinds of interface share the job. The access point is
the one the satellites connect to: it advertises the backhaul SSID and
handles authentication, and there is one per radio. Each `wds-link`
is the port for one satellite: that is where its traffic appears and
what you put in the bridge. With two satellites on `radio1`:

```
radio1
├── backhaul access point, the SSID the satellites connect to
├── wds-jaffa port for jaffa, bridge port
└── wds-tauri port for tauri, bridge port
```

Give the backhaul its own SSID, advertised by the root only, so the
satellites can land nowhere else. For each satellite, add a `wds-link`
interface naming the access point and the satellite's station MAC
address, and make it a port of the bridge. The interface uses the
access point's radio and MAC address, so it takes neither a `radio` nor
a `custom-phys-address`.

<pre class="cli"><code>admin@root:/config/> <b>edit interface backhaul</b>
admin@root:/config/interface/backhaul/> <b>set wifi radio radio1</b>
admin@root:/config/interface/backhaul/> <b>set wifi access-point ssid my-backhaul</b>
admin@root:/config/interface/backhaul/> <b>set wifi access-point security secret backhaul-key</b>
admin@root:/config/interface/backhaul/> <b>end</b>
admin@root:/config/> <b>edit interface wds-jaffa</b>
admin@root:/config/interface/wds-jaffa/> <b>set type wifi</b>
admin@root:/config/interface/wds-jaffa/> <b>set wifi wds-link access-point backhaul</b>
admin@root:/config/interface/wds-jaffa/> <b>set wifi wds-link peer-address 02:13:37:13:37:12</b>
admin@root:/config/interface/wds-jaffa/> <b>set bridge-port bridge br0</b>
admin@root:/config/interface/wds-jaffa/> <b>leave</b>
</code></pre>

VLANs and other bridge port settings are configured on the `wds-link`
interface like on any other port. The port is down until the satellite
connects, and goes down again when it leaves, or within about half a
minute if the satellite disappears without notice:

<pre class="cli"><code>admin@root:/> <b>show interface wds-jaffa</b>
name : wds-jaffa
type : wifi
operational status : up
higher-layer-if : br0
mode : wds-link
connected : yes
signal : -48 dBm (good)
</code></pre>

### Satellite: 4-address station

On the satellite, configure a station for the backhaul SSID with `wds`
enabled and make it a bridge port, next to the wired ports and any local
access points. `peer-bssid` is optional and pins the station to the
root's access point:

<pre class="cli"><code>admin@jaffa:/config/> <b>edit interface uplink</b>
admin@jaffa:/config/interface/uplink/> <b>set wifi radio radio1</b>
admin@jaffa:/config/interface/uplink/> <b>set wifi station ssid my-backhaul</b>
admin@jaffa:/config/interface/uplink/> <b>set wifi station wds true</b>
admin@jaffa:/config/interface/uplink/> <b>set wifi station peer-bssid 02:13:37:13:37:01</b>
admin@jaffa:/config/interface/uplink/> <b>set wifi station security secret backhaul-key</b>
admin@jaffa:/config/interface/uplink/> <b>set bridge-port bridge br0</b>
admin@jaffa:/config/interface/uplink/> <b>leave</b>
</code></pre>

A station without `wds` cannot be a bridge port, the configuration is
rejected.

### Repeater

A repeater is a satellite that also runs an access point for clients,
bridged with the backhaul station. The station and the access point
can share a radio, but then all radios in the backhaul must use the same
channel: the station follows the root's channel and the local access
point has a fixed one. A channel change on the root, for example from
radar detection, leaves the satellites disconnected until they are
reconfigured.

Clients on a repeater keep their own MAC addresses, so DHCP reservations
and per-port VLANs work as on a wired network. With the same client
SSID on the root and the repeaters, the [roaming
features](#fast-roaming-between-access-points) apply as usual. Keep the
backhaul SSID separate from the client SSIDs, or a satellite may connect
to another satellite instead of the root.

A satellite with both a WDS backhaul and a cable to the same LAN forms a
loop, as with any two bridge ports to the same network.

## Troubleshooting

Use `show interface wifi0` to verify signal strength and connection status.
Expand Down
53 changes: 43 additions & 10 deletions package/feature-wifi/wifimedium
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ 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
mac80211's TX path completes. A unicast frame is only acknowledged if its
receiver has been heard on the medium recently, so a station that vanishes
stops acking like on real RF and the AP's inactivity probing works, 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
Expand Down Expand Up @@ -181,9 +183,11 @@ class Netlink:
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):
def tx_ack(self, transmitter, flags, tx_info, tx_info_flags, cookie, ack=True):
if ack:
flags |= HWSIM_TX_STAT_ACK
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_FLAGS, struct.pack("=I", flags))
attrs += put_attr(HWSIM_ATTR_SIGNAL, struct.pack("=i", RX_SIGNAL))
if tx_info:
attrs += put_attr(HWSIM_ATTR_TX_INFO, tx_info)
Expand Down Expand Up @@ -232,7 +236,10 @@ def open_medium(ifname):
# 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)
# A full-size data frame is a 1500 byte MSDU plus 802.11 header, mesh
# control, encryption and LLC, and A-MSDUs are larger still, so the
# carrier needs a far larger MTU than the default 1500.
ifup(ifname, mtu=9000)
sock = socket.socket(socket.AF_PACKET, socket.SOCK_RAW,
socket.htons(ETH_P_WIFIMEDIUM))
sock.bind((ifname, ETH_P_WIFIMEDIUM))
Expand All @@ -251,6 +258,25 @@ def open_medium(ifname):
# raw 802.11 frame.
WIRE = struct.Struct("=6sI")

# Transmitter addresses heard on the medium and when. mac80211 stations send
# a keep-alive at least every 30 s when idle, so one not heard for twice that
# is gone and frames to it go unacknowledged.
ALIVE_TIMEOUT = 60.0
seen = {}


def heard(frame):
if len(frame) >= 16:
seen[frame[10:16]] = time.monotonic()


def acked(frame):
"""Would the receiver of this frame acknowledge it?"""
if len(frame) < 10 or frame[4] & 1:
return True # multicast: no ack expected, keep hwsim's default
last = seen.get(frame[4:10])
return last is not None and time.monotonic() - last < ALIVE_TIMEOUT


def wait_radios_renamed(timeout=20):
"""Wait until the hwsim phys have been renamed phyN -> radioN.
Expand Down Expand Up @@ -307,8 +333,10 @@ def discover_radios():
return radios


def ifup(ifname):
"""Bring an interface up (best effort)."""
def ifup(ifname, mtu=None):
"""Bring an interface up (best effort), optionally with a larger MTU."""
if mtu:
subprocess.run(["ip", "link", "set", ifname, "mtu", str(mtu)], check=False)
subprocess.run(["ip", "link", "set", ifname, "up"], check=False)


Expand Down Expand Up @@ -364,13 +392,17 @@ def main():
# 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))
a.get(HWSIM_ATTR_COOKIE), acked(frame))
# 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)}")
try:
r["sock"].send(ETH_BROADCAST + r["mac"] + ETYPE +
WIRE.pack(tx, freq) + frame)
dbg(f"tx {r['name']} freq={freq} len={len(frame)}")
except OSError as e:
# Lost on the air, like a collision would be.
log(f"tx {r['name']} len={len(frame)} dropped: {e}")
else:
dbg(f"tx from unknown radio {tx.hex()} -- dropped")
off += nla_align(mlen)
Expand All @@ -389,6 +421,7 @@ def main():
continue
tx, freq = WIRE.unpack_from(pkt, 14)
frame = pkt[14 + WIRE.size:]
heard(frame)
# 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)}")
Expand Down
Loading
Loading