From d2426538e8b42e16c18a16e7621e021ffcc194d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20Walstr=C3=B6m?= Date: Fri, 2 Oct 2026 10:47:14 +0200 Subject: [PATCH 01/38] board: bpi-r3: Move ramdisk above the MT7986 WiFi reserved memory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rootfs image loaded at 0x4A000000 covered the WiFi firmware and WED regions at 0x4fc00000, so the kernel could not reserve them. Signed-off-by: Mattias Walström --- board/aarch64/bananapi-bpi-r3/uboot/mt7986-env.dtsi | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/board/aarch64/bananapi-bpi-r3/uboot/mt7986-env.dtsi b/board/aarch64/bananapi-bpi-r3/uboot/mt7986-env.dtsi index 6964340f8..39cb3e1eb 100644 --- a/board/aarch64/bananapi-bpi-r3/uboot/mt7986-env.dtsi +++ b/board/aarch64/bananapi-bpi-r3/uboot/mt7986-env.dtsi @@ -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"; From 8e34a4c01ac468a81ad8eb027b3f35048c4bdaeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20Walstr=C3=B6m?= Date: Fri, 2 Oct 2026 10:47:14 +0200 Subject: [PATCH 02/38] patches: linux: Map the WED firmware regions without requesting them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Mattias Walström --- ..._wed-map-WO-memory-regions-without-r.patch | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 patches/linux/6.18.55/0079-net-ethernet-mtk_wed-map-WO-memory-regions-without-r.patch diff --git a/patches/linux/6.18.55/0079-net-ethernet-mtk_wed-map-WO-memory-regions-without-r.patch b/patches/linux/6.18.55/0079-net-ethernet-mtk_wed-map-WO-memory-regions-without-r.patch new file mode 100644 index 000000000..6f995f7e5 --- /dev/null +++ b/patches/linux/6.18.55/0079-net-ethernet-mtk_wed-map-WO-memory-regions-without-r.patch @@ -0,0 +1,45 @@ +From 9f01f451cd49c71b37d15e26fd3b4ae6c381a95b Mon Sep 17 00:00:00 2001 +From: Mattias Walström +Date: Mon, 5 Oct 2026 14:37:56 +0200 +Subject: [PATCH 79/80] net: ethernet: mtk_wed: map WO memory regions without + requesting them +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +Since the conversion to of_reserved_mem_region_to_resource_byname(), +the WO firmware regions are mapped with devm_ioremap_resource(), which +also requests the region. On MT7986 that request fails and WED never +attaches: + + platform 15010000.wed: error -EBUSY: can't request region for resource [mem 0x4fd00000-0x4fd3ffff] + platform 15010000.wed: failed to attach wed device + +The wo-data region is in addition shared by both WED instances, so a +request-based mapping can never succeed for the second one. Go back +to a plain devm_ioremap(), as the code did before the conversion and +as qcom_wcnss did for the same regression. + +Fixes: e27dba1951ce ("net: Use of_reserved_mem_region_to_resource{_byname}() for "memory-region"") +Signed-off-by: Mattias Walström +--- + drivers/net/ethernet/mediatek/mtk_wed_mcu.c | 6 +++--- + 1 file changed, 3 insertions(+), 3 deletions(-) + +diff --git a/drivers/net/ethernet/mediatek/mtk_wed_mcu.c b/drivers/net/ethernet/mediatek/mtk_wed_mcu.c +index 0d38183c6ba7..be469c753c71 100644 +--- a/drivers/net/ethernet/mediatek/mtk_wed_mcu.c ++++ b/drivers/net/ethernet/mediatek/mtk_wed_mcu.c +@@ -246,9 +246,9 @@ mtk_wed_get_memory_region(struct mtk_wed_hw *hw, const char *name, + + region->phy_addr = res.start; + region->size = resource_size(&res); +- region->addr = devm_ioremap_resource(hw->dev, &res); +- if (IS_ERR(region->addr)) +- return PTR_ERR(region->addr); ++ region->addr = devm_ioremap(hw->dev, region->phy_addr, region->size); ++ if (!region->addr) ++ return -ENOMEM; + + return 0; + } From 263ba3dec3ce85b05b07f3659de6298c3cd445d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20Walstr=C3=B6m?= Date: Fri, 2 Oct 2026 10:47:14 +0200 Subject: [PATCH 03/38] patches: linux: Fix WED attach panic on non-DBDC MT7986 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Mattias Walström --- ...-fix-kernel-panic-on-non-DBDC-MT7986.patch | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 patches/linux/6.18.55/0080-wifi-mt76-wed-fix-kernel-panic-on-non-DBDC-MT7986.patch diff --git a/patches/linux/6.18.55/0080-wifi-mt76-wed-fix-kernel-panic-on-non-DBDC-MT7986.patch b/patches/linux/6.18.55/0080-wifi-mt76-wed-fix-kernel-panic-on-non-DBDC-MT7986.patch new file mode 100644 index 000000000..a572bc06a --- /dev/null +++ b/patches/linux/6.18.55/0080-wifi-mt76-wed-fix-kernel-panic-on-non-DBDC-MT7986.patch @@ -0,0 +1,45 @@ +From ad7b3b544a57029b682d8c6c422d0313abdb503e Mon Sep 17 00:00:00 2001 +From: Mattias Walström +Date: Mon, 5 Oct 2026 14:37:56 +0200 +Subject: [PATCH 80/80] wifi: mt76: wed: fix kernel panic on non-DBDC MT7986 + +mt76_wed_init_rx_buf() hardcodes MT_RXQ_MAIN, but a non-DBDC MT7986 +uses MT_RXQ_BAND1 for its RX data queue, so MT_RXQ_MAIN has no page +pool and attaching WED dereferences NULL: + + Unable to handle kernel NULL pointer dereference at virtual address 0000000000000000 + pc : page_pool_alloc_frag_netmem+0x28/0x260 + lr : page_pool_alloc_frag+0x18/0x50 + mt76_wed_init_rx_buf+0x128/0x240 [mt76] + mtk_wed_start+0x804/0x1310 + mt7915_dma_start+0x278/0x340 [mt7915e] + +Pick the queue from the WED version and the band index instead. + +Upstream: https://ratatoskr.run/linux-mediatek/2026/07/17264034/t +Signed-off-by: Zhi-Jun You
+--- + drivers/net/wireless/mediatek/mt76/wed.c | 7 ++++++- + 1 file changed, 6 insertions(+), 1 deletion(-) + +diff --git a/drivers/net/wireless/mediatek/mt76/wed.c b/drivers/net/wireless/mediatek/mt76/wed.c +index fbd7e59c73aa..2ebf4edc689e 100644 +--- a/drivers/net/wireless/mediatek/mt76/wed.c ++++ b/drivers/net/wireless/mediatek/mt76/wed.c +@@ -33,10 +33,15 @@ u32 mt76_wed_init_rx_buf(struct mtk_wed_device *wed, int size) + { + struct mtk_wed_bm_desc *desc = wed->rx_buf_ring.desc; + struct mt76_dev *dev = mt76_wed_to_dev(wed); +- struct mt76_queue *q = &dev->q_rx[MT_RXQ_MAIN]; + struct mt76_txwi_cache *t = NULL; ++ struct mt76_queue *q; + int i; + ++ if (wed->version == 2 && dev->phy.band_idx) ++ q = &dev->q_rx[MT_RXQ_BAND1]; ++ else ++ q = &dev->q_rx[MT_RXQ_MAIN]; ++ + for (i = 0; i < size; i++) { + dma_addr_t addr; + u32 offset; From 8ed78a9c7bbc91181d447bad3e70ea4c5315d907 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20Walstr=C3=B6m?= Date: Mon, 5 Oct 2026 14:37:18 +0200 Subject: [PATCH 04/38] patches: linux: Renumber for the MT7986 WED patches --- ...1-net-phy-marvell10g-Support-firmware-loading-on-88X33.patch | 2 +- ...2-net-phy-marvell10g-Fix-power-up-when-strapped-to-sta.patch | 2 +- .../0003-net-phy-marvell10g-Add-LED-support-for-88X3310.patch | 2 +- ...4-net-phy-marvell10g-Support-LEDs-tied-to-a-single-med.patch | 2 +- .../6.18.55/0005-net-phy-Do-not-resume-PHY-when-attaching.patch | 2 +- ...6-net-bridge-avoid-classifying-unknown-multicast-as-mr.patch | 2 +- ...7-net-bridge-Ignore-router-ports-when-forwarding-L2-mu.patch | 2 +- ...8-net-bridge-drop-delay-for-applying-strict-multicast-.patch | 2 +- ...9-net-bridge-Differentiate-MDB-additions-from-modifica.patch | 2 +- ...0-nvmem-layouts-onie-tlv-Let-device-probe-even-when-TL.patch | 2 +- ...1-net-usb-r8152-add-r8153b-support-for-link-activity-L.patch | 2 +- ...2-arm64-dts-mediatek-mt7986a-rename-BPi-R3-ports-to-ma.patch | 2 +- ...3-drm-panel-simple-Add-a-timing-for-the-Raspberry-Pi-7.patch | 2 +- .../0014-input-touchscreen-edt-ft5x06-Add-polled-mode.patch | 2 +- ...5-FIX-net-dsa-mv88e6xxx-Fix-timeout-on-waiting-for-PPU.patch | 2 +- ...6-net-dsa-mv88e6xxx-Improve-indirect-register-access-p.patch | 2 +- ...7-net-dsa-mv88e6xxx-Honor-ports-being-managed-via-in-b.patch | 2 +- ...8-net-dsa-mv88e6xxx-Limit-rsvd2cpu-policy-to-user-port.patch | 2 +- ...9-net-dsa-tag_dsa-Use-tag-priority-as-initial-skb-prio.patch | 2 +- ...0-net-dsa-Support-MDB-memberships-whose-L2-addresses-o.patch | 2 +- ...021-net-dsa-Support-EtherType-based-priority-overrides.patch | 2 +- ...2-net-dsa-mv88e6xxx-Support-EtherType-based-priority-o.patch | 2 +- .../0023-net-dsa-mv88e6xxx-Add-mqprio-qdisc-support.patch | 2 +- ...4-net-dsa-mv88e6xxx-Use-VLAN-prio-over-IP-when-both-ar.patch | 2 +- ...25-FIX-net-dsa-mv88e6xxx-Trap-locally-terminated-VLANs.patch | 2 +- ...6-net-dsa-mv88e6xxx-collapse-disabled-state-into-block.patch | 2 +- ...7-net-dsa-mv88e6xxx-Only-activate-LAG-offloading-when-.patch | 2 +- .../0028-net-dsa-mv88e6xxx-Add-LED-support-for-6393X.patch | 2 +- ...9-wifi-brcmfmac-check-connection-state-before-querying.patch | 2 +- ...0-wifi-brcmfmac-suppress-log-spam-for-regulatory-restr.patch | 2 +- ...1-wifi-brcmfmac-reduce-log-noise-during-AP-to-station-.patch | 2 +- ...2-net-phy-air_en8811h-add-OF-device-table-for-auto-loa.patch | 2 +- ...3-drm-vc4-dsi-enable-video-and-then-retry-failed-trans.patch | 2 +- ...0034-drm-vc4-dsi-Clocks-should-be-running-before-reset.patch | 2 +- .../0035-drm-vc4-Ensure-DSI-is-enabled-for-FIFO-resets.patch | 2 +- .../linux/6.18.55/0036-drm-vc4-Reset-DSI-AFE-on-disable.patch | 2 +- ...7-drm-vc4-dsi-Handle-the-different-command-FIFO-widths.patch | 2 +- ...8-drm-bridge-tc358762-Program-the-DPI-mode-into-the-ch.patch | 2 +- .../0039-drm-bridge-tc358762-revert-move-ops-to-enable.patch | 2 +- ...0-drm-bridge-tc358762-Set-pre_enabled-on-pre_enable-to.patch | 2 +- ...1-net-pcs-add-standalone-PCS-registration-infrastructu.patch | 2 +- .../0042-net-pcs-add-MediaTek-MT7988-USXGMII-PCS-driver.patch | 2 +- ...3-net-ethernet-mediatek-add-USXGMII-support-for-MT7988.patch | 2 +- .../0044-arm64-dts-mediatek-mt7988a-add-USXGMII-PCS-nodes.patch | 2 +- ...5-arm64-dts-mediatek-bananapi-bpi-r4-enable-SFP-ports-.patch | 2 +- .../6.18.55/0046-net-phy-sfp-add-OEM-SFP-10G-T-I-quirk.patch | 2 +- ...7-net-dsa-mv88e6xxx-Trap-PTP-frames-on-timestamping-po.patch | 2 +- ...8-wifi-mt76-mt7615-add-MODULE_DEVICE_TABLE-for-mt7622-.patch | 2 +- ...9-PCI-mediatek-gen3-Fix-PERST-control-timing-during-sy.patch | 2 +- ...50-net-dsa-mv88e6xxx-Derive-LED-names-from-device-name.patch | 2 +- ...1-phy-sparx5-serdes-make-it-selectable-for-ARCH_LAN969.patch | 2 +- .../0052-net-sparx5-fix-wrong-chip-ids-for-TSN-SKUs.patch | 2 +- ...3-net-sparx5-configure-serdes-for-1000BASE-X-in-sparx5.patch | 2 +- ...4-dt-bindings-mmc-atmel-sama5d2-sdhci-add-microchip-la.patch | 2 +- .../6.18.55/0055-mmc-sdhci-of-at91-add-LAN969x-support.patch | 2 +- ...6-mmc-sdhci-of-at91-stop-SDCLK-on-reset-and-add-eMMC-h.patch | 2 +- .../0057-net-sparx5-lan969x-populate-netdev-of_node.patch | 2 +- ...0058-arm64-dts-microchip-add-LAN969x-clock-header-file.patch | 2 +- .../6.18.55/0059-arm64-dts-microchip-add-LAN969x-support.patch | 2 +- .../6.18.55/0060-arm64-dts-microchip-add-EV23X71A-board.patch | 2 +- .../6.18.55/0061-arm64-dts-microchip-lan969x-add-OTP-node.patch | 2 +- .../0062-arm64-dts-microchip-lan969x-add-SDMMC-nodes.patch | 2 +- .../6.18.55/0063-arm64-dts-microchip-ev23x71a-enable-eMMC.patch | 2 +- ...4-wifi-brcmfmac-survey-the-requested-interface-not-the.patch | 2 +- ...5-wifi-brcmfmac-honor-caller-s-rtnl-lock-when-stopping.patch | 2 +- ...6-wifi-brcmfmac-let-cfg_to_ndev-return-NULL-and-harden.patch | 2 +- ...7-wifi-brcmfmac-support-deletion-and-recreation-of-the.patch | 2 +- ...8-wifi-brcmfmac-report-port-authorized-after-offloaded.patch | 2 +- ...069-dt-bindings-arm-AT91-document-Novarq-Tactical-1000.patch | 2 +- .../0070-arm64-dts-microchip-add-Novarq-Tactical-1000.patch | 2 +- .../0071-arm64-dts-microchip-tactical-1000-add-port-names.patch | 2 +- .../0072-arm64-dts-microchip-tactical-1000-adapt-to-6.18.patch | 2 +- .../0073-dt-bindings-arm-AT91-document-EV23X71A-board.patch | 2 +- ...4-net-dsa-Skip-DCB-default-priority-init-on-unsupporte.patch | 2 +- ...5-net-dsa-Generalise-the-global-DCB-APP-mirroring-help.patch | 2 +- .../6.18.55/0076-net-dsa-Support-the-PCP-APP-selector.patch | 2 +- .../6.18.55/0077-net-dsa-Support-DCB-priority-rewrite.patch | 2 +- .../0078-net-dsa-Support-the-IEEE-ETS-managed-object.patch | 2 +- 78 files changed, 78 insertions(+), 78 deletions(-) diff --git a/patches/linux/6.18.55/0001-net-phy-marvell10g-Support-firmware-loading-on-88X33.patch b/patches/linux/6.18.55/0001-net-phy-marvell10g-Support-firmware-loading-on-88X33.patch index 761e03a8b..1b18d01a4 100644 --- a/patches/linux/6.18.55/0001-net-phy-marvell10g-Support-firmware-loading-on-88X33.patch +++ b/patches/linux/6.18.55/0001-net-phy-marvell10g-Support-firmware-loading-on-88X33.patch @@ -1,7 +1,7 @@ From 1bdc682ca8860bb8299b6c25cc91ebf032a23cfa Mon Sep 17 00:00:00 2001 From: Tobias Waldekranz Date: Tue, 19 Sep 2023 18:38:10 +0200 -Subject: [PATCH 01/78] net: phy: marvell10g: Support firmware loading on +Subject: [PATCH 01/80] net: phy: marvell10g: Support firmware loading on 88X3310 When probing, if a device is waiting for firmware to be loaded into diff --git a/patches/linux/6.18.55/0002-net-phy-marvell10g-Fix-power-up-when-strapped-to-sta.patch b/patches/linux/6.18.55/0002-net-phy-marvell10g-Fix-power-up-when-strapped-to-sta.patch index 713ec69a7..aac9b3ce0 100644 --- a/patches/linux/6.18.55/0002-net-phy-marvell10g-Fix-power-up-when-strapped-to-sta.patch +++ b/patches/linux/6.18.55/0002-net-phy-marvell10g-Fix-power-up-when-strapped-to-sta.patch @@ -1,7 +1,7 @@ From 920227e8923cf970730a4bed22bd2aa55dc9387e Mon Sep 17 00:00:00 2001 From: Tobias Waldekranz Date: Tue, 21 Nov 2023 20:15:24 +0100 -Subject: [PATCH 02/78] net: phy: marvell10g: Fix power-up when strapped to +Subject: [PATCH 02/80] net: phy: marvell10g: Fix power-up when strapped to start powered down On devices which are hardware strapped to start powered down (PDSTATE diff --git a/patches/linux/6.18.55/0003-net-phy-marvell10g-Add-LED-support-for-88X3310.patch b/patches/linux/6.18.55/0003-net-phy-marvell10g-Add-LED-support-for-88X3310.patch index 370716246..ba70a2721 100644 --- a/patches/linux/6.18.55/0003-net-phy-marvell10g-Add-LED-support-for-88X3310.patch +++ b/patches/linux/6.18.55/0003-net-phy-marvell10g-Add-LED-support-for-88X3310.patch @@ -1,7 +1,7 @@ From c473017acbed8a778f07f9f1ce55028e85a9ad62 Mon Sep 17 00:00:00 2001 From: Tobias Waldekranz Date: Wed, 15 Nov 2023 20:58:42 +0100 -Subject: [PATCH 03/78] net: phy: marvell10g: Add LED support for 88X3310 +Subject: [PATCH 03/80] net: phy: marvell10g: Add LED support for 88X3310 Pickup the LEDs from the state in which the hardware reset or bootloader left them, but also support further configuration via diff --git a/patches/linux/6.18.55/0004-net-phy-marvell10g-Support-LEDs-tied-to-a-single-med.patch b/patches/linux/6.18.55/0004-net-phy-marvell10g-Support-LEDs-tied-to-a-single-med.patch index 09f301669..3a3cdc386 100644 --- a/patches/linux/6.18.55/0004-net-phy-marvell10g-Support-LEDs-tied-to-a-single-med.patch +++ b/patches/linux/6.18.55/0004-net-phy-marvell10g-Support-LEDs-tied-to-a-single-med.patch @@ -1,7 +1,7 @@ From 7ad37cfa715d86cc20f29432f106cedd457e4f26 Mon Sep 17 00:00:00 2001 From: Tobias Waldekranz Date: Tue, 12 Dec 2023 09:51:05 +0100 -Subject: [PATCH 04/78] net: phy: marvell10g: Support LEDs tied to a single +Subject: [PATCH 04/80] net: phy: marvell10g: Support LEDs tied to a single media side In a combo-port setup, i.e. where both the copper and fiber interface diff --git a/patches/linux/6.18.55/0005-net-phy-Do-not-resume-PHY-when-attaching.patch b/patches/linux/6.18.55/0005-net-phy-Do-not-resume-PHY-when-attaching.patch index 4b8eb49ac..e5c7ee359 100644 --- a/patches/linux/6.18.55/0005-net-phy-Do-not-resume-PHY-when-attaching.patch +++ b/patches/linux/6.18.55/0005-net-phy-Do-not-resume-PHY-when-attaching.patch @@ -1,7 +1,7 @@ From 5f95f31c7e48087019db7d673ff32a31f4e06619 Mon Sep 17 00:00:00 2001 From: Tobias Waldekranz Date: Wed, 27 Mar 2024 10:10:19 +0100 -Subject: [PATCH 05/78] net: phy: Do not resume PHY when attaching +Subject: [PATCH 05/80] net: phy: Do not resume PHY when attaching The PHY should not start negotiating with its link-partner until explicitly instructed to do so. diff --git a/patches/linux/6.18.55/0006-net-bridge-avoid-classifying-unknown-multicast-as-mr.patch b/patches/linux/6.18.55/0006-net-bridge-avoid-classifying-unknown-multicast-as-mr.patch index fa5d143f3..4a90cda2e 100644 --- a/patches/linux/6.18.55/0006-net-bridge-avoid-classifying-unknown-multicast-as-mr.patch +++ b/patches/linux/6.18.55/0006-net-bridge-avoid-classifying-unknown-multicast-as-mr.patch @@ -1,7 +1,7 @@ From c77bae505246ca5585adfa9219077af6fe98efa7 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Mon, 4 Mar 2024 16:47:28 +0100 -Subject: [PATCH 06/78] net: bridge: avoid classifying unknown multicast as +Subject: [PATCH 06/80] net: bridge: avoid classifying unknown multicast as mrouters_only Unknown multicast, MAC/IPv4/IPv6, should always be flooded according to diff --git a/patches/linux/6.18.55/0007-net-bridge-Ignore-router-ports-when-forwarding-L2-mu.patch b/patches/linux/6.18.55/0007-net-bridge-Ignore-router-ports-when-forwarding-L2-mu.patch index fcd288fd8..106490dbf 100644 --- a/patches/linux/6.18.55/0007-net-bridge-Ignore-router-ports-when-forwarding-L2-mu.patch +++ b/patches/linux/6.18.55/0007-net-bridge-Ignore-router-ports-when-forwarding-L2-mu.patch @@ -1,7 +1,7 @@ From fe3d0113cb67a4e4287329bc1a3b311f9c707cf1 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Tue, 5 Mar 2024 06:44:41 +0100 -Subject: [PATCH 07/78] net: bridge: Ignore router ports when forwarding L2 +Subject: [PATCH 07/80] net: bridge: Ignore router ports when forwarding L2 multicast Multicast router ports are either statically configured or learned from diff --git a/patches/linux/6.18.55/0008-net-bridge-drop-delay-for-applying-strict-multicast-.patch b/patches/linux/6.18.55/0008-net-bridge-drop-delay-for-applying-strict-multicast-.patch index 4151a986e..a82b18d3d 100644 --- a/patches/linux/6.18.55/0008-net-bridge-drop-delay-for-applying-strict-multicast-.patch +++ b/patches/linux/6.18.55/0008-net-bridge-drop-delay-for-applying-strict-multicast-.patch @@ -1,7 +1,7 @@ From e00f0142f3eef963aef0a046761fda5a2e7712e8 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Thu, 4 Apr 2024 16:36:30 +0200 -Subject: [PATCH 08/78] net: bridge: drop delay for applying strict multicast +Subject: [PATCH 08/80] net: bridge: drop delay for applying strict multicast filtering This *local* patch drops the initial delay before applying strict multicast diff --git a/patches/linux/6.18.55/0009-net-bridge-Differentiate-MDB-additions-from-modifica.patch b/patches/linux/6.18.55/0009-net-bridge-Differentiate-MDB-additions-from-modifica.patch index f4dad3714..eb2716b31 100644 --- a/patches/linux/6.18.55/0009-net-bridge-Differentiate-MDB-additions-from-modifica.patch +++ b/patches/linux/6.18.55/0009-net-bridge-Differentiate-MDB-additions-from-modifica.patch @@ -1,7 +1,7 @@ From f24cd427adb4d2f1c56c339085bdce4ee1d61844 Mon Sep 17 00:00:00 2001 From: Tobias Waldekranz Date: Thu, 16 May 2024 14:51:54 +0200 -Subject: [PATCH 09/78] net: bridge: Differentiate MDB additions from +Subject: [PATCH 09/80] net: bridge: Differentiate MDB additions from modifications Before this change, the reception of an IGMPv3 report (and analogously diff --git a/patches/linux/6.18.55/0010-nvmem-layouts-onie-tlv-Let-device-probe-even-when-TL.patch b/patches/linux/6.18.55/0010-nvmem-layouts-onie-tlv-Let-device-probe-even-when-TL.patch index 2ad996086..e2f37ec34 100644 --- a/patches/linux/6.18.55/0010-nvmem-layouts-onie-tlv-Let-device-probe-even-when-TL.patch +++ b/patches/linux/6.18.55/0010-nvmem-layouts-onie-tlv-Let-device-probe-even-when-TL.patch @@ -1,7 +1,7 @@ From e47f23a9767fc22465731ab728215afa25579c20 Mon Sep 17 00:00:00 2001 From: Tobias Waldekranz Date: Fri, 24 Nov 2023 23:29:55 +0100 -Subject: [PATCH 10/78] nvmem: layouts: onie-tlv: Let device probe even when +Subject: [PATCH 10/80] nvmem: layouts: onie-tlv: Let device probe even when TLV is invalid Before this change, probing an NVMEM device, expected to contain a diff --git a/patches/linux/6.18.55/0011-net-usb-r8152-add-r8153b-support-for-link-activity-L.patch b/patches/linux/6.18.55/0011-net-usb-r8152-add-r8153b-support-for-link-activity-L.patch index ab0ad78fd..cdc1b8615 100644 --- a/patches/linux/6.18.55/0011-net-usb-r8152-add-r8153b-support-for-link-activity-L.patch +++ b/patches/linux/6.18.55/0011-net-usb-r8152-add-r8153b-support-for-link-activity-L.patch @@ -1,7 +1,7 @@ From bd5c4068ec34ff0af11b1f52a3c06d43c8af39b1 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sun, 11 Aug 2024 11:27:35 +0200 -Subject: [PATCH 11/78] net: usb: r8152: add r8153b support for link/activity +Subject: [PATCH 11/80] net: usb: r8152: add r8153b support for link/activity LEDs This patch adds support for the link/activity LEDs on the NanoPi R2S diff --git a/patches/linux/6.18.55/0012-arm64-dts-mediatek-mt7986a-rename-BPi-R3-ports-to-ma.patch b/patches/linux/6.18.55/0012-arm64-dts-mediatek-mt7986a-rename-BPi-R3-ports-to-ma.patch index d8140edde..fb23d8063 100644 --- a/patches/linux/6.18.55/0012-arm64-dts-mediatek-mt7986a-rename-BPi-R3-ports-to-ma.patch +++ b/patches/linux/6.18.55/0012-arm64-dts-mediatek-mt7986a-rename-BPi-R3-ports-to-ma.patch @@ -1,7 +1,7 @@ From 7552cf30d52b0798298af29063a44da182e9b6ee Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sun, 10 Aug 2025 18:52:54 +0200 -Subject: [PATCH 12/78] arm64: dts: mediatek: mt7986a: rename BPi R3 ports to +Subject: [PATCH 12/80] arm64: dts: mediatek: mt7986a: rename BPi R3 ports to match case For ref. see: https://wiki.banana-pi.org/File:Bpi-r3_Metal_case.jpg diff --git a/patches/linux/6.18.55/0013-drm-panel-simple-Add-a-timing-for-the-Raspberry-Pi-7.patch b/patches/linux/6.18.55/0013-drm-panel-simple-Add-a-timing-for-the-Raspberry-Pi-7.patch index 94951986d..925df3c86 100644 --- a/patches/linux/6.18.55/0013-drm-panel-simple-Add-a-timing-for-the-Raspberry-Pi-7.patch +++ b/patches/linux/6.18.55/0013-drm-panel-simple-Add-a-timing-for-the-Raspberry-Pi-7.patch @@ -1,7 +1,7 @@ From 34593fc729c0656763733bf93719b8c8c9408f91 Mon Sep 17 00:00:00 2001 From: Mattias Walström Date: Wed, 20 Aug 2025 21:38:24 +0200 -Subject: [PATCH 13/78] drm/panel-simple: Add a timing for the Raspberry Pi 7" +Subject: [PATCH 13/80] drm/panel-simple: Add a timing for the Raspberry Pi 7" panel The Raspberry Pi 7" 800x480 panel uses a Toshiba TC358762 DSI diff --git a/patches/linux/6.18.55/0014-input-touchscreen-edt-ft5x06-Add-polled-mode.patch b/patches/linux/6.18.55/0014-input-touchscreen-edt-ft5x06-Add-polled-mode.patch index adab4e5d7..75121da43 100644 --- a/patches/linux/6.18.55/0014-input-touchscreen-edt-ft5x06-Add-polled-mode.patch +++ b/patches/linux/6.18.55/0014-input-touchscreen-edt-ft5x06-Add-polled-mode.patch @@ -1,7 +1,7 @@ From e443789978265aac625d474e2ceeebdf62ce5b8e Mon Sep 17 00:00:00 2001 From: Mattias Walström Date: Thu, 21 Aug 2025 11:20:23 +0200 -Subject: [PATCH 14/78] input:touchscreen:edt-ft5x06: Add polled mode +Subject: [PATCH 14/80] input:touchscreen:edt-ft5x06: Add polled mode Not all hardware has interrupts therefore we need to poll the touchscreen. diff --git a/patches/linux/6.18.55/0015-FIX-net-dsa-mv88e6xxx-Fix-timeout-on-waiting-for-PPU.patch b/patches/linux/6.18.55/0015-FIX-net-dsa-mv88e6xxx-Fix-timeout-on-waiting-for-PPU.patch index 72e36b64f..5ad5ec3d4 100644 --- a/patches/linux/6.18.55/0015-FIX-net-dsa-mv88e6xxx-Fix-timeout-on-waiting-for-PPU.patch +++ b/patches/linux/6.18.55/0015-FIX-net-dsa-mv88e6xxx-Fix-timeout-on-waiting-for-PPU.patch @@ -1,7 +1,7 @@ From dab336b722de649666ef241636207f10745a0f1e Mon Sep 17 00:00:00 2001 From: Tobias Waldekranz Date: Tue, 12 Mar 2024 10:27:24 +0100 -Subject: [PATCH 15/78] [FIX] net: dsa: mv88e6xxx: Fix timeout on waiting for +Subject: [PATCH 15/80] [FIX] net: dsa: mv88e6xxx: Fix timeout on waiting for PPU on 6393X In a multi-chip setup, delays of up to 750ms are observed before the diff --git a/patches/linux/6.18.55/0016-net-dsa-mv88e6xxx-Improve-indirect-register-access-p.patch b/patches/linux/6.18.55/0016-net-dsa-mv88e6xxx-Improve-indirect-register-access-p.patch index 17d51c3c4..e8a4dd969 100644 --- a/patches/linux/6.18.55/0016-net-dsa-mv88e6xxx-Improve-indirect-register-access-p.patch +++ b/patches/linux/6.18.55/0016-net-dsa-mv88e6xxx-Improve-indirect-register-access-p.patch @@ -1,7 +1,7 @@ From fbb87a65e898ae04f9801aa820f586d046ba329c Mon Sep 17 00:00:00 2001 From: Tobias Waldekranz Date: Wed, 27 Mar 2024 15:52:43 +0100 -Subject: [PATCH 16/78] net: dsa: mv88e6xxx: Improve indirect register access +Subject: [PATCH 16/80] net: dsa: mv88e6xxx: Improve indirect register access perf on 6393 When operating in multi-chip mode, the 6393 family maps a subset of diff --git a/patches/linux/6.18.55/0017-net-dsa-mv88e6xxx-Honor-ports-being-managed-via-in-b.patch b/patches/linux/6.18.55/0017-net-dsa-mv88e6xxx-Honor-ports-being-managed-via-in-b.patch index 54b80314d..f5917d980 100644 --- a/patches/linux/6.18.55/0017-net-dsa-mv88e6xxx-Honor-ports-being-managed-via-in-b.patch +++ b/patches/linux/6.18.55/0017-net-dsa-mv88e6xxx-Honor-ports-being-managed-via-in-b.patch @@ -1,7 +1,7 @@ From 32bf97b633ba31514b0b0756a1c0891bc67254ba Mon Sep 17 00:00:00 2001 From: Tobias Waldekranz Date: Mon, 22 Apr 2024 23:18:01 +0200 -Subject: [PATCH 17/78] net: dsa: mv88e6xxx: Honor ports being managed via +Subject: [PATCH 17/80] net: dsa: mv88e6xxx: Honor ports being managed via in-band-status Keep all link parameters in their unforced states when the port is diff --git a/patches/linux/6.18.55/0018-net-dsa-mv88e6xxx-Limit-rsvd2cpu-policy-to-user-port.patch b/patches/linux/6.18.55/0018-net-dsa-mv88e6xxx-Limit-rsvd2cpu-policy-to-user-port.patch index a4c117e94..58f61816b 100644 --- a/patches/linux/6.18.55/0018-net-dsa-mv88e6xxx-Limit-rsvd2cpu-policy-to-user-port.patch +++ b/patches/linux/6.18.55/0018-net-dsa-mv88e6xxx-Limit-rsvd2cpu-policy-to-user-port.patch @@ -1,7 +1,7 @@ From fadc315c8a6f0305edb24d3fbba80aa2e712d2a1 Mon Sep 17 00:00:00 2001 From: Tobias Waldekranz Date: Wed, 24 Apr 2024 22:41:04 +0200 -Subject: [PATCH 18/78] net: dsa: mv88e6xxx: Limit rsvd2cpu policy to user +Subject: [PATCH 18/80] net: dsa: mv88e6xxx: Limit rsvd2cpu policy to user ports on 6393X For packets with a DA in the IEEE reserved L2 group range, originating diff --git a/patches/linux/6.18.55/0019-net-dsa-tag_dsa-Use-tag-priority-as-initial-skb-prio.patch b/patches/linux/6.18.55/0019-net-dsa-tag_dsa-Use-tag-priority-as-initial-skb-prio.patch index 088b22c42..5160321e6 100644 --- a/patches/linux/6.18.55/0019-net-dsa-tag_dsa-Use-tag-priority-as-initial-skb-prio.patch +++ b/patches/linux/6.18.55/0019-net-dsa-tag_dsa-Use-tag-priority-as-initial-skb-prio.patch @@ -1,7 +1,7 @@ From 52728881ff09f7e48fb26145d21b1dd83f6643e7 Mon Sep 17 00:00:00 2001 From: Tobias Waldekranz Date: Tue, 28 May 2024 10:38:42 +0200 -Subject: [PATCH 19/78] net: dsa: tag_dsa: Use tag priority as initial +Subject: [PATCH 19/80] net: dsa: tag_dsa: Use tag priority as initial skb->priority Use the 3-bit priority field from the DSA tag as the initial packet diff --git a/patches/linux/6.18.55/0020-net-dsa-Support-MDB-memberships-whose-L2-addresses-o.patch b/patches/linux/6.18.55/0020-net-dsa-Support-MDB-memberships-whose-L2-addresses-o.patch index ca0afe350..0fff69c63 100644 --- a/patches/linux/6.18.55/0020-net-dsa-Support-MDB-memberships-whose-L2-addresses-o.patch +++ b/patches/linux/6.18.55/0020-net-dsa-Support-MDB-memberships-whose-L2-addresses-o.patch @@ -1,7 +1,7 @@ From 5fe097173ea1563a7f1e3b93fa0347699a94e19e Mon Sep 17 00:00:00 2001 From: Tobias Waldekranz Date: Tue, 16 Jan 2024 16:00:55 +0100 -Subject: [PATCH 20/78] net: dsa: Support MDB memberships whose L2 addresses +Subject: [PATCH 20/80] net: dsa: Support MDB memberships whose L2 addresses overlap Multiple IP multicast groups (32 for v4, 2^80 for v6) map to the same diff --git a/patches/linux/6.18.55/0021-net-dsa-Support-EtherType-based-priority-overrides.patch b/patches/linux/6.18.55/0021-net-dsa-Support-EtherType-based-priority-overrides.patch index 8d18a6a2a..44fb6980d 100644 --- a/patches/linux/6.18.55/0021-net-dsa-Support-EtherType-based-priority-overrides.patch +++ b/patches/linux/6.18.55/0021-net-dsa-Support-EtherType-based-priority-overrides.patch @@ -1,7 +1,7 @@ From b1f583e91f7d447d2a81feb2753b8a483c3ff24f Mon Sep 17 00:00:00 2001 From: Tobias Waldekranz Date: Thu, 21 Mar 2024 19:12:15 +0100 -Subject: [PATCH 21/78] net: dsa: Support EtherType based priority overrides +Subject: [PATCH 21/80] net: dsa: Support EtherType based priority overrides --- include/net/dsa.h | 4 ++++ diff --git a/patches/linux/6.18.55/0022-net-dsa-mv88e6xxx-Support-EtherType-based-priority-o.patch b/patches/linux/6.18.55/0022-net-dsa-mv88e6xxx-Support-EtherType-based-priority-o.patch index 363882976..df7d16a28 100644 --- a/patches/linux/6.18.55/0022-net-dsa-mv88e6xxx-Support-EtherType-based-priority-o.patch +++ b/patches/linux/6.18.55/0022-net-dsa-mv88e6xxx-Support-EtherType-based-priority-o.patch @@ -1,7 +1,7 @@ From ba6e63577971f172e2dcc46071d1b3471d9d1a12 Mon Sep 17 00:00:00 2001 From: Tobias Waldekranz Date: Fri, 22 Mar 2024 16:15:43 +0100 -Subject: [PATCH 22/78] net: dsa: mv88e6xxx: Support EtherType based priority +Subject: [PATCH 22/80] net: dsa: mv88e6xxx: Support EtherType based priority overrides --- diff --git a/patches/linux/6.18.55/0023-net-dsa-mv88e6xxx-Add-mqprio-qdisc-support.patch b/patches/linux/6.18.55/0023-net-dsa-mv88e6xxx-Add-mqprio-qdisc-support.patch index d8fed0a32..95c43c7bf 100644 --- a/patches/linux/6.18.55/0023-net-dsa-mv88e6xxx-Add-mqprio-qdisc-support.patch +++ b/patches/linux/6.18.55/0023-net-dsa-mv88e6xxx-Add-mqprio-qdisc-support.patch @@ -1,7 +1,7 @@ From b12a958ebe126f89d0bcd2057733a270de63e883 Mon Sep 17 00:00:00 2001 From: Tobias Waldekranz Date: Tue, 28 May 2024 11:04:22 +0200 -Subject: [PATCH 23/78] net: dsa: mv88e6xxx: Add mqprio qdisc support +Subject: [PATCH 23/80] net: dsa: mv88e6xxx: Add mqprio qdisc support Add support for attaching mqprio qdisc's to mv88e6xxx ports and use the packet's traffic class as the outgoing priority when no PCP bits diff --git a/patches/linux/6.18.55/0024-net-dsa-mv88e6xxx-Use-VLAN-prio-over-IP-when-both-ar.patch b/patches/linux/6.18.55/0024-net-dsa-mv88e6xxx-Use-VLAN-prio-over-IP-when-both-ar.patch index 78c8791f8..b285f639c 100644 --- a/patches/linux/6.18.55/0024-net-dsa-mv88e6xxx-Use-VLAN-prio-over-IP-when-both-ar.patch +++ b/patches/linux/6.18.55/0024-net-dsa-mv88e6xxx-Use-VLAN-prio-over-IP-when-both-ar.patch @@ -1,7 +1,7 @@ From 5905be94b871ac828acbae66f6a2c21ea0c48dfd Mon Sep 17 00:00:00 2001 From: Tobias Waldekranz Date: Wed, 29 May 2024 13:20:41 +0200 -Subject: [PATCH 24/78] net: dsa: mv88e6xxx: Use VLAN prio over IP when both +Subject: [PATCH 24/80] net: dsa: mv88e6xxx: Use VLAN prio over IP when both are available Switch the priority sourcing precdence to prefer VLAN PCP over IP diff --git a/patches/linux/6.18.55/0025-FIX-net-dsa-mv88e6xxx-Trap-locally-terminated-VLANs.patch b/patches/linux/6.18.55/0025-FIX-net-dsa-mv88e6xxx-Trap-locally-terminated-VLANs.patch index f87376948..f6b9cb510 100644 --- a/patches/linux/6.18.55/0025-FIX-net-dsa-mv88e6xxx-Trap-locally-terminated-VLANs.patch +++ b/patches/linux/6.18.55/0025-FIX-net-dsa-mv88e6xxx-Trap-locally-terminated-VLANs.patch @@ -1,7 +1,7 @@ From 508ff078d94e3083fcf905ff59fcb477a3dbe507 Mon Sep 17 00:00:00 2001 From: Tobias Waldekranz Date: Tue, 26 Nov 2024 19:45:59 +0100 -Subject: [PATCH 25/78] [FIX] net: dsa: mv88e6xxx: Trap locally terminated +Subject: [PATCH 25/80] [FIX] net: dsa: mv88e6xxx: Trap locally terminated VLANs Before this change, in a setup like the following, packets assigned to diff --git a/patches/linux/6.18.55/0026-net-dsa-mv88e6xxx-collapse-disabled-state-into-block.patch b/patches/linux/6.18.55/0026-net-dsa-mv88e6xxx-collapse-disabled-state-into-block.patch index 00afddfb0..330674958 100644 --- a/patches/linux/6.18.55/0026-net-dsa-mv88e6xxx-collapse-disabled-state-into-block.patch +++ b/patches/linux/6.18.55/0026-net-dsa-mv88e6xxx-collapse-disabled-state-into-block.patch @@ -1,7 +1,7 @@ From c24a806cd5b1728102098a6e3da24ee19f908884 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Thu, 16 Jan 2025 12:35:12 +0100 -Subject: [PATCH 26/78] net: dsa: mv88e6xxx: collapse disabled state into +Subject: [PATCH 26/80] net: dsa: mv88e6xxx: collapse disabled state into blocking This patch changes the behavior of switchcore ports wrt. the port state. diff --git a/patches/linux/6.18.55/0027-net-dsa-mv88e6xxx-Only-activate-LAG-offloading-when-.patch b/patches/linux/6.18.55/0027-net-dsa-mv88e6xxx-Only-activate-LAG-offloading-when-.patch index d96c4386d..f7a366c25 100644 --- a/patches/linux/6.18.55/0027-net-dsa-mv88e6xxx-Only-activate-LAG-offloading-when-.patch +++ b/patches/linux/6.18.55/0027-net-dsa-mv88e6xxx-Only-activate-LAG-offloading-when-.patch @@ -1,7 +1,7 @@ From b1b26dd227ed5099626c1fcb0e843b81d3daae65 Mon Sep 17 00:00:00 2001 From: Tobias Waldekranz Date: Wed, 12 Feb 2025 22:03:14 +0100 -Subject: [PATCH 27/78] net: dsa: mv88e6xxx: Only activate LAG offloading when +Subject: [PATCH 27/80] net: dsa: mv88e6xxx: Only activate LAG offloading when bridged The current port isolation scheme for mv88e6xxx is detailed here: diff --git a/patches/linux/6.18.55/0028-net-dsa-mv88e6xxx-Add-LED-support-for-6393X.patch b/patches/linux/6.18.55/0028-net-dsa-mv88e6xxx-Add-LED-support-for-6393X.patch index 159a15a7d..c020140be 100644 --- a/patches/linux/6.18.55/0028-net-dsa-mv88e6xxx-Add-LED-support-for-6393X.patch +++ b/patches/linux/6.18.55/0028-net-dsa-mv88e6xxx-Add-LED-support-for-6393X.patch @@ -1,7 +1,7 @@ From 307811da452a9d4b665bc0f06c514d74d8c26ab9 Mon Sep 17 00:00:00 2001 From: Mattias Walström Date: Wed, 14 Jan 2026 18:22:41 +0100 -Subject: [PATCH 28/78] net: dsa: mv88e6xxx: Add LED support for 6393X +Subject: [PATCH 28/80] net: dsa: mv88e6xxx: Add LED support for 6393X Original commit: commit 462277b926140ee2d231317e92afb6cabf640268 diff --git a/patches/linux/6.18.55/0029-wifi-brcmfmac-check-connection-state-before-querying.patch b/patches/linux/6.18.55/0029-wifi-brcmfmac-check-connection-state-before-querying.patch index 1e7ccf9bb..4b8d10ec6 100644 --- a/patches/linux/6.18.55/0029-wifi-brcmfmac-check-connection-state-before-querying.patch +++ b/patches/linux/6.18.55/0029-wifi-brcmfmac-check-connection-state-before-querying.patch @@ -1,7 +1,7 @@ From 59c469b24ce0aee40cde6aa1ea986cf42dfc150c Mon Sep 17 00:00:00 2001 From: Mattias Walström Date: Mon, 19 Jan 2026 13:06:53 +0100 -Subject: [PATCH 29/78] wifi: brcmfmac: check connection state before querying +Subject: [PATCH 29/80] wifi: brcmfmac: check connection state before querying station info MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 diff --git a/patches/linux/6.18.55/0030-wifi-brcmfmac-suppress-log-spam-for-regulatory-restr.patch b/patches/linux/6.18.55/0030-wifi-brcmfmac-suppress-log-spam-for-regulatory-restr.patch index bb686aac7..af05c0969 100644 --- a/patches/linux/6.18.55/0030-wifi-brcmfmac-suppress-log-spam-for-regulatory-restr.patch +++ b/patches/linux/6.18.55/0030-wifi-brcmfmac-suppress-log-spam-for-regulatory-restr.patch @@ -1,7 +1,7 @@ From 96510a92c5e05d36dfd1b66e4e7719adc3b6aaf1 Mon Sep 17 00:00:00 2001 From: Mattias Walström Date: Tue, 20 Jan 2026 20:12:10 +0100 -Subject: [PATCH 30/78] wifi: brcmfmac: suppress log spam for +Subject: [PATCH 30/80] wifi: brcmfmac: suppress log spam for regulatory-restricted channels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 diff --git a/patches/linux/6.18.55/0031-wifi-brcmfmac-reduce-log-noise-during-AP-to-station-.patch b/patches/linux/6.18.55/0031-wifi-brcmfmac-reduce-log-noise-during-AP-to-station-.patch index d6eee3ae1..3ed1c4b76 100644 --- a/patches/linux/6.18.55/0031-wifi-brcmfmac-reduce-log-noise-during-AP-to-station-.patch +++ b/patches/linux/6.18.55/0031-wifi-brcmfmac-reduce-log-noise-during-AP-to-station-.patch @@ -1,7 +1,7 @@ From 10cc35ebdae09afe942ce3e054be910b608af6ca Mon Sep 17 00:00:00 2001 From: Mattias Walström Date: Tue, 20 Jan 2026 20:18:45 +0100 -Subject: [PATCH 31/78] wifi: brcmfmac: reduce log noise during AP to station +Subject: [PATCH 31/80] wifi: brcmfmac: reduce log noise during AP to station transition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 diff --git a/patches/linux/6.18.55/0032-net-phy-air_en8811h-add-OF-device-table-for-auto-loa.patch b/patches/linux/6.18.55/0032-net-phy-air_en8811h-add-OF-device-table-for-auto-loa.patch index 980f7bbfd..619e3f901 100644 --- a/patches/linux/6.18.55/0032-net-phy-air_en8811h-add-OF-device-table-for-auto-loa.patch +++ b/patches/linux/6.18.55/0032-net-phy-air_en8811h-add-OF-device-table-for-auto-loa.patch @@ -1,7 +1,7 @@ From 98a492d3fa5e2472984335bfec5353b293677983 Mon Sep 17 00:00:00 2001 From: Mattias Walström Date: Tue, 17 Feb 2026 21:59:59 +0100 -Subject: [PATCH 32/78] net: phy: air_en8811h: add OF device table for +Subject: [PATCH 32/80] net: phy: air_en8811h: add OF device table for auto-loading mdio_uevent() only emits an OF-style MODALIAS via diff --git a/patches/linux/6.18.55/0033-drm-vc4-dsi-enable-video-and-then-retry-failed-trans.patch b/patches/linux/6.18.55/0033-drm-vc4-dsi-enable-video-and-then-retry-failed-trans.patch index d50b355fd..6784a0c25 100644 --- a/patches/linux/6.18.55/0033-drm-vc4-dsi-enable-video-and-then-retry-failed-trans.patch +++ b/patches/linux/6.18.55/0033-drm-vc4-dsi-enable-video-and-then-retry-failed-trans.patch @@ -1,7 +1,7 @@ From fe79facb35cd0c40ad8ad042680666351196cb22 Mon Sep 17 00:00:00 2001 From: Dave Stevenson Date: Fri, 20 Sep 2024 12:05:18 +0100 -Subject: [PATCH 33/78] drm: vc4: dsi: enable video and then retry failed +Subject: [PATCH 33/80] drm: vc4: dsi: enable video and then retry failed transfers The DSI block appears to be able to come up stuck in a condition where diff --git a/patches/linux/6.18.55/0034-drm-vc4-dsi-Clocks-should-be-running-before-reset.patch b/patches/linux/6.18.55/0034-drm-vc4-dsi-Clocks-should-be-running-before-reset.patch index 59d6dc79e..496726ec7 100644 --- a/patches/linux/6.18.55/0034-drm-vc4-dsi-Clocks-should-be-running-before-reset.patch +++ b/patches/linux/6.18.55/0034-drm-vc4-dsi-Clocks-should-be-running-before-reset.patch @@ -1,7 +1,7 @@ From bd07a3e59ac1c8f8ebfb54f5dc4e96c925f8381d Mon Sep 17 00:00:00 2001 From: Dave Stevenson Date: Wed, 8 Jun 2022 17:23:47 +0100 -Subject: [PATCH 34/78] drm: vc4: dsi: Clocks should be running before reset +Subject: [PATCH 34/80] drm: vc4: dsi: Clocks should be running before reset The initialisation sequence differs slightly from the documentation in that the clocks are meant to be running before resets and diff --git a/patches/linux/6.18.55/0035-drm-vc4-Ensure-DSI-is-enabled-for-FIFO-resets.patch b/patches/linux/6.18.55/0035-drm-vc4-Ensure-DSI-is-enabled-for-FIFO-resets.patch index 5dd80c251..89d798807 100644 --- a/patches/linux/6.18.55/0035-drm-vc4-Ensure-DSI-is-enabled-for-FIFO-resets.patch +++ b/patches/linux/6.18.55/0035-drm-vc4-Ensure-DSI-is-enabled-for-FIFO-resets.patch @@ -1,7 +1,7 @@ From 95a1f57cf66771a609d4155774264d962a99d297 Mon Sep 17 00:00:00 2001 From: Dave Stevenson Date: Fri, 5 Apr 2024 17:51:55 +0100 -Subject: [PATCH 35/78] drm/vc4: Ensure DSI is enabled for FIFO resets +Subject: [PATCH 35/80] drm/vc4: Ensure DSI is enabled for FIFO resets The block must be enabled for the FIFO resets to be actioned, so ensure this is the case. diff --git a/patches/linux/6.18.55/0036-drm-vc4-Reset-DSI-AFE-on-disable.patch b/patches/linux/6.18.55/0036-drm-vc4-Reset-DSI-AFE-on-disable.patch index 4418dc762..a323ed0b4 100644 --- a/patches/linux/6.18.55/0036-drm-vc4-Reset-DSI-AFE-on-disable.patch +++ b/patches/linux/6.18.55/0036-drm-vc4-Reset-DSI-AFE-on-disable.patch @@ -1,7 +1,7 @@ From 2f6f4f9db2f1a65c8b445dc44fce4fe3b7e82755 Mon Sep 17 00:00:00 2001 From: Dave Stevenson Date: Thu, 26 May 2022 18:56:19 +0100 -Subject: [PATCH 36/78] drm: vc4: Reset DSI AFE on disable +Subject: [PATCH 36/80] drm: vc4: Reset DSI AFE on disable vc4_dsi_bridge_disable wasn't resetting things during shutdown, so add that in. diff --git a/patches/linux/6.18.55/0037-drm-vc4-dsi-Handle-the-different-command-FIFO-widths.patch b/patches/linux/6.18.55/0037-drm-vc4-dsi-Handle-the-different-command-FIFO-widths.patch index b8ce8fd24..48a453713 100644 --- a/patches/linux/6.18.55/0037-drm-vc4-dsi-Handle-the-different-command-FIFO-widths.patch +++ b/patches/linux/6.18.55/0037-drm-vc4-dsi-Handle-the-different-command-FIFO-widths.patch @@ -1,7 +1,7 @@ From 985a117c1a98da834b30c3a698675191341b53e1 Mon Sep 17 00:00:00 2001 From: Dave Stevenson Date: Wed, 20 Nov 2024 13:58:08 +0000 -Subject: [PATCH 37/78] drm: vc4: dsi: Handle the different command FIFO widths +Subject: [PATCH 37/80] drm: vc4: dsi: Handle the different command FIFO widths DSI0 and DSI1 have different widths for the command FIFO (24bit vs 32bit), but the driver was assuming the 32bit width of DSI1 diff --git a/patches/linux/6.18.55/0038-drm-bridge-tc358762-Program-the-DPI-mode-into-the-ch.patch b/patches/linux/6.18.55/0038-drm-bridge-tc358762-Program-the-DPI-mode-into-the-ch.patch index e27586d0a..e377b2b4d 100644 --- a/patches/linux/6.18.55/0038-drm-bridge-tc358762-Program-the-DPI-mode-into-the-ch.patch +++ b/patches/linux/6.18.55/0038-drm-bridge-tc358762-Program-the-DPI-mode-into-the-ch.patch @@ -1,7 +1,7 @@ From 3eb94e911cb22e62435f70b33b515e93a5697e5d Mon Sep 17 00:00:00 2001 From: Dave Stevenson Date: Tue, 9 Jan 2024 17:37:00 +0000 -Subject: [PATCH 38/78] drm/bridge: tc358762: Program the DPI mode into the +Subject: [PATCH 38/80] drm/bridge: tc358762: Program the DPI mode into the chip The autodetection of resolution/timing by the TC358762 can lead diff --git a/patches/linux/6.18.55/0039-drm-bridge-tc358762-revert-move-ops-to-enable.patch b/patches/linux/6.18.55/0039-drm-bridge-tc358762-revert-move-ops-to-enable.patch index 32e36366b..256df2a2e 100644 --- a/patches/linux/6.18.55/0039-drm-bridge-tc358762-revert-move-ops-to-enable.patch +++ b/patches/linux/6.18.55/0039-drm-bridge-tc358762-revert-move-ops-to-enable.patch @@ -1,7 +1,7 @@ From e4d2f293250646a8109932ce816acfa1434cfc1c Mon Sep 17 00:00:00 2001 From: Dave Stevenson Date: Tue, 9 Jan 2024 18:44:49 +0000 -Subject: [PATCH 39/78] drm/bridge: tc358762: revert move ops to enable +Subject: [PATCH 39/80] drm/bridge: tc358762: revert move ops to enable Reverts 8a4b2fc9c91a ("drm/bridge: tc358762: Split register programming from pre-enable to enable") as we want the config commands sent before video starts. diff --git a/patches/linux/6.18.55/0040-drm-bridge-tc358762-Set-pre_enabled-on-pre_enable-to.patch b/patches/linux/6.18.55/0040-drm-bridge-tc358762-Set-pre_enabled-on-pre_enable-to.patch index 702d2e779..231fe00b4 100644 --- a/patches/linux/6.18.55/0040-drm-bridge-tc358762-Set-pre_enabled-on-pre_enable-to.patch +++ b/patches/linux/6.18.55/0040-drm-bridge-tc358762-Set-pre_enabled-on-pre_enable-to.patch @@ -1,7 +1,7 @@ From 0a1c5fc25afd628b5547348a4a65b1764f53f287 Mon Sep 17 00:00:00 2001 From: Mattias Walström Date: Sat, 4 Apr 2026 18:04:19 +0200 -Subject: [PATCH 40/78] drm/bridge: tc358762: Set pre_enabled on pre_enable to +Subject: [PATCH 40/80] drm/bridge: tc358762: Set pre_enabled on pre_enable to prevent regulator imbalance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 diff --git a/patches/linux/6.18.55/0041-net-pcs-add-standalone-PCS-registration-infrastructu.patch b/patches/linux/6.18.55/0041-net-pcs-add-standalone-PCS-registration-infrastructu.patch index fd33eebc3..837da1083 100644 --- a/patches/linux/6.18.55/0041-net-pcs-add-standalone-PCS-registration-infrastructu.patch +++ b/patches/linux/6.18.55/0041-net-pcs-add-standalone-PCS-registration-infrastructu.patch @@ -1,7 +1,7 @@ From 384c6992b3b1e67d6515d00f20ac03a4deb2d3e9 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sun, 5 Apr 2026 11:33:00 +0200 -Subject: [PATCH 41/78] net/pcs: add standalone PCS registration infrastructure +Subject: [PATCH 41/80] net/pcs: add standalone PCS registration infrastructure Add a simple registration mechanism that allows platform PCS drivers to register their phylink_pcs instances, and consumers (e.g. Ethernet MAC diff --git a/patches/linux/6.18.55/0042-net-pcs-add-MediaTek-MT7988-USXGMII-PCS-driver.patch b/patches/linux/6.18.55/0042-net-pcs-add-MediaTek-MT7988-USXGMII-PCS-driver.patch index ae942d64f..47181ae19 100644 --- a/patches/linux/6.18.55/0042-net-pcs-add-MediaTek-MT7988-USXGMII-PCS-driver.patch +++ b/patches/linux/6.18.55/0042-net-pcs-add-MediaTek-MT7988-USXGMII-PCS-driver.patch @@ -1,7 +1,7 @@ From 93142130e7b12203fb7b8c421bc5c4f3a6c065e7 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Mon, 6 Apr 2026 14:14:23 +0200 -Subject: [PATCH 42/78] net/pcs: add MediaTek MT7988 USXGMII PCS driver +Subject: [PATCH 42/80] net/pcs: add MediaTek MT7988 USXGMII PCS driver Add a PCS driver for the USXGMII subsystem found in the MediaTek MT7988 SoC (usxgmiisys0 at 0x10080000, usxgmiisys1 at 0x10081000). The hardware diff --git a/patches/linux/6.18.55/0043-net-ethernet-mediatek-add-USXGMII-support-for-MT7988.patch b/patches/linux/6.18.55/0043-net-ethernet-mediatek-add-USXGMII-support-for-MT7988.patch index 82352f2fd..f14cd30bf 100644 --- a/patches/linux/6.18.55/0043-net-ethernet-mediatek-add-USXGMII-support-for-MT7988.patch +++ b/patches/linux/6.18.55/0043-net-ethernet-mediatek-add-USXGMII-support-for-MT7988.patch @@ -1,7 +1,7 @@ From 54ba118cc31f35998a57a2b12809037fc675ad61 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Mon, 6 Apr 2026 14:15:43 +0200 -Subject: [PATCH 43/78] net: ethernet: mediatek: add USXGMII support for MT7988 +Subject: [PATCH 43/80] net: ethernet: mediatek: add USXGMII support for MT7988 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit diff --git a/patches/linux/6.18.55/0044-arm64-dts-mediatek-mt7988a-add-USXGMII-PCS-nodes.patch b/patches/linux/6.18.55/0044-arm64-dts-mediatek-mt7988a-add-USXGMII-PCS-nodes.patch index 1d2a19627..1a7cd1b7f 100644 --- a/patches/linux/6.18.55/0044-arm64-dts-mediatek-mt7988a-add-USXGMII-PCS-nodes.patch +++ b/patches/linux/6.18.55/0044-arm64-dts-mediatek-mt7988a-add-USXGMII-PCS-nodes.patch @@ -1,7 +1,7 @@ From 262f24d9ace1f68b30a1e6f1728be00300addf2e Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Mon, 6 Apr 2026 14:15:56 +0200 -Subject: [PATCH 44/78] arm64: dts: mediatek: mt7988a: add USXGMII PCS nodes +Subject: [PATCH 44/80] arm64: dts: mediatek: mt7988a: add USXGMII PCS nodes Add device nodes for the two USXGMII subsystem blocks (usxgmiisys0 at 0x10080000 and usxgmiisys1 at 0x10081000), each referencing its clock, diff --git a/patches/linux/6.18.55/0045-arm64-dts-mediatek-bananapi-bpi-r4-enable-SFP-ports-.patch b/patches/linux/6.18.55/0045-arm64-dts-mediatek-bananapi-bpi-r4-enable-SFP-ports-.patch index 7b74dcb09..2b434a6b1 100644 --- a/patches/linux/6.18.55/0045-arm64-dts-mediatek-bananapi-bpi-r4-enable-SFP-ports-.patch +++ b/patches/linux/6.18.55/0045-arm64-dts-mediatek-bananapi-bpi-r4-enable-SFP-ports-.patch @@ -1,7 +1,7 @@ From 4ef35eea0eaca6789595196ca8399154ba53f034 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Mon, 6 Apr 2026 14:16:11 +0200 -Subject: [PATCH 45/78] arm64: dts: mediatek: bananapi-bpi-r4: enable SFP+ +Subject: [PATCH 45/80] arm64: dts: mediatek: bananapi-bpi-r4: enable SFP+ ports and WPS button Enable the SFP+ cages wired to gmac1 and gmac2. The USXGMII PCS nodes diff --git a/patches/linux/6.18.55/0046-net-phy-sfp-add-OEM-SFP-10G-T-I-quirk.patch b/patches/linux/6.18.55/0046-net-phy-sfp-add-OEM-SFP-10G-T-I-quirk.patch index c0c5676a2..e853de0ee 100644 --- a/patches/linux/6.18.55/0046-net-phy-sfp-add-OEM-SFP-10G-T-I-quirk.patch +++ b/patches/linux/6.18.55/0046-net-phy-sfp-add-OEM-SFP-10G-T-I-quirk.patch @@ -1,7 +1,7 @@ From 81a4f7212dc829bf60e7c96bcae2317d5545a428 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Tue, 7 Apr 2026 07:34:52 +0200 -Subject: [PATCH 46/78] net: phy: sfp: add OEM SFP-10G-T-I quirk +Subject: [PATCH 46/80] net: phy: sfp: add OEM SFP-10G-T-I quirk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit diff --git a/patches/linux/6.18.55/0047-net-dsa-mv88e6xxx-Trap-PTP-frames-on-timestamping-po.patch b/patches/linux/6.18.55/0047-net-dsa-mv88e6xxx-Trap-PTP-frames-on-timestamping-po.patch index a42c7ec9c..98a2044d4 100644 --- a/patches/linux/6.18.55/0047-net-dsa-mv88e6xxx-Trap-PTP-frames-on-timestamping-po.patch +++ b/patches/linux/6.18.55/0047-net-dsa-mv88e6xxx-Trap-PTP-frames-on-timestamping-po.patch @@ -1,7 +1,7 @@ From d2cf171d24e9d08ad6a3bb43ad6790c4e03b3626 Mon Sep 17 00:00:00 2001 From: Tobias Waldekranz Date: Fri, 17 Apr 2026 09:13:04 +0000 -Subject: [PATCH 47/78] net: dsa: mv88e6xxx: Trap PTP frames on timestamping +Subject: [PATCH 47/80] net: dsa: mv88e6xxx: Trap PTP frames on timestamping ports, on 6393X Similar to the Peridot (6390), the designation of PTP frames as diff --git a/patches/linux/6.18.55/0048-wifi-mt76-mt7615-add-MODULE_DEVICE_TABLE-for-mt7622-.patch b/patches/linux/6.18.55/0048-wifi-mt76-mt7615-add-MODULE_DEVICE_TABLE-for-mt7622-.patch index fc12fd035..b37cc177a 100644 --- a/patches/linux/6.18.55/0048-wifi-mt76-mt7615-add-MODULE_DEVICE_TABLE-for-mt7622-.patch +++ b/patches/linux/6.18.55/0048-wifi-mt76-mt7615-add-MODULE_DEVICE_TABLE-for-mt7622-.patch @@ -1,7 +1,7 @@ From b6ad175129b948b44460af7ef7701e244e3a3b55 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Tue, 28 Apr 2026 15:30:01 +0200 -Subject: [PATCH 48/78] wifi: mt76: mt7615: add MODULE_DEVICE_TABLE for mt7622 +Subject: [PATCH 48/80] wifi: mt76: mt7615: add MODULE_DEVICE_TABLE for mt7622 wmac Without MODULE_DEVICE_TABLE(of, ...) the OF compatible alias is never diff --git a/patches/linux/6.18.55/0049-PCI-mediatek-gen3-Fix-PERST-control-timing-during-sy.patch b/patches/linux/6.18.55/0049-PCI-mediatek-gen3-Fix-PERST-control-timing-during-sy.patch index dddc27bc9..ce1e36619 100644 --- a/patches/linux/6.18.55/0049-PCI-mediatek-gen3-Fix-PERST-control-timing-during-sy.patch +++ b/patches/linux/6.18.55/0049-PCI-mediatek-gen3-Fix-PERST-control-timing-during-sy.patch @@ -1,7 +1,7 @@ From eb6bf2a9d6967617eea9b242603e0352f3856a00 Mon Sep 17 00:00:00 2001 From: Mattias Walström Date: Wed, 22 Apr 2026 10:24:43 +0200 -Subject: [PATCH 49/78] PCI: mediatek-gen3: Fix PERST# control timing during +Subject: [PATCH 49/80] PCI: mediatek-gen3: Fix PERST# control timing during system startup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 diff --git a/patches/linux/6.18.55/0050-net-dsa-mv88e6xxx-Derive-LED-names-from-device-name.patch b/patches/linux/6.18.55/0050-net-dsa-mv88e6xxx-Derive-LED-names-from-device-name.patch index 0cb957ef3..e9208d9be 100644 --- a/patches/linux/6.18.55/0050-net-dsa-mv88e6xxx-Derive-LED-names-from-device-name.patch +++ b/patches/linux/6.18.55/0050-net-dsa-mv88e6xxx-Derive-LED-names-from-device-name.patch @@ -1,7 +1,7 @@ From ebaca6761deffa22bf18f0dfda287fa0e3210694 Mon Sep 17 00:00:00 2001 From: Mattias Walström Date: Wed, 12 Aug 2026 10:08:53 +0200 -Subject: [PATCH 50/78] net: dsa: mv88e6xxx: Derive LED names from device name +Subject: [PATCH 50/80] net: dsa: mv88e6xxx: Derive LED names from device name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit diff --git a/patches/linux/6.18.55/0051-phy-sparx5-serdes-make-it-selectable-for-ARCH_LAN969.patch b/patches/linux/6.18.55/0051-phy-sparx5-serdes-make-it-selectable-for-ARCH_LAN969.patch index 04e64f390..400ebf21d 100644 --- a/patches/linux/6.18.55/0051-phy-sparx5-serdes-make-it-selectable-for-ARCH_LAN969.patch +++ b/patches/linux/6.18.55/0051-phy-sparx5-serdes-make-it-selectable-for-ARCH_LAN969.patch @@ -1,7 +1,7 @@ From 4706c45681cda924ab8eaf9015be7a911c15c183 Mon Sep 17 00:00:00 2001 From: Robert Marko Date: Fri, 31 Oct 2025 13:18:12 +0100 -Subject: [PATCH 51/78] phy: sparx5-serdes: make it selectable for ARCH_LAN969X +Subject: [PATCH 51/80] phy: sparx5-serdes: make it selectable for ARCH_LAN969X LAN969x uses the SparX-5 SERDES driver, so make it selectable for ARCH_LAN969X. diff --git a/patches/linux/6.18.55/0052-net-sparx5-fix-wrong-chip-ids-for-TSN-SKUs.patch b/patches/linux/6.18.55/0052-net-sparx5-fix-wrong-chip-ids-for-TSN-SKUs.patch index 2ac52662f..0ec896cfb 100644 --- a/patches/linux/6.18.55/0052-net-sparx5-fix-wrong-chip-ids-for-TSN-SKUs.patch +++ b/patches/linux/6.18.55/0052-net-sparx5-fix-wrong-chip-ids-for-TSN-SKUs.patch @@ -1,7 +1,7 @@ From 30f7485ae40aaad3d66606c49c196c28d65983ae Mon Sep 17 00:00:00 2001 From: Daniel Machon Date: Wed, 6 May 2026 09:25:38 +0200 -Subject: [PATCH 52/78] net: sparx5: fix wrong chip ids for TSN SKUs +Subject: [PATCH 52/80] net: sparx5: fix wrong chip ids for TSN SKUs The TSN SKUs in enum spx5_target_chiptype have incorrect IDs: diff --git a/patches/linux/6.18.55/0053-net-sparx5-configure-serdes-for-1000BASE-X-in-sparx5.patch b/patches/linux/6.18.55/0053-net-sparx5-configure-serdes-for-1000BASE-X-in-sparx5.patch index 63e7c6fab..ea6023f67 100644 --- a/patches/linux/6.18.55/0053-net-sparx5-configure-serdes-for-1000BASE-X-in-sparx5.patch +++ b/patches/linux/6.18.55/0053-net-sparx5-configure-serdes-for-1000BASE-X-in-sparx5.patch @@ -1,7 +1,7 @@ From 1ac619e910f3911443963cd2359dd89958213313 Mon Sep 17 00:00:00 2001 From: Daniel Machon Date: Wed, 6 May 2026 09:25:39 +0200 -Subject: [PATCH 53/78] net: sparx5: configure serdes for 1000BASE-X in +Subject: [PATCH 53/80] net: sparx5: configure serdes for 1000BASE-X in sparx5_port_init() sparx5_port_init() only invokes sparx5_serdes_set() and the associated diff --git a/patches/linux/6.18.55/0054-dt-bindings-mmc-atmel-sama5d2-sdhci-add-microchip-la.patch b/patches/linux/6.18.55/0054-dt-bindings-mmc-atmel-sama5d2-sdhci-add-microchip-la.patch index 856b42976..25fa66bfa 100644 --- a/patches/linux/6.18.55/0054-dt-bindings-mmc-atmel-sama5d2-sdhci-add-microchip-la.patch +++ b/patches/linux/6.18.55/0054-dt-bindings-mmc-atmel-sama5d2-sdhci-add-microchip-la.patch @@ -1,7 +1,7 @@ From 7fe972637a8ffbdf55391e7f6927649a50caae9a Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Wed, 19 Aug 2026 11:35:29 +0200 -Subject: [PATCH 54/78] dt-bindings: mmc: atmel,sama5d2-sdhci: add +Subject: [PATCH 54/80] dt-bindings: mmc: atmel,sama5d2-sdhci: add microchip,lan969x-sdhci The LAN969x SDMMC controller has its own base clock divider and, unlike diff --git a/patches/linux/6.18.55/0055-mmc-sdhci-of-at91-add-LAN969x-support.patch b/patches/linux/6.18.55/0055-mmc-sdhci-of-at91-add-LAN969x-support.patch index eabf5693f..ed6dd27eb 100644 --- a/patches/linux/6.18.55/0055-mmc-sdhci-of-at91-add-LAN969x-support.patch +++ b/patches/linux/6.18.55/0055-mmc-sdhci-of-at91-add-LAN969x-support.patch @@ -1,7 +1,7 @@ From 7b461e9b726584d2d4cc908a6120138504e52127 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Wed, 19 Aug 2026 11:35:44 +0200 -Subject: [PATCH 55/78] mmc: sdhci-of-at91: add LAN969x support +Subject: [PATCH 55/80] mmc: sdhci-of-at91: add LAN969x support The LAN969x SDMMC controller is an Atmel SDMMC IP block, but the driver has no compatible for it, so the eMMC on LAN969x boards never probes. diff --git a/patches/linux/6.18.55/0056-mmc-sdhci-of-at91-stop-SDCLK-on-reset-and-add-eMMC-h.patch b/patches/linux/6.18.55/0056-mmc-sdhci-of-at91-stop-SDCLK-on-reset-and-add-eMMC-h.patch index eb4ceed8e..ff7300e2b 100644 --- a/patches/linux/6.18.55/0056-mmc-sdhci-of-at91-stop-SDCLK-on-reset-and-add-eMMC-h.patch +++ b/patches/linux/6.18.55/0056-mmc-sdhci-of-at91-stop-SDCLK-on-reset-and-add-eMMC-h.patch @@ -1,7 +1,7 @@ From a55cfe74da903a95ff10f6f3659f6e4d26eb5a13 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Wed, 19 Aug 2026 11:37:48 +0200 -Subject: [PATCH 56/78] mmc: sdhci-of-at91: stop SDCLK on reset and add eMMC +Subject: [PATCH 56/80] mmc: sdhci-of-at91: stop SDCLK on reset and add eMMC hardware reset The controller is reset, and its signaling mode changed, with SDCLK diff --git a/patches/linux/6.18.55/0057-net-sparx5-lan969x-populate-netdev-of_node.patch b/patches/linux/6.18.55/0057-net-sparx5-lan969x-populate-netdev-of_node.patch index 396a1cb41..d7204764c 100644 --- a/patches/linux/6.18.55/0057-net-sparx5-lan969x-populate-netdev-of_node.patch +++ b/patches/linux/6.18.55/0057-net-sparx5-lan969x-populate-netdev-of_node.patch @@ -1,7 +1,7 @@ From 0f61d1e5c4910ed9cd79fb1b3ebccae2d69498d3 Mon Sep 17 00:00:00 2001 From: Robert Marko Date: Mon, 10 Nov 2025 13:42:53 +0100 -Subject: [PATCH 57/78] net: sparx5/lan969x: populate netdev of_node +Subject: [PATCH 57/80] net: sparx5/lan969x: populate netdev of_node Populate of_node for the port netdevs, to make the individual ports of_nodes available in sysfs. diff --git a/patches/linux/6.18.55/0058-arm64-dts-microchip-add-LAN969x-clock-header-file.patch b/patches/linux/6.18.55/0058-arm64-dts-microchip-add-LAN969x-clock-header-file.patch index db7c9cedb..210eb27af 100644 --- a/patches/linux/6.18.55/0058-arm64-dts-microchip-add-LAN969x-clock-header-file.patch +++ b/patches/linux/6.18.55/0058-arm64-dts-microchip-add-LAN969x-clock-header-file.patch @@ -1,7 +1,7 @@ From 3bf84f8d8dcbd2c444ba8249ee5789941def7215 Mon Sep 17 00:00:00 2001 From: Robert Marko Date: Mon, 2 Mar 2026 12:20:11 +0100 -Subject: [PATCH 58/78] arm64: dts: microchip: add LAN969x clock header file +Subject: [PATCH 58/80] arm64: dts: microchip: add LAN969x clock header file LAN969x uses hardware clock indexes, so document theses in a header to make them humanly readable. diff --git a/patches/linux/6.18.55/0059-arm64-dts-microchip-add-LAN969x-support.patch b/patches/linux/6.18.55/0059-arm64-dts-microchip-add-LAN969x-support.patch index 8fa346b04..efa5f279a 100644 --- a/patches/linux/6.18.55/0059-arm64-dts-microchip-add-LAN969x-support.patch +++ b/patches/linux/6.18.55/0059-arm64-dts-microchip-add-LAN969x-support.patch @@ -1,7 +1,7 @@ From 2783fbf634082295b24d8573ce91ea33017bb7b8 Mon Sep 17 00:00:00 2001 From: Robert Marko Date: Mon, 2 Mar 2026 12:20:12 +0100 -Subject: [PATCH 59/78] arm64: dts: microchip: add LAN969x support +Subject: [PATCH 59/80] arm64: dts: microchip: add LAN969x support Add support for Microchip LAN969x switch SoC series by adding the SoC DTSI. diff --git a/patches/linux/6.18.55/0060-arm64-dts-microchip-add-EV23X71A-board.patch b/patches/linux/6.18.55/0060-arm64-dts-microchip-add-EV23X71A-board.patch index 84e3ff4d0..d3e91fe96 100644 --- a/patches/linux/6.18.55/0060-arm64-dts-microchip-add-EV23X71A-board.patch +++ b/patches/linux/6.18.55/0060-arm64-dts-microchip-add-EV23X71A-board.patch @@ -1,7 +1,7 @@ From 129276c0d1704ca77d786d6aa300913b28d92e1f Mon Sep 17 00:00:00 2001 From: Robert Marko Date: Mon, 2 Mar 2026 12:20:14 +0100 -Subject: [PATCH 60/78] arm64: dts: microchip: add EV23X71A board +Subject: [PATCH 60/80] arm64: dts: microchip: add EV23X71A board Microchip EV23X71A is an LAN9696 based evaluation board. diff --git a/patches/linux/6.18.55/0061-arm64-dts-microchip-lan969x-add-OTP-node.patch b/patches/linux/6.18.55/0061-arm64-dts-microchip-lan969x-add-OTP-node.patch index 040b0870d..71b284fe1 100644 --- a/patches/linux/6.18.55/0061-arm64-dts-microchip-lan969x-add-OTP-node.patch +++ b/patches/linux/6.18.55/0061-arm64-dts-microchip-lan969x-add-OTP-node.patch @@ -1,7 +1,7 @@ From 6f418f0c5d3dd1ca1d431e23d8149413c5aa8f9a Mon Sep 17 00:00:00 2001 From: Robert Marko Date: Fri, 15 May 2026 13:59:09 +0200 -Subject: [PATCH 61/78] arm64: dts: microchip: lan969x: add OTP node +Subject: [PATCH 61/80] arm64: dts: microchip: lan969x: add OTP node Add the required OTP on LAN969x. diff --git a/patches/linux/6.18.55/0062-arm64-dts-microchip-lan969x-add-SDMMC-nodes.patch b/patches/linux/6.18.55/0062-arm64-dts-microchip-lan969x-add-SDMMC-nodes.patch index fcfa1253d..70cb88c3c 100644 --- a/patches/linux/6.18.55/0062-arm64-dts-microchip-lan969x-add-SDMMC-nodes.patch +++ b/patches/linux/6.18.55/0062-arm64-dts-microchip-lan969x-add-SDMMC-nodes.patch @@ -1,7 +1,7 @@ From a49eecdbfa8d6daa61c5baa4d2ebf3d006358fcb Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Wed, 19 Aug 2026 11:38:37 +0200 -Subject: [PATCH 62/78] arm64: dts: microchip: lan969x: add SDMMC nodes +Subject: [PATCH 62/80] arm64: dts: microchip: lan969x: add SDMMC nodes The SoC has two SDMMC controllers, neither of which is described, so boards with eMMC have no way to enable it. Add both, disabled by diff --git a/patches/linux/6.18.55/0063-arm64-dts-microchip-ev23x71a-enable-eMMC.patch b/patches/linux/6.18.55/0063-arm64-dts-microchip-ev23x71a-enable-eMMC.patch index 8b2a898d8..5139cb949 100644 --- a/patches/linux/6.18.55/0063-arm64-dts-microchip-ev23x71a-enable-eMMC.patch +++ b/patches/linux/6.18.55/0063-arm64-dts-microchip-ev23x71a-enable-eMMC.patch @@ -1,7 +1,7 @@ From 8dd67f639beee3bbe5c8992d0314b40ad46bb726 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Wed, 19 Aug 2026 11:38:37 +0200 -Subject: [PATCH 63/78] arm64: dts: microchip: ev23x71a: enable eMMC +Subject: [PATCH 63/80] arm64: dts: microchip: ev23x71a: enable eMMC The board has an 8-bit eMMC on SDMMC0, and defines the emmc_sd pinctrl group for it, but nothing enables the controller. diff --git a/patches/linux/6.18.55/0064-wifi-brcmfmac-survey-the-requested-interface-not-the.patch b/patches/linux/6.18.55/0064-wifi-brcmfmac-survey-the-requested-interface-not-the.patch index 8c224bce4..5ac51e6cd 100644 --- a/patches/linux/6.18.55/0064-wifi-brcmfmac-survey-the-requested-interface-not-the.patch +++ b/patches/linux/6.18.55/0064-wifi-brcmfmac-survey-the-requested-interface-not-the.patch @@ -1,7 +1,7 @@ From 10d713882efe78b7be0dbda7e469ce7dd1830933 Mon Sep 17 00:00:00 2001 From: Mattias Walström Date: Tue, 30 Jun 2026 15:42:48 +0200 -Subject: [PATCH 64/78] wifi: brcmfmac: survey the requested interface, not the +Subject: [PATCH 64/80] wifi: brcmfmac: survey the requested interface, not the primary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 diff --git a/patches/linux/6.18.55/0065-wifi-brcmfmac-honor-caller-s-rtnl-lock-when-stopping.patch b/patches/linux/6.18.55/0065-wifi-brcmfmac-honor-caller-s-rtnl-lock-when-stopping.patch index 6b43d2c04..c0abca748 100644 --- a/patches/linux/6.18.55/0065-wifi-brcmfmac-honor-caller-s-rtnl-lock-when-stopping.patch +++ b/patches/linux/6.18.55/0065-wifi-brcmfmac-honor-caller-s-rtnl-lock-when-stopping.patch @@ -1,7 +1,7 @@ From 0d0351b6cb2d86eab7728fcda9a6a89489e5eb0f Mon Sep 17 00:00:00 2001 From: Mattias Walström Date: Tue, 30 Jun 2026 15:43:14 +0200 -Subject: [PATCH 65/78] wifi: brcmfmac: honor caller's rtnl lock when stopping +Subject: [PATCH 65/80] wifi: brcmfmac: honor caller's rtnl lock when stopping primary interface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 diff --git a/patches/linux/6.18.55/0066-wifi-brcmfmac-let-cfg_to_ndev-return-NULL-and-harden.patch b/patches/linux/6.18.55/0066-wifi-brcmfmac-let-cfg_to_ndev-return-NULL-and-harden.patch index 8badcc793..0fa23dbb3 100644 --- a/patches/linux/6.18.55/0066-wifi-brcmfmac-let-cfg_to_ndev-return-NULL-and-harden.patch +++ b/patches/linux/6.18.55/0066-wifi-brcmfmac-let-cfg_to_ndev-return-NULL-and-harden.patch @@ -1,7 +1,7 @@ From 438486c55109e46bf0227704a6e28e3ccb4c751b Mon Sep 17 00:00:00 2001 From: Mattias Walström Date: Tue, 30 Jun 2026 15:46:25 +0200 -Subject: [PATCH 66/78] wifi: brcmfmac: let cfg_to_ndev() return NULL and +Subject: [PATCH 66/80] wifi: brcmfmac: let cfg_to_ndev() return NULL and harden its callers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 diff --git a/patches/linux/6.18.55/0067-wifi-brcmfmac-support-deletion-and-recreation-of-the.patch b/patches/linux/6.18.55/0067-wifi-brcmfmac-support-deletion-and-recreation-of-the.patch index 8adbf256a..2719d697c 100644 --- a/patches/linux/6.18.55/0067-wifi-brcmfmac-support-deletion-and-recreation-of-the.patch +++ b/patches/linux/6.18.55/0067-wifi-brcmfmac-support-deletion-and-recreation-of-the.patch @@ -1,7 +1,7 @@ From 0c6d68f2957c4a73c66a2fbe6524cf54c11ad138 Mon Sep 17 00:00:00 2001 From: Mattias Walström Date: Tue, 30 Jun 2026 16:05:17 +0200 -Subject: [PATCH 67/78] wifi: brcmfmac: support deletion and recreation of the +Subject: [PATCH 67/80] wifi: brcmfmac: support deletion and recreation of the primary interface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 diff --git a/patches/linux/6.18.55/0068-wifi-brcmfmac-report-port-authorized-after-offloaded.patch b/patches/linux/6.18.55/0068-wifi-brcmfmac-report-port-authorized-after-offloaded.patch index 4b5aa554c..a770f6ae7 100644 --- a/patches/linux/6.18.55/0068-wifi-brcmfmac-report-port-authorized-after-offloaded.patch +++ b/patches/linux/6.18.55/0068-wifi-brcmfmac-report-port-authorized-after-offloaded.patch @@ -1,7 +1,7 @@ From e0016e70627bbd29ad17dfdcf6890d6692ad2679 Mon Sep 17 00:00:00 2001 From: Mattias Walström Date: Thu, 10 Sep 2026 08:29:47 +0200 -Subject: [PATCH 68/78] wifi: brcmfmac: report port authorized after offloaded +Subject: [PATCH 68/80] wifi: brcmfmac: report port authorized after offloaded PSK/SAE handshake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 diff --git a/patches/linux/6.18.55/0069-dt-bindings-arm-AT91-document-Novarq-Tactical-1000.patch b/patches/linux/6.18.55/0069-dt-bindings-arm-AT91-document-Novarq-Tactical-1000.patch index b4de3473b..1cf61daa0 100644 --- a/patches/linux/6.18.55/0069-dt-bindings-arm-AT91-document-Novarq-Tactical-1000.patch +++ b/patches/linux/6.18.55/0069-dt-bindings-arm-AT91-document-Novarq-Tactical-1000.patch @@ -1,7 +1,7 @@ From 87bb123eb9add21c4ed4bce67313dcc37b367079 Mon Sep 17 00:00:00 2001 From: Robert Marko Date: Fri, 9 Jan 2026 13:27:00 +0100 -Subject: [PATCH 69/78] dt-bindings: arm: AT91: document Novarq Tactical 1000 +Subject: [PATCH 69/80] dt-bindings: arm: AT91: document Novarq Tactical 1000 Novarq Tactical 1000 is a Microchip LAN9696 based 24x1G + 4x10G SFP switch. diff --git a/patches/linux/6.18.55/0070-arm64-dts-microchip-add-Novarq-Tactical-1000.patch b/patches/linux/6.18.55/0070-arm64-dts-microchip-add-Novarq-Tactical-1000.patch index 783c72ff4..f23f33702 100644 --- a/patches/linux/6.18.55/0070-arm64-dts-microchip-add-Novarq-Tactical-1000.patch +++ b/patches/linux/6.18.55/0070-arm64-dts-microchip-add-Novarq-Tactical-1000.patch @@ -1,7 +1,7 @@ From ded124119dbb2ad94c870b56449f12798043e63f Mon Sep 17 00:00:00 2001 From: Robert Marko Date: Tue, 30 Sep 2025 13:37:49 +0200 -Subject: [PATCH 70/78] arm64: dts: microchip: add Novarq Tactical 1000 +Subject: [PATCH 70/80] arm64: dts: microchip: add Novarq Tactical 1000 Novarq Tactical 1000 is a LAN9696 based switch featuring 24x1G and 4x10G SFP ports. diff --git a/patches/linux/6.18.55/0071-arm64-dts-microchip-tactical-1000-add-port-names.patch b/patches/linux/6.18.55/0071-arm64-dts-microchip-tactical-1000-add-port-names.patch index 1a8306c2e..9a7bb4836 100644 --- a/patches/linux/6.18.55/0071-arm64-dts-microchip-tactical-1000-add-port-names.patch +++ b/patches/linux/6.18.55/0071-arm64-dts-microchip-tactical-1000-add-port-names.patch @@ -1,7 +1,7 @@ From aa86655cabeb7c303f3acbc59a36754d1b5383c5 Mon Sep 17 00:00:00 2001 From: Robert Marko Date: Tue, 30 Jun 2026 11:23:47 +0200 -Subject: [PATCH 71/78] arm64: dts: microchip: tactical-1000: add port names +Subject: [PATCH 71/80] arm64: dts: microchip: tactical-1000: add port names Now that driver supports parsing the "label" property, populate port names as they are physically wired up. diff --git a/patches/linux/6.18.55/0072-arm64-dts-microchip-tactical-1000-adapt-to-6.18.patch b/patches/linux/6.18.55/0072-arm64-dts-microchip-tactical-1000-adapt-to-6.18.patch index 7c61c7250..86a83c0ee 100644 --- a/patches/linux/6.18.55/0072-arm64-dts-microchip-tactical-1000-adapt-to-6.18.patch +++ b/patches/linux/6.18.55/0072-arm64-dts-microchip-tactical-1000-adapt-to-6.18.patch @@ -1,7 +1,7 @@ From a5fe28d8813c1265151399c88961117e95db9c7d Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sun, 20 Sep 2026 12:22:43 +0200 -Subject: [PATCH 72/78] arm64: dts: microchip: tactical-1000: adapt to 6.18 +Subject: [PATCH 72/80] arm64: dts: microchip: tactical-1000: adapt to 6.18 Novarq develop against 7.3, which has three things 6.18 does not: a QSPI controller node, a tmon that also provides the fan PWM, and hence a SoC diff --git a/patches/linux/6.18.55/0073-dt-bindings-arm-AT91-document-EV23X71A-board.patch b/patches/linux/6.18.55/0073-dt-bindings-arm-AT91-document-EV23X71A-board.patch index 5a53d4404..0d8842c25 100644 --- a/patches/linux/6.18.55/0073-dt-bindings-arm-AT91-document-EV23X71A-board.patch +++ b/patches/linux/6.18.55/0073-dt-bindings-arm-AT91-document-EV23X71A-board.patch @@ -1,7 +1,7 @@ From b390f0eaf27fd05c7a1436f3941964758b7a4fb4 Mon Sep 17 00:00:00 2001 From: Robert Marko Date: Mon, 2 Mar 2026 12:20:13 +0100 -Subject: [PATCH 73/78] dt-bindings: arm: AT91: document EV23X71A board +Subject: [PATCH 73/80] dt-bindings: arm: AT91: document EV23X71A board Microchip EV23X71A board is an LAN9696 based evaluation board. diff --git a/patches/linux/6.18.55/0074-net-dsa-Skip-DCB-default-priority-init-on-unsupporte.patch b/patches/linux/6.18.55/0074-net-dsa-Skip-DCB-default-priority-init-on-unsupporte.patch index 6395b557c..d66312e4c 100644 --- a/patches/linux/6.18.55/0074-net-dsa-Skip-DCB-default-priority-init-on-unsupporte.patch +++ b/patches/linux/6.18.55/0074-net-dsa-Skip-DCB-default-priority-init-on-unsupporte.patch @@ -1,7 +1,7 @@ From 8fc86e86edc2464d419084beb306cb95cf5946fc Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Wed, 9 Sep 2026 17:04:21 +0200 -Subject: [PATCH 74/78] net: dsa: Skip DCB default priority init on unsupported +Subject: [PATCH 74/80] net: dsa: Skip DCB default priority init on unsupported switches A driver serving several chip generations has one dsa_switch_ops for diff --git a/patches/linux/6.18.55/0075-net-dsa-Generalise-the-global-DCB-APP-mirroring-help.patch b/patches/linux/6.18.55/0075-net-dsa-Generalise-the-global-DCB-APP-mirroring-help.patch index 9ed0da249..0de88fdc5 100644 --- a/patches/linux/6.18.55/0075-net-dsa-Generalise-the-global-DCB-APP-mirroring-help.patch +++ b/patches/linux/6.18.55/0075-net-dsa-Generalise-the-global-DCB-APP-mirroring-help.patch @@ -1,7 +1,7 @@ From 3d3f7ba164355546fc2188315622356b4f02f681 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Tue, 22 Sep 2026 13:51:07 +0200 -Subject: [PATCH 75/78] net: dsa: Generalise the global DCB APP mirroring +Subject: [PATCH 75/80] net: dsa: Generalise the global DCB APP mirroring helper A switch with one classification table for all its ports programs it diff --git a/patches/linux/6.18.55/0076-net-dsa-Support-the-PCP-APP-selector.patch b/patches/linux/6.18.55/0076-net-dsa-Support-the-PCP-APP-selector.patch index e3d2b348d..a28ff8fb3 100644 --- a/patches/linux/6.18.55/0076-net-dsa-Support-the-PCP-APP-selector.patch +++ b/patches/linux/6.18.55/0076-net-dsa-Support-the-PCP-APP-selector.patch @@ -1,7 +1,7 @@ From 32c1befbedb0f46361960a0d321c743711a87762 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Wed, 9 Sep 2026 17:04:22 +0200 -Subject: [PATCH 76/78] net: dsa: Support the PCP APP selector +Subject: [PATCH 76/80] net: dsa: Support the PCP APP selector Add port_add_pcp_prio, port_del_pcp_prio and port_get_pcp_prio switch ops and route the DCB_APP_SEL_PCP selector to them, mirroring the DSCP diff --git a/patches/linux/6.18.55/0077-net-dsa-Support-DCB-priority-rewrite.patch b/patches/linux/6.18.55/0077-net-dsa-Support-DCB-priority-rewrite.patch index 34fa95eea..f3fbbac89 100644 --- a/patches/linux/6.18.55/0077-net-dsa-Support-DCB-priority-rewrite.patch +++ b/patches/linux/6.18.55/0077-net-dsa-Support-DCB-priority-rewrite.patch @@ -1,7 +1,7 @@ From b45635ee810e5edadedbc000b737281b2c517695 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Wed, 9 Sep 2026 17:10:32 +0200 -Subject: [PATCH 77/78] net: dsa: Support DCB priority rewrite +Subject: [PATCH 77/80] net: dsa: Support DCB priority rewrite The DCB rewrite table maps a priority back to the PCP and DEI, or the DSCP, that frames are remarked with on egress. DSA has no diff --git a/patches/linux/6.18.55/0078-net-dsa-Support-the-IEEE-ETS-managed-object.patch b/patches/linux/6.18.55/0078-net-dsa-Support-the-IEEE-ETS-managed-object.patch index adc56c667..cf7238c5f 100644 --- a/patches/linux/6.18.55/0078-net-dsa-Support-the-IEEE-ETS-managed-object.patch +++ b/patches/linux/6.18.55/0078-net-dsa-Support-the-IEEE-ETS-managed-object.patch @@ -1,7 +1,7 @@ From 621214da6d9ee1c6ebef311b193dab441c53d359 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Tue, 22 Sep 2026 13:53:23 +0200 -Subject: [PATCH 78/78] net: dsa: Support the IEEE ETS managed object +Subject: [PATCH 78/80] net: dsa: Support the IEEE ETS managed object A switch port's transmission selection is configured today through the ets queuing discipline, which numbers its bands the other way round From f3a84054a30115915ed4e2c840f46df8fb0111b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20Walstr=C3=B6m?= Date: Fri, 2 Oct 2026 10:47:14 +0200 Subject: [PATCH 05/38] board: bpi-r3: Enable WED in mt7915e Applies to every MT7986 board built from this BSP: BPI-R3, BPI-R3 Mini and the Acer Connect Vero W6m. --- .../bananapi-bpi-r3/rootfs/etc/modprobe.d/mt7915e-wed.conf | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 board/aarch64/bananapi-bpi-r3/rootfs/etc/modprobe.d/mt7915e-wed.conf diff --git a/board/aarch64/bananapi-bpi-r3/rootfs/etc/modprobe.d/mt7915e-wed.conf b/board/aarch64/bananapi-bpi-r3/rootfs/etc/modprobe.d/mt7915e-wed.conf new file mode 100644 index 000000000..f6575644f --- /dev/null +++ b/board/aarch64/bananapi-bpi-r3/rootfs/etc/modprobe.d/mt7915e-wed.conf @@ -0,0 +1,2 @@ +# Wireless Ethernet Dispatch, lets the PPE forward offloaded flows to the radios +options mt7915e wed_enable=1 From f3f9b3bd326a4ff59aa19600d4b16ed59803ceff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20Walstr=C3=B6m?= Date: Fri, 2 Oct 2026 10:47:25 +0200 Subject: [PATCH 06/38] confd: wifi: Create a hostapd control socket for every BSS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hostapd only creates the socket for the BSS with a ctrl_interface line, so hostapd_cli could not reach the secondary SSIDs on a radio. Signed-off-by: Mattias Walström --- src/confd/src/hardware.c | 1 + 1 file changed, 1 insertion(+) diff --git a/src/confd/src/hardware.c b/src/confd/src/hardware.c index d883b2c39..5d5c2bfec 100644 --- a/src/confd/src/hardware.c +++ b/src/confd/src/hardware.c @@ -361,6 +361,7 @@ static void wifi_gen_ssid_config(FILE *hostapd, struct lyd_node *cif, struct lyd if (is_bss) { fprintf(hostapd, "\n# BSS %s\n", ifname); fprintf(hostapd, "bss=%s\n", ifname); + fprintf(hostapd, "ctrl_interface=/run/hostapd\n"); } /* Check 802.11k/r/v configuration */ From 3f8406f06f943247b8f88f3e930f01415c7d2f28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20Walstr=C3=B6m?= Date: Fri, 2 Oct 2026 10:47:42 +0200 Subject: [PATCH 07/38] hostapd: Allow binding WDS stations to preconfigured interfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds wds_sta_ifname= , so a 4-address station can be bound to a port that already exists and is bridged by someone else. Signed-off-by: Mattias Walström --- ...ding-WDS-stations-to-preconfigured-i.patch | 244 ++++++++++++++++++ 1 file changed, 244 insertions(+) create mode 100644 patches/hostapd/0003-hostapd-Allow-binding-WDS-stations-to-preconfigured-i.patch diff --git a/patches/hostapd/0003-hostapd-Allow-binding-WDS-stations-to-preconfigured-i.patch b/patches/hostapd/0003-hostapd-Allow-binding-WDS-stations-to-preconfigured-i.patch new file mode 100644 index 000000000..dcf2bb56d --- /dev/null +++ b/patches/hostapd/0003-hostapd-Allow-binding-WDS-stations-to-preconfigured-i.patch @@ -0,0 +1,244 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?Mattias=20Walstr=C3=B6m?= +Date: Fri, 2 Oct 2026 10:00:00 +0200 +Subject: [PATCH 3/3] hostapd: Allow binding WDS stations to preconfigured + interfaces +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +A 4-address WDS station is bound to an AP_VLAN interface that hostapd +creates on association, named .sta, and removes again on +disassociation. Nothing outside hostapd can configure such a port +ahead of time, since neither its name nor its lifetime is known. + +Add a per-BSS option mapping a station MAC address to an interface +name: + + wds_sta_ifname= + +A matching station is bound to that interface instead. The interface +must already exist; hostapd brings it up and binds the station on +association, and on disassociation only sets it down. Its bridge +membership is never touched, so an external network manager can +create the port, enslave it and configure it like any other bridge +port before the station ever shows up. + +Stations without a mapping keep the current behaviour. + +Signed-off-by: Mattias Walström +--- +diff -ruN a/hostapd/config_file.c b/hostapd/config_file.c +--- a/hostapd/config_file.c ++++ b/hostapd/config_file.c +@@ -2368,6 +2368,48 @@ + #endif /* CONFIG_TESTING_OPTIONS */ + + ++/* ++ * wds_sta_ifname= ++ * ++ * Bind the 4-address WDS station with the given MAC address to an ++ * existing, externally managed interface instead of creating ++ * .sta. The interface is left in place when the station ++ * disassociates, and its bridge membership is never touched. ++ */ ++static int hostapd_config_parse_wds_sta_ifname(struct hostapd_bss_config *bss, ++ char *pos, int line) ++{ ++ struct hostapd_wds_sta_ifname *e; ++ char *ifname; ++ ++ ifname = os_strchr(pos, ' '); ++ if (!ifname) ++ goto fail; ++ *ifname++ = '\0'; ++ while (*ifname == ' ') ++ ifname++; ++ if (!*ifname || os_strlen(ifname) > IFNAMSIZ) ++ goto fail; ++ ++ e = os_zalloc(sizeof(*e)); ++ if (!e) ++ return 1; ++ if (hwaddr_aton(pos, e->addr)) { ++ os_free(e); ++ goto fail; ++ } ++ os_strlcpy(e->ifname, ifname, sizeof(e->ifname)); ++ e->next = bss->wds_sta_ifname; ++ bss->wds_sta_ifname = e; ++ ++ return 0; ++fail: ++ wpa_printf(MSG_ERROR, "Line %d: invalid wds_sta_ifname '%s'", ++ line, pos); ++ return 1; ++} ++ ++ + static int hostapd_config_fill(struct hostapd_config *conf, + struct hostapd_bss_config *bss, + const char *buf, char *pos, int line) +@@ -2383,6 +2425,9 @@ + os_strlcpy(bss->vlan_bridge, pos, sizeof(bss->vlan_bridge)); + } else if (os_strcmp(buf, "wds_bridge") == 0) { + os_strlcpy(bss->wds_bridge, pos, sizeof(bss->wds_bridge)); ++ } else if (os_strcmp(buf, "wds_sta_ifname") == 0) { ++ if (hostapd_config_parse_wds_sta_ifname(bss, pos, line)) ++ return 1; + } else if (os_strcmp(buf, "driver") == 0) { + int j; + const struct wpa_driver_ops *driver = NULL; +diff -ruN a/src/ap/ap_config.c b/src/ap/ap_config.c +--- a/src/ap/ap_config.c ++++ b/src/ap/ap_config.c +@@ -890,6 +890,12 @@ + os_free(conf->radius); + os_free(conf->radius_das_shared_secret); + hostapd_config_free_vlan(conf); ++ while (conf->wds_sta_ifname) { ++ struct hostapd_wds_sta_ifname *e = conf->wds_sta_ifname; ++ ++ conf->wds_sta_ifname = e->next; ++ os_free(e); ++ } + os_free(conf->time_zone); + os_free(conf->supported_rates); + os_free(conf->basic_rates); +diff -ruN a/src/ap/ap_config.h b/src/ap/ap_config.h +--- a/src/ap/ap_config.h ++++ b/src/ap/ap_config.h +@@ -281,6 +281,12 @@ + /** + * struct hostapd_bss_config - Per-BSS configuration + */ ++struct hostapd_wds_sta_ifname { ++ struct hostapd_wds_sta_ifname *next; ++ u8 addr[ETH_ALEN]; ++ char ifname[IFNAMSIZ + 1]; ++}; ++ + struct hostapd_bss_config { + char iface[IFNAMSIZ + 1]; + char bridge[IFNAMSIZ + 1]; +@@ -357,6 +363,7 @@ + struct mac_acl_entry *deny_mac; + int num_deny_mac; + int wds_sta; ++ struct hostapd_wds_sta_ifname *wds_sta_ifname; + int isolate; + int start_disabled; + +diff -ruN a/src/ap/ap_drv_ops.c b/src/ap/ap_drv_ops.c +--- a/src/ap/ap_drv_ops.c ++++ b/src/ap/ap_drv_ops.c +@@ -391,7 +391,9 @@ + int hostapd_set_wds_sta(struct hostapd_data *hapd, char *ifname_wds, + const u8 *addr, int aid, int val) + { ++ struct hostapd_wds_sta_ifname *e; + const char *bridge = NULL; ++ char name[IFNAMSIZ + 1]; + + if (hapd->driver == NULL || hapd->driver->set_wds_sta == NULL) + return -1; +@@ -399,6 +401,20 @@ + bridge = hapd->conf->wds_bridge; + else if (hapd->conf->bridge[0]) + bridge = hapd->conf->bridge; ++ ++ if (!ifname_wds) ++ ifname_wds = name; ++ ifname_wds[0] = '\0'; ++ for (e = hapd->conf->wds_sta_ifname; e; e = e->next) { ++ if (os_memcmp(e->addr, addr, ETH_ALEN) != 0) ++ continue; ++ /* Externally managed interface: hand the driver its name ++ * and keep it out of any bridge handling. */ ++ os_strlcpy(ifname_wds, e->ifname, IFNAMSIZ + 1); ++ bridge = NULL; ++ break; ++ } ++ + return hapd->driver->set_wds_sta(hapd->drv_priv, addr, aid, val, + bridge, ifname_wds); + } +diff -ruN a/src/drivers/driver.h b/src/drivers/driver.h +--- a/src/drivers/driver.h ++++ b/src/drivers/driver.h +@@ -4518,7 +4518,11 @@ + * @bridge_ifname: Bridge interface to use for the WDS station or %NULL + * to indicate that bridge is not to be used + * @ifname_wds: Buffer to return the interface name for the new WDS +- * station or %NULL to indicate name is not returned. ++ * station or %NULL to indicate name is not returned. If the ++ * buffer holds a name on entry, that existing interface is used ++ * as is: it is not created, not added to or removed from a ++ * bridge, and is only set down, not removed, when the station ++ * is unbound. + * Returns: 0 on success, -1 on failure + */ + int (*set_wds_sta)(void *priv, const u8 *addr, int aid, int val, +diff -ruN a/src/drivers/driver_nl80211.c b/src/drivers/driver_nl80211.c +--- a/src/drivers/driver_nl80211.c ++++ b/src/drivers/driver_nl80211.c +@@ -9317,22 +9317,35 @@ + char name[IFNAMSIZ + 1]; + union wpa_event_data event; + bool add_br = false; ++ bool external = false; + int ret; + +- ret = os_snprintf(name, sizeof(name), "%s.sta%d", bss->ifname, aid); +- if (ret >= (int) sizeof(name)) +- wpa_printf(MSG_WARNING, +- "nl80211: WDS interface name was truncated"); +- else if (ret < 0) +- return ret; +- +- if (ifname_wds) +- os_strlcpy(ifname_wds, name, IFNAMSIZ + 1); ++ if (ifname_wds && ifname_wds[0]) { ++ os_strlcpy(name, ifname_wds, sizeof(name)); ++ external = true; ++ } else { ++ ret = os_snprintf(name, sizeof(name), "%s.sta%d", bss->ifname, ++ aid); ++ if (ret >= (int) sizeof(name)) ++ wpa_printf(MSG_WARNING, ++ "nl80211: WDS interface name was truncated"); ++ else if (ret < 0) ++ return ret; ++ ++ if (ifname_wds) ++ os_strlcpy(ifname_wds, name, IFNAMSIZ + 1); ++ } + + wpa_printf(MSG_DEBUG, "nl80211: Set WDS STA addr=" MACSTR + " aid=%d val=%d name=%s", MAC2STR(addr), aid, val, name); + if (val) { + if (!if_nametoindex(name)) { ++ if (external) { ++ wpa_printf(MSG_ERROR, ++ "nl80211: WDS STA interface %s does not exist", ++ name); ++ return -1; ++ } + if (nl80211_create_iface(drv, name, + NL80211_IFTYPE_AP_VLAN, + bss->addr, 1, NULL, NULL, 0) < +@@ -9363,6 +9376,13 @@ + return i802_set_sta_vlan(priv, addr, name, 0, + NL80211_DRV_LINK_ID_NA); + } else { ++ if (external) { ++ i802_set_sta_vlan(priv, addr, bss->ifname, 0, ++ NL80211_DRV_LINK_ID_NA); ++ linux_set_iface_flags(drv->global->ioctl_sock, name, 0); ++ return 0; ++ } ++ + if (bridge_ifname && + linux_br_del_if(drv->global->ioctl_sock, bridge_ifname, + name) < 0) From 6230b22fb221b8b9048a7390ea2dc20ddb8ef767 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20Walstr=C3=B6m?= Date: Fri, 2 Oct 2026 10:47:42 +0200 Subject: [PATCH 08/38] confd: wifi: Add 4-address WDS link and station modes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A wds-link interface is a bridge port of a local access point for one remote 4-address station, created as an AP_VLAN up front and bound by hostapd via wds_sta_ifname. A station with wds enabled may be a bridge port. Also judge hostapd config by the APs left in config on commit, since the changed interface on a radio need not be an AP anymore. Signed-off-by: Mattias Walström --- src/confd/src/core.c | 11 ++ src/confd/src/hardware.c | 73 +++++++--- src/confd/src/if-wifi.c | 76 ++++++++-- src/confd/src/interfaces.c | 7 +- src/confd/src/interfaces.h | 2 + src/confd/yang/confd/infix-if-bridge.yang | 10 +- ...9.yang => infix-if-bridge@2026-10-02.yang} | 0 src/confd/yang/confd/infix-if-wifi.yang | 134 +++++++++++++++++- ...-24.yang => infix-if-wifi@2026-10-02.yang} | 0 9 files changed, 272 insertions(+), 41 deletions(-) rename src/confd/yang/confd/{infix-if-bridge@2026-04-29.yang => infix-if-bridge@2026-10-02.yang} (100%) rename src/confd/yang/confd/{infix-if-wifi@2026-09-24.yang => infix-if-wifi@2026-10-02.yang} (100%) diff --git a/src/confd/src/core.c b/src/confd/src/core.c index 7ebf70d51..7905fd6cc 100644 --- a/src/confd/src/core.c +++ b/src/confd/src/core.c @@ -469,6 +469,17 @@ static confd_dependency_t dep_wifi_interfaces(struct lyd_node **diff, struct lyd radio_node = lydx_get_xpathf(config, "/ietf-interfaces:interfaces/interface[name='%s']/infix-interfaces:wifi/radio", ifname); + if (!radio_node) { + /* A WDS link has no radio of its own, follow its access point */ + struct lyd_node *ap = lydx_get_xpathf(config, + "/ietf-interfaces:interfaces/interface[name='%s']/infix-interfaces:wifi/wds-link/access-point", + ifname); + + if (ap) + radio_node = lydx_get_xpathf(config, + "/ietf-interfaces:interfaces/interface[name='%s']/infix-interfaces:wifi/radio", + lyd_get_value(ap)); + } if (!radio_node) continue; diff --git a/src/confd/src/hardware.c b/src/confd/src/hardware.c index 5d5c2bfec..4398b4861 100644 --- a/src/confd/src/hardware.c +++ b/src/confd/src/hardware.c @@ -339,6 +339,38 @@ static int wifi_find_radio_aps(struct lyd_node *cifs, const char *radio_name, return 0; } +/* Emit the 4-address WDS ports (wds-link interfaces) of an AP */ +static void wifi_gen_wds_ports(FILE *hostapd, struct lyd_node *config, const char *ap_ifname) +{ + struct lyd_node *cifs, *cif; + bool first = true; + + cifs = lydx_get_descendant(config, "interfaces", "interface", NULL); + LYX_LIST_FOR_EACH(cifs, cif, "interface") { + struct lyd_node *wds; + const char *ap; + + wds = lydx_get_descendant(lyd_child(cif), "wifi", "wds-link", NULL); + if (!wds) + continue; + + ap = lydx_get_cattr(wds, "access-point"); + if (!ap || strcmp(ap, ap_ifname)) + continue; + + if (first) { + fprintf(hostapd, "# 4-address WDS ports\n"); + fprintf(hostapd, "wds_sta=1\n"); + /* A deauth from a vanishing peer is easily lost, and the + * default 300 s leaves a dead backhaul port up that long. */ + fprintf(hostapd, "ap_max_inactivity=30\n"); + first = false; + } + fprintf(hostapd, "wds_sta_ifname=%s %s\n", + lydx_get_cattr(wds, "peer-address"), lydx_get_cattr(cif, "name")); + } +} + /* Helper: Write SSID and security configuration (shared between primary and BSS) */ static void wifi_gen_ssid_config(FILE *hostapd, struct lyd_node *cif, struct lyd_node *config, bool is_bss, const char *band) { @@ -506,6 +538,8 @@ static void wifi_gen_ssid_config(FILE *hostapd, struct lyd_node *cif, struct lyd fprintf(hostapd, "bss_transition=1\n"); } + wifi_gen_wds_ports(hostapd, config, ifname); + /* OKC: Opportunistic Key Caching */ if (roaming) { const char *okc = lydx_get_cattr(roaming, "okc"); @@ -1254,13 +1288,14 @@ int hardware_change(sr_session_ctx_t *session, struct lyd_node *config, struct l goto err; } } else if (!strcmp(class, "infix-hardware:wifi")) { - struct lyd_node *interfaces_config, *interfaces_diff; struct lyd_node **wifi_iface_list = NULL; - struct lyd_node *ap; + struct lyd_node *interfaces_config; struct lyd_node *cwifi_radio; int wifi_iface_count = 0; char src[40], dst[40]; + char **ap_list = NULL; int ap_interfaces = 0; + int i; switch (event) { case SR_EV_ABORT: @@ -1268,27 +1303,25 @@ int hardware_change(sr_session_ctx_t *session, struct lyd_node *config, struct l case SR_EV_CHANGE: break; case SR_EV_DONE: - interfaces_diff = lydx_get_descendant(diff, "interfaces", "interface", NULL); - - wifi_find_interfaces_on_radio(interfaces_diff, name, - &wifi_iface_list, &wifi_iface_count); - if (wifi_iface_count > 0) { - ap = lydx_get_descendant(wifi_iface_list[0], "interface", "wifi", "access-point", NULL); - if (ap && lydx_get_op(ap) != LYDX_OP_DELETE) { - snprintf(src, sizeof(src), HOSTAPD_CONF_NEXT, name); - snprintf(dst, sizeof(dst), HOSTAPD_CONF, name); - - if (fexistf(HOSTAPD_CONF_NEXT, name)) { - (void)rename(src, dst); - ap_interfaces++; - } - } - } - if (!ap_interfaces) { + /* The changed interface need not be an AP, a + * wds-link or station on the radio also lands + * here, so judge by the APs left in config. */ + interfaces_config = lydx_get_descendant(config, "interfaces", "interface", NULL); + wifi_find_radio_aps(interfaces_config, name, &ap_list, &ap_interfaces); + for (i = 0; i < ap_interfaces; i++) + free(ap_list[i]); + free(ap_list); + + if (ap_interfaces) { + snprintf(src, sizeof(src), HOSTAPD_CONF_NEXT, name); + snprintf(dst, sizeof(dst), HOSTAPD_CONF, name); + + if (fexistf(HOSTAPD_CONF_NEXT, name)) + (void)rename(src, dst); + } else { erasef(HOSTAPD_CONF, name); erasef(HOSTAPD_CONF_NEXT, name); } - free(wifi_iface_list); /* All radios share one hostapd process; the service is * (re)generated after the component loop below. */ wifi_changed = 1; diff --git a/src/confd/src/if-wifi.c b/src/confd/src/if-wifi.c index 8b9fca53a..5a43ed2ff 100644 --- a/src/confd/src/if-wifi.c +++ b/src/confd/src/if-wifi.c @@ -109,7 +109,7 @@ int wifi_validate_secret(sr_session_ctx_t *session, struct lyd_node *cif) wifi_mode_t wifi_get_mode(struct lyd_node *iface) { - struct lyd_node *ap, *mesh, *wifi; + struct lyd_node *ap, *mesh, *wds, *wifi; wifi = lydx_get_child(iface, "wifi"); if (!wifi) @@ -127,6 +127,12 @@ wifi_mode_t wifi_get_mode(struct lyd_node *iface) return wifi_mesh; } + wds = lydx_get_child(wifi, "wds-link"); + if (wds) { + if (lydx_get_op(wds) != LYDX_OP_DELETE) + return wifi_wds; + } + /* * Need to return station even if "station" also is false, * because station is the default scanning mode. @@ -154,6 +160,12 @@ int wifi_mode_changed(struct lyd_node *wifi) if (node && (op == LYDX_OP_CREATE || op == LYDX_OP_DELETE)) return 1; + node = lydx_get_child(wifi, "wds-link"); + if (node) + op = lydx_get_op(node); + if (node && (op == LYDX_OP_CREATE || op == LYDX_OP_DELETE)) + return 1; + return 0; } @@ -164,6 +176,7 @@ int wifi_gen_station(struct lyd_node *cif) { const char *ifname, *ssid, *secret_name, *security_mode, *radio; struct lyd_node *security, *secret_node, *radio_node, *station, *wifi; + const char *bssid = NULL; unsigned char *secret = NULL; FILE *wpa_supplicant = NULL; char *security_str = NULL; @@ -180,6 +193,7 @@ int wifi_gen_station(struct lyd_node *cif) station = lydx_get_child(wifi, "station"); if (station) { ssid = lydx_get_cattr(station, "ssid"); + bssid = lydx_get_cattr(station, "peer-bssid"); security = lydx_get_child(station, "security"); security_mode = lydx_get_cattr(security, "mode"); secret_name = lydx_get_cattr(security, "secret"); @@ -239,9 +253,13 @@ int wifi_gen_station(struct lyd_node *cif) fprintf(wpa_supplicant, "network={\n" " bgscan=\"\"\n" - " ssid=\"%s\"\n" + " scan_ssid=1\n" + " ssid=\"%s\"\n", ssid); + if (bssid) + fprintf(wpa_supplicant, " bssid=%s\n", bssid); + fprintf(wpa_supplicant, " %s\n" - "}\n", ssid, security_str); + "}\n", security_str); free(security_str); } else { /* Scan-only mode - no station container configured */ @@ -481,6 +499,24 @@ static int wifi_get_probe_timeout(sr_session_ctx_t *session, const char *radio) /* * Add WiFi virtual interface using iw */ +int wifi_add_deps(struct lyd_node *cif) +{ + struct lyd_node *wds; + const char *ap; + int err; + + wds = lydx_get_descendant(lyd_child(cif), "wifi", "wds-link", NULL); + if (!wds) + return 0; + + ap = lydx_get_cattr(wds, "access-point"); + err = dagger_add_dep(&confd.netdag, lydx_get_cattr(cif, "name"), ap); + if (err) + return ERR_IFACE(cif, err, "Unable to depend on \"%s\"", ap); + + return 0; +} + int wifi_add_iface(struct lyd_node *cif, struct dagger *net) { const char *ifname, *radio; @@ -498,10 +534,14 @@ int wifi_add_iface(struct lyd_node *cif, struct dagger *net) return SR_ERR_INVAL_ARG; } - radio = lydx_get_cattr(wifi, "radio"); - if (!radio) { - ERROR("WiFi interface %s: missing radio reference", ifname); - return SR_ERR_INVAL_ARG; + mode = wifi_get_mode(cif); + if (mode == wifi_wds) { + /* A WDS link has no radio of its own, it is a port of its AP */ + const char *ap = lydx_get_cattr(lydx_get_child(wifi, "wds-link"), "access-point"); + + radio = lydx_get_cattr(lydx_get_xpathf(cif, "../interface[name='%s']/wifi", ap), "radio"); + } else { + radio = lydx_get_cattr(wifi, "radio"); } iw = dagger_fopen_net_init(net, ifname, NETDAG_INIT_PRE, "wifi-iface.sh"); @@ -510,12 +550,13 @@ int wifi_add_iface(struct lyd_node *cif, struct dagger *net) return SR_ERR_INTERNAL; } - mode = wifi_get_mode(cif); probe_timeout = wifi_get_probe_timeout(net->session, radio); fprintf(iw, "# Generated by Infix confd - WiFi Interface Creation\n"); fprintf(iw, "# Create %s interface %s on radio %s\n", - mode == wifi_station ? "station" : (mode == wifi_mesh ? "mesh" : "access point"), ifname, radio); + mode == wifi_station ? "station" : + mode == wifi_mesh ? "mesh" : + mode == wifi_wds ? "wds-link" : "access point", ifname, radio); /* Wait for PHY if probe-timeout is set (slow USB dongles) */ if (probe_timeout > 0) { @@ -540,12 +581,25 @@ int wifi_add_iface(struct lyd_node *cif, struct dagger *net) fprintf(iw, "fi\n\n"); switch(mode) { - case wifi_station: - fprintf(iw, "iw phy %s interface add %s type managed\n", radio, ifname); + case wifi_station: { + struct lyd_node *station = lydx_get_child(wifi, "station"); + + fprintf(iw, "iw phy %s interface add %s type managed%s\n", radio, ifname, + station && lydx_is_enabled(station, "wds") ? " 4addr on" : ""); wifi_gen_station(cif); fprintf(iw, "initctl -bfq enable wifi@%s\n", ifname); fprintf(iw, "initctl -bfq touch wifi@%s\n", ifname); break; + } + case wifi_wds: { + const char *ap = lydx_get_cattr(lydx_get_child(wifi, "wds-link"), "access-point"); + + /* An AP_VLAN pairs with the AP of the same MAC address, and + * hostapd brings it up when the station associates. */ + fprintf(iw, "iw dev %s interface add %s type __ap_vlan" + " addr $(cat /sys/class/net/%s/address) 4addr on\n", ap, ifname, ap); + break; + } case wifi_ap: fprintf(iw, "iw phy %s interface add %s type __ap\n", radio, ifname); break; diff --git a/src/confd/src/interfaces.c b/src/confd/src/interfaces.c index 822600426..1d62f8557 100644 --- a/src/confd/src/interfaces.c +++ b/src/confd/src/interfaces.c @@ -755,8 +755,10 @@ static sr_error_t netdag_gen_iface(sr_session_ctx_t *session, struct dagger *net attr = lydx_get_cattr(cif, "description"); fprintf(ip, "link set alias \"%s\" dev %s\n", attr ?: "", ifname); - /* Bring interface back up, if enabled */ - if (lydx_is_enabled(cif, "enabled")) + /* Bring interface back up, if enabled. A wds-link port cannot + * come up before hostapd runs its AP, hostapd does it instead. */ + if (lydx_is_enabled(cif, "enabled") && + !(iftype_from_iface(cif) == IFT_WIFI && wifi_get_mode(cif) == wifi_wds)) fprintf(ip, "link set dev %s up state up\n", ifname); err = err ? : netdag_gen_sysctl(net, cif, dif); @@ -788,6 +790,7 @@ static int netdag_init_iface(struct lyd_node *cif) case IFT_VETH: return veth_add_deps(cif); case IFT_WIFI: + return wifi_add_deps(cif); case IFT_DUMMY: case IFT_ETH: case IFT_GRE: diff --git a/src/confd/src/interfaces.h b/src/confd/src/interfaces.h index 3ff5063e2..37a3181b4 100644 --- a/src/confd/src/interfaces.h +++ b/src/confd/src/interfaces.h @@ -132,10 +132,12 @@ typedef enum wifi_mode_t { wifi_station, wifi_ap, wifi_mesh, + wifi_wds, wifi_unknown } wifi_mode_t; int wifi_validate_secret(sr_session_ctx_t *session, struct lyd_node *cif); +int wifi_add_deps(struct lyd_node *cif); int wifi_add_iface(struct lyd_node *cif, struct dagger *net); int wifi_del_iface(struct lyd_node *dif, struct dagger *net); int wifi_mode_changed(struct lyd_node *wifi); diff --git a/src/confd/yang/confd/infix-if-bridge.yang b/src/confd/yang/confd/infix-if-bridge.yang index 7c5eff40b..5b3bba7db 100644 --- a/src/confd/yang/confd/infix-if-bridge.yang +++ b/src/confd/yang/confd/infix-if-bridge.yang @@ -29,6 +29,12 @@ submodule infix-if-bridge { contact "kernelkit@googlegroups.com"; description "Linux bridge extension for ietf-interfaces."; + revision 2026-10-02 { + description + "Allow WDS link interfaces and 4-address stations as bridge ports."; + reference "internal"; + } + revision 2026-04-29 { description "Add operational state for multicast router ports per bridge."; reference "internal"; @@ -939,8 +945,8 @@ submodule infix-if-bridge { must "not(../ip:ipv4/ip:address or ../ip:ipv6/ip:address)" { error-message "Bridge ports cannot have IP addresses configured."; } - must "not(derived-from-or-self(../if:type, 'infix-ift:wifi')) or ../infix-if:wifi/infix-if:access-point or ../infix-if:wifi/infix-if:mesh-point" { - error-message "WiFi interfaces can only be bridge ports when configured as Access Points or Mesh Points."; + must "not(derived-from-or-self(../if:type, 'infix-ift:wifi')) or ../infix-if:wifi/infix-if:access-point or ../infix-if:wifi/infix-if:mesh-point or ../infix-if:wifi/infix-if:wds-link or ../infix-if:wifi/infix-if:station/infix-if:wds = 'true'" { + error-message "WiFi interfaces can only be bridge ports as access point, mesh point, WDS link, or station with wds enabled."; } description "Bridge association and port specific settings."; uses bridge-port-common; diff --git a/src/confd/yang/confd/infix-if-bridge@2026-04-29.yang b/src/confd/yang/confd/infix-if-bridge@2026-10-02.yang similarity index 100% rename from src/confd/yang/confd/infix-if-bridge@2026-04-29.yang rename to src/confd/yang/confd/infix-if-bridge@2026-10-02.yang diff --git a/src/confd/yang/confd/infix-if-wifi.yang b/src/confd/yang/confd/infix-if-wifi.yang index 078e50204..a88518eaa 100644 --- a/src/confd/yang/confd/infix-if-wifi.yang +++ b/src/confd/yang/confd/infix-if-wifi.yang @@ -43,11 +43,19 @@ submodule infix-if-wifi { interfaces provide network-layer configuration (SSID, security). Key features: - - Dual mode support: AP and Station + - Modes: Access Point, Station, 802.11s Mesh Point and 4-address + (WDS) link - Multi-SSID: Multiple APs on same radio - Security: WPA2/WPA3 with keystore integration - Operational state: Connection status, RSSI, client lists"; + revision 2026-10-02 { + description + "Add 4-address (WDS) support: wds-link mode for access point side + ports, and the station leaves 'wds' and 'peer-bssid'."; + reference "internal"; + } + revision 2026-09-24 { description "Constrain the character set of mesh-id and nas-identifier."; @@ -132,18 +140,21 @@ submodule infix-if-wifi { The interface must reference a radio defined in infix-wifi-radio module, which provides the physical layer configuration."; + must "radio or wds-link" { + error-message "A WiFi interface must reference a radio"; + } + leaf radio { type leafref { path "/iehw:hardware/iehw:component/iehw:name"; } - mandatory true; must "derived-from-or-self(/iehw:hardware/iehw:component[iehw:name=current()]/iehw:class, 'ih:wifi')" { error-message "Referenced hardware component must be a WiFi radio (class 'ih:wifi')"; } - must "count(/if:interfaces/if:interface[wifi/radio = current()][not(wifi/access-point)]) <= 1" { + must "count(/if:interfaces/if:interface[wifi/radio = current()][not(wifi/access-point)][not(wifi/wds-link)]) <= 1" { error-message "Only one station or scan interface is allowed per radio"; } - must "count(/if:interfaces/if:interface[wifi/radio = current()][not(infix-if:custom-phys-address/*)]) <= 1" { + must "count(/if:interfaces/if:interface[wifi/radio = current()][not(infix-if:custom-phys-address/*)][not(wifi/wds-link)]) <= 1" { error-message "Only one interface per radio can use the default MAC address. Configure custom-phys-address on additional interfaces."; } @@ -152,7 +163,8 @@ submodule infix-if-wifi { References a hardware component with class 'ih:wifi'. The radio must exist and be configured before creating - virtual interfaces. + virtual interfaces. Not set for a WDS link, which uses + the radio of its access point. Example: 'phy0' for the first WiFi radio. @@ -171,9 +183,11 @@ submodule infix-if-wifi { - Station mode: Connect to an existing WiFi network - Access Point mode: Create a WiFi network for clients - Mesh Point mode: Create an 802.11s mesh link + - WDS link: Bridge port for one 4-address station on a local + access point Note: A radio can host either: - - Multiple AP interfaces (multi-SSID), OR + - Multiple AP interfaces (multi-SSID), with WDS links, OR - A single Station interface, OR - A single Mesh Point interface @@ -193,6 +207,28 @@ submodule infix-if-wifi { Example use case: Connect to upstream WiFi network."; + leaf wds { + type boolean; + default false; + description + "4-address (WDS) mode. + + Lets the station forward frames for other devices, which + is what allows it to be a bridge port. Use it to bridge + the station with wired ports or a local access point, as + in a repeater. The access point must accept 4-address + stations, see the wds-link mode."; + } + + leaf peer-bssid { + type yang:mac-address; + description + "Only associate to the access point with this BSSID. + + Without it the station picks any access point advertising + the SSID."; + } + leaf ssid { type string { length "1..32"; @@ -811,6 +847,92 @@ submodule infix-if-wifi { } } } + + case wds-link { + container wds-link { + presence "Configure a 4-address WDS port of a local access point"; + + description + "Bridge port for one 4-address (WDS) station on an access + point of this device. + + The access point binds the station with the given MAC + address to this interface. Add the interface to the access + point's bridge and configure VLANs like for any other port; + one wds-link per repeater or remote bridge. The port is up + while the station is associated. + + The interface uses the access point's radio and MAC + address."; + + must "not(../../custom-phys-address/*)" { + error-message "A wds-link uses the MAC address of its access point"; + } + + must "not(../radio)" { + error-message "A wds-link uses the radio of its access point, do not set radio"; + } + + leaf access-point { + type leafref { + path "/if:interfaces/if:interface/if:name"; + } + mandatory true; + must "/if:interfaces/if:interface[if:name = current()]/wifi/access-point" { + error-message "wds-link must reference an access point interface on this device"; + } + description + "Access point interface this port belongs to."; + } + + leaf peer-address { + type yang:mac-address; + mandatory true; + must "count(/if:interfaces/if:interface[wifi/wds-link/access-point = current()/../access-point][wifi/wds-link/peer-address = current()]) = 1" { + error-message "Only one wds-link per station on an access point"; + } + description + "MAC address of the 4-address station bound to this port."; + } + + /* Operational state */ + + leaf connected { + config false; + type boolean; + description + "True while the station is associated and bound to this + port."; + } + + leaf signal-strength { + config false; + type int16; + units "dBm"; + description + "Signal strength of the station in dBm. Only present + while connected."; + } + + leaf rx-speed { + config false; + type uint32; + units "100 kbps"; + description + "Last received data rate from the station in 100 kbps. + Only present while connected."; + } + + leaf tx-speed { + config false; + type uint32; + units "100 kbps"; + description + "Last transmitted data rate to the station in 100 kbps. + Only present while connected."; + } + } + } } } } diff --git a/src/confd/yang/confd/infix-if-wifi@2026-09-24.yang b/src/confd/yang/confd/infix-if-wifi@2026-10-02.yang similarity index 100% rename from src/confd/yang/confd/infix-if-wifi@2026-09-24.yang rename to src/confd/yang/confd/infix-if-wifi@2026-10-02.yang From f2ea87adb7d24f3a5fd4f1463601d808c5341075 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20Walstr=C3=B6m?= Date: Fri, 2 Oct 2026 10:47:42 +0200 Subject: [PATCH 09/38] statd: wifi: Report wds-link state in operational data and CLI AP_VLAN ports never leave operstate UNKNOWN, so oper-status is now derived from the UP and LOWER_UP flags for such interfaces. --- board/common/rootfs/usr/libexec/infix/iw.py | 29 +++++++++++++++ src/statd/python/cli_pretty/cli_pretty.py | 23 ++++++++++++ .../python/yanger/ietf_interfaces/link.py | 7 ++++ .../python/yanger/ietf_interfaces/wifi.py | 36 ++++++++++++++++++- 4 files changed, 94 insertions(+), 1 deletion(-) diff --git a/board/common/rootfs/usr/libexec/infix/iw.py b/board/common/rootfs/usr/libexec/infix/iw.py index ff6335148..727c3957a 100755 --- a/board/common/rootfs/usr/libexec/infix/iw.py +++ b/board/common/rootfs/usr/libexec/infix/iw.py @@ -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 link' output for station mode @@ -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': [ @@ -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'} diff --git a/src/statd/python/cli_pretty/cli_pretty.py b/src/statd/python/cli_pretty/cli_pretty.py index ad661b556..d4eff3fde 100755 --- a/src/statd/python/cli_pretty/cli_pretty.py +++ b/src/statd/python/cli_pretty/cli_pretty.py @@ -1457,6 +1457,16 @@ def pr_proto_wifi(self, pipe=''): peers_data = mesh.get("peers", {}) peers = peers_data.get("peer", []) data_str = f"{mode}, mesh-id: {mesh_id}, peers: {len(peers)}" + elif "wds-link" in self.wifi: + wds = self.wifi["wds-link"] + mode = "WDS" + signal = wds.get("signal-strength") + if wds.get("connected") and signal is not None: + data_str = f"{mode}, connected, signal: {signal_to_status(signal)}" + elif wds.get("connected"): + data_str = f"{mode}, connected" + else: + data_str = f"{mode}, not connected" else: station=self.wifi.get("station", {}) ssid = station.get("ssid", "------") @@ -1764,6 +1774,19 @@ def _addr_lines(addrs): print(f"{'mesh-id':<{19}}: {mesh_id}") print(f"{'connected peers':<{19}}: {len(peers)}") self.pr_wifi_peers() + elif "wds-link" in self.wifi: + wds = self.wifi['wds-link'] + signal = wds.get('signal-strength') + print(f"{'mode':<{19}}: wds-link") + print(f"{'connected':<{19}}: {'yes' if wds.get('connected') else 'no'}") + if signal is not None: + print(f"{'signal':<{19}}: {signal} dBm ({signal_to_status(signal)})") + rx_speed = wds.get('rx-speed') + tx_speed = wds.get('tx-speed') + if rx_speed is not None: + print(f"{'rx bitrate':<{19}}: {rx_speed / 10:.1f} Mbps") + if tx_speed is not None: + print(f"{'tx bitrate':<{19}}: {tx_speed / 10:.1f} Mbps") else: mode = "station" station = self.wifi.get('station', {}) diff --git a/src/statd/python/yanger/ietf_interfaces/link.py b/src/statd/python/yanger/ietf_interfaces/link.py index f5ef6a7ca..6c88da12b 100644 --- a/src/statd/python/yanger/ietf_interfaces/link.py +++ b/src/statd/python/yanger/ietf_interfaces/link.py @@ -111,6 +111,13 @@ def iplink2yang_operstate(iplink): "LOWERLAYERDOWN": "lower-layer-down", "NOTPRESENT": "not-present" } + if iplink["operstate"] == "UNKNOWN": + # Interfaces without carrier handling (AP_VLAN, tunnels) stay + # in UNKNOWN; the link flags tell the real state. + flags = iplink.get("flags", []) + if "UP" not in flags: + return "down" + return "up" if "LOWER_UP" in flags else "lower-layer-down" return xlate.get(iplink["operstate"], "unknown") diff --git a/src/statd/python/yanger/ietf_interfaces/wifi.py b/src/statd/python/yanger/ietf_interfaces/wifi.py index 06617f4bb..7dc0f69d3 100644 --- a/src/statd/python/yanger/ietf_interfaces/wifi.py +++ b/src/statd/python/yanger/ietf_interfaces/wifi.py @@ -28,6 +28,16 @@ def get_iw_stations(ifname): return [] +def get_iw_wds_ports(ifname): + """Get the WDS ports of an AP via iw.py""" + try: + data = HOST.run(('/usr/libexec/infix/iw.py', 'wds', ifname), default='[]') + return json.loads(data) + except Exception: + pass + return [] + + def get_iw_mesh_param(ifname): """Get mesh parameters via iw.py (mesh point mode)""" try: @@ -60,8 +70,11 @@ def wifi_ap(ifname): if info.get('ssid'): ap_data['ssid'] = info['ssid'] - # Get connected stations + # Connected stations, including the 4-address ones the kernel lists + # under the AP's WDS ports rather than under the AP itself stations = get_iw_stations(ifname) + for port in get_iw_wds_ports(ifname): + stations += get_iw_stations(port) if stations: ap_data['stations'] = {'station': stations} @@ -153,6 +166,25 @@ def wifi_station(ifname): return {'station': station_data} if station_data else {} +def wifi_wds(ifname): + """Operational data for a wds-link port (AP_VLAN). + + The station bound to the port is the only entry in its station dump, + so that is both the connected flag and the link quality. + """ + stations = get_iw_stations(ifname) + if not stations: + return {'wds-link': {'connected': False}} + + sta = stations[0] + data = {'connected': True} + for key in ('signal-strength', 'rx-speed', 'tx-speed'): + if sta.get(key) is not None: + data[key] = sta[key] + + return {'wds-link': data} + + def wifi(ifname): """Main entry point - detect mode and return appropriate data""" info = get_iw_info(ifname) @@ -165,6 +197,8 @@ def wifi(ifname): if mode == 'ap': result.update(wifi_ap(ifname)) + elif mode == 'ap/vlan': + result.update(wifi_wds(ifname)) elif mode == 'mesh point': result.update(wifi_mesh(ifname, info)) else: From b7de35ae6f2094421a99e77f8f062f6e42757c3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20Walstr=C3=B6m?= Date: Fri, 2 Oct 2026 10:47:42 +0200 Subject: [PATCH 10/38] webui: Show wds-link WiFi interfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Mattias Walström --- src/webui/internal/handlers/interfaces.go | 29 +++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/webui/internal/handlers/interfaces.go b/src/webui/internal/handlers/interfaces.go index c1dc9ea4e..1acfd4042 100644 --- a/src/webui/internal/handlers/interfaces.go +++ b/src/webui/internal/handlers/interfaces.go @@ -96,6 +96,18 @@ type wifiJSON struct { AccessPoint *wifiAPJSON `json:"access-point"` Station *wifiStationJSON `json:"station"` MeshPoint *wifiMeshJSON `json:"mesh-point"` + WDSLink *wifiWDSJSON `json:"wds-link"` +} + +// wifiWDSJSON mirrors the wds-link container: a 4-address station bound +// to this port of a local access point. +type wifiWDSJSON struct { + AccessPoint string `json:"access-point"` + PeerAddress string `json:"peer-address"` + Connected *bool `json:"connected"` + SignalStrength *int `json:"signal-strength"` + RxSpeed int `json:"rx-speed"` + TxSpeed int `json:"tx-speed"` } type wifiAPJSON struct { @@ -565,6 +577,12 @@ func makeIfaceEntry(iface ifaceJSON, fwdSet map[string]bool) ifaceEntry { } else if mp := iface.WiFi.MeshPoint; mp != nil { n := len(mp.Peers.Peer) e.Detail = fmt.Sprintf("Mesh, mesh-id: %s, peers: %d", mp.MeshID, n) + } else if wds := iface.WiFi.WDSLink; wds != nil { + if wds.Connected != nil && *wds.Connected { + e.Detail = "WDS, connected" + } else { + e.Detail = "WDS, not connected" + } } } @@ -771,6 +789,17 @@ func buildDetailData(r *http.Request, iface *ifaceJSON) ifaceDetailData { for _, p := range mp.Peers.Peer { d.WiFiStations = append(d.WiFiStations, buildWifiStaEntry(p)) } + } else if wds := iface.WiFi.WDSLink; wds != nil { + d.WiFiMode = "WDS Link" + if wds.SignalStrength != nil { + d.WiFiSignal = fmt.Sprintf("%d dBm", *wds.SignalStrength) + } + if wds.RxSpeed > 0 { + d.WiFiRxSpeed = fmt.Sprintf("%.1f Mbps", float64(wds.RxSpeed)/10) + } + if wds.TxSpeed > 0 { + d.WiFiTxSpeed = fmt.Sprintf("%.1f Mbps", float64(wds.TxSpeed)/10) + } } else if st := iface.WiFi.Station; st != nil { d.WiFiMode = "Station" d.WiFiSSID = st.SSID From 74013a9714c7b59c58010b10853df662b8aec528 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20Walstr=C3=B6m?= Date: Fri, 2 Oct 2026 10:47:42 +0200 Subject: [PATCH 11/38] test: Add WiFi WDS link and repeater tests The virtual medium now only acknowledges unicast frames to peers heard within the last minute, so a vanished station is detected by the AP's inactivity probing like on real RF. --- package/feature-wifi/wifimedium | 32 ++- test/case/interfaces/wifi.yaml | 6 + .../interfaces/wifi_wds_link_2dut/Readme.adoc | 1 + .../interfaces/wifi_wds_link_2dut/test.adoc | 45 ++++ .../interfaces/wifi_wds_link_2dut/test.py | 157 ++++++++++++ .../wifi_wds_link_2dut/topology.dot | 40 +++ .../wifi_wds_link_2dut/topology.svg | 68 +++++ .../interfaces/wifi_wds_repeater/Readme.adoc | 1 + .../interfaces/wifi_wds_repeater/test.adoc | 54 ++++ .../case/interfaces/wifi_wds_repeater/test.py | 239 ++++++++++++++++++ .../interfaces/wifi_wds_repeater/topology.dot | 59 +++++ .../interfaces/wifi_wds_repeater/topology.svg | 121 +++++++++ test/infamy/wifi.py | 40 ++- 13 files changed, 858 insertions(+), 5 deletions(-) create mode 120000 test/case/interfaces/wifi_wds_link_2dut/Readme.adoc create mode 100644 test/case/interfaces/wifi_wds_link_2dut/test.adoc create mode 100755 test/case/interfaces/wifi_wds_link_2dut/test.py create mode 100644 test/case/interfaces/wifi_wds_link_2dut/topology.dot create mode 100644 test/case/interfaces/wifi_wds_link_2dut/topology.svg create mode 120000 test/case/interfaces/wifi_wds_repeater/Readme.adoc create mode 100644 test/case/interfaces/wifi_wds_repeater/test.adoc create mode 100755 test/case/interfaces/wifi_wds_repeater/test.py create mode 100644 test/case/interfaces/wifi_wds_repeater/topology.dot create mode 100644 test/case/interfaces/wifi_wds_repeater/topology.svg diff --git a/package/feature-wifi/wifimedium b/package/feature-wifi/wifimedium index 58aeb4d84..25d07c686 100755 --- a/package/feature-wifi/wifimedium +++ b/package/feature-wifi/wifimedium @@ -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 @@ -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) @@ -251,6 +255,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. @@ -364,7 +387,7 @@ 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: @@ -389,6 +412,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)}") diff --git a/test/case/interfaces/wifi.yaml b/test/case/interfaces/wifi.yaml index 974e35e2b..245499dd8 100644 --- a/test/case/interfaces/wifi.yaml +++ b/test/case/interfaces/wifi.yaml @@ -10,3 +10,9 @@ - name: WiFi Band Steering across a dual-band Access Point case: wifi_band_steering/test.py + +- name: WiFi 4-address (WDS) link between two bridges + case: wifi_wds_link_2dut/test.py + +- name: WiFi repeater with two SSIDs in VLANs over a 4-address (WDS) backhaul + case: wifi_wds_repeater/test.py diff --git a/test/case/interfaces/wifi_wds_link_2dut/Readme.adoc b/test/case/interfaces/wifi_wds_link_2dut/Readme.adoc new file mode 120000 index 000000000..ae32c8412 --- /dev/null +++ b/test/case/interfaces/wifi_wds_link_2dut/Readme.adoc @@ -0,0 +1 @@ +test.adoc \ No newline at end of file diff --git a/test/case/interfaces/wifi_wds_link_2dut/test.adoc b/test/case/interfaces/wifi_wds_link_2dut/test.adoc new file mode 100644 index 000000000..fbab6130a --- /dev/null +++ b/test/case/interfaces/wifi_wds_link_2dut/test.adoc @@ -0,0 +1,45 @@ +=== WiFi 4-address (WDS) link between two bridges + +ifdef::topdoc[:imagesdir: {topdoc}../../test/case/interfaces/wifi_wds_link_2dut] + +==== Description + +Two DUTs: the root runs an access point with a WDS port, the satellite a +4-address station. Both ends are bridge ports, so the radio link joins +the two bridges at layer 2. + +On the root, wds0 is created by configuration before any station shows up +and gets its VLAN membership like any other bridge port. The access point +binds the station with the configured MAC address to it: the port comes up +when the station associates and goes down, but stays, when it leaves. On +the satellite, the station is a bridge port, which needs 4-address mode. + +The DHCP lease over the link is the data-plane check: the request and the +reply cross both bridges and the radio in opposite directions. + +Topology: +.... + host ==(mgmt)== root ))) ~ cell ~ ((( satellite ==(mgmt)== host + br0/vlan10 -- wds0 ~~~~~~~~~~~~~~~~~~~~~~ wifi0 -- br0 +.... + +==== Topology + +image::topology.svg[WiFi 4-address (WDS) link between two bridges topology, align=center, scaledwidth=75%] + +==== Sequence + +. Set up topology and attach to the root and the satellite +. Configure the root with access point 'infix-wds' and WDS port wds0 untagged in VLAN 10 on br0 +. Verify wds0 on the root exists, is down and is an untagged member of VLAN 10 +. Configure the satellite with a 4-address station for 'infix-wds' in br0, br0 as DHCP client +. Verify the satellite's wifi0 associates to 'infix-wds' +. Verify wds0 on the root comes up and reports the station connected +. Verify access point wifi0 on the root lists the satellite 02:00:00:00:00:02 as a station +. Verify the satellite leases 192.168.20.100 on br0 over the WDS link +. Disable wifi0 on the satellite +. Verify wds0 on the root goes down but remains a member of VLAN 10 +. Enable wifi0 on the satellite again +. Verify wds0 on the root comes up again + + diff --git a/test/case/interfaces/wifi_wds_link_2dut/test.py b/test/case/interfaces/wifi_wds_link_2dut/test.py new file mode 100755 index 000000000..caee52810 --- /dev/null +++ b/test/case/interfaces/wifi_wds_link_2dut/test.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 +r""" +WiFi 4-address (WDS) link between two bridges + +Two DUTs: the root runs an access point with a WDS port, the satellite a +4-address station. Both ends are bridge ports, so the radio link joins +the two bridges at layer 2. + +On the root, wds0 is created by configuration before any station shows up +and gets its VLAN membership like any other bridge port. The access point +binds the station with the configured MAC address to it: the port comes up +when the station associates and goes down, but stays, when it leaves. On +the satellite, the station is a bridge port, which needs 4-address mode. + +The DHCP lease over the link is the data-plane check: the request and the +reply cross both bridges and the radio in opposite directions. + +Topology: +.... + host ==(mgmt)== root ))) ~ cell ~ ((( satellite ==(mgmt)== host + br0/vlan10 -- wds0 ~~~~~~~~~~~~~~~~~~~~~~ wifi0 -- br0 +.... +""" +import infamy +import infamy.iface as iface +import infamy.wifi as wifi +from infamy.util import until, parallel + +SSID = "infix-wds" +PSK = "infixinfix" + +ROOT_AP_MAC = "02:00:00:00:00:01" +SAT_MAC = "02:00:00:00:00:02" + +SUBNET = "192.168.20.0/24" +ROOT_IP = "192.168.20.1" +LEASE = "192.168.20.100" + + +def root_config(): + return { + "ietf-hardware": {"hardware": {"component": [ + wifi.radio("radio0", band="2.4GHz", channel=1)]}}, + "ietf-keystore": wifi.keystore({"wifi": PSK}), + "ietf-interfaces": {"interfaces": {"interface": [ + { + "name": "br0", + "type": "infix-if-type:bridge", + "enabled": True, + "bridge": {"vlans": {"vlan": [ + {"vid": 10, "untagged": ["wds0"], "tagged": ["br0"]}, + ]}}, + }, + { + "name": "vlan10", + "type": "infix-if-type:vlan", + "enabled": True, + "vlan": {"id": 10, "lower-layer-if": "br0"}, + "ipv4": {"address": [{"ip": ROOT_IP, "prefix-length": 24}]}, + }, + wifi.iface("wifi0", ROOT_AP_MAC, { + "radio": "radio0", + "access-point": { + "ssid": SSID, + "security": {"mode": "wpa2-wpa3-personal", "secret": "wifi"}, + }, + }), + wifi.wds_link("wds0", "wifi0", SAT_MAC, bridge="br0", pvid=10), + ]}}, + "infix-dhcp-server": {"dhcp-server": {"subnet": [{ + "subnet": SUBNET, + "pool": {"start-address": LEASE, "end-address": LEASE}, + }]}}, + } + + +def satellite_config(): + return { + "ietf-hardware": {"hardware": {"component": [wifi.radio("radio0")]}}, + "ietf-keystore": wifi.keystore({"wifi": PSK}), + "ietf-interfaces": {"interfaces": {"interface": [ + { + "name": "br0", + "type": "infix-if-type:bridge", + "enabled": True, + "ietf-ip:ipv4": {"infix-dhcp-client:dhcp": {}}, + }, + wifi.iface("wifi0", SAT_MAC, { + "radio": "radio0", + "station": { + "ssid": SSID, + "wds": True, + "peer-bssid": ROOT_AP_MAC, + "security": {"mode": "auto", "secret": "wifi"}, + }, + }, bridge="br0"), + ]}}, + } + + +def station_enabled(enabled): + return {"ietf-interfaces": {"interfaces": {"interface": [ + {"name": "wifi0", "enabled": enabled}]}}} + + +with infamy.Test() as test: + with test.step("Set up topology and attach to the root and the satellite"): + env = infamy.Env() + root, satellite = parallel( + lambda: env.attach("root", "mgmt"), + lambda: env.attach("satellite", "mgmt"), + ) + wifi.skip_unless_supported(test, root, satellite) + + with test.step("Configure the root with access point 'infix-wds' and WDS port wds0 untagged in VLAN 10 on br0"): + root.put_config_dicts(root_config()) + + with test.step("Verify wds0 on the root exists, is down and is an untagged member of VLAN 10"): + until(lambda: iface.exist(root, "wds0"), attempts=30) + until(lambda: "wds0" in wifi.bridge_vlan_members(root, "br0", 10), attempts=30) + if iface.is_oper_up(root, "wds0"): + test.fail() + + with test.step("Configure the satellite with a 4-address station for 'infix-wds' in br0, br0 as DHCP client"): + satellite.put_config_dicts(satellite_config()) + + with test.step("Verify the satellite's wifi0 associates to 'infix-wds'"): + until(lambda: wifi.associated(satellite, SSID), attempts=60, interval=2) + + with test.step("Verify wds0 on the root comes up and reports the station connected"): + until(lambda: iface.is_oper_up(root, "wds0"), attempts=30, interval=2) + until(lambda: wifi.wds_connected(root, "wds0"), attempts=30, interval=2) + + with test.step("Verify access point wifi0 on the root lists the satellite 02:00:00:00:00:02 as a station"): + until(lambda: SAT_MAC in wifi.ap_stations(root, "wifi0"), attempts=30, interval=2) + + with test.step("Verify the satellite leases 192.168.20.100 on br0 over the WDS link"): + until(lambda: iface.address_exist(satellite, "br0", LEASE), + attempts=60, interval=2) + + with test.step("Disable wifi0 on the satellite"): + satellite.put_config_dicts(station_enabled(False)) + + with test.step("Verify wds0 on the root goes down but remains a member of VLAN 10"): + until(lambda: not iface.is_oper_up(root, "wds0"), attempts=90, interval=2) + until(lambda: not wifi.wds_connected(root, "wds0"), attempts=30, interval=2) + if "wds0" not in wifi.bridge_vlan_members(root, "br0", 10): + test.fail() + + with test.step("Enable wifi0 on the satellite again"): + satellite.put_config_dicts(station_enabled(True)) + + with test.step("Verify wds0 on the root comes up again"): + until(lambda: iface.is_oper_up(root, "wds0"), attempts=60, interval=2) + until(lambda: wifi.wds_connected(root, "wds0"), attempts=30, interval=2) + + test.succeed() diff --git a/test/case/interfaces/wifi_wds_link_2dut/topology.dot b/test/case/interfaces/wifi_wds_link_2dut/topology.dot new file mode 100644 index 000000000..c626b88ae --- /dev/null +++ b/test/case/interfaces/wifi_wds_link_2dut/topology.dot @@ -0,0 +1,40 @@ +graph "wifi-wds-link-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 | mgmt2 }", + pos="0,0!", + requires="controller", + ]; + + root [ + label="{ mgmt | wifi } | root", + pos="6,2!", + requires="infix", + ]; + + satellite [ + label="{ mgmt | wifi } | satellite", + pos="6,-2!", + requires="infix", + ]; + + // The wireless cell the root and the satellite share, see + // wifi_ap_station_2dut for how it maps onto RF and onto hwsim. + cell [ + label="cell", + pos="9,0!", + requires="wifi-radio", + ]; + + host:mgmt1 -- root:mgmt [requires="mgmt", color="lightgray"] + host:mgmt2 -- satellite:mgmt [requires="mgmt", color="lightgray"] + + root:wifi -- cell [requires="wifi2.4GHz", style="dashed"] + satellite:wifi -- cell [requires="wifi2.4GHz", style="dashed"] +} diff --git a/test/case/interfaces/wifi_wds_link_2dut/topology.svg b/test/case/interfaces/wifi_wds_link_2dut/topology.svg new file mode 100644 index 000000000..d17cd1db9 --- /dev/null +++ b/test/case/interfaces/wifi_wds_link_2dut/topology.svg @@ -0,0 +1,68 @@ + + + + + + +wifi-wds-link-2dut + + + +host + +host + +mgmt1 + +mgmt2 + + + +root + +mgmt + +wifi + +root + + + +host:mgmt1--root:mgmt + + + + +satellite + +mgmt + +wifi + +satellite + + + +host:mgmt2--satellite:mgmt + + + + +cell + +cell + + + +root:wifi--cell + + + + +satellite:wifi--cell + + + + diff --git a/test/case/interfaces/wifi_wds_repeater/Readme.adoc b/test/case/interfaces/wifi_wds_repeater/Readme.adoc new file mode 120000 index 000000000..ae32c8412 --- /dev/null +++ b/test/case/interfaces/wifi_wds_repeater/Readme.adoc @@ -0,0 +1 @@ +test.adoc \ No newline at end of file diff --git a/test/case/interfaces/wifi_wds_repeater/test.adoc b/test/case/interfaces/wifi_wds_repeater/test.adoc new file mode 100644 index 000000000..166ee8b46 --- /dev/null +++ b/test/case/interfaces/wifi_wds_repeater/test.adoc @@ -0,0 +1,54 @@ +=== WiFi repeater with two SSIDs in VLANs over a 4-address (WDS) backhaul + +ifdef::topdoc[:imagesdir: {topdoc}../../test/case/interfaces/wifi_wds_repeater] + +==== Description + +Four DUTs. The root runs the backhaul access point 'infix-backhaul' and +a VLAN filtering bridge: the WDS port and the wired uplink carry VLAN 10 +and VLAN 20 tagged, and the root serves DHCP in each VLAN. The repeater +has a 4-address station on the backhaul, carrying both VLANs tagged, and +two access points on the same radio as access ports: 'infix-home' +untagged in VLAN 10 and 'infix-guest' untagged in VLAN 20. Two plain +stations join them, one per SSID. + +Each station must lease an address from the DHCP server of its own VLAN +on the root. That proves both that the 4-address backhaul carries the +stations' own MAC addresses and that the VLAN tags survive the trip. +The host behind the root reaches both stations on their VLANs. + +Taking the backhaul down and up again shows it is a transparent bridge +port: the stations lose and regain reach without re-associating. + +Topology: +.... + host ==(lan, VLAN 10+20)== root (AP 'infix-backhaul') + wds0 ))) ~ cell ~ ((( wifi0 repeater wifi1 (AP 'infix-home', VLAN 10) ))) home + wifi2 (AP 'infix-guest', VLAN 20) ))) guest +.... + +==== Topology + +image::topology.svg[WiFi repeater with two SSIDs in VLANs over a 4-address (WDS) backhaul topology, align=center, scaledwidth=75%] + +==== Sequence + +. Set up topology and attach to the root, the repeater, home and guest +. Configure the root with access point 'infix-backhaul', WDS port wds0 and the uplink tagged in VLAN 10 and 20, DHCP in each VLAN +. Configure the repeater with a 4-address station tagged in VLAN 10 and 20, access point 'infix-home' untagged in VLAN 10 and 'infix-guest' untagged in VLAN 20 +. Configure home as a DHCP station for 'infix-home' and guest as a DHCP station for 'infix-guest' +. Verify the repeater's wifi0 associates to 'infix-backhaul' +. Verify wds0 on the root is up +. Verify home is on the repeater's 'infix-home' access point, BSSID 02:00:00:00:0a:02 +. Verify guest is on the repeater's 'infix-guest' access point, BSSID 02:00:00:00:0b:02 +. Verify home leases 10.10.0.9 from the root's VLAN 10 DHCP server through the backhaul +. Verify guest leases 10.20.0.9 from the root's VLAN 20 DHCP server through the backhaul +. Verify the host reaches home at 10.10.0.9 on VLAN 10 through the repeater +. Verify the host reaches guest at 10.20.0.9 on VLAN 20 through the repeater +. Disable the repeater's backhaul station wifi0 +. Verify home at 10.10.0.9 and guest at 10.20.0.9 are no longer reachable from the host +. Enable the repeater's backhaul station wifi0 again +. Verify the host reaches home at 10.10.0.9 and guest at 10.20.0.9 again +. Verify home and guest are still on their access points + + diff --git a/test/case/interfaces/wifi_wds_repeater/test.py b/test/case/interfaces/wifi_wds_repeater/test.py new file mode 100755 index 000000000..68311ade1 --- /dev/null +++ b/test/case/interfaces/wifi_wds_repeater/test.py @@ -0,0 +1,239 @@ +#!/usr/bin/env python3 +r""" +WiFi repeater with two SSIDs in VLANs over a 4-address (WDS) backhaul + +Four DUTs. The root runs the backhaul access point 'infix-backhaul' and +a VLAN filtering bridge: the WDS port and the wired uplink carry VLAN 10 +and VLAN 20 tagged, and the root serves DHCP in each VLAN. The repeater +has a 4-address station on the backhaul, carrying both VLANs tagged, and +two access points on the same radio as access ports: 'infix-home' +untagged in VLAN 10 and 'infix-guest' untagged in VLAN 20. Two plain +stations join them, one per SSID. + +Each station must lease an address from the DHCP server of its own VLAN +on the root. That proves both that the 4-address backhaul carries the +stations' own MAC addresses and that the VLAN tags survive the trip. +The host behind the root reaches both stations on their VLANs. + +Taking the backhaul down and up again shows it is a transparent bridge +port: the stations lose and regain reach without re-associating. + +Topology: +.... + host ==(lan, VLAN 10+20)== root (AP 'infix-backhaul') + wds0 ))) ~ cell ~ ((( wifi0 repeater wifi1 (AP 'infix-home', VLAN 10) ))) home + wifi2 (AP 'infix-guest', VLAN 20) ))) guest +.... +""" +import infamy +import infamy.iface as iface +import infamy.wifi as wifi +from infamy.util import until, parallel + +BACKHAUL_SSID = "infix-backhaul" +HOME_SSID = "infix-home" +GUEST_SSID = "infix-guest" +PSK = "infixinfix" + +ROOT_AP_MAC = "02:00:00:00:00:01" +REPEATER_STA_MAC = "02:00:00:00:00:02" +HOME_AP_MAC = "02:00:00:00:0a:02" +GUEST_AP_MAC = "02:00:00:00:0b:02" +HOME_MAC = "02:00:00:00:00:09" +GUEST_MAC = "02:00:00:00:00:0a" + +HOME_HOST_IP = "10.10.0.1" +HOME_ROOT_IP = "10.10.0.2" +HOME_IP = "10.10.0.9" +GUEST_HOST_IP = "10.20.0.1" +GUEST_ROOT_IP = "10.20.0.2" +GUEST_IP = "10.20.0.9" + +SECRETS = {"backhaul": PSK, "home": PSK, "guest": PSK} + + +def root_config(uplink): + return { + "ietf-hardware": {"hardware": {"component": [ + wifi.radio("radio0", band="2.4GHz", channel=1)]}}, + "ietf-keystore": wifi.keystore(SECRETS), + "ietf-interfaces": {"interfaces": {"interface": [ + {"name": "br0", "type": "infix-if-type:bridge", "enabled": True, + "bridge": {"vlans": {"vlan": [ + {"vid": 10, "tagged": [uplink, "wds0", "br0"]}, + {"vid": 20, "tagged": [uplink, "wds0", "br0"]}, + ]}}}, + {"name": "vlan10", "type": "infix-if-type:vlan", "enabled": True, + "vlan": {"id": 10, "lower-layer-if": "br0"}, + "ipv4": {"address": [{"ip": HOME_ROOT_IP, "prefix-length": 24}]}}, + {"name": "vlan20", "type": "infix-if-type:vlan", "enabled": True, + "vlan": {"id": 20, "lower-layer-if": "br0"}, + "ipv4": {"address": [{"ip": GUEST_ROOT_IP, "prefix-length": 24}]}}, + {"name": uplink, "enabled": True, + "infix-interfaces:bridge-port": {"bridge": "br0"}}, + wifi.iface("wifi0", ROOT_AP_MAC, { + "radio": "radio0", + "access-point": { + "ssid": BACKHAUL_SSID, + "security": {"mode": "wpa2-wpa3-personal", "secret": "backhaul"}, + }, + }), + wifi.wds_link("wds0", "wifi0", REPEATER_STA_MAC, bridge="br0"), + ]}}, + "infix-dhcp-server": {"dhcp-server": {"subnet": [ + {"subnet": "10.10.0.0/24", + "pool": {"start-address": "10.10.0.100", "end-address": "10.10.0.100"}, + "host": [{"address": HOME_IP, "match": {"mac-address": HOME_MAC}}]}, + {"subnet": "10.20.0.0/24", + "pool": {"start-address": "10.20.0.100", "end-address": "10.20.0.100"}, + "host": [{"address": GUEST_IP, "match": {"mac-address": GUEST_MAC}}]}, + ]}}, + } + + +def repeater_config(): + return { + "ietf-hardware": {"hardware": {"component": [ + wifi.radio("radio0", band="2.4GHz", channel=1)]}}, + "ietf-keystore": wifi.keystore(SECRETS), + "ietf-interfaces": {"interfaces": {"interface": [ + {"name": "br0", "type": "infix-if-type:bridge", "enabled": True, + "bridge": {"vlans": {"vlan": [ + {"vid": 10, "untagged": ["wifi1"], "tagged": ["wifi0"]}, + {"vid": 20, "untagged": ["wifi2"], "tagged": ["wifi0"]}, + ]}}}, + wifi.iface("wifi0", REPEATER_STA_MAC, { + "radio": "radio0", + "station": { + "ssid": BACKHAUL_SSID, + "wds": True, + "peer-bssid": ROOT_AP_MAC, + "security": {"mode": "auto", "secret": "backhaul"}, + }, + }, bridge="br0"), + wifi.iface("wifi1", HOME_AP_MAC, { + "radio": "radio0", + "access-point": { + "ssid": HOME_SSID, + "security": {"mode": "wpa2-wpa3-personal", "secret": "home"}, + }, + }, bridge="br0", pvid=10), + wifi.iface("wifi2", GUEST_AP_MAC, { + "radio": "radio0", + "access-point": { + "ssid": GUEST_SSID, + "security": {"mode": "wpa2-wpa3-personal", "secret": "guest"}, + }, + }, bridge="br0", pvid=20), + ]}}, + } + + +def station_config(mac, ssid, secret): + return { + "ietf-hardware": {"hardware": {"component": [wifi.radio("radio0")]}}, + "ietf-keystore": wifi.keystore(SECRETS), + "ietf-interfaces": {"interfaces": {"interface": [ + wifi.iface("wifi0", mac, { + "radio": "radio0", + "station": { + "ssid": ssid, + "security": {"mode": "auto", "secret": secret}, + }, + }, ipv4={"infix-dhcp-client:dhcp": {}}), + ]}}, + } + + +def backhaul_enabled(enabled): + return {"ietf-interfaces": {"interfaces": {"interface": [ + {"name": "wifi0", "enabled": enabled}]}}} + + +def reaches(ns, addr): + try: + ns.ping(addr) + return True + except Exception: + return False + + +with infamy.Test() as test: + with test.step("Set up topology and attach to the root, the repeater, home and guest"): + env = infamy.Env() + root, repeater, home, guest = parallel( + lambda: env.attach("root", "mgmt"), + lambda: env.attach("repeater", "mgmt"), + lambda: env.attach("home", "mgmt"), + lambda: env.attach("guest", "mgmt"), + ) + wifi.skip_unless_supported(test, root, repeater, home, guest) + + with test.step("Configure the root with access point 'infix-backhaul', WDS port wds0 and the uplink tagged in VLAN 10 and 20, DHCP in each VLAN"): + _, uplink = env.ltop.xlate("root", "uplink") + root.put_config_dicts(root_config(uplink)) + + with test.step("Configure the repeater with a 4-address station tagged in VLAN 10 and 20, access point 'infix-home' untagged in VLAN 10 and 'infix-guest' untagged in VLAN 20"): + repeater.put_config_dicts(repeater_config()) + + with test.step("Configure home as a DHCP station for 'infix-home' and guest as a DHCP station for 'infix-guest'"): + parallel( + lambda: home.put_config_dicts(station_config(HOME_MAC, HOME_SSID, "home")), + lambda: guest.put_config_dicts(station_config(GUEST_MAC, GUEST_SSID, "guest")), + ) + + with test.step("Verify the repeater's wifi0 associates to 'infix-backhaul'"): + until(lambda: wifi.associated(repeater, BACKHAUL_SSID), attempts=60, interval=2) + + with test.step("Verify wds0 on the root is up"): + until(lambda: iface.is_oper_up(root, "wds0"), attempts=30, interval=2) + + with test.step("Verify home is on the repeater's 'infix-home' access point, BSSID 02:00:00:00:0a:02"): + until(lambda: wifi.station_bssid(home) == HOME_AP_MAC, attempts=60, interval=2) + + with test.step("Verify guest is on the repeater's 'infix-guest' access point, BSSID 02:00:00:00:0b:02"): + until(lambda: wifi.station_bssid(guest) == GUEST_AP_MAC, attempts=60, interval=2) + + with test.step("Verify home leases 10.10.0.9 from the root's VLAN 10 DHCP server through the backhaul"): + until(lambda: iface.address_exist(home, "wifi0", HOME_IP), attempts=60, interval=2) + + with test.step("Verify guest leases 10.20.0.9 from the root's VLAN 20 DHCP server through the backhaul"): + until(lambda: iface.address_exist(guest, "wifi0", GUEST_IP), attempts=60, interval=2) + + _, hlan = env.ltop.xlate("host", "lan") + with infamy.IsolatedMacVlan(hlan) as ns: + ns.runsh(f""" + set -ex + ip link add dev vlan10 link iface up type vlan id 10 + ip link add dev vlan20 link iface up type vlan id 20 + ip addr add {HOME_HOST_IP}/24 dev vlan10 + ip addr add {GUEST_HOST_IP}/24 dev vlan20 + """) + + with test.step("Verify the host reaches home at 10.10.0.9 on VLAN 10 through the repeater"): + until(lambda: reaches(ns, HOME_IP), attempts=30, interval=2) + + with test.step("Verify the host reaches guest at 10.20.0.9 on VLAN 20 through the repeater"): + until(lambda: reaches(ns, GUEST_IP), attempts=30, interval=2) + + with test.step("Disable the repeater's backhaul station wifi0"): + repeater.put_config_dicts(backhaul_enabled(False)) + + with test.step("Verify home at 10.10.0.9 and guest at 10.20.0.9 are no longer reachable from the host"): + until(lambda: not iface.is_oper_up(root, "wds0"), attempts=30, interval=2) + ns.must_not_reach(HOME_IP) + ns.must_not_reach(GUEST_IP) + + with test.step("Enable the repeater's backhaul station wifi0 again"): + repeater.put_config_dicts(backhaul_enabled(True)) + + with test.step("Verify the host reaches home at 10.10.0.9 and guest at 10.20.0.9 again"): + until(lambda: iface.is_oper_up(root, "wds0"), attempts=60, interval=2) + until(lambda: reaches(ns, HOME_IP), attempts=30, interval=2) + until(lambda: reaches(ns, GUEST_IP), attempts=30, interval=2) + + with test.step("Verify home and guest are still on their access points"): + if wifi.station_bssid(home) != HOME_AP_MAC or wifi.station_bssid(guest) != GUEST_AP_MAC: + test.fail() + + test.succeed() diff --git a/test/case/interfaces/wifi_wds_repeater/topology.dot b/test/case/interfaces/wifi_wds_repeater/topology.dot new file mode 100644 index 000000000..3d5489e30 --- /dev/null +++ b/test/case/interfaces/wifi_wds_repeater/topology.dot @@ -0,0 +1,59 @@ +graph "wifi-wds-repeater" { + 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 | mgmt2 | mgmt3 | mgmt4 | lan }", + pos="0,0!", + requires="controller", + ]; + + root [ + label="{ mgmt | uplink | wifi } | root", + pos="6,3!", + requires="infix", + ]; + + repeater [ + label="{ mgmt | wifi } | repeater", + pos="9,0!", + requires="infix", + ]; + + home [ + label="{ mgmt | wifi } | home", + pos="6,-3!", + requires="infix", + ]; + + guest [ + label="{ mgmt | wifi } | guest", + pos="12,-3!", + requires="infix", + ]; + + // One cell: the repeater's backhaul station and its two access points + // share a radio, so all four nodes hear each other. Each SSID is + // advertised by exactly one node, which decides who joins whom. + cell [ + label="cell", + pos="12,0!", + requires="wifi-radio", + ]; + + host:mgmt1 -- root:mgmt [requires="mgmt", color="lightgray"] + host:mgmt2 -- repeater:mgmt [requires="mgmt", color="lightgray"] + host:mgmt3 -- home:mgmt [requires="mgmt", color="lightgray"] + host:mgmt4 -- guest:mgmt [requires="mgmt", color="lightgray"] + + host:lan -- root:uplink [color="black"] + + root:wifi -- cell [requires="wifi2.4GHz", style="dashed"] + repeater:wifi -- cell [requires="wifi2.4GHz", style="dashed"] + home:wifi -- cell [requires="wifi2.4GHz", style="dashed"] + guest:wifi -- cell [requires="wifi2.4GHz", style="dashed"] +} diff --git a/test/case/interfaces/wifi_wds_repeater/topology.svg b/test/case/interfaces/wifi_wds_repeater/topology.svg new file mode 100644 index 000000000..5d0ca7f8a --- /dev/null +++ b/test/case/interfaces/wifi_wds_repeater/topology.svg @@ -0,0 +1,121 @@ + + + + + + +wifi-wds-repeater + + + +host + +host + +mgmt1 + +mgmt2 + +mgmt3 + +mgmt4 + +lan + + + +root + +mgmt + +uplink + +wifi + +root + + + +host:mgmt1--root:mgmt + + + + +host:lan--root:uplink + + + + +repeater + +mgmt + +wifi + +repeater + + + +host:mgmt2--repeater:mgmt + + + + +home + +mgmt + +wifi + +home + + + +host:mgmt3--home:mgmt + + + + +guest + +mgmt + +wifi + +guest + + + +host:mgmt4--guest:mgmt + + + + +cell + +cell + + + +root:wifi--cell + + + + +repeater:wifi--cell + + + + +home:wifi--cell + + + + +guest:wifi--cell + + + + diff --git a/test/infamy/wifi.py b/test/infamy/wifi.py index 942c838b0..92eb8473d 100644 --- a/test/infamy/wifi.py +++ b/test/infamy/wifi.py @@ -31,7 +31,7 @@ def keystore(secrets): ]}}} -def iface(name, mac, wifi, ipv4=None, bridge=None): +def iface(name, mac, wifi, ipv4=None, bridge=None, pvid=None): """ietf-interfaces entry for a WiFi VIF. wifi is the infix-interfaces:wifi container: the radio plus one of @@ -48,6 +48,29 @@ def iface(name, mac, wifi, ipv4=None, bridge=None): ifc["ietf-ip:ipv4"] = ipv4 if bridge: ifc["infix-interfaces:bridge-port"] = {"bridge": bridge} + if pvid is not None: + ifc["infix-interfaces:bridge-port"]["pvid"] = pvid + return ifc + + +def wds_link(name, ap, peer, bridge=None, pvid=None): + """ietf-interfaces entry for a wds-link port of access point ap. + + The port inherits the AP's radio and MAC address, so unlike iface() it + takes neither a radio nor a custom-phys-address. + """ + ifc = { + "name": name, + "type": "infix-if-type:wifi", + "enabled": True, + "infix-interfaces:wifi": { + "wds-link": {"access-point": ap, "peer-address": peer}, + }, + } + if bridge: + ifc["infix-interfaces:bridge-port"] = {"bridge": bridge} + if pvid is not None: + ifc["infix-interfaces:bridge-port"]["pvid"] = pvid return ifc @@ -84,6 +107,21 @@ def station_bssid(target, ifname="wifi0"): return (station(target, ifname).get("bssid") or "").lower() +def wds_connected(target, ifname): + """True once the station of the wds-link port ifname is bound to it.""" + return _wifi(target.get_iface(ifname)).get("wds-link", {}).get("connected") is True + + +def bridge_vlan_members(target, bridge, vid, tagging="untagged"): + """Ports listed as tagged/untagged members of vid on bridge, from operational.""" + ifc = target.get_iface(bridge) or {} + br = ifc.get("bridge") or ifc.get("infix-interfaces:bridge") or {} + for vlan in (br.get("vlans") or {}).get("vlan") or []: + if vlan.get("vid") == vid: + return set(vlan.get(tagging) or []) + return set() + + def ap_stations(target, ifname="wifi0"): """MACs of the stations currently associated to this AP BSS, lowercase.""" ap = _wifi(target.get_iface(ifname)).get("access-point") or {} From c32e7a47868799f562276b4622a564ac36fdb30f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20Walstr=C3=B6m?= Date: Fri, 2 Oct 2026 10:47:42 +0200 Subject: [PATCH 12/38] doc: Document WDS backhaul and repeaters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Mattias Walström --- doc/ChangeLog.md | 13 +++++ doc/wifi.md | 140 ++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 139 insertions(+), 14 deletions(-) diff --git a/doc/ChangeLog.md b/doc/ChangeLog.md index 89de004c4..0c4666890 100644 --- a/doc/ChangeLog.md +++ b/doc/ChangeLog.md @@ -30,6 +30,19 @@ 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 + +[wds]: https://www.kernelkit.org/infix/latest/wifi/#wds-backhaul-and-repeaters + [v26.09.0][] - 2026-09-30 ------------------------- diff --git a/doc/wifi.md b/doc/wifi.md index 233b07ba4..38ca5c313 100644 --- a/doc/wifi.md +++ b/doc/wifi.md @@ -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. @@ -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 @@ -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 @@ -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: @@ -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 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 @@ -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 @@ -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`. + +
admin@root:/config/> edit interface backhaul
+admin@root:/config/interface/backhaul/> set wifi radio radio1
+admin@root:/config/interface/backhaul/> set wifi access-point ssid my-backhaul
+admin@root:/config/interface/backhaul/> set wifi access-point security secret backhaul-key
+admin@root:/config/interface/backhaul/> end
+admin@root:/config/> edit interface wds-jaffa
+admin@root:/config/interface/wds-jaffa/> set type wifi
+admin@root:/config/interface/wds-jaffa/> set wifi wds-link access-point backhaul
+admin@root:/config/interface/wds-jaffa/> set wifi wds-link peer-address 02:13:37:13:37:12
+admin@root:/config/interface/wds-jaffa/> set bridge-port bridge br0
+admin@root:/config/interface/wds-jaffa/> leave
+
+ +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: + +
admin@root:/> show interface wds-jaffa
+name               : wds-jaffa
+type               : wifi
+operational status : up
+higher-layer-if    : br0
+mode               : wds-link
+connected          : yes
+signal             : -48 dBm (good)
+
+ +### 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: + +
admin@jaffa:/config/> edit interface uplink
+admin@jaffa:/config/interface/uplink/> set wifi radio radio1
+admin@jaffa:/config/interface/uplink/> set wifi station ssid my-backhaul
+admin@jaffa:/config/interface/uplink/> set wifi station wds true
+admin@jaffa:/config/interface/uplink/> set wifi station peer-bssid 02:13:37:13:37:01
+admin@jaffa:/config/interface/uplink/> set wifi station security secret backhaul-key
+admin@jaffa:/config/interface/uplink/> set bridge-port bridge br0
+admin@jaffa:/config/interface/uplink/> leave
+
+ +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. From 7fe9ff66d7b7cad6d1d422d2b0255b86d7d77d8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20Walstr=C3=B6m?= Date: Fri, 2 Oct 2026 13:44:21 +0200 Subject: [PATCH 13/38] confd: wifi: Detach a WiFi interface from hostapd before deleting it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hostapd adopts a re-added netdev of a name it still holds and turns it back into an AP, which broke a station created right after an AP of the same name was removed. Run hostapd with a global control socket and tell it to drop the interface first. Signed-off-by: Mattias Walström --- src/confd/src/hardware.c | 2 +- src/confd/src/if-wifi.c | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/confd/src/hardware.c b/src/confd/src/hardware.c index 4398b4861..aa391b7b6 100644 --- a/src/confd/src/hardware.c +++ b/src/confd/src/hardware.c @@ -1466,7 +1466,7 @@ int hardware_change(sr_session_ctx_t *session, struct lyd_node *config, struct l } else { fprintf(fp, "# Generated by confd, do not edit.\n"); fprintf(fp, "service name:hostapd \\\n"); - fprintf(fp, "\t[2345] hostapd -P /run/hostapd.pid"); + fprintf(fp, "\t[2345] hostapd -g /run/hostapd.global -P /run/hostapd.pid"); for (i = 0; i < gl.gl_pathc; i++) fprintf(fp, " %s", gl.gl_pathv[i]); fprintf(fp, " \\\n\t-- Wi-Fi Access Points\n"); diff --git a/src/confd/src/if-wifi.c b/src/confd/src/if-wifi.c index 5a43ed2ff..303b5d3ad 100644 --- a/src/confd/src/if-wifi.c +++ b/src/confd/src/if-wifi.c @@ -641,6 +641,10 @@ int wifi_del_iface(struct lyd_node *dif, struct dagger *net) fprintf(iw, "initctl -bfq disable mesh@%s\n", ifname); fprintf(iw, "initctl -bfq disable wifi@%s\n", ifname); + /* hostapd adopts a re-added netdev of a name it still holds and + * turns it back into an AP, so make it forget the name first. + * hostapd_cli cannot talk to the global socket, wpa_cli can. */ + fprintf(iw, "wpa_cli -g /run/hostapd.global raw \"REMOVE %s\" >/dev/null 2>&1\n", ifname); fprintf(iw, "ip link set %s down\n", ifname); fprintf(iw, "iw dev %s disconnect 2>/dev/null\n", ifname); fprintf(iw, "iw dev %s del 2>/dev/null || ip link del %s 2>/dev/null || true\n", ifname, ifname); From 14c4f707a2ebccf7a6d4146c60243316a456247f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20Walstr=C3=B6m?= Date: Fri, 2 Oct 2026 14:14:13 +0200 Subject: [PATCH 14/38] confd: wifi: Apply station and mesh changes on commit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adding station settings to a scan-only interface, or changing them, only rewrote the wpa_supplicant config; nothing told the daemon. Reload it on config changes and start it over when the netdev is recreated. Fix #1679 Signed-off-by: Mattias Walström --- doc/ChangeLog.md | 7 ++ src/confd/src/if-wifi.c | 68 ++++++++++++++ src/confd/src/interfaces.c | 7 +- src/confd/src/interfaces.h | 2 + test/case/interfaces/wifi.yaml | 3 + .../wifi_station_from_scan/Readme.adoc | 1 + .../wifi_station_from_scan/test.adoc | 32 +++++++ .../interfaces/wifi_station_from_scan/test.py | 93 +++++++++++++++++++ .../wifi_station_from_scan/topology.dot | 44 +++++++++ .../wifi_station_from_scan/topology.svg | 68 ++++++++++++++ 10 files changed, 319 insertions(+), 6 deletions(-) create mode 120000 test/case/interfaces/wifi_station_from_scan/Readme.adoc create mode 100644 test/case/interfaces/wifi_station_from_scan/test.adoc create mode 100755 test/case/interfaces/wifi_station_from_scan/test.py create mode 100644 test/case/interfaces/wifi_station_from_scan/topology.dot create mode 100644 test/case/interfaces/wifi_station_from_scan/topology.svg diff --git a/doc/ChangeLog.md b/doc/ChangeLog.md index 0c4666890..1fbb27501 100644 --- a/doc/ChangeLog.md +++ b/doc/ChangeLog.md @@ -41,6 +41,13 @@ All notable changes to the project are documented in this file. 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 diff --git a/src/confd/src/if-wifi.c b/src/confd/src/if-wifi.c index 303b5d3ad..a6e9f1f86 100644 --- a/src/confd/src/if-wifi.c +++ b/src/confd/src/if-wifi.c @@ -166,9 +166,72 @@ int wifi_mode_changed(struct lyd_node *wifi) if (node && (op == LYDX_OP_CREATE || op == LYDX_OP_DELETE)) return 1; + /* Scan-only <-> station, and the 4-address flag is set at creation */ + node = lydx_get_child(wifi, "station"); + if (node) + op = lydx_get_op(node); + if (node && (op == LYDX_OP_CREATE || op == LYDX_OP_DELETE)) + return 1; + if (node && lydx_get_child(node, "wds")) + return 1; + return 0; } +/* + * A changed wpa_supplicant config only takes effect when the daemon + * reloads it, so nudge the service whenever the wifi subtree changed. + */ +static int wifi_gen_reload(struct lyd_node *dif, struct lyd_node *cif, struct dagger *net) +{ + const char *ifname = lydx_get_cattr(cif, "name"); + const char *svc; + FILE *fp; + + if (!lydx_get_child(dif, "wifi")) + return SR_ERR_OK; + + switch (wifi_get_mode(cif)) { + case wifi_station: + svc = "wifi"; + break; + case wifi_mesh: + svc = "mesh"; + break; + default: + return SR_ERR_OK; + } + + fp = dagger_fopen_net_init(net, ifname, NETDAG_INIT_DAEMON, "wifi-reload.sh"); + if (!fp) + return SR_ERR_INTERNAL; + + fprintf(fp, "initctl -bfq touch %s@%s\n", svc, ifname); + fclose(fp); + + return SR_ERR_OK; +} + +/* Settings of an existing station or mesh point changed */ +int wifi_gen_settings(sr_session_ctx_t *session, struct lyd_node *dif, + struct lyd_node *cif, struct dagger *net) +{ + int rc; + + switch (wifi_get_mode(cif)) { + case wifi_station: + rc = wifi_validate_secret(session, cif) ? : wifi_gen_station(cif); + break; + case wifi_mesh: + rc = wifi_gen_mesh(cif); + break; + default: + return SR_ERR_OK; + } + + return rc ? : wifi_gen_reload(dif, cif, net); +} + /* * Generate wpa_supplicant config for station mode */ @@ -589,6 +652,10 @@ int wifi_add_iface(struct lyd_node *cif, struct dagger *net) wifi_gen_station(cif); fprintf(iw, "initctl -bfq enable wifi@%s\n", ifname); fprintf(iw, "initctl -bfq touch wifi@%s\n", ifname); + /* A running instance from before the netdev was recreated + * only gets a SIGHUP from the touch and keeps stale driver + * state, so make sure it starts over on the new netdev. */ + fprintf(iw, "initctl -bnq restart wpa_supplicant:%s\n", ifname); break; } case wifi_wds: { @@ -608,6 +675,7 @@ int wifi_add_iface(struct lyd_node *cif, struct dagger *net) wifi_gen_mesh(cif); fprintf(iw, "initctl -bfq enable mesh@%s\n", ifname); fprintf(iw, "initctl -bfq touch mesh@%s\n", ifname); + fprintf(iw, "initctl -bnq restart wpa_supplicant:%s\n", ifname); break; default: ERROR("WiFi mode %d unknown", mode); diff --git a/src/confd/src/interfaces.c b/src/confd/src/interfaces.c index 1d62f8557..7dede9e23 100644 --- a/src/confd/src/interfaces.c +++ b/src/confd/src/interfaces.c @@ -453,12 +453,7 @@ static int netdag_gen_afspec_set(sr_session_ctx_t *session, struct dagger *net, case IFT_ETH: return netdag_gen_ethtool(net, cif, dif); case IFT_WIFI: - if (wifi_get_mode(cif) == wifi_station) - return wifi_validate_secret(session, cif) - ? : wifi_gen_station(cif); - if (wifi_get_mode(cif) == wifi_mesh) - return wifi_gen_mesh(cif); - return 0; + return wifi_gen_settings(session, dif, cif, net); case IFT_DUMMY: case IFT_GRE: case IFT_GRETAP: diff --git a/src/confd/src/interfaces.h b/src/confd/src/interfaces.h index 37a3181b4..193dbd87b 100644 --- a/src/confd/src/interfaces.h +++ b/src/confd/src/interfaces.h @@ -141,6 +141,8 @@ int wifi_add_deps(struct lyd_node *cif); int wifi_add_iface(struct lyd_node *cif, struct dagger *net); int wifi_del_iface(struct lyd_node *dif, struct dagger *net); int wifi_mode_changed(struct lyd_node *wifi); +int wifi_gen_settings(sr_session_ctx_t *session, struct lyd_node *dif, + struct lyd_node *cif, struct dagger *net); int wifi_gen_station(struct lyd_node *cif); int wifi_gen_mesh(struct lyd_node *cif); wifi_mode_t wifi_get_mode(struct lyd_node *wifi); diff --git a/test/case/interfaces/wifi.yaml b/test/case/interfaces/wifi.yaml index 245499dd8..0e5c28195 100644 --- a/test/case/interfaces/wifi.yaml +++ b/test/case/interfaces/wifi.yaml @@ -16,3 +16,6 @@ - name: WiFi repeater with two SSIDs in VLANs over a 4-address (WDS) backhaul case: wifi_wds_repeater/test.py + +- name: WiFi station set up from a scan-only interface + case: wifi_station_from_scan/test.py diff --git a/test/case/interfaces/wifi_station_from_scan/Readme.adoc b/test/case/interfaces/wifi_station_from_scan/Readme.adoc new file mode 120000 index 000000000..ae32c8412 --- /dev/null +++ b/test/case/interfaces/wifi_station_from_scan/Readme.adoc @@ -0,0 +1 @@ +test.adoc \ No newline at end of file diff --git a/test/case/interfaces/wifi_station_from_scan/test.adoc b/test/case/interfaces/wifi_station_from_scan/test.adoc new file mode 100644 index 000000000..3eb98e127 --- /dev/null +++ b/test/case/interfaces/wifi_station_from_scan/test.adoc @@ -0,0 +1,32 @@ +=== WiFi station set up from a scan-only interface + +ifdef::topdoc[:imagesdir: {topdoc}../../test/case/interfaces/wifi_station_from_scan] + +==== Description + +A WiFi interface with only a radio, no station or access point, is in +scan-only mode. That is how the factory configuration of boards with a +built-in radio ships, so the usual way to get online is to add the station +settings to that existing interface. The connection has to come up from +that change alone, without a reboot or a service restart. + +Topology: +.... + host ==(mgmt)== ap ))) ~ cell ~ ((( station ==(mgmt)== host +.... + +==== Topology + +image::topology.svg[WiFi station set up from a scan-only interface topology, align=center, scaledwidth=75%] + +==== Sequence + +. Set up topology and attach to the ap and the station +. Configure the ap with access point 'infix-scan' and a DHCP server on 192.168.21.1 +. Configure wifi0 on the station with only radio0, scan-only mode +. Verify the station sees 'infix-scan' in its scan results +. Add station settings for 'infix-scan' to wifi0 on the station +. Verify the station associates to 'infix-scan' without a restart +. Verify the station leases 192.168.21.100 over wifi + + diff --git a/test/case/interfaces/wifi_station_from_scan/test.py b/test/case/interfaces/wifi_station_from_scan/test.py new file mode 100755 index 000000000..ab4ba6fe5 --- /dev/null +++ b/test/case/interfaces/wifi_station_from_scan/test.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +r""" +WiFi station set up from a scan-only interface + +A WiFi interface with only a radio, no station or access point, is in +scan-only mode. That is how the factory configuration of boards with a +built-in radio ships, so the usual way to get online is to add the station +settings to that existing interface. The connection has to come up from +that change alone, without a reboot or a service restart. + +Topology: +.... + host ==(mgmt)== ap ))) ~ cell ~ ((( station ==(mgmt)== host +.... +""" +import infamy +import infamy.iface as iface +import infamy.wifi as wifi +from infamy.util import until, parallel + +SSID = "infix-scan" +PSK = "infixinfix" + +SUBNET = "192.168.21.0/24" +AP_IP = "192.168.21.1" +LEASE = "192.168.21.100" + + +with infamy.Test() as test: + with test.step("Set up topology and attach to the ap and the station"): + env = infamy.Env() + ap, station = parallel( + lambda: env.attach("ap", "mgmt"), + lambda: env.attach("station", "mgmt"), + ) + wifi.skip_unless_supported(test, ap, station) + + with test.step("Configure the ap with access point 'infix-scan' and a DHCP server on 192.168.21.1"): + ap.put_config_dicts({ + "ietf-hardware": {"hardware": {"component": [ + wifi.radio("radio0", band="2.4GHz", channel=1)]}}, + "ietf-keystore": wifi.keystore({"wifi": PSK}), + "ietf-interfaces": {"interfaces": {"interface": [ + wifi.iface("wifi0", "02:00:00:00:00:01", { + "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": LEASE, "end-address": LEASE}, + }]}}, + }) + + with test.step("Configure wifi0 on the station with only radio0, scan-only mode"): + station.put_config_dicts({ + "ietf-hardware": {"hardware": {"component": [wifi.radio("radio0")]}}, + "ietf-interfaces": {"interfaces": {"interface": [ + wifi.iface("wifi0", "02:00:00:00:00:02", {"radio": "radio0"}, + ipv4={"infix-dhcp-client:dhcp": {}}), + ]}}, + }) + + with test.step("Verify the station sees 'infix-scan' in its scan results"): + until(lambda: SSID in {n.get("ssid") for n in + wifi.station(station).get("scan-results") or []}, + attempts=60, interval=2) + + with test.step("Add station settings for 'infix-scan' to wifi0 on the station"): + station.put_config_dicts({ + "ietf-keystore": wifi.keystore({"wifi": PSK}), + "ietf-interfaces": {"interfaces": {"interface": [ + {"name": "wifi0", "infix-interfaces:wifi": { + "radio": "radio0", + "station": { + "ssid": SSID, + "security": {"mode": "auto", "secret": "wifi"}, + }, + }}, + ]}}, + }) + + with test.step("Verify the station associates to 'infix-scan' without a restart"): + until(lambda: wifi.associated(station, SSID), attempts=60, interval=2) + + with test.step("Verify the station leases 192.168.21.100 over wifi"): + until(lambda: iface.address_exist(station, "wifi0", LEASE), + attempts=60, interval=2) + + test.succeed() diff --git a/test/case/interfaces/wifi_station_from_scan/topology.dot b/test/case/interfaces/wifi_station_from_scan/topology.dot new file mode 100644 index 000000000..7a05357a1 --- /dev/null +++ b/test/case/interfaces/wifi_station_from_scan/topology.dot @@ -0,0 +1,44 @@ +graph "wifi-station-from-scan" { + 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 | mgmt2 }", + pos="0,0!", + requires="controller", + ]; + + ap [ + label="{ mgmt | wifi } | ap", + pos="6,2!", + requires="infix", + ]; + + station [ + label="{ mgmt | 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"] +} diff --git a/test/case/interfaces/wifi_station_from_scan/topology.svg b/test/case/interfaces/wifi_station_from_scan/topology.svg new file mode 100644 index 000000000..9c8741aff --- /dev/null +++ b/test/case/interfaces/wifi_station_from_scan/topology.svg @@ -0,0 +1,68 @@ + + + + + + +wifi-station-from-scan + + + +host + +host + +mgmt1 + +mgmt2 + + + +ap + +mgmt + +wifi + +ap + + + +host:mgmt1--ap:mgmt + + + + +station + +mgmt + +wifi + +station + + + +host:mgmt2--station:mgmt + + + + +cell + +cell + + + +ap:wifi--cell + + + + +station:wifi--cell + + + + From dcbe9f79b67bbfc272017055181d4bd0c28e799d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20Walstr=C3=B6m?= Date: Sat, 3 Oct 2026 23:03:04 +0200 Subject: [PATCH 15/38] test: wifimedium: Raise the carrier MTU and drop oversized frames MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A full-size data frame with 802.11 headers and encryption exceeds the 1500 byte carrier MTU, and the send error took the whole relay down. Signed-off-by: Mattias Walström --- package/feature-wifi/wifimedium | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/package/feature-wifi/wifimedium b/package/feature-wifi/wifimedium index 25d07c686..55d335e88 100755 --- a/package/feature-wifi/wifimedium +++ b/package/feature-wifi/wifimedium @@ -236,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)) @@ -330,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) @@ -391,9 +396,13 @@ def main(): # 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) From d86f5f5fceb7de4a519eceaee7612172c79d150e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20Walstr=C3=B6m?= Date: Mon, 5 Oct 2026 16:02:12 +0200 Subject: [PATCH 16/38] confd: wifi: Collect the channel survey on request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The survey container under the radio only ever held the operating channel once the radio was connected or serving clients, the kernel has no data for channels it never visits, and every hardware GET paid for an iw call per radio to learn that. Replace it with a channel-survey action that scans all channels first. Going off channel pauses traffic for a few seconds, so this is something an operator asks for. The scan is triggered and then waited for over iw event, the combined iw scan spins on its netlink socket for minutes on the test kernel. Signed-off-by: Mattias Walström --- board/common/rootfs/usr/libexec/infix/iw.py | 161 ++++++++++++++++++ src/confd/src/core.c | 4 + src/confd/src/core.h | 9 + src/confd/src/hardware.c | 122 +++++++++++++ src/confd/src/system-software.c | 7 - src/confd/yang/confd.inc | 2 +- src/confd/yang/confd/infix-hardware.yang | 156 +++++++++-------- ...24.yang => infix-hardware@2026-10-05.yang} | 0 src/statd/python/yanger/ietf_hardware.py | 47 ----- 9 files changed, 383 insertions(+), 125 deletions(-) rename src/confd/yang/confd/{infix-hardware@2026-09-24.yang => infix-hardware@2026-10-05.yang} (100%) diff --git a/board/common/rootfs/usr/libexec/infix/iw.py b/board/common/rootfs/usr/libexec/infix/iw.py index 727c3957a..d0627d42c 100755 --- a/board/common/rootfs/usr/libexec/infix/iw.py +++ b/board/common/rootfs/usr/libexec/infix/iw.py @@ -7,12 +7,17 @@ iw.py dev - List all interfaces grouped by PHY iw.py info - Get PHY or interface information iw.py survey - Get channel survey data + iw.py survey-scan [passive] + - Scan all channels, then get survey data """ import sys import json import subprocess import re +import secrets +import select +import time def decode_iw_ssid(ssid): """Decode iw escaped SSID (\\xHH) to UTF-8, stripping non-printable chars.""" try: @@ -486,6 +491,153 @@ def parse_dev(): return result +def parse_dev_types(): + """ + Parse 'iw dev' output + Returns: dict mapping PHY numbers to list of (interface, type) tuples + """ + output = run_iw('dev') + if not output: + return {} + + result = {} + current_phy = None + current_if = None + + for line in output.splitlines(): + stripped = line.strip() + if line.startswith('phy#'): + current_phy = line.replace('phy#', '').strip() + result.setdefault(current_phy, []) + current_if = None + elif current_phy and stripped.startswith('Interface '): + current_if = stripped.split(None, 1)[1] + result[current_phy].append([current_if, None]) + elif current_if and stripped.startswith('type '): + result[current_phy][-1][1] = stripped.split(None, 1)[1] + + return {phy: [tuple(e) for e in entries] for phy, entries in result.items()} + + +def wait_scan_done(events, ifname, timeout): + """ + Read 'iw event' output until the scan on ifname finishes or aborts. + Returns 'finished', 'aborted' or None on timeout. + """ + deadline = time.monotonic() + timeout + while True: + remaining = deadline - time.monotonic() + if remaining <= 0: + return None + ready, _, _ = select.select([events.stdout], [], [], remaining) + if not ready: + return None + line = events.stdout.readline() + if not line: + return None + if not line.startswith(f'{ifname} '): + continue + if 'scan finished' in line: + return 'finished' + if 'scan aborted' in line: + return 'aborted' + + +def survey_scan(radio, passive=False): + """ + Scan every channel on the radio, then dump the survey. + + Returns the parse_survey() list, or {'error': ...}. The scan runs + on one interface of the radio, preferring a station or mesh point + over an access point since those can scan without extra flags. + + The scan is triggered and then waited for over 'iw event', the + combined 'iw scan' spins on the netlink socket for minutes on some + kernels. A trigger fails with EBUSY while wpa_supplicant runs its + own scan, so wait for that one to finish and try again. + """ + try: + with open(f'/sys/class/ieee80211/{radio}/index') as f: + phy = f.read().strip() + except OSError: + return {'error': f'no such radio: {radio}'} + + ifaces = parse_dev_types().get(phy, []) + order = ['managed', 'mesh point', 'AP'] + ifaces = sorted((i for i in ifaces if i[1] != 'AP/VLAN'), + key=lambda i: order.index(i[1]) if i[1] in order else len(order)) + + # A radio nobody has configured yet has no interface to scan with, + # which is exactly when a survey helps pick a band and channel. + # Borrow one for the duration of the scan, under a name no one + # would configure. + tmp = None + if not ifaces: + tmp = 'survey-' + secrets.token_hex(3) + try: + subprocess.run(['iw', 'phy', radio, 'interface', 'add', tmp, 'type', 'managed'], + capture_output=True, text=True, timeout=5, check=True) + subprocess.run(['ip', 'link', 'set', tmp, 'up'], + capture_output=True, text=True, timeout=5, check=True) + except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e: + subprocess.run(['iw', 'dev', tmp, 'del'], capture_output=True) + err = getattr(e, 'stderr', '') or '' + return {'error': f'no interface on {radio} to scan with: {err.strip() or e}'} + ifaces = [(tmp, 'managed')] + + try: + return survey_scan_on(ifaces[0], passive) + finally: + if tmp: + subprocess.run(['iw', 'dev', tmp, 'del'], capture_output=True) + + +def survey_scan_on(iface, passive): + """Scan on one (ifname, iftype), then dump the survey, see survey_scan().""" + ifname, iftype = iface + args = ['iw', 'dev', ifname, 'scan', 'trigger'] + if iftype == 'AP': + args.append('ap-force') + if passive: + args.append('passive') + + events = subprocess.Popen(['iw', 'event'], stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, text=True) + try: + err = 'scan failed' + for _ in range(5): + result = subprocess.run(args, capture_output=True, text=True, timeout=10) + if result.returncode == 0: + break + err = result.stderr.strip() or err + if '(-16)' not in err: + return {'error': f'scan on {ifname} failed: {err}'} + wait_scan_done(events, ifname, 20) + else: + return {'error': f'scan on {ifname} failed: {err}'} + + state = wait_scan_done(events, ifname, 45) + if state is None: + return {'error': f'scan on {ifname} timed out'} + if state == 'aborted': + return {'error': f'scan on {ifname} was aborted'} + except subprocess.TimeoutExpired: + return {'error': f'scan on {ifname} timed out'} + finally: + events.kill() + events.wait() + + channels = parse_survey(ifname) + if not any(ch['in_use'] for ch in channels): + # Not every driver flags the operating channel, mac80211_hwsim + # does not, take it from the interface instead. + freq = parse_interface_info(ifname).get('frequency') + for ch in channels: + ch['in_use'] = ch['frequency'] == freq + + return channels + + def parse_wds_ports(ifname): """ List the WDS ports of an access point: the AP/VLAN interfaces on the @@ -649,6 +801,7 @@ def main(): 'dev': 'List all interfaces grouped by PHY', 'info': 'Get PHY or interface information (requires device)', 'survey': 'Get channel survey data (requires interface)', + 'survey-scan': 'Scan all channels, then get survey data (requires radio)', '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)', @@ -664,6 +817,7 @@ def main(): 'iw.py link wlan0', 'iw.py mesh wifi0', 'iw.py survey wlan0', + 'iw.py survey-scan radio0 passive', 'iw.py caps radio0' ] }, indent=2)) @@ -711,6 +865,11 @@ def main(): data = {'error': 'survey command requires interface argument'} else: data = parse_survey(sys.argv[2]) + elif command == 'survey-scan': + if len(sys.argv) < 3: + data = {'error': 'survey-scan command requires radio argument'} + else: + data = survey_scan(sys.argv[2], 'passive' in sys.argv[3:]) elif command == 'caps': if len(sys.argv) < 3: data = {'error': 'caps command requires PHY/radio argument'} @@ -720,6 +879,8 @@ def main(): data = {'error': f'Unknown command: {command}'} print(json.dumps(data, indent=2, ensure_ascii=False)) + if command == 'survey-scan' and isinstance(data, dict) and 'error' in data: + sys.exit(1) except Exception as e: print(json.dumps({'error': str(e)})) diff --git a/src/confd/src/core.c b/src/confd/src/core.c index 7905fd6cc..574e0786f 100644 --- a/src/confd/src/core.c +++ b/src/confd/src/core.c @@ -992,6 +992,10 @@ int sr_plugin_init_cb(sr_session_ctx_t *session, void **priv) if (rc) goto err; + rc = hardware_rpc_init(&confd); + if (rc) + goto err; + rc = support_rpc_init(&confd); if (rc) goto err; diff --git a/src/confd/src/core.h b/src/confd/src/core.h index 707c6f72a..91d3808a6 100644 --- a/src/confd/src/core.h +++ b/src/confd/src/core.h @@ -196,6 +196,14 @@ static inline int register_rpc(sr_session_ctx_t *session, const char *xpath, return rc; } +/* Fail an RPC/action with MSG as the NETCONF error message */ +static inline int rpc_failed(sr_session_ctx_t *session, const char *msg) +{ + sr_session_set_netconf_error(session, "application", "operation-failed", + NULL, NULL, msg, 0); + return SR_ERR_OPERATION_FAILED; +} + static inline int register_rpc_tree(sr_session_ctx_t *session, const char *xpath, sr_rpc_tree_cb cb, void *arg, sr_subscription_ctx_t **sub) { @@ -283,6 +291,7 @@ int services_change(sr_session_ctx_t *session, struct lyd_node *config, struct l /* hardware.c */ int hardware_candidate_init(struct confd *confd); +int hardware_rpc_init(struct confd *confd); int hardware_change(sr_session_ctx_t *session, struct lyd_node *config, struct lyd_node *diff, sr_event_t event, struct confd *confd); /* keystore.c */ diff --git a/src/confd/src/hardware.c b/src/confd/src/hardware.c index aa391b7b6..8c3a241f9 100644 --- a/src/confd/src/hardware.c +++ b/src/confd/src/hardware.c @@ -1485,6 +1485,128 @@ int hardware_change(sr_session_ctx_t *session, struct lyd_node *config, struct l return rc; } + +/* + * Scan all channels on a radio and report how busy each one is, the + * channel-survey action in infix-hardware.yang. The scan takes the + * radio off its operating channel for a few seconds, which is why this + * is an action and not operational data. The helper does the scan and + * the parsing, see iw.py survey-scan. + */ +static int wifi_channel_survey(sr_session_ctx_t *session, uint32_t sub_id, const char *op_path, + const struct lyd_node *input, sr_event_t event, uint32_t request_id, + struct lyd_node *output, void *priv) +{ + static const struct { const char *json, *yang; } times[] = { + { "noise", "noise" }, + { "active_time", "active-time" }, + { "busy_time", "busy-time" }, + { "receive_time", "receive-time" }, + { "transmit_time", "transmit-time" }, + }; + struct lyd_node *component, *chan; + const char *radio, *passive; + json_error_t jerr; + json_t *root, *entry; + size_t index; + FILE *pp; + + if (event != SR_EV_RPC) + return SR_ERR_OK; + + component = lyd_parent(lyd_parent(input)); + radio = component ? lydx_get_cattr(component, "name") : NULL; + if (!radio) + return rpc_failed(session, "Cannot tell which radio to survey"); + + passive = lydx_get_cattr((struct lyd_node *)input, "passive"); + pp = popenf("r", "/usr/libexec/infix/iw.py survey-scan %s %s", radio, + passive && !strcmp(passive, "true") ? "passive" : ""); + if (!pp) + return rpc_failed(session, "Failed starting channel survey"); + + root = json_loadf(pp, 0, &jerr); + pclose(pp); + if (!root) + return rpc_failed(session, "Channel survey returned no data"); + + if (!json_is_array(root)) { + json_t *err = json_object_get(root, "error"); + char msg[256]; + + snprintf(msg, sizeof(msg), "Channel survey failed: %s", + json_is_string(err) ? json_string_value(err) : "unknown error"); + json_decref(root); + return rpc_failed(session, msg); + } + + json_array_foreach(root, index, entry) { + json_t *freq = json_object_get(entry, "frequency"); + char val[32]; + + if (!json_is_integer(freq)) + continue; + + snprintf(val, sizeof(val), "%lld", json_integer_value(freq)); + if (lyd_new_list(output, NULL, "channel", LYD_NEW_VAL_OUTPUT, &chan, val)) { + ERROR("channel-survey: failed adding channel %s", val); + continue; + } + + lyd_new_term(chan, NULL, "in-use", + json_is_true(json_object_get(entry, "in_use")) ? "true" : "false", + 0, NULL); + + for (size_t i = 0; i < NELEMS(times); i++) { + json_t *v = json_object_get(entry, times[i].json); + + if (!json_is_integer(v)) + continue; + + snprintf(val, sizeof(val), "%lld", json_integer_value(v)); + lyd_new_term(chan, NULL, times[i].yang, val, 0, NULL); + } + } + json_decref(root); + + return SR_ERR_OK; +} + +/* The action only exists when the wifi feature is enabled, see wifi.inc */ +static bool wifi_feature_enabled(struct confd *confd) +{ + const struct lys_module *mod; + const struct ly_ctx *ctx; + bool enabled = false; + + ctx = sr_acquire_context(confd->conn); + if (!ctx) + return false; + + mod = ly_ctx_get_module_implemented(ctx, "infix-hardware"); + if (mod) + enabled = lys_feature_value(mod, "wifi") == LY_SUCCESS; + sr_release_context(confd->conn); + + return enabled; +} + +int hardware_rpc_init(struct confd *confd) +{ + int rc = 0; + + if (!wifi_feature_enabled(confd)) + return SR_ERR_OK; + + REGISTER_RPC_TREE(confd->session, XPATH_BASE_ "/component/infix-hardware:wifi-radio/channel-survey", + wifi_channel_survey, NULL, &confd->sub); + + return SR_ERR_OK; +fail: + ERROR("Init hardware rpc failed: %s", sr_strerror(rc)); + return rc; +} + int hardware_candidate_init(struct confd *confd) { int rc = 0; diff --git a/src/confd/src/system-software.c b/src/confd/src/system-software.c index a714944c5..f184a283a 100644 --- a/src/confd/src/system-software.c +++ b/src/confd/src/system-software.c @@ -93,13 +93,6 @@ static int infix_system_sw_set_boot_order(sr_session_ctx_t *session, uint32_t su return SR_ERR_OK; } -static int rpc_failed(sr_session_ctx_t *session, const char *msg) -{ - sr_session_set_netconf_error(session, "application", "operation-failed", - NULL, NULL, msg, 0); - return SR_ERR_OPERATION_FAILED; -} - /* Append output leaf PATH/LEAF from the state file, skipped when absent. */ static int add_output(sr_val_t **output, size_t *cnt, const char *path, const char *leaf, json_t *val) diff --git a/src/confd/yang/confd.inc b/src/confd/yang/confd.inc index 054dca82b..9c0f0ecac 100644 --- a/src/confd/yang/confd.inc +++ b/src/confd/yang/confd.inc @@ -27,7 +27,7 @@ MODULES=( "infix-syslog@2026-09-24.yang" "iana-hardware@2018-03-13.yang" "ietf-hardware@2018-03-13.yang -e hardware-state -e hardware-sensor" - "infix-hardware@2026-09-24.yang" + "infix-hardware@2026-10-05.yang" "ieee802-dot1q-types@2022-10-29.yang" "infix-ip@2026-04-28.yang" "infix-if-type@2026-01-07.yang" diff --git a/src/confd/yang/confd/infix-hardware.yang b/src/confd/yang/confd/infix-hardware.yang index 6769774b6..d292f90a3 100644 --- a/src/confd/yang/confd/infix-hardware.yang +++ b/src/confd/yang/confd/infix-hardware.yang @@ -21,6 +21,12 @@ module infix-hardware { contact "kernelkit@googlegroups.com"; description "Vital Product Data augmentation of ieee-hardware and deviations."; + revision 2026-10-05 { + description "Replace the WiFi radio survey container with the channel-survey + action, survey data is collected on request only."; + reference "internal"; + } + revision 2026-09-24 { description "Constrain the character set of hardware component names."; reference "internal"; @@ -528,98 +534,108 @@ module infix-hardware { } /* - * Channel survey data (operational state) + * Channel survey, on demand */ - container survey { - config false; + action channel-survey { description - "WiFi channel survey data providing channel utilization - and interference information. - - This data is collected from the WiFi driver and provides - insights into channel occupancy, noise levels, and RF activity."; - - list channel { - key "frequency"; - description - "Per-channel survey information. + "Scan all channels the radio supports and report how busy each + one is. - Includes utilization metrics for all channels scanned by - the radio, not just the currently active channel."; + The radio leaves its operating channel for the duration of + the scan, a few seconds, so traffic on the radio pauses. + Survey data is not collected in the background, this action + is the only way to get it."; - leaf frequency { - type uint32; - units "MHz"; + input { + leaf passive { + type boolean; + default false; description - "Channel center frequency in MHz. - - Examples: - - 2412 MHz (2.4 GHz channel 1) - - 5180 MHz (5 GHz channel 36) - - 5955 MHz (6 GHz channel 1)"; + "Listen only, do not send probe requests. A passive scan + dwells longer on each channel and gives a better busy-time + sample, at the cost of a longer pause in traffic."; } + } - leaf in-use { - type boolean; + output { + list channel { + key "frequency"; description - "Whether this channel is currently in use by the radio. + "Survey of one channel. Channel utilization is + busy-time / active-time."; + + leaf frequency { + type uint32; + units "MHz"; + description + "Channel center frequency in MHz. + + Examples: + - 2412 MHz (2.4 GHz channel 1) + - 5180 MHz (5 GHz channel 36) + - 5955 MHz (6 GHz channel 1)"; + } - Only one channel will have this set to true at a time."; - } + leaf in-use { + type boolean; + description + "Whether this is the radio's operating channel."; + } - leaf noise { - type int16; - units "dBm"; - description - "Background noise level on this channel in dBm. + leaf noise { + type int16; + units "dBm"; + description + "Background noise level on this channel in dBm. - Lower (more negative) values indicate cleaner RF environment. + Lower (more negative) values indicate cleaner RF environment. - Typical values: - - -95 to -100 dBm: Very low noise (excellent) - - -85 to -95 dBm: Low noise (good) - - -75 to -85 dBm: Moderate noise - - -65 to -75 dBm: High noise (congested)"; - } + Typical values: + - -95 to -100 dBm: Very low noise (excellent) + - -85 to -95 dBm: Low noise (good) + - -75 to -85 dBm: Moderate noise + - -65 to -75 dBm: High noise (congested)"; + } - leaf active-time { - type uint32; - units "milliseconds"; - description - "Total time the radio was active on this channel. + leaf active-time { + type uint32; + units "milliseconds"; + description + "Total time the radio was active on this channel. - This is the survey measurement period for this channel."; - } + This is the survey measurement period for this channel."; + } - leaf busy-time { - type uint32; - units "milliseconds"; - description - "Time the channel was detected as busy. + leaf busy-time { + type uint32; + units "milliseconds"; + description + "Time the channel was detected as busy. - Includes time spent receiving frames, transmitting frames, - and time the channel was busy due to other sources. + Includes time spent receiving frames, transmitting frames, + and time the channel was busy due to other sources. - Channel utilization = (busy-time / active-time) * 100%"; - } + Channel utilization = (busy-time / active-time) * 100%"; + } - leaf receive-time { - type uint32; - units "milliseconds"; - description - "Time spent receiving frames on this channel. + leaf receive-time { + type uint32; + units "milliseconds"; + description + "Time spent receiving frames on this channel. - Subset of busy-time spent on frame reception."; - } + Subset of busy-time spent on frame reception."; + } - leaf transmit-time { - type uint32; - units "milliseconds"; - description - "Time spent transmitting frames on this channel. + leaf transmit-time { + type uint32; + units "milliseconds"; + description + "Time spent transmitting frames on this channel. - Subset of busy-time spent on frame transmission."; + Subset of busy-time spent on frame transmission."; + } } } } diff --git a/src/confd/yang/confd/infix-hardware@2026-09-24.yang b/src/confd/yang/confd/infix-hardware@2026-10-05.yang similarity index 100% rename from src/confd/yang/confd/infix-hardware@2026-09-24.yang rename to src/confd/yang/confd/infix-hardware@2026-10-05.yang diff --git a/src/statd/python/yanger/ietf_hardware.py b/src/statd/python/yanger/ietf_hardware.py index b52580924..0ba4e1c8d 100644 --- a/src/statd/python/yanger/ietf_hardware.py +++ b/src/statd/python/yanger/ietf_hardware.py @@ -601,39 +601,6 @@ def thermal_sensor_components(): return components -def get_survey_data(ifname): - """Get channel survey data using iw.py script""" - channels = [] - - try: - survey_data = HOST.run_json(("/usr/libexec/infix/iw.py", "survey", ifname), default=[]) - - for entry in survey_data: - channel = { - "frequency": entry.get("frequency"), - "in-use": entry.get("in_use", False) - } - - # Add optional fields if present - if "noise" in entry: - channel["noise"] = entry["noise"] - if "active_time" in entry: - channel["active-time"] = entry["active_time"] - if "busy_time" in entry: - channel["busy-time"] = entry["busy_time"] - if "receive_time" in entry: - channel["receive-time"] = entry["receive_time"] - if "transmit_time" in entry: - channel["transmit-time"] = entry["transmit_time"] - - channels.append(channel) - - except Exception: - pass - - return channels - - def get_phy_info(phy_name): """Get complete PHY information using iw.py script""" try: @@ -752,20 +719,6 @@ def wifi_radio_components(): num_ifaces = iw_info.get('num_virtual_interfaces', 0) wifi_radio_data['num-virtual-interfaces'] = num_ifaces - # Get survey data if we have an interface - iface = phy_data.get("iface") - if iface: - try: - channels = get_survey_data(iface) - - if channels: - wifi_radio_data["survey"] = { - "channel": channels - } - except Exception: - # If survey fails, continue without survey data - pass - # Add wifi-radio data to component if wifi_radio_data: component["infix-hardware:wifi-radio"] = wifi_radio_data From e0cb708882e9695fcd2e85daeddd8609234ad8cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20Walstr=C3=B6m?= Date: Mon, 5 Oct 2026 16:02:12 +0200 Subject: [PATCH 17/38] cli: Add show hardware survey MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Calls the channel-survey action on the radio component and feeds the channel map renderer, which until now had no command at all. The rpc tool gains -j to print an RPC output tree as JSON, the value printer cannot render a list. Signed-off-by: Mattias Walström --- .../usr/libexec/infix/wifi-channel-map.py | 218 ++++++++++-------- src/bin/copy.c | 83 ++++++- src/bin/show/__init__.py | 47 ++++ src/klish-plugin-infix/src/infix.c | 8 + src/klish-plugin-infix/xml/infix.xml | 27 ++- 5 files changed, 282 insertions(+), 101 deletions(-) diff --git a/board/common/rootfs/usr/libexec/infix/wifi-channel-map.py b/board/common/rootfs/usr/libexec/infix/wifi-channel-map.py index 706058517..01781d570 100755 --- a/board/common/rootfs/usr/libexec/infix/wifi-channel-map.py +++ b/board/common/rootfs/usr/libexec/infix/wifi-channel-map.py @@ -54,6 +54,17 @@ def get_channel_frequency(channel, band='2.4'): return None +def freq_to_band(freq): + """Band name for a frequency in MHz, or None""" + if 2400 <= freq <= 2500: + return '2.4 GHz' + if 5000 <= freq <= 5900: + return '5 GHz' + if 5925 <= freq <= 7125: + return '6 GHz' + return None + + def get_busy_percentage(channel_data): """Calculate channel busy percentage""" active = channel_data.get('active-time', 0) @@ -84,7 +95,7 @@ def draw_channel_graph_2_4ghz(survey_data): for ch_data in survey_data: freq = ch_data.get('frequency') ch_num = freq_to_channel(freq) - if ch_num and 1 <= ch_num <= 14: + if ch_num and freq_to_band(freq) == '2.4 GHz': busy_pct = get_busy_percentage(ch_data) channels[ch_num] = { 'freq': freq, @@ -107,14 +118,27 @@ def draw_channel_graph_2_4ghz(survey_data): print(f"Non-overlapping channels: 1, 6, 11 (shown in {Colors.GREEN}green{Colors.RESET})") print() - # Draw frequency scale + # 80 columns span 2400-2500 MHz, so one column is 1.25 MHz and a + # 20 MHz channel is 16 columns wide, centred on its frequency. + width = 80 + cols_per_mhz = width / 100 + + def col(freq): + return int(round((freq - 2400) * cols_per_mhz)) + + ruler = [' '] * width + ticks = [' '] * width + for mhz in range(2400, 2500, 20): + label = str(mhz) + for i, c in enumerate(label): + if col(mhz) + i < width: + ruler[col(mhz) + i] = c + ticks[col(mhz)] = '|' print("Frequency (MHz):") - print("2400 2420 2440 2460 2480") - print("|-----------|-----------|-----------|-----------|") + print(" " + ''.join(ruler)) + print(" " + ''.join(ticks).replace(' ', '-')) - # Draw each channel as a bar showing its 20 MHz width - # Each channel occupies ~4 adjacent channels worth of space - for ch in range(1, 14): + for ch in range(1, 15): if ch not in channels: continue @@ -123,7 +147,6 @@ def draw_channel_graph_2_4ghz(survey_data): is_in_use = data['in_use'] noise = data['noise'] - # Determine color based on status if is_in_use: color = Colors.BG_BLUE marker = '█' @@ -133,49 +156,25 @@ def draw_channel_graph_2_4ghz(survey_data): elif busy_pct >= 25: color = Colors.YELLOW marker = '▒' - elif busy_pct > 0: + elif busy_pct >= 1: color = Colors.CYAN marker = '░' else: color = Colors.GRAY marker = '·' - # Non-overlapping channels get green color if ch in [1, 6, 11] and not is_in_use and busy_pct < 10: color = Colors.GREEN - # Calculate position (each channel is offset by 5 MHz = 1 position) - # Channel 1 is at 2412 MHz, base is 2400 - offset = ((data['freq'] - 2400) // 5) - - # Draw channel bar (20 MHz = 4 positions wide) - line = ' ' * 80 - line_arr = list(line) - - # Mark the channel span (20 MHz width) - for i in range(4): - pos = offset + i - 2 # Center the 20 MHz around channel - if 0 <= pos < len(line_arr): - line_arr[pos] = marker - - # Add channel label - label_pos = offset - if 0 <= label_pos < len(line_arr) - 5: - # Clear space for label - for i in range(5): - if label_pos + i < len(line_arr): - line_arr[label_pos + i] = ' ' - - line = ''.join(line_arr) - - # Status indicators - status = "" - if is_in_use: - status = f" {Colors.BOLD}[IN USE]{Colors.RESET}" + line = [' '] * width + for pos in range(col(data['freq'] - 10), col(data['freq'] + 10)): + if 0 <= pos < width: + line[pos] = marker + status = f" {Colors.BOLD}[IN USE]{Colors.RESET}" if is_in_use else "" busy_color = get_utilization_color(busy_pct) - print(f"{color}Ch{ch:2d}{Colors.RESET} {color}{line}{Colors.RESET} " + print(f"{color}Ch{ch:2d}{Colors.RESET} {color}{''.join(line)}{Colors.RESET} " f"{busy_color}{busy_pct:5.1f}%{Colors.RESET} " f"{noise:4d}dBm{status}") @@ -196,12 +195,17 @@ def draw_channel_list(survey_data): print(f"{'Ch':<4} {'Freq':<6} {'Noise':<8} {'Busy%':<8} {'Utilization Bar':<40}") print("-" * 80) + band = None for ch_data in sorted(survey_data, key=lambda x: x.get('frequency', 0)): freq = ch_data.get('frequency') ch_num = freq_to_channel(freq) if not ch_num: continue + if freq_to_band(freq) != band: + band = freq_to_band(freq) + print(f"{Colors.BOLD}{band}{Colors.RESET}") + noise = ch_data.get('noise', -100) busy_pct = get_busy_percentage(ch_data) is_in_use = ch_data.get('in-use', False) @@ -237,7 +241,7 @@ def draw_overlap_pie(survey_data): for ch_data in survey_data: freq = ch_data.get('frequency') ch_num = freq_to_channel(freq) - if ch_num and 1 <= ch_num <= 13: + if ch_num and freq_to_band(freq) == '2.4 GHz': busy_pct = get_busy_percentage(ch_data) channels[ch_num] = { 'busy': busy_pct, @@ -549,78 +553,108 @@ def busy_to_height(busy_pct): def draw_recommendations(survey_data, json_output=False): - """Analyze channels and provide recommendations""" - # Parse channel data - channels = {} - in_use_channel = None + """Analyze channels and provide recommendations, per band""" + bands = {} + current = None for ch_data in survey_data: freq = ch_data.get('frequency') ch_num = freq_to_channel(freq) - if ch_num: - busy_pct = get_busy_percentage(ch_data) - channels[ch_num] = { - 'busy': busy_pct, - 'noise': ch_data.get('noise', -100), - 'in_use': ch_data.get('in-use', False) - } - if ch_data.get('in-use'): - in_use_channel = ch_num - - # Find least congested non-overlapping channels - best_channels = [] - for ch in [1, 6, 11]: - if ch in channels: - best_channels.append((ch, channels[ch]['busy'])) + band = freq_to_band(freq) if freq else None + if not ch_num or not band: + continue - best_channels.sort(key=lambda x: x[1]) + entry = { + 'channel': ch_num, + 'frequency': freq, + 'busy': get_busy_percentage(ch_data), + 'noise': ch_data.get('noise', -100), + } + bands.setdefault(band, []).append(entry) + if ch_data.get('in-use'): + current = dict(entry, band=band) + + # Least busy channels per band. On 2.4 GHz only the three channels + # that do not overlap are worth recommending. + best = {} + for band, entries in bands.items(): + if band == '2.4 GHz': + entries = [e for e in entries if e['channel'] in (1, 6, 11)] + best[band] = sorted(entries, key=lambda e: e['busy'])[:3] - # JSON output if json_output: output = { - "recommended_channels": [ - {"channel": ch, "busy_percent": round(busy, 1)} - for ch, busy in best_channels - ] + "recommended_channels": { + band: [{"channel": e['channel'], "busy_percent": round(e['busy'], 1)} + for e in entries] + for band, entries in best.items() + } } - if in_use_channel: - output["current_channel"] = in_use_channel - output["current_busy_percent"] = round(channels.get(in_use_channel, {}).get('busy', 0), 1) + if current: + output["current_channel"] = current['channel'] + output["current_band"] = current['band'] + output["current_busy_percent"] = round(current['busy'], 1) print(json.dumps(output, indent=2)) return - # Text output print(f"\n{Colors.BOLD}Channel Recommendations{Colors.RESET}") print("=" * 80) - if in_use_channel: - print(f"Current channel: {Colors.BOLD}{in_use_channel}{Colors.RESET}") - current_busy = channels.get(in_use_channel, {}).get('busy', 0) - if current_busy > 50: - print(f" {Colors.RED}⚠{Colors.RESET} High congestion detected ({current_busy:.1f}% busy)") - elif current_busy > 25: - print(f" {Colors.YELLOW}⚠{Colors.RESET} Moderate congestion ({current_busy:.1f}% busy)") + if current: + print(f"Current channel: {Colors.BOLD}{current['channel']}{Colors.RESET} ({current['band']})") + busy = current['busy'] + if busy > 50: + print(f" {Colors.RED}⚠{Colors.RESET} High congestion detected ({busy:.1f}% busy)") + elif busy > 25: + print(f" {Colors.YELLOW}⚠{Colors.RESET} Moderate congestion ({busy:.1f}% busy)") else: - print(f" {Colors.GREEN}✓{Colors.RESET} Good channel utilization ({current_busy:.1f}% busy)") + print(f" {Colors.GREEN}✓{Colors.RESET} Good channel utilization ({busy:.1f}% busy)") - print(f"\nRecommended non-overlapping channels (2.4 GHz):") - for i, (ch, busy) in enumerate(best_channels[:3], 1): - color = get_utilization_color(busy) - marker = "★" if i == 1 else " " - print(f" {marker} Channel {ch:2d}: {color}{busy:5.1f}% busy{Colors.RESET}") + for band in ('2.4 GHz', '5 GHz', '6 GHz'): + if band not in best: + continue + what = "non-overlapping channels" if band == '2.4 GHz' else "channels" + print(f"\nLeast busy {what} ({band}):") + for i, e in enumerate(best[band], 1): + color = get_utilization_color(e['busy']) + marker = "★" if i == 1 else " " + print(f" {marker} Channel {e['channel']:3d}: {color}{e['busy']:5.1f}% busy{Colors.RESET}") print() +def find_channels(data): + """ + Collect every survey channel list in the input. + + The channel-survey action output is wrapped in its path, with + 'rpc -j' that is hardware/component/wifi-radio/channel-survey, with + RESTCONF it is 'infix-hardware:output'. Dig for the lists rather + than assume one wrapping. + """ + found = [] + if isinstance(data, dict): + for key, value in data.items(): + if key == 'channel' and isinstance(value, list) and \ + all(isinstance(ch, dict) and 'frequency' in ch for ch in value): + found.extend(value) + else: + found.extend(find_channels(value)) + elif isinstance(data, list): + for item in data: + found.extend(find_channels(item)) + return found + + def main(): parser = argparse.ArgumentParser( description='Visualize WiFi channel overlap and utilization', formatter_class=argparse.RawDescriptionHelpFormatter, epilog=''' Examples: - # Read from yanger output (show all sections) - yanger -x "ixll -A ssh host sudo" ietf-hardware | %(prog)s + # Read the channel-survey action output (show all sections) + rpc -j "/ietf-hardware:hardware/component[name='radio0']/infix-hardware:wifi-radio/channel-survey" | %(prog)s # Read from file %(prog)s survey_data.json @@ -670,22 +704,10 @@ def main(): else: data = json.load(sys.stdin) - # Extract survey data from hardware components - survey_data = [] - hardware = data.get('ietf-hardware:hardware', {}) - components = hardware.get('component', []) - - for component in components: - if component.get('class') == 'infix-hardware:wifi': - wifi_radio = component.get('infix-hardware:wifi-radio', {}) - survey = wifi_radio.get('survey', {}) - channels = survey.get('channel', []) - if channels: - survey_data.extend(channels) - + survey_data = find_channels(data) if not survey_data: print("No WiFi survey data found in input", file=sys.stderr) - print("Expected format: yanger ietf-hardware output with wifi-radio survey data", file=sys.stderr) + print("Expected format: output of the infix-hardware channel-survey action", file=sys.stderr) sys.exit(1) # Generate SVG if requested (exclusive mode) diff --git a/src/bin/copy.c b/src/bin/copy.c index e4cacaaef..8fd47f550 100644 --- a/src/bin/copy.c +++ b/src/bin/copy.c @@ -47,6 +47,7 @@ static char *xpath = "/*"; static int debug; static int force; static int timeout; +static int json_out; static int dry_run; static int sanitize; static int redact; @@ -952,6 +953,7 @@ static int usage_rpc(int rc) "Options:\n" " -d Enable debug mode, verbose output on stderr\n" " -h This help text\n" + " -j Print RPC output as JSON\n" " -t SEC Timeout for the operation, or default %d sec\n" " -v Show version\n" "\n" @@ -985,6 +987,77 @@ static bool is_leaflist(sr_conn_ctx_t *conn, const char *rpc_xpath, const char * return rc; } +/* + * Tree variant of rpc_exec(), prints the output as JSON. Used by show + * for actions with list output, which sr_print_val() cannot render. + */ +static int rpc_exec_json(sr_conn_ctx_t *conn, sr_session_ctx_t *sess, + const char *rpc_xpath, int argc, char *argv[]) +{ + struct lyd_node *tree = NULL, *op = NULL; + const struct ly_ctx *ctx; + sr_data_t *output = NULL; + char *str = NULL; + int rc = 1, err = 0, i; + + ctx = sr_acquire_context(conn); + if (!ctx) { + warnx("failed acquiring libyang context"); + return 1; + } + + if (lyd_new_path(NULL, ctx, rpc_xpath, NULL, 0, &tree) || + lyd_find_path(tree, rpc_xpath, 0, &op)) { + warnx("invalid RPC xpath %s", rpc_xpath); + goto cleanup; + } + + for (i = 0; i < argc - 1; i += 2) { + const char *key = argv[i]; + char *val, *token, *saveptr; + + val = strdup(argv[i + 1]); + if (!val) { + warnx("Memory allocation failed"); + goto cleanup; + } + + if (strchr(val, ',') && is_leaflist(conn, rpc_xpath, key)) { + for (token = strtok_r(val, ",", &saveptr); token; + token = strtok_r(NULL, ",", &saveptr)) + err = lyd_new_path(op, NULL, key, token, 0, NULL); + } else + err = lyd_new_path(op, NULL, key, val, 0, NULL); + free(val); + + if (err) { + warnx("invalid RPC argument %s = %s", key, argv[i + 1]); + goto cleanup; + } + } + + dbg("Sending RPC %s (timeout: %d ms)", rpc_xpath, timeout * 1000); + err = sr_rpc_send_tree(sess, tree, timeout * 1000, &output); + if (err != SR_ERR_OK) { + sysrepo_print_error(sess); + warnx("RPC execution failed: %s", sr_strerror(err)); + goto cleanup; + } + + if (output && output->tree && + !lyd_print_mem(&str, output->tree, LYD_JSON, LYD_PRINT_SIBLINGS)) + puts(str); + free(str); + rc = 0; + +cleanup: + sr_release_data(output); + lyd_free_all(tree); + sr_release_context(conn); + + return rc; +} + /* Execute RPC from CLI arguments: xpath and key-value pairs */ static int rpc_exec(const char *rpc_xpath, int argc, char *argv[]) { @@ -1002,6 +1075,11 @@ static int rpc_exec(const char *rpc_xpath, int argc, char *argv[]) if (err != SR_ERR_OK) return 1; + if (json_out) { + rc = rpc_exec_json(conn, sess, rpc_xpath, argc, argv); + goto cleanup; + } + for (i = 0; i < argc - 1; i += 2) { const char *key = argv[i]; const char *val = argv[i + 1]; @@ -1125,13 +1203,16 @@ static int rpc_main(int argc, char *argv[]) timeout = fgetint("/etc/default/confd", "=", "CONFD_TIMEOUT"); - while ((c = getopt(argc, argv, "dht:v")) != EOF) { + while ((c = getopt(argc, argv, "dhjt:v")) != EOF) { switch(c) { case 'd': debug = 1; break; case 'h': return usage_rpc(0); + case 'j': + json_out = 1; + break; case 't': timeout = atoi(optarg); break; diff --git a/src/bin/show/__init__.py b/src/bin/show/__init__.py index e45e9e3a8..55016f844 100755 --- a/src/bin/show/__init__.py +++ b/src/bin/show/__init__.py @@ -2,6 +2,7 @@ import re import subprocess +import sys import json from typing import List import os @@ -82,17 +83,63 @@ def tftp(args: List[str]) -> None: def hardware(args: List[str]) -> None: + """show hardware [ [survey [passive]]]""" + if len(args) >= 2 and args[1] == "survey": + channel_survey(args[0], "passive" in args[2:]) + return + data = get_json("/ietf-hardware:hardware") if not data: print("No hardware data retrieved.") return + if args: + hw = data.get("ietf-hardware:hardware", {}) + hw["component"] = [c for c in hw.get("component", []) if c.get("name") == args[0]] + if not hw["component"]: + print(f"No hardware component named {args[0]}.") + return + if RAW_OUTPUT: print(json.dumps(data, indent=2)) return cli_pretty(data, "show-hardware") +def channel_survey(radio: str, passive: bool) -> None: + """Run the channel-survey action on a WiFi radio and render the channel map""" + xpath = f"/ietf-hardware:hardware/component[name='{radio}']/infix-hardware:wifi-radio/channel-survey" + cmd = ["rpc", "-j", xpath] + if passive: + cmd += ["passive", "true"] + + # Progress goes to stderr, past the pager, which would otherwise + # print the chart twice when it arrives piecemeal after the scan + print(f"Scanning channels on {radio}, this takes a few seconds ...", + file=sys.stderr, flush=True) + result = subprocess.run(cmd, capture_output=True, text=True) + if result.returncode != 0: + if "does not exist" in result.stderr: + print(f"No WiFi radio named {radio}.") + return + # The rpc tool prints the device's reason, then its own verdict + for line in result.stderr.splitlines(): + if line.startswith("rpc: ") and "RPC execution failed" not in line: + print(re.sub(r" \(\d+\)$", "", line[len("rpc: "):])) + return + print(result.stderr.strip() or "Channel survey failed") + return + + if RAW_OUTPUT: + print(result.stdout) + return + + chart = subprocess.run(["/usr/libexec/infix/wifi-channel-map.py"], + input=result.stdout, capture_output=True, text=True) + sys.stdout.write(chart.stdout or chart.stderr) + sys.stdout.flush() + + def ntp(args: List[str]) -> None: # Create argument parser for ntp subcommands parser = argparse.ArgumentParser(prog='show ntp', add_help=False) diff --git a/src/klish-plugin-infix/src/infix.c b/src/klish-plugin-infix/src/infix.c index 57b8619aa..6d83cfabe 100644 --- a/src/klish-plugin-infix/src/infix.c +++ b/src/klish-plugin-infix/src/infix.c @@ -353,6 +353,13 @@ int infix_ifaces(kcontext_t *ctx) return 0; } +int infix_wifi_radios(kcontext_t *ctx) +{ + (void)ctx; + system("ls /sys/class/ieee80211/ 2>/dev/null"); + return 0; +} + /* Note: uses shellf() for pipes, but all arguments are hardcoded by callers */ static int firewall_dbus_completion(const char *interface, const char *method, const char *parser) { @@ -857,6 +864,7 @@ int kplugin_infix_init(kcontext_t *ctx) kplugin_add_syms(plugin, ksym_new("path", infix_path)); kplugin_add_syms(plugin, ksym_new("rename", infix_rename)); kplugin_add_syms(plugin, ksym_new("ifaces", infix_ifaces)); + kplugin_add_syms(plugin, ksym_new("wifi_radios", infix_wifi_radios)); kplugin_add_syms(plugin, ksym_new("users", infix_users)); kplugin_add_syms(plugin, ksym_new("groups", infix_groups)); kplugin_add_syms(plugin, ksym_new("sym_keys", infix_sym_keys)); diff --git a/src/klish-plugin-infix/xml/infix.xml b/src/klish-plugin-infix/xml/infix.xml index 1f7312a08..20c57718e 100644 --- a/src/klish-plugin-infix/xml/infix.xml +++ b/src/klish-plugin-infix/xml/infix.xml @@ -122,6 +122,13 @@ + + + + + + + @@ -666,9 +673,25 @@ echo "Public: $pub" show mdns - + + + + + + show hardware "$KLISH_PARAM_component" survey |pager + + + + + show hardware "$KLISH_PARAM_component" survey passive |pager + + + + + + - show hardware |pager + show hardware ${KLISH_PARAM_component:+"$KLISH_PARAM_component"} |pager From 63a722f9e0b78474dd288b69b736cb6a37206224 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20Walstr=C3=B6m?= Date: Mon, 5 Oct 2026 16:02:12 +0200 Subject: [PATCH 18/38] webui: Scan channels on request from the WiFi page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The always-on survey card showed a single bar on a busy radio. Replace it with a Scan channels button per radio that runs the channel-survey action and draws the result. Signed-off-by: Mattias Walström --- src/webui/internal/handlers/interfaces.go | 11 +- src/webui/internal/handlers/wifi.go | 72 ++++++++---- .../internal/handlers/wifi_survey_test.go | 103 ++++++++++++++++++ src/webui/internal/server/server.go | 1 + src/webui/templates/pages/wifi.html | 26 ++++- 5 files changed, 178 insertions(+), 35 deletions(-) create mode 100644 src/webui/internal/handlers/wifi_survey_test.go diff --git a/src/webui/internal/handlers/interfaces.go b/src/webui/internal/handlers/interfaces.go index 1acfd4042..3a690ff40 100644 --- a/src/webui/internal/handlers/interfaces.go +++ b/src/webui/internal/handlers/interfaces.go @@ -189,15 +189,8 @@ type wifiScanResultJSON struct { Encryption []string `json:"encryption"` } -// WiFi radio survey RESTCONF structures (from ietf-hardware:hardware). - -type wifiRadioJSON struct { - Survey *wifiSurveyJSON `json:"survey"` -} - -type wifiSurveyJSON struct { - Channel []surveyChanJSON `json:"channel"` -} +// WiFi channel survey, the output of the infix-hardware channel-survey +// action on a radio. type surveyChanJSON struct { Frequency int `json:"frequency"` diff --git a/src/webui/internal/handlers/wifi.go b/src/webui/internal/handlers/wifi.go index e1ce23097..5695bd161 100644 --- a/src/webui/internal/handlers/wifi.go +++ b/src/webui/internal/handlers/wifi.go @@ -4,19 +4,18 @@ package handlers import ( "context" + "errors" "fmt" "html/template" "log" "net/http" + "net/url" "sync" + "time" "infix/webui/internal/restconf" ) -// wifiRadioHWJSON extends the hardware component wifi-radio container with -// operational fields from infix-hardware YANG that are not in wifiRadioJSON. -// wifiRadioJSON (defined in interfaces.go) only covers survey data; this -// struct captures the full operational state returned by RESTCONF. // wifiMaxIfJSON maps the max-interfaces container from infix-hardware YANG. type wifiMaxIfJSON struct { AP int `json:"ap"` @@ -33,7 +32,6 @@ type wifiRadioHWJSON struct { Driver string `json:"driver"` Bands []wifiBandJSON `json:"bands"` MaxInterfaces *wifiMaxIfJSON `json:"max-interfaces"` - Survey *wifiSurveyJSON `json:"survey"` } type wifiBandJSON struct { @@ -74,8 +72,8 @@ type WiFiRadio struct { VHTCapable bool HECapable bool Bands []WiFiBand - SurveySVG template.HTML Interfaces []WiFiInterface + Survey wifiSurveyData } type WiFiBand struct { @@ -86,17 +84,6 @@ type WiFiBand struct { HECapable bool } -// ChannelSurvey holds processed survey data for one channel. -type ChannelSurvey struct { - Frequency int - Channel int - InUse bool - Noise int - ActiveTime int64 - BusyTime int64 - UtilPct int // BusyTime/ActiveTime * 100 -} - // WiFiInterface is the template data for a virtual WiFi interface. type WiFiInterface struct { Name string @@ -207,6 +194,51 @@ func (h *WiFiHandler) Overview(w http.ResponseWriter, r *http.Request) { } } +// wifiSurveyData is the template data for the channel survey fragment of +// one radio: the chart after a scan, or why there is none. +type wifiSurveyData struct { + Radio string + SVG template.HTML + Error string +} + +// Survey runs the channel-survey action on a radio and renders the result +// as the survey fragment of the radio's card. The scan takes the radio off +// its channel for a few seconds, so it only runs on request. +// POST /wifi/{name}/survey +func (h *WiFiHandler) Survey(w http.ResponseWriter, r *http.Request) { + data := wifiSurveyData{Radio: r.PathValue("name")} + + var reply struct { + Output struct { + Channel []surveyChanJSON `json:"channel"` + } `json:"infix-hardware:output"` + } + + ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second) + defer cancel() + + path := "/data/ietf-hardware:hardware/component=" + url.PathEscape(data.Radio) + + "/infix-hardware:wifi-radio/channel-survey" + if err := h.RC.CallRPC(ctx, path, nil, &reply); err != nil { + log.Printf("wifi: channel survey %s: %v", data.Radio, err) + data.Error = "Channel survey failed" + var re *restconf.Error + if errors.As(err, &re) && re.Message != "" { + data.Error = re.Message + } + } else if len(reply.Output.Channel) == 0 { + data.Error = "The radio reported no survey data" + } else { + data.SVG = renderSurveySVG(reply.Output.Channel) + } + + if err := h.Template.ExecuteTemplate(w, "wifi-survey", data); err != nil { + log.Printf("wifi: template error: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + } +} + // buildWiFiRadios assembles the WiFiRadio slice from hardware components // and interface data, matching interfaces to their radio by name. func buildWiFiRadios(components []hwComponentWiFiJSON, ifaces []ifaceJSON) []WiFiRadio { @@ -226,6 +258,7 @@ func buildWiFiRadios(components []hwComponentWiFiJSON, ifaces []ifaceJSON) []WiF Driver: r.Driver, Channel: wifiChannelString(r.Channel), Manufacturer: c.MfgName, + Survey: wifiSurveyData{Radio: c.Name}, } // Capability flags: check per-band capabilities; if any band supports @@ -277,11 +310,6 @@ func buildWiFiRadios(components []hwComponentWiFiJSON, ifaces []ifaceJSON) []WiF radio.MaxAP = fmt.Sprintf("%d", r.MaxInterfaces.AP) } - // Generate channel survey SVG if survey data exists. - if r.Survey != nil && len(r.Survey.Channel) > 0 { - radio.SurveySVG = renderSurveySVG(r.Survey.Channel) - } - // Attach wifi interfaces that reference this radio. radio.Interfaces = buildWiFiInterfaces(c.Name, ifaces) diff --git a/src/webui/internal/handlers/wifi_survey_test.go b/src/webui/internal/handlers/wifi_survey_test.go new file mode 100644 index 000000000..9937ca7b6 --- /dev/null +++ b/src/webui/internal/handlers/wifi_survey_test.go @@ -0,0 +1,103 @@ +// SPDX-License-Identifier: MIT + +package handlers + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "infix/webui/internal/restconf" +) + +const surveyActionPath = "/data/ietf-hardware:hardware/component=radio0/infix-hardware:wifi-radio/channel-survey" + +// fakeSurveyAction serves the channel-survey action on radio0 with reply. +func fakeSurveyAction(t *testing.T, reply string, status int) *httptest.Server { + t.Helper() + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != surveyActionPath || r.Method != http.MethodPost { + t.Errorf("unexpected request %s %s", r.Method, r.URL.Path) + http.Error(w, "unexpected", http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "application/yang-data+json") + w.WriteHeader(status) + io.WriteString(w, reply) //nolint:errcheck + })) + t.Cleanup(srv.Close) + return srv +} + +func surveyRequest(t *testing.T, srv *httptest.Server) string { + t.Helper() + h := &WiFiHandler{ + Template: realTemplates(t, nil, "layouts/*.html", "pages/wifi.html"), + RC: restconf.NewClient(srv.URL, true), + } + req := httptest.NewRequest(http.MethodPost, "/wifi/radio0/survey", nil) + req.SetPathValue("name", "radio0") + req = req.WithContext(restconf.ContextWithCredentials(req.Context(), + restconf.Credentials{Username: "admin", Password: "secret"})) + rec := httptest.NewRecorder() + h.Survey(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status %d, want 200", rec.Code) + } + return rec.Body.String() +} + +func TestWiFiSurveyRendersChart(t *testing.T) { + reply := `{"infix-hardware:output":{"channel":[ + {"frequency":2412,"in-use":false,"noise":-92,"active-time":120,"busy-time":15}, + {"frequency":2437,"in-use":true,"noise":-92,"active-time":1000,"busy-time":125, + "receive-time":60,"transmit-time":30}]}}` + out := surveyRequest(t, fakeSurveyAction(t, reply, http.StatusOK)) + + for _, want := range []string{`class="survey-chart"`, "Scan again", `hx-post="/wifi/radio0/survey"`} { + if !strings.Contains(out, want) { + t.Errorf("missing %q in\n%s", want, out) + } + } + if strings.Contains(out, "alert-error") { + t.Errorf("unexpected error in\n%s", out) + } +} + +func TestWiFiSurveyShowsDeviceError(t *testing.T) { + reply := `{"ietf-restconf:errors":{"error":[{"error-type":"application", + "error-tag":"operation-failed", + "error-message":"Channel survey failed: no interface on radio0 to scan with"}]}}` + out := surveyRequest(t, fakeSurveyAction(t, reply, http.StatusInternalServerError)) + + for _, want := range []string{"alert-error", "no interface on radio0", "Scan channels"} { + if !strings.Contains(out, want) { + t.Errorf("missing %q in\n%s", want, out) + } + } + if strings.Contains(out, "survey-chart") { + t.Errorf("chart rendered on error:\n%s", out) + } +} + +func TestWiFiPageOffersSurveyScan(t *testing.T) { + tmpl := realTemplates(t, nil, "layouts/*.html", "pages/wifi.html") + comps := []hwComponentWiFiJSON{{Name: "radio0", WiFiRadio: &wifiRadioHWJSON{Band: "2.4GHz"}}} + data := wifiData{Radios: buildWiFiRadios(comps, nil)} + + var buf strings.Builder + if err := tmpl.ExecuteTemplate(&buf, "content", data); err != nil { + t.Fatalf("render: %v", err) + } + out := buf.String() + for _, want := range []string{`id="wifi-survey-radio0"`, `hx-post="/wifi/radio0/survey"`, "Scan channels"} { + if !strings.Contains(out, want) { + t.Errorf("missing %q in\n%s", want, out) + } + } + if strings.Contains(out, "survey-chart") { + t.Errorf("chart rendered before any scan:\n%s", out) + } +} diff --git a/src/webui/internal/server/server.go b/src/webui/internal/server/server.go index 6f95b8425..3a4cd7fd5 100644 --- a/src/webui/internal/server/server.go +++ b/src/webui/internal/server/server.go @@ -308,6 +308,7 @@ func New( mux.HandleFunc("POST /maintenance/system/datetime", sys.SetDatetime) mux.HandleFunc("GET /routing", routing.Overview) mux.HandleFunc("GET /wifi", wifi.Overview) + mux.HandleFunc("POST /wifi/{name}/survey", wifi.Survey) mux.HandleFunc("GET /hardware", hw.Overview) mux.HandleFunc("GET /vpn", vpn.Overview) mux.HandleFunc("GET /dhcp", dhcp.Overview) diff --git a/src/webui/templates/pages/wifi.html b/src/webui/templates/pages/wifi.html index 9cfdab16c..79d71d4c2 100644 --- a/src/webui/templates/pages/wifi.html +++ b/src/webui/templates/pages/wifi.html @@ -49,14 +49,12 @@ - {{if .SurveySVG}}
Channel Survey
-
- {{.SurveySVG}} +
+ {{template "wifi-survey" .Survey}}
- {{end}} {{range .Interfaces}} @@ -160,3 +158,23 @@ {{end}} {{end}} + +{{define "wifi-survey"}} +
+ {{if .SVG}} + {{.SVG}} + {{else if .Error}} +
{{.Error}}
+ {{else}} +

Scans every channel the radio supports and shows how busy each one is. + The radio leaves its channel for a few seconds, so traffic pauses during the scan.

+ {{end}} +
+
+ +
+{{end}} From fd30da4c76d0ac99d482327cf995f6fcd092a4cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20Walstr=C3=B6m?= Date: Mon, 5 Oct 2026 16:02:12 +0200 Subject: [PATCH 19/38] doc: Document the WiFi channel survey MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Mattias Walström --- doc/ChangeLog.md | 6 ++++++ doc/wifi.md | 26 ++++++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/doc/ChangeLog.md b/doc/ChangeLog.md index 1fbb27501..4f064e168 100644 --- a/doc/ChangeLog.md +++ b/doc/ChangeLog.md @@ -21,6 +21,12 @@ All notable changes to the project are documented in this file. per port, and a Health card listing services that are not running, a pending reboot, and sensor readings. Disk Usage no longer lists the read-only root filesystem +- WiFi channel survey is now on request: `show hardware survey` in the + CLI, a Scan channels button per radio on the WebUI WiFi page, or the + `channel-survey` action on the radio. The survey covers every channel the + radio supports, also while it is connected or serving clients. The + always-on `survey` container under the radio is gone, it only ever held + the operating channel on a busy radio and slowed down every hardware query ### Fixes diff --git a/doc/wifi.md b/doc/wifi.md index 38ca5c313..f92da8295 100644 --- a/doc/wifi.md +++ b/doc/wifi.md @@ -328,6 +328,32 @@ 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. +### Channel Survey + +A channel survey shows how busy each channel is, which helps when picking a +channel for an access point or when a link performs worse than its signal +strength suggests. The radio has to leave its operating channel to measure +the others, so traffic on it pauses for a few seconds. Because of that the +survey only runs when asked for, it is not collected in the background. + +
admin@example:/> show hardware radio0 survey
+
+ +The output lists every channel the radio supports with its noise floor and +utilization, marks the operating channel, and suggests the least busy +channels per band. A radio that has no interface yet can be surveyed too, +which helps when picking a band and channel for it. Add `passive` to listen longer on each channel without sending +probe requests, which gives a steadier utilization reading at the cost of a +longer pause. + +In the WebUI, each radio on the WiFi page has a **Scan channels** button that +draws the same survey as a chart. + +Over NETCONF or RESTCONF the survey is the `channel-survey` action on the +radio's hardware component. Its output is a list of channels keyed by +frequency, with the busy, receive and transmit time out of the total time +the radio spent listening on each. + ## Passphrase Requirements To ensure your connection is secure and compatible with all network From ff5e7fac6b96c32e9fddd52d15772e83a59c305c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20Walstr=C3=B6m?= Date: Mon, 5 Oct 2026 16:02:12 +0200 Subject: [PATCH 20/38] test: Add WiFi channel survey test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Runs the channel-survey action on a station associated to an AP and on the AP itself. Both transports get a call_action_output helper that returns the action output as a dict, lists included. Signed-off-by: Mattias Walström --- test/case/interfaces/wifi.yaml | 3 + .../wifi_channel_survey/Readme.adoc | 1 + .../interfaces/wifi_channel_survey/test.adoc | 34 ++++++++ .../interfaces/wifi_channel_survey/test.py | 86 +++++++++++++++++++ .../wifi_channel_survey/topology.dot | 44 ++++++++++ .../wifi_channel_survey/topology.svg | 68 +++++++++++++++ test/infamy/netconf.py | 29 +++++++ test/infamy/restconf.py | 12 +++ test/infamy/transport.py | 10 +++ test/infamy/wifi.py | 20 +++++ 10 files changed, 307 insertions(+) create mode 120000 test/case/interfaces/wifi_channel_survey/Readme.adoc create mode 100644 test/case/interfaces/wifi_channel_survey/test.adoc create mode 100755 test/case/interfaces/wifi_channel_survey/test.py create mode 100644 test/case/interfaces/wifi_channel_survey/topology.dot create mode 100644 test/case/interfaces/wifi_channel_survey/topology.svg diff --git a/test/case/interfaces/wifi.yaml b/test/case/interfaces/wifi.yaml index 0e5c28195..55efbeaf4 100644 --- a/test/case/interfaces/wifi.yaml +++ b/test/case/interfaces/wifi.yaml @@ -19,3 +19,6 @@ - name: WiFi station set up from a scan-only interface case: wifi_station_from_scan/test.py + +- name: WiFi channel survey on a connected radio + case: wifi_channel_survey/test.py diff --git a/test/case/interfaces/wifi_channel_survey/Readme.adoc b/test/case/interfaces/wifi_channel_survey/Readme.adoc new file mode 120000 index 000000000..ae32c8412 --- /dev/null +++ b/test/case/interfaces/wifi_channel_survey/Readme.adoc @@ -0,0 +1 @@ +test.adoc \ No newline at end of file diff --git a/test/case/interfaces/wifi_channel_survey/test.adoc b/test/case/interfaces/wifi_channel_survey/test.adoc new file mode 100644 index 000000000..44da16e43 --- /dev/null +++ b/test/case/interfaces/wifi_channel_survey/test.adoc @@ -0,0 +1,34 @@ +=== WiFi channel survey on a connected radio + +ifdef::topdoc[:imagesdir: {topdoc}../../test/case/interfaces/wifi_channel_survey] + +==== Description + +A channel survey tells how busy each channel is. Collecting it means +leaving the operating channel, so it is not done in the background but +on request, with the channel-survey action on the radio. The action has +to work on a radio that is in use: here on the ap, which is serving a +station, and on the station, which is associated to the ap. + +Topology: +.... + host ==(mgmt)== ap ))) ~ cell ~ ((( station ==(mgmt)== host +.... + +==== Topology + +image::topology.svg[WiFi channel survey on a connected radio 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 channel 1 and the station on radio0 +. Verify the station associates to the ap over the wifi link +. Run a channel survey on radio0 of the station +. Verify the station's survey reports 2412 MHz as the channel in use +. Verify the station's survey covers more channels than the one in use +. Run a channel survey on radio0 of the ap +. Verify the ap's survey reports 2412 MHz as the channel in use +. Verify the station is still associated to the ap after the surveys + + diff --git a/test/case/interfaces/wifi_channel_survey/test.py b/test/case/interfaces/wifi_channel_survey/test.py new file mode 100755 index 000000000..97d624b1c --- /dev/null +++ b/test/case/interfaces/wifi_channel_survey/test.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +r""" +WiFi channel survey on a connected radio + +A channel survey tells how busy each channel is. Collecting it means +leaving the operating channel, so it is not done in the background but +on request, with the channel-survey action on the radio. The action has +to work on a radio that is in use: here on the ap, which is serving a +station, and on the station, which is associated to the ap. + +Topology: +.... + host ==(mgmt)== ap ))) ~ cell ~ ((( station ==(mgmt)== host +.... +""" +import infamy +import infamy.wifi as wifi +from infamy.util import until, parallel + +SSID = "infix-survey" +PSK = "infixinfix" +FREQ = 2412 # channel 1 + + +with infamy.Test() as test: + with test.step("Set up topology and attach to the ap and the station"): + env = infamy.Env() + ap, station = parallel( + lambda: env.attach("ap", "mgmt"), + lambda: env.attach("station", "mgmt"), + ) + wifi.skip_unless_supported(test, ap, station) + + with test.step("Configure the ap as an Access Point on channel 1 and the station on radio0"): + parallel( + lambda: ap.put_config_dicts({ + "ietf-hardware": {"hardware": {"component": [ + wifi.radio("radio0", band="2.4GHz", channel=1)]}}, + "ietf-keystore": wifi.keystore({"wifi": PSK}), + "ietf-interfaces": {"interfaces": {"interface": [ + wifi.iface("wifi0", "02:00:00:00:00:01", { + "radio": "radio0", + "access-point": { + "ssid": SSID, + "security": {"mode": "wpa2-wpa3-personal", "secret": "wifi"}, + }, + }), + ]}}, + }), + lambda: station.put_config_dicts({ + "ietf-hardware": {"hardware": {"component": [wifi.radio("radio0")]}}, + "ietf-keystore": wifi.keystore({"wifi": PSK}), + "ietf-interfaces": {"interfaces": {"interface": [ + wifi.iface("wifi0", "02:00:00:00:00:02", { + "radio": "radio0", + "station": { + "ssid": SSID, + "security": {"mode": "auto", "secret": "wifi"}, + }, + }), + ]}}, + }), + ) + + with test.step("Verify the station associates to the ap over the wifi link"): + until(lambda: wifi.associated(station, SSID), attempts=60, interval=2) + + with test.step("Run a channel survey on radio0 of the station"): + channels = wifi.channel_survey(station, "radio0") + + with test.step("Verify the station's survey reports 2412 MHz as the channel in use"): + assert wifi.in_use_frequency(channels) == FREQ, channels + + with test.step("Verify the station's survey covers more channels than the one in use"): + assert len(channels) > 1, channels + + with test.step("Run a channel survey on radio0 of the ap"): + channels = wifi.channel_survey(ap, "radio0") + + with test.step("Verify the ap's survey reports 2412 MHz as the channel in use"): + assert wifi.in_use_frequency(channels) == FREQ, channels + + with test.step("Verify the station is still associated to the ap after the surveys"): + until(lambda: wifi.associated(station, SSID), attempts=30, interval=2) + + test.succeed() diff --git a/test/case/interfaces/wifi_channel_survey/topology.dot b/test/case/interfaces/wifi_channel_survey/topology.dot new file mode 100644 index 000000000..4e8fd2123 --- /dev/null +++ b/test/case/interfaces/wifi_channel_survey/topology.dot @@ -0,0 +1,44 @@ +graph "wifi-channel-survey" { + 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 | mgmt2 }", + pos="0,0!", + requires="controller", + ]; + + ap [ + label="{ mgmt | wifi } | ap", + pos="6,2!", + requires="infix", + ]; + + station [ + label="{ mgmt | 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"] +} diff --git a/test/case/interfaces/wifi_channel_survey/topology.svg b/test/case/interfaces/wifi_channel_survey/topology.svg new file mode 100644 index 000000000..f78f26411 --- /dev/null +++ b/test/case/interfaces/wifi_channel_survey/topology.svg @@ -0,0 +1,68 @@ + + + + + + +wifi-channel-survey + + + +host + +host + +mgmt1 + +mgmt2 + + + +ap + +mgmt + +wifi + +ap + + + +host:mgmt1--ap:mgmt + + + + +station + +mgmt + +wifi + +station + + + +host:mgmt2--station:mgmt + + + + +cell + +cell + + + +ap:wifi--cell + + + + +station:wifi--cell + + + + diff --git a/test/infamy/netconf.py b/test/infamy/netconf.py index 23242aebe..566d7a62a 100644 --- a/test/infamy/netconf.py +++ b/test/infamy/netconf.py @@ -441,6 +441,35 @@ def call_action(self, xpath, input_data=None): xml = "" + lyd.print_mem("xml", with_siblings=True, pretty=False) + "" return self.ncc.dispatch(xml) + def call_action_output(self, xpath, input_data=None): + """Call NETCONF action, returning the output as a nested dict""" + reply = self.call_action(xpath, input_data) + xml = reply.xml + if isinstance(xml, str): + xml = xml.encode() + + def to_dict(elem): + if not len(elem): + return (elem.text or "").strip() + out = {} + for child in elem: + name = lxml.etree.QName(child).localname + value = to_dict(child) + if name in out: + if not isinstance(out[name], list): + out[name] = [out[name]] + out[name].append(value) + else: + out[name] = value + return out + + output = to_dict(fromstring(xml)) + # Lists with one entry come back as a dict, lift them to a list + for key, value in output.items(): + if isinstance(value, dict) and key not in ("ok",): + output[key] = [value] + return output + def get_schemas_list(self): schemas = [] data = self.get_dict("/netconf-state") diff --git a/test/infamy/restconf.py b/test/infamy/restconf.py index b23a90690..0df048900 100644 --- a/test/infamy/restconf.py +++ b/test/infamy/restconf.py @@ -542,6 +542,18 @@ def call_action(self, xpath, input_data=None): return response.content + def call_action_output(self, xpath, input_data=None): + """Call RESTCONF action, returning the output as a nested dict""" + content = self.call_action(xpath, input_data) + if not content: + return {} + + data = json.loads(content) + for key, value in data.items(): + if key.endswith(":output"): + return value + return data + def delete_xpath(self, xpath): """Delete XPath from running config""" coverage.track_xpath(xpath) diff --git a/test/infamy/transport.py b/test/infamy/transport.py index 8866d6f4b..d8d36a2d1 100644 --- a/test/infamy/transport.py +++ b/test/infamy/transport.py @@ -75,6 +75,16 @@ def call_action(self, xpath, input_data=None): """ pass + @abstractmethod + def call_action_output(self, xpath, input_data=None): + """Invoke a YANG action at `xpath`, returning its output. + + The output is a nested dict of the action's output nodes, lists + as Python lists. Leaf values are strings on NETCONF and typed + on RESTCONF, compare with str(). + """ + pass + def __getitem__(self, key): if key in self.mapping: return self.mapping[key] diff --git a/test/infamy/wifi.py b/test/infamy/wifi.py index 92eb8473d..c670542ba 100644 --- a/test/infamy/wifi.py +++ b/test/infamy/wifi.py @@ -74,6 +74,26 @@ def wds_link(name, ap, peer, bridge=None, pvid=None): return ifc +def channel_survey(target, radio="radio0", passive=False): + """Run the channel-survey action on radio, return its channel list. + + Each entry has at least a frequency, the operating channel has + in-use set. Values are strings on NETCONF, compare with str(). + """ + xpath = f"/ietf-hardware:hardware/component[name='{radio}']" \ + "/infix-hardware:wifi-radio/channel-survey" + output = target.call_action_output(xpath, {"passive": passive} if passive else None) + return output.get("channel", []) + + +def in_use_frequency(channels): + """Frequency in MHz of the channel marked in-use, or None.""" + for ch in channels: + if str(ch.get("in-use")).lower() == "true": + return int(ch["frequency"]) + return None + + def skip_unless_supported(test, *targets): """Skip the test unless every target advertises the wifi feature.""" for target in targets: From 16cf9f48c7e46a8d4ba73271f44598588911eed8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20Walstr=C3=B6m?= Date: Tue, 6 Oct 2026 10:37:38 +0200 Subject: [PATCH 21/38] confd: wifi: Move the country code to a box-wide setting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The regulatory domain is one setting in the kernel, yet the model asked for a country code per radio, and it only ever reached the kernel once a radio had an interface, through hostapd or wpa_supplicant. A radio without one sat in the world domain, with no 6 GHz and a listen-only 5 GHz band, which is exactly when a channel survey is wanted. Replace the per-radio leaf with hardware/wifi/country-code, default "00", and apply the domain from confd on every change. Access points and mesh points require a real country. Bump confd to 1.11 and migrate existing configurations: the first radio naming a country wins, radios left at "00" set nothing. Signed-off-by: Mattias Walström --- .../etc/factory-config.cfg | 10 ++-- .../bananapi,bpi-r3/etc/factory-config.cfg | 9 ++-- .../etc/factory-config.cfg | 9 ++-- .../etc/factory-config.cfg | 2 +- .../bananapi,bpi-r4/etc/factory-config.cfg | 2 +- .../bananapi,bpi-r64/etc/factory-config.cfg | 8 +-- .../etc/factory-config.cfg | 2 +- .../etc/factory-config.cfg | 36 +------------ .../raspberrypi,400/etc/factory-config.cfg | 7 +-- .../etc/factory-config.cfg | 2 +- src/confd/bin/gen-hardware | 1 - src/confd/configure.ac | 3 +- .../migrate/1.11/10-wifi-country-code.sh | 39 ++++++++++++++ src/confd/share/migrate/1.11/Makefile.am | 2 + src/confd/share/migrate/Makefile.am | 2 +- src/confd/src/hardware.c | 36 +++++++++++-- src/confd/src/if-wifi.c | 13 +++-- src/confd/yang/confd.inc | 2 +- src/confd/yang/confd/infix-hardware.yang | 54 ++++++++++++------- ...05.yang => infix-hardware@2026-10-06.yang} | 0 src/confd/yang/confd/infix-if-wifi.yang | 15 ++++-- ...-02.yang => infix-if-wifi@2026-10-06.yang} | 0 22 files changed, 158 insertions(+), 96 deletions(-) create mode 100644 src/confd/share/migrate/1.11/10-wifi-country-code.sh create mode 100644 src/confd/share/migrate/1.11/Makefile.am rename src/confd/yang/confd/{infix-hardware@2026-10-05.yang => infix-hardware@2026-10-06.yang} (100%) rename src/confd/yang/confd/{infix-if-wifi@2026-10-02.yang => infix-if-wifi@2026-10-06.yang} (100%) diff --git a/board/aarch64/acer-connect-vero-w6m/rootfs/usr/share/product/acer,connect-vero-w/etc/factory-config.cfg b/board/aarch64/acer-connect-vero-w6m/rootfs/usr/share/product/acer,connect-vero-w/etc/factory-config.cfg index d8d42b8f6..eb1e5b9da 100644 --- a/board/aarch64/acer-connect-vero-w6m/rootfs/usr/share/product/acer,connect-vero-w/etc/factory-config.cfg +++ b/board/aarch64/acer-connect-vero-w6m/rootfs/usr/share/product/acer,connect-vero-w/etc/factory-config.cfg @@ -22,7 +22,6 @@ "name": "radio0", "class": "infix-hardware:wifi", "infix-hardware:wifi-radio": { - "country-code": "DE", "band": "2.4GHz", "channel": "auto" } @@ -31,7 +30,6 @@ "name": "radio1", "class": "infix-hardware:wifi", "infix-hardware:wifi-radio": { - "country-code": "DE", "band": "6GHz", "channel": "auto" } @@ -40,12 +38,14 @@ "name": "radio2", "class": "infix-hardware:wifi", "infix-hardware:wifi-radio": { - "country-code": "DE", "band": "5GHz", "channel": "auto" } } - ] + ], + "infix-hardware:wifi": { + "country-code": "DE" + } }, "ietf-interfaces:interfaces": { "interface": [ @@ -457,7 +457,7 @@ ] }, "infix-meta:meta": { - "version": "1.10" + "version": "1.11" }, "infix-services:mdns": { "enabled": true diff --git a/board/aarch64/bananapi-bpi-r3/rootfs/usr/share/product/bananapi,bpi-r3/etc/factory-config.cfg b/board/aarch64/bananapi-bpi-r3/rootfs/usr/share/product/bananapi,bpi-r3/etc/factory-config.cfg index 799bb7aef..c018183b3 100644 --- a/board/aarch64/bananapi-bpi-r3/rootfs/usr/share/product/bananapi,bpi-r3/etc/factory-config.cfg +++ b/board/aarch64/bananapi-bpi-r3/rootfs/usr/share/product/bananapi,bpi-r3/etc/factory-config.cfg @@ -22,7 +22,6 @@ "name": "radio0", "class": "infix-hardware:wifi", "infix-hardware:wifi-radio": { - "country-code": "DE", "band": "2.4GHz", "channel": "auto" } @@ -31,12 +30,14 @@ "name": "radio1", "class": "infix-hardware:wifi", "infix-hardware:wifi-radio": { - "country-code": "DE", "band": "5GHz", "channel": "auto" } } - ] + ], + "infix-hardware:wifi": { + "country-code": "DE" + } }, "ietf-interfaces:interfaces": { "interface": [ @@ -450,7 +451,7 @@ ] }, "infix-meta:meta": { - "version": "1.10" + "version": "1.11" }, "infix-services:mdns": { "enabled": true diff --git a/board/aarch64/bananapi-bpi-r3/rootfs/usr/share/product/bananapi,bpi-r3mini/etc/factory-config.cfg b/board/aarch64/bananapi-bpi-r3/rootfs/usr/share/product/bananapi,bpi-r3mini/etc/factory-config.cfg index a4b6256cb..182c6f535 100644 --- a/board/aarch64/bananapi-bpi-r3/rootfs/usr/share/product/bananapi,bpi-r3mini/etc/factory-config.cfg +++ b/board/aarch64/bananapi-bpi-r3/rootfs/usr/share/product/bananapi,bpi-r3mini/etc/factory-config.cfg @@ -22,7 +22,6 @@ "name": "radio0", "class": "infix-hardware:wifi", "infix-hardware:wifi-radio": { - "country-code": "DE", "band": "2.4GHz", "channel": "auto" } @@ -31,12 +30,14 @@ "name": "radio1", "class": "infix-hardware:wifi", "infix-hardware:wifi-radio": { - "country-code": "DE", "band": "5GHz", "channel": "auto" } } - ] + ], + "infix-hardware:wifi": { + "country-code": "DE" + } }, "ietf-interfaces:interfaces": { "interface": [ @@ -416,7 +417,7 @@ ] }, "infix-meta:meta": { - "version": "1.10" + "version": "1.11" }, "infix-services:mdns": { "enabled": true diff --git a/board/aarch64/bananapi-bpi-r4/rootfs/usr/share/product/bananapi,bpi-r4-2g5/etc/factory-config.cfg b/board/aarch64/bananapi-bpi-r4/rootfs/usr/share/product/bananapi,bpi-r4-2g5/etc/factory-config.cfg index 78c9a69bd..c95c464d5 100644 --- a/board/aarch64/bananapi-bpi-r4/rootfs/usr/share/product/bananapi,bpi-r4-2g5/etc/factory-config.cfg +++ b/board/aarch64/bananapi-bpi-r4/rootfs/usr/share/product/bananapi,bpi-r4-2g5/etc/factory-config.cfg @@ -374,7 +374,7 @@ ] }, "infix-meta:meta": { - "version": "1.10" + "version": "1.11" }, "infix-services:mdns": { "enabled": true diff --git a/board/aarch64/bananapi-bpi-r4/rootfs/usr/share/product/bananapi,bpi-r4/etc/factory-config.cfg b/board/aarch64/bananapi-bpi-r4/rootfs/usr/share/product/bananapi,bpi-r4/etc/factory-config.cfg index d5fa27042..7b6d66926 100644 --- a/board/aarch64/bananapi-bpi-r4/rootfs/usr/share/product/bananapi,bpi-r4/etc/factory-config.cfg +++ b/board/aarch64/bananapi-bpi-r4/rootfs/usr/share/product/bananapi,bpi-r4/etc/factory-config.cfg @@ -366,7 +366,7 @@ ] }, "infix-meta:meta": { - "version": "1.10" + "version": "1.11" }, "infix-services:mdns": { "enabled": true diff --git a/board/aarch64/bananapi-bpi-r64/rootfs/usr/share/product/bananapi,bpi-r64/etc/factory-config.cfg b/board/aarch64/bananapi-bpi-r64/rootfs/usr/share/product/bananapi,bpi-r64/etc/factory-config.cfg index af616e23c..632787cbd 100644 --- a/board/aarch64/bananapi-bpi-r64/rootfs/usr/share/product/bananapi,bpi-r64/etc/factory-config.cfg +++ b/board/aarch64/bananapi-bpi-r64/rootfs/usr/share/product/bananapi,bpi-r64/etc/factory-config.cfg @@ -15,12 +15,14 @@ "name": "radio0", "class": "infix-hardware:wifi", "infix-hardware:wifi-radio": { - "country-code": "DE", "band": "2.4GHz", "channel": "auto" } } - ] + ], + "infix-hardware:wifi": { + "country-code": "DE" + } }, "ietf-interfaces:interfaces": { "interface": [ @@ -408,7 +410,7 @@ ] }, "infix-meta:meta": { - "version": "1.10" + "version": "1.11" }, "infix-services:mdns": { "enabled": true diff --git a/board/aarch64/friendlyarm-nanopi-r2s/rootfs/usr/share/product/friendlyarm,nanopi-r2s/etc/factory-config.cfg b/board/aarch64/friendlyarm-nanopi-r2s/rootfs/usr/share/product/friendlyarm,nanopi-r2s/etc/factory-config.cfg index ea01bc806..3ef1d99aa 100644 --- a/board/aarch64/friendlyarm-nanopi-r2s/rootfs/usr/share/product/friendlyarm,nanopi-r2s/etc/factory-config.cfg +++ b/board/aarch64/friendlyarm-nanopi-r2s/rootfs/usr/share/product/friendlyarm,nanopi-r2s/etc/factory-config.cfg @@ -350,7 +350,7 @@ ] }, "infix-meta:meta": { - "version": "1.10" + "version": "1.11" }, "infix-services:mdns": { "enabled": true diff --git a/board/aarch64/raspberrypi-rpi64/rootfs/usr/share/product/raspberrypi,4-model-b/etc/factory-config.cfg b/board/aarch64/raspberrypi-rpi64/rootfs/usr/share/product/raspberrypi,4-model-b/etc/factory-config.cfg index 33998c5d0..09f87bfd0 100644 --- a/board/aarch64/raspberrypi-rpi64/rootfs/usr/share/product/raspberrypi,4-model-b/etc/factory-config.cfg +++ b/board/aarch64/raspberrypi-rpi64/rootfs/usr/share/product/raspberrypi,4-model-b/etc/factory-config.cfg @@ -13,10 +13,7 @@ }, { "name": "radio0", - "class": "infix-hardware:wifi", - "infix-hardware:wifi-radio": { - "country-code": "00" - } + "class": "infix-hardware:wifi" } ] }, @@ -245,35 +242,6 @@ } ] }, - "infix-schedule:schedules": { - "schedule": [ - { - "name": "nightly", - "description": "Every night at 03:00", - "recurrence": { - "frequency": "ietf-schedule:daily", - "byhour": [ - 3 - ] - } - }, - { - "name": "weekly", - "description": "Sunday nights at 03:00", - "recurrence": { - "frequency": "ietf-schedule:weekly", - "byday": [ - { - "weekday": "sunday" - } - ], - "byhour": [ - 3 - ] - } - } - ] - }, "ntp": { "enabled": true, "server": [ @@ -298,7 +266,7 @@ "infix-system:motd-banner": "Li0tLS0tLS0uCnwgIC4gLiAgfCBJbmZpeCBPUyDigJQgSW1tdXRhYmxlLkZyaWVuZGx5LlNlY3VyZQp8LS4gdiAuLXwgaHR0cHM6Ly9rZXJuZWxraXQub3JnCictJy0tLSctJwo=" }, "infix-meta:meta": { - "version": "1.10" + "version": "1.11" }, "infix-services:mdns": { "enabled": true diff --git a/board/aarch64/raspberrypi-rpi64/rootfs/usr/share/product/raspberrypi,400/etc/factory-config.cfg b/board/aarch64/raspberrypi-rpi64/rootfs/usr/share/product/raspberrypi,400/etc/factory-config.cfg index 0094635ba..4a4f80e5f 100644 --- a/board/aarch64/raspberrypi-rpi64/rootfs/usr/share/product/raspberrypi,400/etc/factory-config.cfg +++ b/board/aarch64/raspberrypi-rpi64/rootfs/usr/share/product/raspberrypi,400/etc/factory-config.cfg @@ -27,10 +27,7 @@ }, { "name": "radio0", - "class": "infix-hardware:wifi", - "infix-hardware:wifi-radio": { - "country-code": "00" - } + "class": "infix-hardware:wifi" } ] }, @@ -283,7 +280,7 @@ "infix-system:motd-banner": "Li0tLS0tLS0uCnwgIC4gLiAgfCBJbmZpeCBPUyDigJQgSW1tdXRhYmxlLkZyaWVuZGx5LlNlY3VyZQp8LS4gdiAuLXwgaHR0cHM6Ly9rZXJuZWxraXQub3JnCictJy0tLSctJwo=" }, "infix-meta:meta": { - "version": "1.10" + "version": "1.11" }, "infix-services:mdns": { "enabled": true diff --git a/board/arm/raspberrypi-rpi2/rootfs/usr/share/product/raspberrypi,2-model-b/etc/factory-config.cfg b/board/arm/raspberrypi-rpi2/rootfs/usr/share/product/raspberrypi,2-model-b/etc/factory-config.cfg index 75cca7917..4b4eb4cc0 100644 --- a/board/arm/raspberrypi-rpi2/rootfs/usr/share/product/raspberrypi,2-model-b/etc/factory-config.cfg +++ b/board/arm/raspberrypi-rpi2/rootfs/usr/share/product/raspberrypi,2-model-b/etc/factory-config.cfg @@ -254,7 +254,7 @@ "infix-system:motd-banner": "Li0tLS0tLS0uCnwgIC4gLiAgfCBJbmZpeCBPUyDigJQgSW1tdXRhYmxlLkZyaWVuZGx5LlNlY3VyZQp8LS4gdiAuLXwgaHR0cHM6Ly9rZXJuZWxraXQub3JnCictJy0tLSctJwo=" }, "infix-meta:meta": { - "version": "1.10" + "version": "1.11" }, "infix-services:mdns": { "enabled": true diff --git a/src/confd/bin/gen-hardware b/src/confd/bin/gen-hardware index 8679bc730..3870a7302 100755 --- a/src/confd/bin/gen-hardware +++ b/src/confd/bin/gen-hardware @@ -53,7 +53,6 @@ gen_radio() "name": "$radio", "class": "infix-hardware:wifi", "infix-hardware:wifi-radio": { - "country-code": "00", "band": "$band", "channel": "auto" } diff --git a/src/confd/configure.ac b/src/confd/configure.ac index 28f0c6e2f..1a13a1ffb 100644 --- a/src/confd/configure.ac +++ b/src/confd/configure.ac @@ -1,6 +1,6 @@ AC_PREREQ(2.61) # confd version is same as system YANG model version, step on breaking changes -AC_INIT([confd], [1.10], [https://github.com/kernelkit/infix/issues]) +AC_INIT([confd], [1.11], [https://github.com/kernelkit/infix/issues]) AM_INIT_AUTOMAKE(1.11 foreign subdir-objects) AM_SILENT_RULES(yes) @@ -24,6 +24,7 @@ AC_CONFIG_FILES([ share/migrate/1.8/Makefile share/migrate/1.9/Makefile share/migrate/1.10/Makefile + share/migrate/1.11/Makefile yang/Makefile yang/confd/Makefile yang/test-mode/Makefile diff --git a/src/confd/share/migrate/1.11/10-wifi-country-code.sh b/src/confd/share/migrate/1.11/10-wifi-country-code.sh new file mode 100644 index 000000000..899fa173a --- /dev/null +++ b/src/confd/share/migrate/1.11/10-wifi-country-code.sh @@ -0,0 +1,39 @@ +#!/bin/sh +# Move the WiFi country code from each radio to the box-wide +# hardware/wifi/country-code leaf. +# +# The regulatory domain is one setting in the kernel, so a code per radio +# was misleading, and it was only ever applied once the radio had an +# interface. The first radio naming a real country wins. A radio left +# at the world domain ("00") sets nothing, that is the default without +# the leaf. + +file=$1 +temp=${file}.tmp + +# The radios could disagree, the kernel has only one domain, so whichever +# daemon started last won. Say which one the migration keeps. +codes=$(jq -r '[ (.["ietf-hardware:hardware"].component // [])[] + | .["infix-hardware:wifi-radio"]?["country-code"]? // empty + | select(. != "00") ] | unique | join(" ")' "$file") +case $codes in + *" "*) + logger -t migrate -p user.warning \ + "$file: radios have different country codes ($codes), keeping the first radio's" + ;; +esac + +jq ' + (.["ietf-hardware:hardware"].component // []) + | [ .[] | .["infix-hardware:wifi-radio"]?["country-code"]? // empty + | select(. != "00") ] + | .[0] as $cc + | input + | if $cc != null then + .["ietf-hardware:hardware"]["infix-hardware:wifi"] = {"country-code": $cc} + else . end + | if .["ietf-hardware:hardware"].component then + .["ietf-hardware:hardware"].component |= + [ .[] | del(.["infix-hardware:wifi-radio"]["country-code"]) ] + else . end +' "$file" "$file" > "$temp" && mv "$temp" "$file" diff --git a/src/confd/share/migrate/1.11/Makefile.am b/src/confd/share/migrate/1.11/Makefile.am new file mode 100644 index 000000000..f1a52eed3 --- /dev/null +++ b/src/confd/share/migrate/1.11/Makefile.am @@ -0,0 +1,2 @@ +migratedir = $(pkgdatadir)/migrate/1.11 +dist_migrate_DATA = 10-wifi-country-code.sh diff --git a/src/confd/share/migrate/Makefile.am b/src/confd/share/migrate/Makefile.am index 755ac16a4..2f73b3c33 100644 --- a/src/confd/share/migrate/Makefile.am +++ b/src/confd/share/migrate/Makefile.am @@ -1,2 +1,2 @@ -SUBDIRS = 1.0 1.1 1.2 1.3 1.4 1.5 1.6 1.7 1.8 1.9 1.10 +SUBDIRS = 1.0 1.1 1.2 1.3 1.4 1.5 1.6 1.7 1.8 1.9 1.10 1.11 migratedir = $(pkgdatadir)/migrate diff --git a/src/confd/src/hardware.c b/src/confd/src/hardware.c index 8c3a241f9..051c2585c 100644 --- a/src/confd/src/hardware.c +++ b/src/confd/src/hardware.c @@ -900,9 +900,18 @@ static void wifi_build_vht_capab(char *out, size_t sz, unsigned int vht_cap, int } } +/* The box-wide WiFi country code, NULL when none is configured */ +static const char *wifi_country_code(struct lyd_node *config) +{ + struct lyd_node *wifi; + + wifi = lydx_get_descendant(config, "hardware", "wifi", NULL); + return lydx_get_cattr(wifi, "country-code"); +} + /* Helper: Write radio-specific configuration */ static void wifi_gen_radio_config(FILE *hostapd, const char *radio_name, - struct lyd_node *radio_node) + struct lyd_node *radio_node, struct lyd_node *config) { const char *country, *channel, *band, *width; unsigned int ht_cap = 0, vht_cap = 0; @@ -911,7 +920,7 @@ static void wifi_gen_radio_config(FILE *hostapd, const char *radio_name, int ch = 0; bool legacy_rates, he = false; - country = lydx_get_cattr(radio_node, "country-code"); + country = wifi_country_code(config); band = lydx_get_cattr(radio_node, "band"); channel = lydx_get_cattr(radio_node, "channel"); width = lydx_get_cattr(radio_node, "channel-width"); @@ -1168,7 +1177,7 @@ static int wifi_gen_aps_on_radio(const char *radio_name, struct lyd_node *cifs, fprintf(hostapd, "\n"); /* Radio-specific configuration */ - wifi_gen_radio_config(hostapd, radio_name, radio_node); + wifi_gen_radio_config(hostapd, radio_name, radio_node, config); /* Add BSS sections for secondary APs (multi-SSID) */ for (i = 1; i < ap_count; i++) { @@ -1243,6 +1252,7 @@ int hardware_change(sr_session_ctx_t *session, struct lyd_node *config, struct l sr_event_t event, struct confd *confd) { struct lyd_node *difs = NULL, *dif = NULL; + int country_changed = 0; int rc = SR_ERR_OK; int gps_changed = 0; int wifi_changed = 0; @@ -1250,7 +1260,22 @@ int hardware_change(sr_session_ctx_t *session, struct lyd_node *config, struct l if (!lydx_find_xpathf(diff, XPATH_BASE_)) return SR_ERR_OK; - difs = lydx_get_descendant(diff, "hardware", "component", NULL); + /* + * The country code is one setting for every radio. Apply the + * regulatory domain here, not from hostapd, so a radio without an + * interface is in the right domain too. Every radio's hostapd + * config carries the code, so visit them all when it changes. + */ + if (lydx_get_xpathf(diff, XPATH_BASE_ "/infix-hardware:wifi/country-code")) { + country_changed = 1; + if (event == SR_EV_DONE) + systemf("iw reg set %s", wifi_country_code(config) ?: "00"); + } + + if (country_changed) + difs = lydx_get_descendant(config, "hardware", "component", NULL); + else + difs = lydx_get_descendant(diff, "hardware", "component", NULL); LYX_LIST_FOR_EACH(difs, dif, "component") { enum lydx_op op; @@ -1267,6 +1292,9 @@ int hardware_change(sr_session_ctx_t *session, struct lyd_node *config, struct l continue; class = lydx_get_cattr(cif, "class"); + if (country_changed && strcmp(class, "infix-hardware:wifi") && + !lydx_get_xpathf(diff, XPATH_BASE_ "/component[name='%s']", name)) + continue; /* Handle USB components */ if (!strcmp(class, "infix-hardware:usb")) { diff --git a/src/confd/src/if-wifi.c b/src/confd/src/if-wifi.c index a6e9f1f86..7a5697a1e 100644 --- a/src/confd/src/if-wifi.c +++ b/src/confd/src/if-wifi.c @@ -237,8 +237,8 @@ int wifi_gen_settings(sr_session_ctx_t *session, struct lyd_node *dif, */ int wifi_gen_station(struct lyd_node *cif) { - const char *ifname, *ssid, *secret_name, *security_mode, *radio; - struct lyd_node *security, *secret_node, *radio_node, *station, *wifi; + const char *ifname, *ssid, *secret_name, *security_mode; + struct lyd_node *security, *secret_node, *station, *wifi; const char *bssid = NULL; unsigned char *secret = NULL; FILE *wpa_supplicant = NULL; @@ -252,7 +252,6 @@ int wifi_gen_station(struct lyd_node *cif) if (!wifi) return SR_ERR_OK; - radio = lydx_get_cattr(wifi, "radio"); station = lydx_get_child(wifi, "station"); if (station) { ssid = lydx_get_cattr(station, "ssid"); @@ -268,9 +267,8 @@ int wifi_gen_station(struct lyd_node *cif) secret_name = NULL; } - radio_node = lydx_get_xpathf(cif, - "/ietf-hardware:hardware/component[name='%s']/infix-hardware:wifi-radio", radio); - country = lydx_get_cattr(radio_node, "country-code"); + country = lydx_get_cattr(lydx_get_xpathf(cif, "/ietf-hardware:hardware/infix-hardware:wifi"), + "country-code"); if (secret_name && strcmp(security_mode, "disabled") != 0) { const char *b64; @@ -439,7 +437,8 @@ int wifi_gen_mesh(struct lyd_node *cif) radio_node = lydx_get_xpathf(cif, "/ietf-hardware:hardware/component[name='%s']/infix-hardware:wifi-radio", radio); - country = lydx_get_cattr(radio_node, "country-code"); + country = lydx_get_cattr(lydx_get_xpathf(cif, "/ietf-hardware:hardware/infix-hardware:wifi"), + "country-code"); band = lydx_get_cattr(radio_node, "band"); width = lydx_get_cattr(radio_node, "channel-width"); channel = atoi(lydx_get_cattr(radio_node, "channel") ? : "0"); diff --git a/src/confd/yang/confd.inc b/src/confd/yang/confd.inc index 9c0f0ecac..18fd1a425 100644 --- a/src/confd/yang/confd.inc +++ b/src/confd/yang/confd.inc @@ -27,7 +27,7 @@ MODULES=( "infix-syslog@2026-09-24.yang" "iana-hardware@2018-03-13.yang" "ietf-hardware@2018-03-13.yang -e hardware-state -e hardware-sensor" - "infix-hardware@2026-10-05.yang" + "infix-hardware@2026-10-06.yang" "ieee802-dot1q-types@2022-10-29.yang" "infix-ip@2026-04-28.yang" "infix-if-type@2026-01-07.yang" diff --git a/src/confd/yang/confd/infix-hardware.yang b/src/confd/yang/confd/infix-hardware.yang index d292f90a3..6cf098440 100644 --- a/src/confd/yang/confd/infix-hardware.yang +++ b/src/confd/yang/confd/infix-hardware.yang @@ -21,6 +21,12 @@ module infix-hardware { contact "kernelkit@googlegroups.com"; description "Vital Product Data augmentation of ieee-hardware and deviations."; + revision 2026-10-06 { + description "Move the WiFi country code from each radio to the box-wide + hardware/wifi container, the regulatory domain is one setting."; + reference "internal"; + } + revision 2026-10-05 { description "Replace the WiFi radio survey container with the channel-survey action, survey data is collected on request only."; @@ -204,6 +210,34 @@ module infix-hardware { deviation "/iehw:hardware/iehw:component/iehw:asset-id" { deviate not-supported; } + augment "/iehw:hardware" { + description + "Settings shared by every WiFi radio in the system."; + + container wifi { + if-feature wifi; + description + "WiFi settings that apply to all radios."; + + leaf country-code { + type iwcc:country-code; + default "00"; + description + "Two-letter ISO 3166-1 country code, the regulatory domain for + every WiFi radio in the system. It decides which channels + and transmit power levels the radios may use. + + The default '00' is the world domain: no 6 GHz, the 5 GHz + band listen-only, and no access point or mesh point can be + configured. + + Examples: 'US', 'DE', 'JP'. + + WARNING: Incorrect values may violate local laws and regulations."; + } + } + } + augment "/iehw:hardware/iehw:component" { leaf phys-address { type yang:phys-address; @@ -292,22 +326,6 @@ module infix-hardware { a WiFi radio (class 'ih:wifi'). WiFi radios are physical devices that can host multiple virtual WiFi interfaces (APs or Stations)."; - leaf country-code { - type iwcc:country-code; - mandatory true; - description - "Two-letter ISO 3166-1 country code for regulatory compliance. - - Sets the regulatory domain for this radio, determining: - - Allowed channels and frequencies - - Maximum transmit power - - DFS (Dynamic Frequency Selection) requirements - - Examples: 'US', 'DE', 'JP'. - - WARNING: Incorrect values may violate local laws and regulations."; - } - leaf channel { type union { type uint16 { @@ -328,7 +346,7 @@ module infix-hardware { Channel availability depends on: - Configured band (2.4/5/6 GHz) - - Regulatory domain (country-code) + - Regulatory domain (hardware wifi country-code) - Hardware capabilities Common channels: @@ -445,7 +463,7 @@ module infix-hardware { Channels depend on: - Hardware capabilities - - Configured country-code + - The system's WiFi country code - Band selection This list reflects actual usable channels after applying diff --git a/src/confd/yang/confd/infix-hardware@2026-10-05.yang b/src/confd/yang/confd/infix-hardware@2026-10-06.yang similarity index 100% rename from src/confd/yang/confd/infix-hardware@2026-10-05.yang rename to src/confd/yang/confd/infix-hardware@2026-10-06.yang diff --git a/src/confd/yang/confd/infix-if-wifi.yang b/src/confd/yang/confd/infix-if-wifi.yang index a88518eaa..8af593ad3 100644 --- a/src/confd/yang/confd/infix-if-wifi.yang +++ b/src/confd/yang/confd/infix-if-wifi.yang @@ -49,6 +49,13 @@ submodule infix-if-wifi { - Security: WPA2/WPA3 with keystore integration - Operational state: Connection status, RSSI, client lists"; + revision 2026-10-06 { + description + "Access point and mesh point require the box-wide WiFi country code, + the per-radio country-code leaf is gone."; + reference "internal"; + } + revision 2026-10-02 { description "Add 4-address (WDS) support: wds-link mode for access point side @@ -411,8 +418,8 @@ submodule infix-if-wifi { error-message "Parent radio must have 'channel' configured for Access Point mode"; } - must "/iehw:hardware/iehw:component[iehw:name = current()/../radio]/ih:wifi-radio/ih:country-code != '00'" { - error-message "Country code '00' (world regulatory domain) is not allowed for Access Point mode. Please configure a specific country code on the radio."; + must "/iehw:hardware/ih:wifi/ih:country-code != '00'" { + error-message "Set the WiFi country code (hardware wifi country-code) before configuring an access point, the world domain '00' is not allowed."; } leaf ssid { @@ -718,8 +725,8 @@ submodule infix-if-wifi { error-message "Parent radio must have 'channel' configured for mesh mode"; } - must "/iehw:hardware/iehw:component[iehw:name = current()/../radio]/ih:wifi-radio/ih:country-code != '00'" { - error-message "Country code '00' is not allowed for mesh mode."; + must "/iehw:hardware/ih:wifi/ih:country-code != '00'" { + error-message "Set the WiFi country code (hardware wifi country-code) before configuring a mesh point, the world domain '00' is not allowed."; } must "not(/if:interfaces/if:interface[wifi/access-point][wifi/radio = current()/../radio])" { diff --git a/src/confd/yang/confd/infix-if-wifi@2026-10-02.yang b/src/confd/yang/confd/infix-if-wifi@2026-10-06.yang similarity index 100% rename from src/confd/yang/confd/infix-if-wifi@2026-10-02.yang rename to src/confd/yang/confd/infix-if-wifi@2026-10-06.yang From 010d324709a025da2f4733c171d17f863f003235 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20Walstr=C3=B6m?= Date: Tue, 6 Oct 2026 10:37:38 +0200 Subject: [PATCH 22/38] webui: Use the box-wide WiFi country code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The country moves from each radio's form to a WiFi card on the hardware page. The interface editor and the wizard still offer it next to the radio fields, so a first radio can be set up in one go, but write it to the shared leaf. Signed-off-by: Mattias Walström --- .../internal/handlers/configure_hardware.go | 71 +++++++++++++------ .../internal/handlers/configure_interfaces.go | 71 +++++++++++-------- src/webui/internal/handlers/dashboard.go | 16 +++++ src/webui/internal/handlers/wifi.go | 1 - src/webui/internal/handlers/wifi_save_test.go | 20 ++++++ src/webui/internal/server/server.go | 1 + src/webui/static/js/app.js | 9 +-- .../templates/pages/configure-hardware.html | 40 ++++++----- .../templates/pages/configure-interfaces.html | 10 +-- 9 files changed, 155 insertions(+), 84 deletions(-) diff --git a/src/webui/internal/handlers/configure_hardware.go b/src/webui/internal/handlers/configure_hardware.go index bb4a4d94c..621907cfb 100644 --- a/src/webui/internal/handlers/configure_hardware.go +++ b/src/webui/internal/handlers/configure_hardware.go @@ -31,6 +31,9 @@ import ( const hwRoot = "/ietf-hardware:hardware" const hwCandPath = candidatePath + hwRoot +// hwWiFiCountryPath is the schema path of the box-wide WiFi country code. +const hwWiFiCountryPath = hwRoot + "/infix-hardware:wifi/country-code" + // hwCompCfgRow is one configured component in the main table. Per-class // fields are populated only for the matching Class. IsUSB/IsWiFi/IsGPS // spare the template from dispatching on stringly-typed Class slugs. @@ -47,18 +50,15 @@ type hwCompCfgRow struct { Unlocked bool // admin-state == "unlocked" // WiFi-specific. - CountryCode string - Channel string - Band string + Channel string + Band string // Schema descriptions carried per-row so the fold-out forms are // self-contained — Go templates can't pass extra arguments through // {{template}}. - CountryOptions []schema.IdentityOption BandOptions []schema.IdentityOption DescDescription string DescAdminState string - DescCountry string DescBand string DescChannel string } @@ -79,7 +79,10 @@ type cfgHardwarePageData struct { AvailableUSB []hwAvailable AvailableWiFi []hwAvailable AvailableGPS []hwAvailable + // Box-wide WiFi country code, one setting for every radio. + CountryCode string CountryOptions []schema.IdentityOption + DescCountry string BandOptions []schema.IdentityOption Desc map[string]string Error string @@ -112,13 +115,13 @@ func (h *ConfigureHardwareHandler) Overview(w http.ResponseWriter, r *http.Reque compPath := "/ietf-hardware:hardware/component" radioPath := compPath + "/infix-hardware:wifi-radio" data.Desc = map[string]string{ - "description": schema.DescriptionOf(mgr, compPath+"/description"), - "admin-state": schema.DescriptionOf(mgr, compPath+"/state/admin-state"), - "country-code": schema.DescriptionOf(mgr, radioPath+"/country-code"), - "channel": schema.DescriptionOf(mgr, radioPath+"/channel"), - "band": schema.DescriptionOf(mgr, radioPath+"/band"), + "description": schema.DescriptionOf(mgr, compPath+"/description"), + "admin-state": schema.DescriptionOf(mgr, compPath+"/state/admin-state"), + "channel": schema.DescriptionOf(mgr, radioPath+"/channel"), + "band": schema.DescriptionOf(mgr, radioPath+"/band"), } - data.CountryOptions = schema.OptionsFor(mgr, radioPath+"/country-code") + data.DescCountry = schema.DescriptionOf(mgr, hwWiFiCountryPath) + data.CountryOptions = schema.OptionsFor(mgr, hwWiFiCountryPath) data.BandOptions = schema.OptionsFor(mgr, radioPath+"/band") } @@ -146,6 +149,7 @@ func (h *ConfigureHardwareHandler) Overview(w http.ResponseWriter, r *http.Reque log.Printf("configure hardware: operational fetch: %v", operErr) } + data.CountryCode = cfgWrap.wifiCountryCode() configured := make(map[string]bool, len(cfgWrap.Hardware.Component)) for _, c := range cfgWrap.Hardware.Component { configured[c.Name] = true @@ -209,13 +213,10 @@ func (h *ConfigureHardwareHandler) buildRow(c hwComponentJSON, class string, dat row.Unlocked = c.State != nil && c.State.AdminState == adminStateUnlocked case classWiFi: row.IsWiFi = true - row.CountryOptions = data.CountryOptions row.BandOptions = data.BandOptions - row.DescCountry = data.Desc["country-code"] row.DescBand = data.Desc["band"] row.DescChannel = data.Desc["channel"] if c.WiFiRadio != nil { - row.CountryCode = c.WiFiRadio.CountryCode row.Band = c.WiFiRadio.Band row.Channel = wifiChannelString(c.WiFiRadio.Channel) } @@ -341,6 +342,35 @@ func (h *ConfigureHardwareHandler) CreateHardware(w http.ResponseWriter, r *http renderSavedRedirect(w, name+" added", "/configure/hardware") } +// SaveWiFiCountry writes or clears the box-wide WiFi country code. +// POST /configure/hardware/wifi +func (h *ConfigureHardwareHandler) SaveWiFiCountry(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + if err := h.putWiFiCountry(r.Context(), r.FormValue("country-code")); err != nil { + log.Printf("configure hardware wifi country: %v", err) + renderSaveError(w, err) + return + } + renderSaved(w, "WiFi country code saved") +} + +// putWiFiCountry sets the box-wide country code, or removes it when +// country is empty so the radios fall back to the world domain default. +func (h *ConfigureHardwareHandler) putWiFiCountry(ctx context.Context, country string) error { + country = strings.TrimSpace(country) + path := hwCandPath + "/infix-hardware:wifi/country-code" + if country == "" { + if err := h.RC.Delete(ctx, path); err != nil && !restconf.IsNotFound(err) { + return err + } + return nil + } + return h.RC.Put(ctx, path, map[string]any{"infix-hardware:country-code": country}) +} + func (h *ConfigureHardwareHandler) putAdminState(ctx context.Context, name, state string) error { return h.RC.Put(ctx, hwComponentPath(name)+"/state/admin-state", map[string]any{"ietf-hardware:admin-state": state}) @@ -351,15 +381,12 @@ func (h *ConfigureHardwareHandler) putWiFiRadio(ctx context.Context, name string map[string]any{"infix-hardware:wifi-radio": radio}) } -// parseWiFiRadio builds the wifi-radio body from form fields. Country -// code is mandatory; band and channel are optional and only included -// when non-empty so we don't clobber YANG defaults with empty strings. +// parseWiFiRadio builds the wifi-radio body from form fields. Band and +// channel are optional and only included when non-empty so we don't +// clobber YANG defaults with empty strings; the result may be empty, +// which still creates the presence container. func parseWiFiRadio(r *http.Request) (map[string]any, error) { - country := strings.TrimSpace(r.FormValue("country-code")) - if country == "" { - return nil, fmt.Errorf("country code is required") - } - radio := map[string]any{"country-code": country} + radio := map[string]any{} if band := strings.TrimSpace(r.FormValue("band")); band != "" { radio["band"] = band } diff --git a/src/webui/internal/handlers/configure_interfaces.go b/src/webui/internal/handlers/configure_interfaces.go index 86c8e12f1..44474ae6a 100644 --- a/src/webui/internal/handlers/configure_interfaces.go +++ b/src/webui/internal/handlers/configure_interfaces.go @@ -351,7 +351,7 @@ func (h *ConfigureInterfacesHandler) Overview(w http.ResponseWriter, r *http.Req // by the inline "+ New radio" form in the WiFi fieldset and by // the WiFi interface row's mirrored radio editor. const radioSchemaPath = "/ietf-hardware:hardware/component/infix-hardware:wifi-radio" - data.WizardCountryOptions = schema.OptionsFor(mgr, radioSchemaPath+"/country-code") + data.WizardCountryOptions = schema.OptionsFor(mgr, hwWiFiCountryPath) data.WizardBandOptions = schema.OptionsFor(mgr, radioSchemaPath+"/band") data.CountryOptions = data.WizardCountryOptions data.BandOptions = data.WizardBandOptions @@ -481,7 +481,7 @@ func (h *ConfigureInterfacesHandler) Overview(w http.ResponseWriter, r *http.Req // Configured WiFi radios live in candidate (so the picker reflects // uncommitted edits the user is in the middle of). Available radios // = detected (operational class=wifi) - those already in candidate. - data.WizardWifiRadios = buildWifiRadioOptions(hwCand.Hardware.Component) + data.WizardWifiRadios = buildWifiRadioOptions(hwCand.Hardware.Component, hwCand.wifiCountryCode()) configuredRadioNames := make(map[string]bool, len(data.WizardWifiRadios)) for _, r := range data.WizardWifiRadios { configuredRadioNames[r.Name] = true @@ -507,7 +507,7 @@ func (h *ConfigureInterfacesHandler) Overview(w http.ResponseWriter, r *http.Req } // Populate the mirrored radio editor for WiFi interface rows from // the already-fetched candidate hardware tree (no extra fetch). - radios := indexWifiRadios(hwCand.Hardware.Component) + radios := indexWifiRadios(hwCand.Hardware.Component, hwCand.wifiCountryCode()) for i := range data.Interfaces { row := &data.Interfaces[i] if !row.IsWifi || row.WiFi == nil || row.WiFi.Radio == "" { @@ -1074,11 +1074,7 @@ func (h *ConfigureInterfacesHandler) WizardCreateRadio(w http.ResponseWriter, r renderSaveError(w, fmt.Errorf("radio name is required")) return } - if country == "" { - renderSaveError(w, fmt.Errorf("country code is required")) - return - } - radio := map[string]any{"country-code": country} + radio := map[string]any{} if band != "" { radio["band"] = band } @@ -1100,6 +1096,16 @@ func (h *ConfigureInterfacesHandler) WizardCreateRadio(w http.ResponseWriter, r "class": "infix-hardware:wifi", "infix-hardware:wifi-radio": radio, } + // The country code is one setting for every radio; the form offers + // it here so a first radio can be set up in one go. + if country != "" { + cc := map[string]any{"infix-hardware:country-code": country} + if err := h.RC.Put(r.Context(), candidatePath+hwWiFiCountryPath, cc); err != nil { + log.Printf("wizard create radio %q: country: %v", name, err) + renderSaveError(w, err) + return + } + } body := map[string]any{"ietf-hardware:component": []map[string]any{comp}} path := candidatePath + "/ietf-hardware:hardware/component=" + url.PathEscape(name) if err := h.RC.Put(r.Context(), path, body); err != nil { @@ -1115,7 +1121,7 @@ func (h *ConfigureInterfacesHandler) renderRadioPicker(w http.ResponseWriter, r if err := h.RC.Get(r.Context(), candidatePath+"/ietf-hardware:hardware", &hwCand); err != nil { log.Printf("wizard radio refresh: %v", err) } - radios := buildWifiRadioOptions(hwCand.Hardware.Component) + radios := buildWifiRadioOptions(hwCand.Hardware.Component, hwCand.wifiCountryCode()) if !containsRadioName(radios, selected) { // Race / fetch failure — surface the new radio anyway. radios = append([]wifiRadioOption{{Name: selected, Label: selected}}, radios...) @@ -1804,25 +1810,29 @@ func (h *ConfigureInterfacesHandler) SaveWifi(w http.ResponseWriter, r *http.Req // Both halves go in one patch, so a rejected save leaves the // candidate untouched. p := restconf.NewYangPatch(candidatePath) - // Radio half, only when the form actually carried a country (the - // wifi-radio container's mandatory leaf). Without it parseWiFiRadio - // would reject a form whose user only touched the WiFi side and left - // the radio fields untouched-empty. + // Hardware half: the radio's band and channel when the form carried + // them, and the box-wide country code when it was picked. A form + // whose user only touched the WiFi side leaves hardware alone. + hw := map[string]any{} if strings.TrimSpace(r.FormValue("country-code")) != "" { + hw["infix-hardware:wifi"] = map[string]any{ + "country-code": strings.TrimSpace(r.FormValue("country-code")), + } + } + if strings.TrimSpace(r.FormValue("band")) != "" || strings.TrimSpace(r.FormValue("channel")) != "" { rc, err := parseWiFiRadio(r) if err != nil { renderSaveError(w, err) return } - p.Merge(hwRoot, map[string]any{ - "ietf-hardware:hardware": map[string]any{ - "component": []map[string]any{{ - "name": radio, - "class": "infix-hardware:wifi", - "infix-hardware:wifi-radio": rc, - }}, - }, - }) + hw["component"] = []map[string]any{{ + "name": radio, + "class": "infix-hardware:wifi", + "infix-hardware:wifi-radio": rc, + }} + } + if len(hw) > 0 { + p.Merge(hwRoot, map[string]any{"ietf-hardware:hardware": hw}) } wifi := map[string]any{"radio": radio, mode: leaf} p.Replace(ifaceTarget(name)+"/infix-interfaces:wifi", map[string]any{"infix-interfaces:wifi": wifi}) @@ -1977,9 +1987,9 @@ func ifaceTarget(name string) string { // indexWifiRadios picks WiFi radio components out of the hardware // candidate tree and returns the minimal subset the WiFi interface -// editor mirrors (name, country, band, channel). Components without -// a wifi-radio container are skipped. -func indexWifiRadios(comps []hwComponentJSON) map[string]*ifaceRadioMirror { +// editor mirrors (name, band, channel, plus the box-wide country). +// Components without a wifi-radio container are skipped. +func indexWifiRadios(comps []hwComponentJSON, country string) map[string]*ifaceRadioMirror { out := make(map[string]*ifaceRadioMirror, len(comps)) for _, c := range comps { if c.WiFiRadio == nil { @@ -1987,7 +1997,7 @@ func indexWifiRadios(comps []hwComponentJSON) map[string]*ifaceRadioMirror { } m := &ifaceRadioMirror{ Name: c.Name, - CountryCode: c.WiFiRadio.CountryCode, + CountryCode: country, Band: c.WiFiRadio.Band, } if ch, ok := c.WiFiRadio.Channel.(float64); ok && ch > 0 { @@ -2658,9 +2668,10 @@ type wifiRadioOption struct { // configured WiFi radios (class=wifi with a wifi-radio container present // in running config) and returns picker entries with a label that hints // at band/channel/country. APReady reflects the YANG must-clauses on -// access-point — band, channel, and country-code all set, with country -// != "00" (world regulatory domain is rejected for AP mode). -func buildWifiRadioOptions(comps []hwComponentJSON) []wifiRadioOption { +// access-point — band and channel set on the radio and the box-wide +// country code set to something other than "00" (the world regulatory +// domain is rejected for AP mode). +func buildWifiRadioOptions(comps []hwComponentJSON, country string) []wifiRadioOption { var out []wifiRadioOption for _, c := range comps { if shortClass(c.Class) != classWiFi || c.WiFiRadio == nil { @@ -2669,7 +2680,7 @@ func buildWifiRadioOptions(comps []hwComponentJSON) []wifiRadioOption { ch := wifiChannelString(c.WiFiRadio.Channel) opt := wifiRadioOption{ Name: c.Name, - Country: c.WiFiRadio.CountryCode, + Country: country, Band: c.WiFiRadio.Band, Channel: ch, } diff --git a/src/webui/internal/handlers/dashboard.go b/src/webui/internal/handlers/dashboard.go index 2e476e640..7f78a0553 100644 --- a/src/webui/internal/handlers/dashboard.go +++ b/src/webui/internal/handlers/dashboard.go @@ -410,10 +410,26 @@ const ( type hardwareWrapper struct { Hardware struct { + WiFi *hwWiFiJSON `json:"infix-hardware:wifi"` Component []hwComponentJSON `json:"component"` } `json:"ietf-hardware:hardware"` } +// hwWiFiJSON is the box-wide WiFi container, one country code for every +// radio. +type hwWiFiJSON struct { + CountryCode string `json:"country-code"` +} + +// wifiCountryCode returns the box-wide WiFi country code, "00" (the +// world domain, the YANG default) when none is configured. +func (w hardwareWrapper) wifiCountryCode() string { + if w.Hardware.WiFi == nil || w.Hardware.WiFi.CountryCode == "" { + return "00" + } + return w.Hardware.WiFi.CountryCode +} + type hwComponentJSON struct { Name string `json:"name"` Class string `json:"class"` diff --git a/src/webui/internal/handlers/wifi.go b/src/webui/internal/handlers/wifi.go index 5695bd161..e2aba1072 100644 --- a/src/webui/internal/handlers/wifi.go +++ b/src/webui/internal/handlers/wifi.go @@ -24,7 +24,6 @@ type wifiMaxIfJSON struct { } type wifiRadioHWJSON struct { - CountryCode string `json:"country-code"` // ISO 3166-1, rw Channel interface{} `json:"channel"` // uint16 or "auto", rw Band string `json:"band"` // rw Frequency int `json:"frequency"` // MHz, operational diff --git a/src/webui/internal/handlers/wifi_save_test.go b/src/webui/internal/handlers/wifi_save_test.go index ede183884..230bb5ac9 100644 --- a/src/webui/internal/handlers/wifi_save_test.go +++ b/src/webui/internal/handlers/wifi_save_test.go @@ -77,3 +77,23 @@ func TestSaveWifiBadRadioWritesNothing(t *testing.T) { t.Fatalf("candidate written despite form error: %+v", rc.Patches) } } + +func TestSaveWifiCountryGoesToBoxWideLeaf(t *testing.T) { + w, rc := postSaveWifi(t, url.Values{ + "mode": {"access-point"}, "radio": {"radio0"}, "ssid": {"lab"}, + "sec-mode": {"wpa2-wpa3-personal"}, "secret": {"psk"}, + "country-code": {"SE"}, "band": {"5GHz"}, "channel": {"36"}, + }) + if w.Code != http.StatusOK { + t.Fatalf("status %d: %s", w.Code, w.Body.String()) + } + hw := rc.Patches[0].Edits[0].Value.(map[string]any)["ietf-hardware:hardware"].(map[string]any) + wifi, _ := hw["infix-hardware:wifi"].(map[string]any) + if wifi["country-code"] != "SE" { + t.Errorf("country not on the box-wide leaf: %v", hw) + } + comps := hw["component"].([]map[string]any) + if _, has := comps[0]["infix-hardware:wifi-radio"].(map[string]any)["country-code"]; has { + t.Errorf("country still on the radio: %v", comps[0]) + } +} diff --git a/src/webui/internal/server/server.go b/src/webui/internal/server/server.go index 3a4cd7fd5..2d95fe8dc 100644 --- a/src/webui/internal/server/server.go +++ b/src/webui/internal/server/server.go @@ -409,6 +409,7 @@ func New( mux.HandleFunc("GET /configure/hardware", cfgHw.Overview) mux.HandleFunc("POST /configure/hardware", cfgHw.CreateHardware) mux.HandleFunc("POST /configure/hardware/usb/{name}", cfgHw.SaveUSBPort) + mux.HandleFunc("POST /configure/hardware/wifi", cfgHw.SaveWiFiCountry) mux.HandleFunc("POST /configure/hardware/wifi/{name}", cfgHw.SaveWiFiRadio) mux.HandleFunc("POST /configure/hardware/gps/{name}", cfgHw.SaveGPS) mux.HandleFunc("DELETE /configure/hardware/{name}", cfgHw.DeleteComponent) diff --git a/src/webui/static/js/app.js b/src/webui/static/js/app.js index b946bb495..934121459 100644 --- a/src/webui/static/js/app.js +++ b/src/webui/static/js/app.js @@ -1411,8 +1411,7 @@ function setBlockEnabled(el, on) { }); // Configure > Hardware "+ Add hardware" picker: sync the hidden class - // input from the selected option's data-class and reveal class-specific - // fields (currently WiFi country-code). + // input from the selected option's data-class. document.addEventListener('change', function (e) { var sel = e.target.closest && e.target.closest('#add-hw-picker'); if (!sel) return; @@ -1420,12 +1419,6 @@ function setBlockEnabled(el, on) { var cls = (opt && opt.getAttribute('data-class')) || ''; var classInput = document.getElementById('add-hw-class'); if (classInput) classInput.value = cls; - var wifi = document.getElementById('add-hw-wifi-fields'); - // setBlockEnabled, not .hidden: the country select is inside this - // nested hidden span, so opening the row left it disabled. - if (wifi) setBlockEnabled(wifi, cls === 'wifi'); - var country = document.getElementById('add-hw-wifi-country'); - if (country) country.required = (cls === 'wifi'); }); })(); diff --git a/src/webui/templates/pages/configure-hardware.html b/src/webui/templates/pages/configure-hardware.html index 7096185f4..0ca6e44d1 100644 --- a/src/webui/templates/pages/configure-hardware.html +++ b/src/webui/templates/pages/configure-hardware.html @@ -20,6 +20,27 @@ {{end}}
+ {{if .CountryOptions}} +
+
WiFi
+
+ + + + + + +
Country code{{template "field-info" .DescCountry}} + + + + +
+
+
+ {{end}}
Hardware unlocked {{else}}locked{{end}} {{else if $c.IsWiFi}} - {{$c.CountryCode}}{{if $c.Band}} · {{$c.Band}}{{end}} + {{if $c.Band}}{{$c.Band}}{{else}}configured{{end}} {{else if $c.IsGPS}} configured {{else}}—{{end}} @@ -109,13 +130,6 @@ {{end}} - - @@ -199,16 +213,6 @@ hx-confirm="Reset description to its YANG default?">{{template "icon-reset"}} - - Country code{{template "field-info" .DescCountry}} - - - - - Band{{template "field-info" .DescBand}} diff --git a/src/webui/templates/pages/configure-interfaces.html b/src/webui/templates/pages/configure-interfaces.html index 5e4109ee7..45fd18cc1 100644 --- a/src/webui/templates/pages/configure-interfaces.html +++ b/src/webui/templates/pages/configure-interfaces.html @@ -437,8 +437,8 @@

WiFi

Country code - + {{range $.WizardCountryOptions}}{{end}} @@ -1544,10 +1544,10 @@

Add Interface

- Country code{{template "field-info" "ISO 3166-1 country code — required. Regulators tie the radio's channel/power allowance to this; '00' (world) cannot be used in Access Point mode."}} + Country code{{template "field-info" "ISO 3166-1 country code, one setting shared by every radio in the system. Regulators tie the channel/power allowance to this; '00' (world) cannot be used in Access Point mode."}} - + {{range .WizardCountryOptions}}{{end}} From 26f4d4d24c469f5762ba4013c9d66f4d862a7299 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20Walstr=C3=B6m?= Date: Tue, 6 Oct 2026 10:37:38 +0200 Subject: [PATCH 23/38] test: Use the box-wide WiFi country code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Mattias Walström --- test/case/interfaces/wifi_ap_multi_station/test.py | 5 ++--- test/case/interfaces/wifi_ap_station_2dut/test.py | 5 ++--- test/case/interfaces/wifi_band_steering/test.py | 7 +++---- test/case/interfaces/wifi_channel_survey/test.py | 5 ++--- test/case/interfaces/wifi_mesh_roaming/test.py | 8 +++----- test/case/interfaces/wifi_station_from_scan/test.py | 5 ++--- test/case/interfaces/wifi_wds_link_2dut/test.py | 5 ++--- test/case/interfaces/wifi_wds_repeater/test.py | 8 +++----- test/infamy/wifi.py | 12 ++++++++++-- 9 files changed, 29 insertions(+), 31 deletions(-) diff --git a/test/case/interfaces/wifi_ap_multi_station/test.py b/test/case/interfaces/wifi_ap_multi_station/test.py index 6d2bb6e88..f9c19aa65 100755 --- a/test/case/interfaces/wifi_ap_multi_station/test.py +++ b/test/case/interfaces/wifi_ap_multi_station/test.py @@ -78,8 +78,7 @@ def leased(dut): with test.step("Configure the ap as an Access Point on radio0"): ap.put_config_dicts({ - "ietf-hardware": {"hardware": {"component": [ - wifi.radio("radio0", band="2.4GHz", channel=1)]}}, + "ietf-hardware": wifi.hardware(wifi.radio("radio0", band="2.4GHz", channel=1)), "ietf-keystore": wifi.keystore({"wifi": PSK}), "ietf-interfaces": {"interfaces": {"interface": [ wifi.iface("wifi0", AP_MAC, { @@ -99,7 +98,7 @@ def leased(dut): with test.step("Configure the stations on radio0"): def configure_station(mac, dut): dut.put_config_dicts({ - "ietf-hardware": {"hardware": {"component": [wifi.radio("radio0")]}}, + "ietf-hardware": wifi.hardware(wifi.radio("radio0")), "ietf-keystore": wifi.keystore({"wifi": PSK}), "ietf-interfaces": {"interfaces": {"interface": [ wifi.iface("wifi0", mac, { diff --git a/test/case/interfaces/wifi_ap_station_2dut/test.py b/test/case/interfaces/wifi_ap_station_2dut/test.py index 8a596f501..d7f2130bc 100755 --- a/test/case/interfaces/wifi_ap_station_2dut/test.py +++ b/test/case/interfaces/wifi_ap_station_2dut/test.py @@ -53,8 +53,7 @@ with test.step("Configure the ap as an Access Point and the station on radio0"): parallel( lambda: ap.put_config_dicts({ - "ietf-hardware": {"hardware": {"component": [ - wifi.radio("radio0", band="2.4GHz", channel=1)]}}, + "ietf-hardware": wifi.hardware(wifi.radio("radio0", band="2.4GHz", channel=1)), "ietf-keystore": wifi.keystore({"wifi": PSK}), "ietf-interfaces": {"interfaces": {"interface": [ # hwsim defaults every radio0 to 02:00:00:00:00:00, so the AP @@ -74,7 +73,7 @@ }]}}, }), lambda: station.put_config_dicts({ - "ietf-hardware": {"hardware": {"component": [wifi.radio("radio0")]}}, + "ietf-hardware": wifi.hardware(wifi.radio("radio0")), "ietf-keystore": wifi.keystore({"wifi": PSK}), "ietf-interfaces": {"interfaces": {"interface": [ wifi.iface("wifi0", "02:00:00:00:00:02", { diff --git a/test/case/interfaces/wifi_band_steering/test.py b/test/case/interfaces/wifi_band_steering/test.py index 3eab7d0f5..cb93b3a58 100755 --- a/test/case/interfaces/wifi_band_steering/test.py +++ b/test/case/interfaces/wifi_band_steering/test.py @@ -83,10 +83,9 @@ def ap_bss(name, radio_name, bssid): # dedicated band-steering cell (cell2) in test/virt/quad. parallel( lambda: ap.put_config_dicts({ - "ietf-hardware": {"hardware": {"component": [ + "ietf-hardware": wifi.hardware( wifi.radio("radio2", band="2.4GHz", channel=1), - wifi.radio("radio3", band="5GHz", channel=36), - ]}}, + wifi.radio("radio3", band="5GHz", channel=36)), "ietf-keystore": wifi.keystore({"wifi": PSK}), "ietf-interfaces": {"interfaces": {"interface": [ {"name": "br0", "type": "infix-if-type:bridge", "enabled": True, @@ -104,7 +103,7 @@ def ap_bss(name, radio_name, bssid): # (cell2). No band/channel pinned: the one radio scans both bands and # lets band steering decide where it lands. lambda: client.put_config_dicts({ - "ietf-hardware": {"hardware": {"component": [wifi.radio("radio2")]}}, + "ietf-hardware": wifi.hardware(wifi.radio("radio2")), "ietf-keystore": wifi.keystore({"wifi": PSK}), "ietf-interfaces": {"interfaces": {"interface": [ wifi.iface("wifi0", CLIENT_MAC, { diff --git a/test/case/interfaces/wifi_channel_survey/test.py b/test/case/interfaces/wifi_channel_survey/test.py index 97d624b1c..94091bdc1 100755 --- a/test/case/interfaces/wifi_channel_survey/test.py +++ b/test/case/interfaces/wifi_channel_survey/test.py @@ -34,8 +34,7 @@ with test.step("Configure the ap as an Access Point on channel 1 and the station on radio0"): parallel( lambda: ap.put_config_dicts({ - "ietf-hardware": {"hardware": {"component": [ - wifi.radio("radio0", band="2.4GHz", channel=1)]}}, + "ietf-hardware": wifi.hardware(wifi.radio("radio0", band="2.4GHz", channel=1)), "ietf-keystore": wifi.keystore({"wifi": PSK}), "ietf-interfaces": {"interfaces": {"interface": [ wifi.iface("wifi0", "02:00:00:00:00:01", { @@ -48,7 +47,7 @@ ]}}, }), lambda: station.put_config_dicts({ - "ietf-hardware": {"hardware": {"component": [wifi.radio("radio0")]}}, + "ietf-hardware": wifi.hardware(wifi.radio("radio0")), "ietf-keystore": wifi.keystore({"wifi": PSK}), "ietf-interfaces": {"interfaces": {"interface": [ wifi.iface("wifi0", "02:00:00:00:00:02", { diff --git a/test/case/interfaces/wifi_mesh_roaming/test.py b/test/case/interfaces/wifi_mesh_roaming/test.py index a2c1ecc06..c85c3c1ff 100755 --- a/test/case/interfaces/wifi_mesh_roaming/test.py +++ b/test/case/interfaces/wifi_mesh_roaming/test.py @@ -96,10 +96,9 @@ def gw_config(mesh_mac, ap_mac, uplink=None): "infix-interfaces:bridge-port": {"bridge": "br0"}, }) return { - "ietf-hardware": {"hardware": {"component": [ + "ietf-hardware": wifi.hardware( wifi.radio("radio0", band="5GHz", channel=36), - wifi.radio("radio1", band="2.4GHz", channel=1), - ]}}, + wifi.radio("radio1", band="2.4GHz", channel=1)), "ietf-keystore": wifi.keystore(SECRETS), "ietf-interfaces": {"interfaces": {"interface": interfaces}}, } @@ -136,8 +135,7 @@ def gw_config(mesh_mac, ap_mac, uplink=None): # 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": [ - wifi.radio("radio1", band="2.4GHz", channel=1)]}}, + "ietf-hardware": wifi.hardware(wifi.radio("radio1", band="2.4GHz", channel=1)), "ietf-keystore": wifi.keystore(SECRETS), "ietf-interfaces": {"interfaces": {"interface": [ wifi.iface("wifi0", CLIENT_MAC, { diff --git a/test/case/interfaces/wifi_station_from_scan/test.py b/test/case/interfaces/wifi_station_from_scan/test.py index ab4ba6fe5..9af9a916f 100755 --- a/test/case/interfaces/wifi_station_from_scan/test.py +++ b/test/case/interfaces/wifi_station_from_scan/test.py @@ -37,8 +37,7 @@ with test.step("Configure the ap with access point 'infix-scan' and a DHCP server on 192.168.21.1"): ap.put_config_dicts({ - "ietf-hardware": {"hardware": {"component": [ - wifi.radio("radio0", band="2.4GHz", channel=1)]}}, + "ietf-hardware": wifi.hardware(wifi.radio("radio0", band="2.4GHz", channel=1)), "ietf-keystore": wifi.keystore({"wifi": PSK}), "ietf-interfaces": {"interfaces": {"interface": [ wifi.iface("wifi0", "02:00:00:00:00:01", { @@ -57,7 +56,7 @@ with test.step("Configure wifi0 on the station with only radio0, scan-only mode"): station.put_config_dicts({ - "ietf-hardware": {"hardware": {"component": [wifi.radio("radio0")]}}, + "ietf-hardware": wifi.hardware(wifi.radio("radio0")), "ietf-interfaces": {"interfaces": {"interface": [ wifi.iface("wifi0", "02:00:00:00:00:02", {"radio": "radio0"}, ipv4={"infix-dhcp-client:dhcp": {}}), diff --git a/test/case/interfaces/wifi_wds_link_2dut/test.py b/test/case/interfaces/wifi_wds_link_2dut/test.py index caee52810..bdc09f8dd 100755 --- a/test/case/interfaces/wifi_wds_link_2dut/test.py +++ b/test/case/interfaces/wifi_wds_link_2dut/test.py @@ -39,8 +39,7 @@ def root_config(): return { - "ietf-hardware": {"hardware": {"component": [ - wifi.radio("radio0", band="2.4GHz", channel=1)]}}, + "ietf-hardware": wifi.hardware(wifi.radio("radio0", band="2.4GHz", channel=1)), "ietf-keystore": wifi.keystore({"wifi": PSK}), "ietf-interfaces": {"interfaces": {"interface": [ { @@ -76,7 +75,7 @@ def root_config(): def satellite_config(): return { - "ietf-hardware": {"hardware": {"component": [wifi.radio("radio0")]}}, + "ietf-hardware": wifi.hardware(wifi.radio("radio0")), "ietf-keystore": wifi.keystore({"wifi": PSK}), "ietf-interfaces": {"interfaces": {"interface": [ { diff --git a/test/case/interfaces/wifi_wds_repeater/test.py b/test/case/interfaces/wifi_wds_repeater/test.py index 68311ade1..6cb525a8a 100755 --- a/test/case/interfaces/wifi_wds_repeater/test.py +++ b/test/case/interfaces/wifi_wds_repeater/test.py @@ -54,8 +54,7 @@ def root_config(uplink): return { - "ietf-hardware": {"hardware": {"component": [ - wifi.radio("radio0", band="2.4GHz", channel=1)]}}, + "ietf-hardware": wifi.hardware(wifi.radio("radio0", band="2.4GHz", channel=1)), "ietf-keystore": wifi.keystore(SECRETS), "ietf-interfaces": {"interfaces": {"interface": [ {"name": "br0", "type": "infix-if-type:bridge", "enabled": True, @@ -93,8 +92,7 @@ def root_config(uplink): def repeater_config(): return { - "ietf-hardware": {"hardware": {"component": [ - wifi.radio("radio0", band="2.4GHz", channel=1)]}}, + "ietf-hardware": wifi.hardware(wifi.radio("radio0", band="2.4GHz", channel=1)), "ietf-keystore": wifi.keystore(SECRETS), "ietf-interfaces": {"interfaces": {"interface": [ {"name": "br0", "type": "infix-if-type:bridge", "enabled": True, @@ -131,7 +129,7 @@ def repeater_config(): def station_config(mac, ssid, secret): return { - "ietf-hardware": {"hardware": {"component": [wifi.radio("radio0")]}}, + "ietf-hardware": wifi.hardware(wifi.radio("radio0")), "ietf-keystore": wifi.keystore(SECRETS), "ietf-interfaces": {"interfaces": {"interface": [ wifi.iface("wifi0", mac, { diff --git a/test/infamy/wifi.py b/test/infamy/wifi.py index c670542ba..dcf5046d3 100644 --- a/test/infamy/wifi.py +++ b/test/infamy/wifi.py @@ -6,9 +6,9 @@ import base64 -def radio(name, country="SE", band=None, channel=None): +def radio(name, band=None, channel=None): """ietf-hardware component for a WiFi radio.""" - settings = {"country-code": country} + settings = {} if band: settings["band"] = band if channel is not None: @@ -20,6 +20,14 @@ def radio(name, country="SE", band=None, channel=None): } +def hardware(*radios, country="SE"): + """ietf-hardware config: the radios plus the box-wide WiFi country code.""" + return {"hardware": { + "infix-hardware:wifi": {"country-code": country}, + "component": list(radios), + }} + + def keystore(secrets): """ietf-keystore config with a passphrase entry per {name: psk}.""" return {"keystore": {"symmetric-keys": {"symmetric-key": [ From b64febe5e3c9f50f84f971e48bbf86fb27ac0b86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20Walstr=C3=B6m?= Date: Tue, 6 Oct 2026 10:37:38 +0200 Subject: [PATCH 24/38] doc: Document the box-wide WiFi country code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Mattias Walström --- board/aarch64/raspberrypi-rpi64/README.md | 10 +++--- doc/ChangeLog.md | 5 +++ doc/wifi.md | 41 ++++++++++++----------- 3 files changed, 32 insertions(+), 24 deletions(-) diff --git a/board/aarch64/raspberrypi-rpi64/README.md b/board/aarch64/raspberrypi-rpi64/README.md index 711bc2eb4..68151d68b 100644 --- a/board/aarch64/raspberrypi-rpi64/README.md +++ b/board/aarch64/raspberrypi-rpi64/README.md @@ -118,19 +118,19 @@ Then configure the WiFi interface using the keystore reference: ``` admin@infix:/> configure +admin@infix:/config/> set hardware wifi country-code US admin@infix:/config/> edit interface wifi0 admin@infix:/config/interface/wifi0/> set ipv4 dhcp-client -admin@infix:/config/interface/wifi0/> set wifi ssid YourNetworkName -admin@infix:/config/interface/wifi0/> set wifi secret mywifi -admin@infix:/config/interface/wifi0/> set wifi country-code US +admin@infix:/config/interface/wifi0/> set wifi station ssid YourNetworkName +admin@infix:/config/interface/wifi0/> set wifi station security secret mywifi admin@infix:/config/interface/wifi0/> leave ``` > [!NOTE] > The WiFi password (8-63 characters) is stored securely in the keystore as > `mywifi` (or any name you choose), which is then referenced in the WiFi -> configuration. The country-code must match your location for regulatory -> compliance (e.g., US, SE, DE, JP). +> configuration. The country code is one setting for all radios and must +> match your location for regulatory compliance (e.g., US, SE, DE, JP). ### Touch Screen Support diff --git a/doc/ChangeLog.md b/doc/ChangeLog.md index 4f064e168..f3ee80c99 100644 --- a/doc/ChangeLog.md +++ b/doc/ChangeLog.md @@ -21,6 +21,11 @@ All notable changes to the project are documented in this file. per port, and a Health card listing services that are not running, a pending reboot, and sensor readings. Disk Usage no longer lists the read-only root filesystem +- The WiFi country code is now one setting for the whole system, `hardware + wifi country-code`, instead of one per radio. It defaults to the world + domain and is applied as soon as it is set, so a radio without an + interface is in the right regulatory domain too. Existing configurations + are migrated, the first radio's code wins - WiFi channel survey is now on request: `show hardware survey` in the CLI, a Scan channels button per radio on the WebUI WiFi page, or the `channel-survey` action on the radio. The survey covers every channel the diff --git a/doc/wifi.md b/doc/wifi.md index f92da8295..a1e37eff4 100644 --- a/doc/wifi.md +++ b/doc/wifi.md @@ -95,14 +95,21 @@ Radios are automatically discovered and named `radio0`, `radio1`, etc. ### Country Code ⚠ -The radio defaults to "00" for World domain, but some systems may ship with a -factory default country code (typically "DE" for the BPi-R3). +The country code is one setting for all radios in the system, since the +regulatory domain is one setting in the kernel. It defaults to "00", the +world domain: no 6 GHz, listen-only on 5 GHz, and no access point or mesh +point can be configured. Some systems ship with a factory default +(typically "DE" for the BPi-R3). + +
admin@example:/> configure
+admin@example:/config/> set hardware wifi country-code DE
+admin@example:/config/> leave
+
> [!IMPORTANT] Legal notice! -> The `country-code` setting is **legally required** and determines -> which WiFi channels and power levels are permitted in your -> location. Using an incorrect country code may violate local wireless -> regulations. +> The country code is **legally required** and determines which WiFi +> channels and power levels are permitted in your location. Using an +> incorrect country code may violate local wireless regulations. **Common country codes, see [ISO 3166-1 alpha-2][1] for the complete list**: @@ -123,13 +130,13 @@ factory default country code (typically "DE" for the BPi-R3). ### Basic Radio Setup -Configure the radio with channel, power, and regulatory domain. +Configure the radio with band and channel. The country code above covers +all radios. **For Station (client) mode:**
admin@example:/> configure
 admin@example:/config/> edit hardware component radio0 wifi-radio
-admin@example:/config/hardware/component/radio0/wifi-radio/> set country-code DE
 admin@example:/config/hardware/component/radio0/wifi-radio/> leave
 
@@ -137,7 +144,6 @@ admin@example:/config/hardware/component/radio0/wifi-radio/> leave
admin@example:/> configure
 admin@example:/config/> edit hardware component radio0 wifi-radio
-admin@example:/config/hardware/component/radio0/wifi-radio/> set country-code DE
 admin@example:/config/hardware/component/radio0/wifi-radio/> set band 5GHz
 admin@example:/config/hardware/component/radio0/wifi-radio/> set channel 36
 admin@example:/config/hardware/component/radio0/wifi-radio/> set channel-width 80MHz
@@ -146,8 +152,6 @@ admin@example:/config/hardware/component/radio0/wifi-radio/> leave
 
 **Key radio parameters:**
 
-- `country-code`: Two-letter [ISO 3166-1 alpha-2][1] code, determines allowed
-  channels and maximum power. Examples: US, DE, GB, SE, FR, JP.  
   **⚠ Must match your physical location for legal compliance! ⚠**
 - `band`: 2.4GHz, 5GHz, or 6GHz (required for AP mode). Automatically enables
   appropriate WiFi standards:
@@ -273,7 +277,6 @@ interface referencing it:
 
 
admin@example:/> configure
 admin@example:/config/> edit hardware component radio0 wifi-radio
-admin@example:/config/hardware/component/radio0/wifi-radio/> set country-code DE
 admin@example:/config/hardware/component/radio0/wifi-radio/> leave
 
@@ -499,8 +502,8 @@ IoT devices, or segregating traffic into different VLANs. **Step 1: Configure the radio** (shared by all APs)
admin@example:/> configure
+admin@example:/config/> set hardware wifi country-code DE
 admin@example:/config/> edit hardware component radio0 wifi-radio
-admin@example:/config/hardware/component/radio0/wifi-radio/> set country-code DE
 admin@example:/config/hardware/component/radio0/wifi-radio/> set band 5GHz
 admin@example:/config/hardware/component/radio0/wifi-radio/> set channel 36
 admin@example:/config/hardware/component/radio0/wifi-radio/> leave
@@ -770,15 +773,15 @@ vendors without proprietary components.
 
 ### Mesh configuration
 
-A mesh point requires the radio to have `band`, `channel`, and a valid
-`country-code` configured. Mesh and AP modes cannot coexist on the same
-radio.
+A mesh point requires the radio to have `band` and `channel` configured,
+and the system a country code.  Mesh and AP modes cannot coexist on the
+same radio.
 
 **Step 1: Configure the radio**
 
 
admin@example:/> configure
+admin@example:/config/> set hardware wifi country-code DE
 admin@example:/config/> edit hardware component radio1 wifi-radio
-admin@example:/config/hardware/component/radio1/wifi-radio/> set country-code DE
 admin@example:/config/hardware/component/radio1/wifi-radio/> set band 5GHz
 admin@example:/config/hardware/component/radio1/wifi-radio/> set channel 36
 admin@example:/config/hardware/component/radio1/wifi-radio/> leave
@@ -957,8 +960,8 @@ If issues arise, try the following troubleshooting steps:
    the passphrase matches the network password
 3. **Review logs**: Check system logs with `show log` for Wi-Fi related
    errors
-4. **Regulatory compliance**: Ensure the country-code on the radio
-   matches your location
+4. **Regulatory compliance**: Ensure the WiFi country code matches your
+   location
 5. **Hardware detection**: Confirm the WiFi radio appears in `show
    hardware`
 

From dd6af00aa0d96d7a33ecca3ad0faa09fc890fbd9 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Mattias=20Walstr=C3=B6m?= 
Date: Tue, 6 Oct 2026 10:56:13 +0200
Subject: [PATCH 25/38] yang: Limit WiFi country codes to the regulatory
 database
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

The kernel ignores a country code the wireless regulatory database has
no rules for, so 69 of the ISO codes validated fine and then left the
radios in the world domain without a word.  Keep the 181 countries the
database knows plus '00', say what the world domain means, and drop the
CAPWAP references that never applied.

Signed-off-by: Mattias Walström 
---
 .../yang/confd/infix-wifi-country-codes.yang  | 107 ++++--------------
 ... infix-wifi-country-codes@2026-10-06.yang} |   0
 2 files changed, 23 insertions(+), 84 deletions(-)
 rename src/confd/yang/confd/{infix-wifi-country-codes@2025-11-28.yang => infix-wifi-country-codes@2026-10-06.yang} (100%)

diff --git a/src/confd/yang/confd/infix-wifi-country-codes.yang b/src/confd/yang/confd/infix-wifi-country-codes.yang
index 9c797e42a..5b6d01f74 100644
--- a/src/confd/yang/confd/infix-wifi-country-codes.yang
+++ b/src/confd/yang/confd/infix-wifi-country-codes.yang
@@ -1,6 +1,6 @@
 module infix-wifi-country-codes {
   yang-version 1.1;
-  namespace "urn:infix:wifi-country-codes";
+  namespace "urn:infix:wifi-country-codes:ns:yang:1.0";
   prefix iwcc;
 
   organization   "KernelKit";
@@ -8,46 +8,48 @@ module infix-wifi-country-codes {
   contact        "kernelkit@googlegroups.com";
 
   description
-    "This module defines country codes for WiFi regulatory domain
-     configuration based on ISO 3166-1 alpha-2 standard.
+    "Country codes for the WiFi regulatory domain, ISO 3166-1 alpha-2,
+     limited to the countries the Linux wireless regulatory database
+     has rules for.";
 
-     This model provides country code definitions for use in
-     802.11 wireless LAN regulatory compliance configuration.
+  revision 2026-10-06 {
+    description
+      "Limit the list to the countries the Linux regulatory database has
+       rules for, a code without rules is silently ignored by the kernel
+       and leaves the radios in the world domain.";
+    reference
+      "wireless-regdb 2026.09.03";
+  }
 
-     The regulatory domain configuration follows the principles
-     established in IETF RFCs for wireless access point management.";
   revision 2025-11-28 {
     description
       "Add support for 00 - World regulatory domain.";
     reference
-      "Internal";    
-  }	
+      "Internal";
+  }
+
   revision 2025-06-02 {
     description
       "Initial revision for WiFi country code support.";
     reference
-      "RFC 5415: Control And Provisioning of Wireless Access Points (CAPWAP) Protocol Specification
-       RFC 5416: Control and Provisioning of Wireless Access Points (CAPWAP) Protocol Binding for IEEE 802.11";
+      "ISO 3166-1:2020 Codes for the representation of names of countries
+       and their subdivisions -- Part 1: Country codes";
   }
 
   typedef country-code {
     type enumeration {
-      enum "00" { description "World regulatory domain (no country restrictions)"; }
+      enum "00" { description "World regulatory domain, the most restrictive one: no 6 GHz, listen-only on 5 GHz, no access point"; }
       enum "AD" { description "Andorra"; }
       enum "AE" { description "United Arab Emirates"; }
       enum "AF" { description "Afghanistan"; }
-      enum "AG" { description "Antigua and Barbuda"; }
       enum "AI" { description "Anguilla"; }
       enum "AL" { description "Albania"; }
       enum "AM" { description "Armenia"; }
-      enum "AO" { description "Angola"; }
-      enum "AQ" { description "Antarctica"; }
       enum "AR" { description "Argentina"; }
       enum "AS" { description "American Samoa"; }
       enum "AT" { description "Austria"; }
       enum "AU" { description "Australia"; }
       enum "AW" { description "Aruba"; }
-      enum "AX" { description "Åland Islands"; }
       enum "AZ" { description "Azerbaijan"; }
       enum "BA" { description "Bosnia and Herzegovina"; }
       enum "BB" { description "Barbados"; }
@@ -56,41 +58,29 @@ module infix-wifi-country-codes {
       enum "BF" { description "Burkina Faso"; }
       enum "BG" { description "Bulgaria"; }
       enum "BH" { description "Bahrain"; }
-      enum "BI" { description "Burundi"; }
-      enum "BJ" { description "Benin"; }
       enum "BL" { description "Saint Barthélemy"; }
       enum "BM" { description "Bermuda"; }
       enum "BN" { description "Brunei Darussalam"; }
       enum "BO" { description "Bolivia"; }
-      enum "BQ" { description "Bonaire, Sint Eustatius and Saba"; }
       enum "BR" { description "Brazil"; }
       enum "BS" { description "Bahamas"; }
       enum "BT" { description "Bhutan"; }
-      enum "BV" { description "Bouvet Island"; }
       enum "BW" { description "Botswana"; }
       enum "BY" { description "Belarus"; }
       enum "BZ" { description "Belize"; }
       enum "CA" { description "Canada"; }
-      enum "CC" { description "Cocos (Keeling) Islands"; }
-      enum "CD" { description "Congo, Democratic Republic of the"; }
       enum "CF" { description "Central African Republic"; }
-      enum "CG" { description "Congo"; }
       enum "CH" { description "Switzerland"; }
       enum "CI" { description "Côte d'Ivoire"; }
-      enum "CK" { description "Cook Islands"; }
       enum "CL" { description "Chile"; }
-      enum "CM" { description "Cameroon"; }
       enum "CN" { description "China"; }
       enum "CO" { description "Colombia"; }
       enum "CR" { description "Costa Rica"; }
       enum "CU" { description "Cuba"; }
-      enum "CV" { description "Cabo Verde"; }
-      enum "CW" { description "Curaçao"; }
       enum "CX" { description "Christmas Island"; }
       enum "CY" { description "Cyprus"; }
       enum "CZ" { description "Czechia"; }
       enum "DE" { description "Germany"; }
-      enum "DJ" { description "Djibouti"; }
       enum "DK" { description "Denmark"; }
       enum "DM" { description "Dominica"; }
       enum "DO" { description "Dominican Republic"; }
@@ -98,37 +88,25 @@ module infix-wifi-country-codes {
       enum "EC" { description "Ecuador"; }
       enum "EE" { description "Estonia"; }
       enum "EG" { description "Egypt"; }
-      enum "EH" { description "Western Sahara"; }
-      enum "ER" { description "Eritrea"; }
       enum "ES" { description "Spain"; }
       enum "ET" { description "Ethiopia"; }
       enum "FI" { description "Finland"; }
-      enum "FJ" { description "Fiji"; }
-      enum "FK" { description "Falkland Islands (Malvinas)"; }
       enum "FM" { description "Micronesia"; }
       enum "FO" { description "Faroe Islands"; }
       enum "FR" { description "France"; }
-      enum "GA" { description "Gabon"; }
       enum "GB" { description "United Kingdom"; }
       enum "GD" { description "Grenada"; }
       enum "GE" { description "Georgia"; }
       enum "GF" { description "French Guiana"; }
-      enum "GG" { description "Guernsey"; }
       enum "GH" { description "Ghana"; }
       enum "GI" { description "Gibraltar"; }
       enum "GL" { description "Greenland"; }
-      enum "GM" { description "Gambia"; }
-      enum "GN" { description "Guinea"; }
       enum "GP" { description "Guadeloupe"; }
-      enum "GQ" { description "Equatorial Guinea"; }
       enum "GR" { description "Greece"; }
-      enum "GS" { description "South Georgia and the South Sandwich Islands"; }
       enum "GT" { description "Guatemala"; }
       enum "GU" { description "Guam"; }
-      enum "GW" { description "Guinea-Bissau"; }
       enum "GY" { description "Guyana"; }
       enum "HK" { description "Hong Kong"; }
-      enum "HM" { description "Heard Island and McDonald Islands"; }
       enum "HN" { description "Honduras"; }
       enum "HR" { description "Croatia"; }
       enum "HT" { description "Haiti"; }
@@ -138,71 +116,52 @@ module infix-wifi-country-codes {
       enum "IL" { description "Israel"; }
       enum "IM" { description "Isle of Man"; }
       enum "IN" { description "India"; }
-      enum "IO" { description "British Indian Ocean Territory"; }
-      enum "IQ" { description "Iraq"; }
       enum "IR" { description "Iran"; }
       enum "IS" { description "Iceland"; }
       enum "IT" { description "Italy"; }
-      enum "JE" { description "Jersey"; }
       enum "JM" { description "Jamaica"; }
       enum "JO" { description "Jordan"; }
       enum "JP" { description "Japan"; }
       enum "KE" { description "Kenya"; }
-      enum "KG" { description "Kyrgyzstan"; }
       enum "KH" { description "Cambodia"; }
-      enum "KI" { description "Kiribati"; }
-      enum "KM" { description "Comoros"; }
       enum "KN" { description "Saint Kitts and Nevis"; }
       enum "KP" { description "Korea, Democratic People's Republic of"; }
       enum "KR" { description "Korea, Republic of"; }
       enum "KW" { description "Kuwait"; }
       enum "KY" { description "Cayman Islands"; }
       enum "KZ" { description "Kazakhstan"; }
-      enum "LA" { description "Lao People's Democratic Republic"; }
       enum "LB" { description "Lebanon"; }
       enum "LC" { description "Saint Lucia"; }
       enum "LI" { description "Liechtenstein"; }
       enum "LK" { description "Sri Lanka"; }
-      enum "LR" { description "Liberia"; }
       enum "LS" { description "Lesotho"; }
       enum "LT" { description "Lithuania"; }
       enum "LU" { description "Luxembourg"; }
       enum "LV" { description "Latvia"; }
-      enum "LY" { description "Libya"; }
       enum "MA" { description "Morocco"; }
       enum "MC" { description "Monaco"; }
       enum "MD" { description "Moldova"; }
       enum "ME" { description "Montenegro"; }
       enum "MF" { description "Saint Martin (French part)"; }
-      enum "MG" { description "Madagascar"; }
       enum "MH" { description "Marshall Islands"; }
       enum "MK" { description "North Macedonia"; }
-      enum "ML" { description "Mali"; }
-      enum "MM" { description "Myanmar"; }
       enum "MN" { description "Mongolia"; }
       enum "MO" { description "Macao"; }
       enum "MP" { description "Northern Mariana Islands"; }
       enum "MQ" { description "Martinique"; }
       enum "MR" { description "Mauritania"; }
-      enum "MS" { description "Montserrat"; }
       enum "MT" { description "Malta"; }
       enum "MU" { description "Mauritius"; }
       enum "MV" { description "Maldives"; }
       enum "MW" { description "Malawi"; }
       enum "MX" { description "Mexico"; }
       enum "MY" { description "Malaysia"; }
-      enum "MZ" { description "Mozambique"; }
       enum "NA" { description "Namibia"; }
-      enum "NC" { description "New Caledonia"; }
-      enum "NE" { description "Niger"; }
-      enum "NF" { description "Norfolk Island"; }
       enum "NG" { description "Nigeria"; }
       enum "NI" { description "Nicaragua"; }
       enum "NL" { description "Netherlands"; }
       enum "NO" { description "Norway"; }
       enum "NP" { description "Nepal"; }
-      enum "NR" { description "Nauru"; }
-      enum "NU" { description "Niue"; }
       enum "NZ" { description "New Zealand"; }
       enum "OM" { description "Oman"; }
       enum "PA" { description "Panama"; }
@@ -213,9 +172,7 @@ module infix-wifi-country-codes {
       enum "PK" { description "Pakistan"; }
       enum "PL" { description "Poland"; }
       enum "PM" { description "Saint Pierre and Miquelon"; }
-      enum "PN" { description "Pitcairn"; }
       enum "PR" { description "Puerto Rico"; }
-      enum "PS" { description "Palestine, State of"; }
       enum "PT" { description "Portugal"; }
       enum "PW" { description "Palau"; }
       enum "PY" { description "Paraguay"; }
@@ -226,52 +183,33 @@ module infix-wifi-country-codes {
       enum "RU" { description "Russian Federation"; }
       enum "RW" { description "Rwanda"; }
       enum "SA" { description "Saudi Arabia"; }
-      enum "SB" { description "Solomon Islands"; }
-      enum "SC" { description "Seychelles"; }
-      enum "SD" { description "Sudan"; }
       enum "SE" { description "Sweden"; }
       enum "SG" { description "Singapore"; }
-      enum "SH" { description "Saint Helena, Ascension and Tristan da Cunha"; }
       enum "SI" { description "Slovenia"; }
-      enum "SJ" { description "Svalbard and Jan Mayen"; }
       enum "SK" { description "Slovakia"; }
-      enum "SL" { description "Sierra Leone"; }
       enum "SM" { description "San Marino"; }
       enum "SN" { description "Senegal"; }
-      enum "SO" { description "Somalia"; }
       enum "SR" { description "Suriname"; }
-      enum "SS" { description "South Sudan"; }
-      enum "ST" { description "Sao Tome and Principe"; }
       enum "SV" { description "El Salvador"; }
       enum "SX" { description "Sint Maarten (Dutch part)"; }
       enum "SY" { description "Syrian Arab Republic"; }
-      enum "SZ" { description "Eswatini"; }
       enum "TC" { description "Turks and Caicos Islands"; }
       enum "TD" { description "Chad"; }
-      enum "TF" { description "French Southern Territories"; }
       enum "TG" { description "Togo"; }
       enum "TH" { description "Thailand"; }
-      enum "TJ" { description "Tajikistan"; }
-      enum "TK" { description "Tokelau"; }
-      enum "TL" { description "Timor-Leste"; }
-      enum "TM" { description "Turkmenistan"; }
       enum "TN" { description "Tunisia"; }
-      enum "TO" { description "Tonga"; }
       enum "TR" { description "Turkey"; }
       enum "TT" { description "Trinidad and Tobago"; }
-      enum "TV" { description "Tuvalu"; }
       enum "TW" { description "Taiwan"; }
       enum "TZ" { description "Tanzania"; }
       enum "UA" { description "Ukraine"; }
       enum "UG" { description "Uganda"; }
-      enum "UM" { description "United States Minor Outlying Islands"; }
       enum "US" { description "United States of America"; }
       enum "UY" { description "Uruguay"; }
       enum "UZ" { description "Uzbekistan"; }
       enum "VA" { description "Holy See (Vatican City State)"; }
       enum "VC" { description "Saint Vincent and the Grenadines"; }
       enum "VE" { description "Venezuela"; }
-      enum "VG" { description "Virgin Islands, British"; }
       enum "VI" { description "Virgin Islands, U.S."; }
       enum "VN" { description "Viet Nam"; }
       enum "VU" { description "Vanuatu"; }
@@ -280,14 +218,15 @@ module infix-wifi-country-codes {
       enum "YE" { description "Yemen"; }
       enum "YT" { description "Mayotte"; }
       enum "ZA" { description "South Africa"; }
-      enum "ZM" { description "Zambia"; }
       enum "ZW" { description "Zimbabwe"; }
     }
     description
-      "Complete list of ISO 3166-1 alpha-2 country codes for
-       regulatory domain configuration.";
+      "ISO 3166-1 alpha-2 country codes for the WiFi regulatory domain,
+       limited to the countries the Linux wireless regulatory database
+       has rules for, plus '00' for the world domain.";
     reference
       "ISO 3166-1:2020 Codes for the representation of names of countries
-       and their subdivisions -- Part 1: Country codes";
+       and their subdivisions -- Part 1: Country codes
+       https://git.kernel.org/pub/scm/linux/kernel/git/wens/wireless-regdb.git";
   }
 }
diff --git a/src/confd/yang/confd/infix-wifi-country-codes@2025-11-28.yang b/src/confd/yang/confd/infix-wifi-country-codes@2026-10-06.yang
similarity index 100%
rename from src/confd/yang/confd/infix-wifi-country-codes@2025-11-28.yang
rename to src/confd/yang/confd/infix-wifi-country-codes@2026-10-06.yang

From a3678bbb0d0351d2bfdaef47dc91a93ba0c855fd Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Mattias=20Walstr=C3=B6m?= 
Date: Wed, 7 Oct 2026 00:02:33 +0200
Subject: [PATCH 26/38] confd: wifi: Enable management frame protection on
 stations
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

A WPA3-only access point requires it, and on 6 GHz every access point
does.  wpa_supplicant silently skipped those networks as candidates, so
the station saw them in the scan but never tried to associate.  Set it
as capable rather than required, WPA2 networks without it still work.

Signed-off-by: Mattias Walström 
---
 doc/ChangeLog.md                                | 3 +++
 src/confd/src/if-wifi.c                         | 4 ++++
 test/case/interfaces/wifi_wds_link_2dut/test.py | 2 +-
 3 files changed, 8 insertions(+), 1 deletion(-)

diff --git a/doc/ChangeLog.md b/doc/ChangeLog.md
index f3ee80c99..77019a008 100644
--- a/doc/ChangeLog.md
+++ b/doc/ChangeLog.md
@@ -58,6 +58,9 @@ All notable changes to the project are documented in this file.
   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
+- A WiFi station never connected to a WPA3-only access point, which
+  includes every access point on 6 GHz.  The network showed up in the
+  scan results but the station stayed down without any log message
 
 [wds]: https://www.kernelkit.org/infix/latest/wifi/#wds-backhaul-and-repeaters
 
diff --git a/src/confd/src/if-wifi.c b/src/confd/src/if-wifi.c
index 7a5697a1e..15b860305 100644
--- a/src/confd/src/if-wifi.c
+++ b/src/confd/src/if-wifi.c
@@ -304,8 +304,12 @@ int wifi_gen_station(struct lyd_node *cif)
 		if (!strcmp(security_mode, "disabled"))
 			asprintf(&security_str, "key_mgmt=NONE");
 		else if (secret)
+			/* ieee80211w=1: MFP capable.  WPA3-only APs, and every
+			 * AP on 6 GHz, require it and are skipped as candidates
+			 * without it; WPA2 APs without MFP still work. */
 			asprintf(&security_str,
 				 "key_mgmt=FT-SAE FT-PSK SAE WPA-PSK\n"
+				 "  ieee80211w=1\n"
 				 "  psk=\"%s\"", secret);
 
 		/* bgscan="" disables background scanning once associated: on a
diff --git a/test/case/interfaces/wifi_wds_link_2dut/test.py b/test/case/interfaces/wifi_wds_link_2dut/test.py
index bdc09f8dd..70a30f1b7 100755
--- a/test/case/interfaces/wifi_wds_link_2dut/test.py
+++ b/test/case/interfaces/wifi_wds_link_2dut/test.py
@@ -61,7 +61,7 @@ def root_config():
                 "radio": "radio0",
                 "access-point": {
                     "ssid": SSID,
-                    "security": {"mode": "wpa2-wpa3-personal", "secret": "wifi"},
+                    "security": {"mode": "wpa3-personal", "secret": "wifi"},
                 },
             }),
             wifi.wds_link("wds0", "wifi0", SAT_MAC, bridge="br0", pvid=10),

From c8104e23ee64c28e724427acaf94f367fbfc16b9 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Mattias=20Walstr=C3=B6m?= 
Date: Wed, 7 Oct 2026 00:02:33 +0200
Subject: [PATCH 27/38] statd: wifi: Tell WPA3-only networks apart in scan
 results
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

wpa_supplicant labels every RSN network WPA2, so an SAE-only network
showed up as WPA2-Personal.  Classify by key management instead.

Signed-off-by: Mattias Walström 
---
 doc/ChangeLog.md                                |  1 +
 src/statd/python/yanger/ietf_interfaces/wifi.py | 11 +++++++----
 2 files changed, 8 insertions(+), 4 deletions(-)

diff --git a/doc/ChangeLog.md b/doc/ChangeLog.md
index 77019a008..1fff65e86 100644
--- a/doc/ChangeLog.md
+++ b/doc/ChangeLog.md
@@ -61,6 +61,7 @@ All notable changes to the project are documented in this file.
 - A WiFi station never connected to a WPA3-only access point, which
   includes every access point on 6 GHz.  The network showed up in the
   scan results but the station stayed down without any log message
+- WiFi scan results listed WPA3-only networks as WPA2-Personal
 
 [wds]: https://www.kernelkit.org/infix/latest/wifi/#wds-backhaul-and-repeaters
 
diff --git a/src/statd/python/yanger/ietf_interfaces/wifi.py b/src/statd/python/yanger/ietf_interfaces/wifi.py
index 7dc0f69d3..39615a8d1 100644
--- a/src/statd/python/yanger/ietf_interfaces/wifi.py
+++ b/src/statd/python/yanger/ietf_interfaces/wifi.py
@@ -287,12 +287,15 @@ def extract_encryption(flags):
         'auth_type': 'Unknown'
     }
 
-    # Extract WPA protocols
-    if 'WPA3' in flags:
+    # wpa_supplicant labels every RSN network WPA2, so WPA3 is told
+    # apart by its key management: SAE only is WPA3, SAE next to PSK
+    # is a WPA2/WPA3 transition network.
+    rsn = 'WPA2-' in flags
+    if rsn and 'SAE' in flags:
         encryption_info['protocols'].append('WPA3')
-    if 'WPA2' in flags:
+    if rsn and ('PSK' in flags or 'EAP' in flags):
         encryption_info['protocols'].append('WPA2')
-    if 'WPA-' in flags and 'WPA2' not in flags and 'WPA3' not in flags:
+    if 'WPA-' in flags and not rsn:
         encryption_info['protocols'].append('WPA')
 
     # Extract key management methods

From 3b04fa307b217b47c76bf674e3fe00468088ceec Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Mattias=20Walstr=C3=B6m?= 
Date: Wed, 7 Oct 2026 00:02:33 +0200
Subject: [PATCH 28/38] webui: Click the survey chart to open it in an overlay

The chart is capped at 220 px in the radio card, too small to read on
a radio with many channels.  A click opens a copy in a dialog sized to
the window, closed with the button, the backdrop or Escape.
---
 src/webui/static/css/style.css      | 31 +++++++++++++++++++++++++++++
 src/webui/static/js/app.js          | 22 ++++++++++++++++++++
 src/webui/templates/pages/wifi.html | 13 +++++++++++-
 3 files changed, 65 insertions(+), 1 deletion(-)

diff --git a/src/webui/static/css/style.css b/src/webui/static/css/style.css
index c69ae3b9a..a44b4f1cb 100644
--- a/src/webui/static/css/style.css
+++ b/src/webui/static/css/style.css
@@ -2205,6 +2205,37 @@ details[open] > .cfg-multi-summary {
   width: auto;
 }
 
+/* Click the chart to open it in an overlay sized to the window. */
+.survey-zoom {
+  display: block;
+  width: 100%;
+  padding: 0;
+  border: 0;
+  background: none;
+  cursor: zoom-in;
+}
+
+.survey-zoom .survey-chart {
+  margin: 0 auto;
+}
+
+.survey-dialog {
+  min-width: 0;
+  width: min(96vw, 1400px);
+}
+
+.survey-dialog .iface-modal-body {
+  padding: 1rem;
+}
+
+.survey-dialog .survey-chart {
+  display: block;
+  width: 100%;
+  height: auto;
+  max-height: 82vh;
+  margin: 0 auto;
+}
+
 .wifi-ssid {
   font-weight: 400;
   color: var(--fg-muted);
diff --git a/src/webui/static/js/app.js b/src/webui/static/js/app.js
index 934121459..126e982ef 100644
--- a/src/webui/static/js/app.js
+++ b/src/webui/static/js/app.js
@@ -1100,6 +1100,28 @@ function setBlockEnabled(el, on) {
     });
   });
 
+  // WiFi channel survey: click the chart to open a copy of it in the
+  // page's survey dialog, sized to the window.  Delegated so it survives
+  // the htmx swap that replaces the chart on every scan.  A click on the
+  // backdrop closes the dialog, like the close button.
+  document.addEventListener('click', function (e) {
+    var zoom = e.target.closest && e.target.closest('[data-survey-zoom]');
+    if (!zoom) return;
+    var dlg = document.getElementById('survey-dialog');
+    var svg = zoom.querySelector('svg');
+    if (!dlg || !dlg.showModal || !svg) return;
+    var body = document.getElementById('survey-dialog-body');
+    var radio = document.getElementById('survey-dialog-radio');
+    body.innerHTML = '';
+    body.appendChild(svg.cloneNode(true));
+    if (radio) radio.textContent = zoom.getAttribute('data-survey-zoom') || '';
+    dlg.showModal();
+  });
+  document.addEventListener('click', function (e) {
+    var dlg = e.target;
+    if (dlg && dlg.id === 'survey-dialog' && dlg.open) dlg.close();
+  });
+
   // Add Interface modal — open via data-show-modal, close via
   // data-close-modal. Mirrors the existing data-show/data-hide vocabulary
   // for action-on-target attributes. Native .showModal() gives us
diff --git a/src/webui/templates/pages/wifi.html b/src/webui/templates/pages/wifi.html
index 79d71d4c2..a97527ac5 100644
--- a/src/webui/templates/pages/wifi.html
+++ b/src/webui/templates/pages/wifi.html
@@ -157,12 +157,23 @@
   

No WiFi radios detected.

{{end}} + +
+

Channel Survey

+ +
+
+
{{end}} {{define "wifi-survey"}}
{{if .SVG}} - {{.SVG}} + {{else if .Error}}
{{.Error}}
{{else}} From 6f4ff7407bbd56d7ab0a1ddc82e49a2730c7f3a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20Walstr=C3=B6m?= Date: Wed, 7 Oct 2026 08:15:21 +0200 Subject: [PATCH 29/38] webui: Survive a broken YANG schema cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A module file cut short, left by a restart in the middle of a download, made goyang dereference nil at every start. Write cached files through a temporary name, turn a parser panic into an error, drop a cache that fails to load so the next refresh fetches it again, and prune files the device no longer lists so two revisions of a module never load together. Signed-off-by: Mattias Walström --- doc/ChangeLog.md | 3 + .../internal/schema/cache_broken_test.go | 109 ++++++++++++++++++ src/webui/internal/schema/fetch.go | 46 +++++++- src/webui/internal/schema/manager.go | 11 +- src/webui/internal/schema/refresh.go | 24 +++- 5 files changed, 188 insertions(+), 5 deletions(-) create mode 100644 src/webui/internal/schema/cache_broken_test.go diff --git a/doc/ChangeLog.md b/doc/ChangeLog.md index 1fff65e86..cccf63ed3 100644 --- a/doc/ChangeLog.md +++ b/doc/ChangeLog.md @@ -62,6 +62,9 @@ All notable changes to the project are documented in this file. includes every access point on 6 GHz. The network showed up in the scan results but the station stayed down without any log message - WiFi scan results listed WPA3-only networks as WPA2-Personal +- WebUI: a YANG module file cut short in the schema cache, for example + by a restart during download, crashed the WebUI at every start until + the cache was removed by hand [wds]: https://www.kernelkit.org/infix/latest/wifi/#wds-backhaul-and-repeaters diff --git a/src/webui/internal/schema/cache_broken_test.go b/src/webui/internal/schema/cache_broken_test.go new file mode 100644 index 000000000..c173e84b1 --- /dev/null +++ b/src/webui/internal/schema/cache_broken_test.go @@ -0,0 +1,109 @@ +package schema + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "infix/webui/internal/restconf" +) + +// A module cut short, next to a submodule that uses a type from it, makes +// goyang dereference nil instead of reporting an error. +func writeBrokenCache(t *testing.T, dir string) { + t.Helper() + files := map[string]string{ + "m@2026-01-01.yang": "module m { yang-version 1.1; namespace \"urn:m\"; prefix m; include s; typedef t { ", + "s@2026-01-01.yang": "submodule s { yang-version 1.1; belongs-to m { prefix m; } leaf x { type t; } }", + } + for name, body := range files { + if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0640); err != nil { + t.Fatal(err) + } + } +} + +func TestLoadRejectsBrokenModule(t *testing.T) { + dir := t.TempDir() + writeBrokenCache(t, dir) + if _, err := Load(dir); err == nil { + t.Fatal("Load accepted a cut-short module") + } +} + +func TestLoadFromCacheDropsBrokenCache(t *testing.T) { + dir := t.TempDir() + writeBrokenCache(t, dir) + if err := os.WriteFile(filepath.Join(dir, ".version"), []byte("v1\n"), 0640); err != nil { + t.Fatal(err) + } + + if err := NewCache(nil, dir, "v1").LoadFromCache(); err == nil { + t.Fatal("broken cache loaded without error") + } + entries, _ := os.ReadDir(dir) + for _, e := range entries { + if strings.HasSuffix(e.Name(), ".yang") { + t.Errorf("%s kept in a broken cache", e.Name()) + } + } + if b, _ := os.ReadFile(filepath.Join(dir, ".version")); string(b) != "v1\n" { + t.Errorf("stamp = %q", b) + } +} + +// fakeFetcher serves a modules-state listing and the module files from a +// map. Other Fetcher methods are never called. +type fakeFetcher struct { + restconf.Fetcher + modules map[string]string // name@revision -> body +} + +func (f fakeFetcher) Get(_ context.Context, path string, target any) error { + ms := target.(*rfc7895ModulesState) + for key := range f.modules { + name, rev, _ := strings.Cut(key, "@") + ms.ModulesState.Module = append(ms.ModulesState.Module, struct { + Name string `json:"name"` + Revision string `json:"revision"` + Submodule []struct { + Name string `json:"name"` + Revision string `json:"revision"` + } `json:"submodule"` + }{Name: name, Revision: rev}) + } + return nil +} + +func (f fakeFetcher) GetYANG(_ context.Context, name, revision string) ([]byte, error) { + return []byte(f.modules[name+"@"+revision]), nil +} + +func TestFetchModulesPrunesStaleRevisions(t *testing.T) { + dir := t.TempDir() + stale := filepath.Join(dir, "example@2025-01-01.yang") + if err := os.WriteFile(stale, []byte("module example { namespace x; prefix x; }"), 0640); err != nil { + t.Fatal(err) + } + + rc := fakeFetcher{modules: map[string]string{ + "example@2026-01-01": "module example { namespace x; prefix x; }", + }} + if _, err := FetchModules(context.Background(), rc, dir); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(stale); !os.IsNotExist(err) { + t.Errorf("stale revision kept: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "example@2026-01-01.yang")); err != nil { + t.Errorf("current revision missing: %v", err) + } + entries, _ := os.ReadDir(dir) + for _, e := range entries { + if strings.HasPrefix(e.Name(), ".example") { + t.Errorf("temporary file left behind: %s", e.Name()) + } + } +} diff --git a/src/webui/internal/schema/fetch.go b/src/webui/internal/schema/fetch.go index 41a55fbc1..940a47287 100644 --- a/src/webui/internal/schema/fetch.go +++ b/src/webui/internal/schema/fetch.go @@ -6,6 +6,7 @@ import ( "log" "os" "path/filepath" + "strings" "infix/webui/internal/restconf" ) @@ -71,9 +72,32 @@ func FetchModules(ctx context.Context, rc restconf.Fetcher, cacheDir string) ([] } downloaded = append(downloaded, m) } + prune(cacheDir, modules) return downloaded, nil } +// prune removes cached YANG files the device no longer lists, such as an +// earlier revision of a module, so that two revisions never load together. +func prune(cacheDir string, modules []ModuleInfo) { + keep := make(map[string]bool, len(modules)) + for _, m := range modules { + keep[m.filename()] = true + } + entries, err := os.ReadDir(cacheDir) + if err != nil { + return + } + for _, e := range entries { + name := e.Name() + if e.IsDir() || !strings.HasSuffix(name, ".yang") || keep[name] { + continue + } + if err := os.Remove(filepath.Join(cacheDir, name)); err == nil { + log.Printf("schema: dropped %s, no longer on the device", name) + } + } +} + // listModules queries the device for the list of implemented YANG modules. func listModules(ctx context.Context, rc restconf.Fetcher) ([]ModuleInfo, error) { // Try RFC 7895 modules-state first. @@ -117,7 +141,27 @@ func downloadIfMissing(ctx context.Context, rc restconf.Fetcher, cacheDir string return fmt.Errorf("GET /yang/%s: %w", m.filename(), err) } - if err := os.WriteFile(dest, data, 0640); err != nil { + // Write through a temporary name so that a restart in the middle of + // the download never leaves a cut-short module behind. + tmp, err := os.CreateTemp(cacheDir, "."+m.Name+".*") + if err != nil { + return fmt.Errorf("write %s: %w", dest, err) + } + if _, err := tmp.Write(data); err != nil { + tmp.Close() + os.Remove(tmp.Name()) + return fmt.Errorf("write %s: %w", dest, err) + } + if err := tmp.Close(); err != nil { + os.Remove(tmp.Name()) + return fmt.Errorf("write %s: %w", dest, err) + } + if err := os.Chmod(tmp.Name(), 0640); err != nil { + os.Remove(tmp.Name()) + return fmt.Errorf("write %s: %w", dest, err) + } + if err := os.Rename(tmp.Name(), dest); err != nil { + os.Remove(tmp.Name()) return fmt.Errorf("write %s: %w", dest, err) } log.Printf("schema: cached %s", m.filename()) diff --git a/src/webui/internal/schema/manager.go b/src/webui/internal/schema/manager.go index b888cf744..8095e1fd4 100644 --- a/src/webui/internal/schema/manager.go +++ b/src/webui/internal/schema/manager.go @@ -23,7 +23,16 @@ type Manager struct { // Load parses all .yang files in yangDir and returns a Manager. // Errors from Process() that are non-fatal (e.g. unresolved augments for // modules that were not downloaded) are logged but do not abort loading. -func Load(yangDir string) (*Manager, error) { +func Load(yangDir string) (mgr *Manager, err error) { + // goyang dereferences nil on some malformed input, for example a + // module file cut short, instead of returning an error. The cache is + // rebuilt from the device on failure, so turn that into an error. + defer func() { + if r := recover(); r != nil { + mgr, err = nil, fmt.Errorf("schema: parse %s: %v", yangDir, r) + } + }() + ms := yang.NewModules() ms.Path = []string{yangDir} diff --git a/src/webui/internal/schema/refresh.go b/src/webui/internal/schema/refresh.go index 31279d460..18243f617 100644 --- a/src/webui/internal/schema/refresh.go +++ b/src/webui/internal/schema/refresh.go @@ -41,13 +41,23 @@ func (c *Cache) dropStale() error { if b, err := os.ReadFile(stamp); err == nil && strings.TrimSpace(string(b)) == c.version { return nil } + log.Printf("schema: cache in %s is for another image, dropped", c.dir) + return c.drop() +} + +// drop empties the cache directory and stamps it for this image, so the +// next Refresh downloads every module again. +func (c *Cache) drop() error { if err := os.RemoveAll(c.dir); err != nil { return err } if err := os.MkdirAll(c.dir, 0750); err != nil { return err } - log.Printf("schema: cache in %s is for another image, dropped", c.dir) + if c.version == "" { + return nil + } + stamp := filepath.Join(c.dir, ".version") return os.WriteFile(stamp, []byte(c.version+"\n"), 0640) } @@ -80,7 +90,12 @@ func (c *Cache) LoadFromCache() error { mgr, err := Load(c.dir) if err != nil { - return fmt.Errorf("schema: load from cache: %w", err) + // A broken file would fail every start, drop the lot and let + // the next Refresh fetch a fresh copy. + if derr := c.drop(); derr != nil { + return fmt.Errorf("schema: load from cache: %w (and dropping it: %v)", err, derr) + } + return fmt.Errorf("schema: load from cache: %w, cache dropped", err) } c.mu.Lock() c.manager = mgr @@ -122,7 +137,10 @@ func (c *Cache) Refresh(ctx context.Context) error { mgr, err := Load(c.dir) if err != nil { - return fmt.Errorf("schema refresh: load: %w", err) + if derr := c.drop(); derr != nil { + return fmt.Errorf("schema refresh: load: %w (and dropping the cache: %v)", err, derr) + } + return fmt.Errorf("schema refresh: load: %w, cache dropped", err) } c.mu.Lock() From afb6a7b3a878dad3ebe4916a89f8ac5e9189fd52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20Walstr=C3=B6m?= Date: Wed, 7 Oct 2026 09:12:15 +0200 Subject: [PATCH 30/38] confd: Run the hardware handler after the interfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hostapd binds to WiFi netdevs. With the hardware handler first, a commit that recreates an access point netdev, e.g. for a new MAC address, restarted hostapd before the interface pipeline rebuilt the netdev. hostapd bound to the one about to be deleted and kept reporting the access point enabled while the new netdev sat idle. Run the interfaces first, then the hardware. Signed-off-by: Mattias Walström --- doc/ChangeLog.md | 3 ++ src/confd/src/core.c | 10 ++++--- src/confd/src/hardware.c | 6 +++- .../interfaces/wifi_wds_repeater/test.adoc | 11 ++++++- .../case/interfaces/wifi_wds_repeater/test.py | 29 +++++++++++++++++-- 5 files changed, 51 insertions(+), 8 deletions(-) diff --git a/doc/ChangeLog.md b/doc/ChangeLog.md index cccf63ed3..47df4b1d0 100644 --- a/doc/ChangeLog.md +++ b/doc/ChangeLog.md @@ -65,6 +65,9 @@ All notable changes to the project are documented in this file. - WebUI: a YANG module file cut short in the schema cache, for example by a restart during download, crashed the WebUI at every start until the cache was removed by hand +- Changing the MAC address of a WiFi access point could leave it, and + the other access points on the same radio, down until the WiFi service + was restarted [wds]: https://www.kernelkit.org/infix/latest/wifi/#wds-backhaul-and-repeaters diff --git a/src/confd/src/core.c b/src/confd/src/core.c index 574e0786f..a5abbcdea 100644 --- a/src/confd/src/core.c +++ b/src/confd/src/core.c @@ -697,14 +697,16 @@ static int change_cb(sr_session_ctx_t *session, uint32_t sub_id, const char *mod } } - /* ietf-hardware */ - if ((rc = hardware_change(session, config, diff, event, confd))) - goto free_diff; - /* ietf-interfaces */ if ((rc = interfaces_change(session, config, diff, event, confd))) goto free_diff; + /* ietf-hardware, after the interfaces: hostapd binds to WiFi + * netdevs, so it must be (re)started only once the interface + * pipeline has created or recreated them. */ + if ((rc = hardware_change(session, config, diff, event, confd))) + goto free_diff; + /* infix-dhcp-client*/ if ((rc = dhcp_client_change(session, config, diff, event, confd))) goto free_diff; diff --git a/src/confd/src/hardware.c b/src/confd/src/hardware.c index 051c2585c..adba498bd 100644 --- a/src/confd/src/hardware.c +++ b/src/confd/src/hardware.c @@ -249,7 +249,7 @@ static const char *wifi_find_higher_band_twin(struct lyd_node *config, if (!wifi) continue; ap = lydx_get_child(wifi, "access-point"); - if (!ap) + if (!ap || !lydx_is_enabled(cif, "enabled")) continue; ssid = lydx_get_cattr(ap, "ssid"); if (!ssid || strcmp(ssid, current_ssid)) @@ -317,6 +317,10 @@ static int wifi_find_radio_aps(struct lyd_node *cifs, const char *radio_name, ap = lydx_get_child(wifi, "access-point"); if (!ap) continue; + /* hostapd brings every BSS it is given up, a disabled one + * must not be in its config at all. */ + if (!lydx_is_enabled(cif, "enabled")) + continue; list = realloc(list, sizeof(char *) * (n + 1)); ifname = lydx_get_cattr(cif, "name"); diff --git a/test/case/interfaces/wifi_wds_repeater/test.adoc b/test/case/interfaces/wifi_wds_repeater/test.adoc index 166ee8b46..252c468db 100644 --- a/test/case/interfaces/wifi_wds_repeater/test.adoc +++ b/test/case/interfaces/wifi_wds_repeater/test.adoc @@ -20,6 +20,11 @@ The host behind the root reaches both stations on their VLANs. Taking the backhaul down and up again shows it is a transparent bridge port: the stations lose and regain reach without re-associating. +Finally the guest access point gets a new MAC address, which recreates +its netdev. The home access point on the same radio must not be +disturbed, and the guest access point has to come back at the new +address with its client. + Topology: .... host ==(lan, VLAN 10+20)== root (AP 'infix-backhaul') @@ -49,6 +54,10 @@ image::topology.svg[WiFi repeater with two SSIDs in VLANs over a 4-address (WDS) . Verify home at 10.10.0.9 and guest at 10.20.0.9 are no longer reachable from the host . Enable the repeater's backhaul station wifi0 again . Verify the host reaches home at 10.10.0.9 and guest at 10.20.0.9 again -. Verify home and guest are still on their access points +. Change the address of the repeater's 'infix-guest' access point to 02:00:00:00:0b:12 +. Verify wifi2 on the repeater reports the new address and both access points are up +. Verify guest associates to 'infix-guest' at the new address, BSSID 02:00:00:00:0b:12 +. Verify the host reaches home at 10.10.0.9 and guest at 10.20.0.9 after the address change +. Verify home is still on its access point diff --git a/test/case/interfaces/wifi_wds_repeater/test.py b/test/case/interfaces/wifi_wds_repeater/test.py index 6cb525a8a..bc92e981f 100755 --- a/test/case/interfaces/wifi_wds_repeater/test.py +++ b/test/case/interfaces/wifi_wds_repeater/test.py @@ -18,6 +18,11 @@ Taking the backhaul down and up again shows it is a transparent bridge port: the stations lose and regain reach without re-associating. +Finally the guest access point gets a new MAC address, which recreates +its netdev. The home access point on the same radio must not be +disturbed, and the guest access point has to come back at the new +address with its client. + Topology: .... host ==(lan, VLAN 10+20)== root (AP 'infix-backhaul') @@ -39,6 +44,7 @@ REPEATER_STA_MAC = "02:00:00:00:00:02" HOME_AP_MAC = "02:00:00:00:0a:02" GUEST_AP_MAC = "02:00:00:00:0b:02" +GUEST_AP_MAC_NEW = "02:00:00:00:0b:12" HOME_MAC = "02:00:00:00:00:09" GUEST_MAC = "02:00:00:00:00:0a" @@ -230,8 +236,27 @@ def reaches(ns, addr): until(lambda: reaches(ns, HOME_IP), attempts=30, interval=2) until(lambda: reaches(ns, GUEST_IP), attempts=30, interval=2) - with test.step("Verify home and guest are still on their access points"): - if wifi.station_bssid(home) != HOME_AP_MAC or wifi.station_bssid(guest) != GUEST_AP_MAC: + with test.step("Change the address of the repeater's 'infix-guest' access point to 02:00:00:00:0b:12"): + repeater.put_config_dicts({"ietf-interfaces": {"interfaces": {"interface": [{ + "name": "wifi2", + "custom-phys-address": {"static": GUEST_AP_MAC_NEW}, + }]}}}) + + with test.step("Verify wifi2 on the repeater reports the new address and both access points are up"): + until(lambda: (iface.get_phys_address(repeater, "wifi2") or "").lower() == GUEST_AP_MAC_NEW, + attempts=30, interval=2) + until(lambda: iface.is_oper_up(repeater, "wifi2"), attempts=45, interval=2) + until(lambda: iface.is_oper_up(repeater, "wifi1"), attempts=30, interval=2) + + with test.step("Verify guest associates to 'infix-guest' at the new address, BSSID 02:00:00:00:0b:12"): + until(lambda: wifi.station_bssid(guest) == GUEST_AP_MAC_NEW, attempts=60, interval=2) + + with test.step("Verify the host reaches home at 10.10.0.9 and guest at 10.20.0.9 after the address change"): + until(lambda: reaches(ns, HOME_IP), attempts=30, interval=2) + until(lambda: reaches(ns, GUEST_IP), attempts=30, interval=2) + + with test.step("Verify home is still on its access point"): + if wifi.station_bssid(home) != HOME_AP_MAC: test.fail() test.succeed() From 2e970d13235f8812403d8b05a6fc19143b822aec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20Walstr=C3=B6m?= Date: Wed, 7 Oct 2026 15:13:26 +0200 Subject: [PATCH 31/38] hostapd: Add ft_iface for the 802.11r key holder exchange MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The exchange is sent on the bridge the BSS is a port of. On a VLAN filtering bridge a frame from the bridge device lands in the bridge's own untagged VLAN, if any, not in the access points' VLAN, and naming another interface as the bridge makes hostapd move the BSS into it. Add a per-BSS ft_iface option for the interface to use instead. Signed-off-by: Mattias Walström --- ...ace-for-the-802.11r-key-holder-excha.patch | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 patches/hostapd/0004-hostapd-Add-ft_iface-for-the-802.11r-key-holder-excha.patch diff --git a/patches/hostapd/0004-hostapd-Add-ft_iface-for-the-802.11r-key-holder-excha.patch b/patches/hostapd/0004-hostapd-Add-ft_iface-for-the-802.11r-key-holder-excha.patch new file mode 100644 index 000000000..8c76a3077 --- /dev/null +++ b/patches/hostapd/0004-hostapd-Add-ft_iface-for-the-802.11r-key-holder-excha.patch @@ -0,0 +1,79 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?Mattias=20Walstr=C3=B6m?= +Date: Tue, 7 Oct 2026 15:00:00 +0200 +Subject: [PATCH 4/4] hostapd: Add ft_iface for the 802.11r key holder exchange +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +The R0KH/R1KH exchange is sent on the bridge the BSS is a port of, or +on the BSS interface itself. On a VLAN filtering bridge a frame sent +from the bridge device belongs to the bridge's own untagged VLAN, if it +has one, which need not be the VLAN of the access points. Nor can the +bridge option name another interface: hostapd then moves the BSS into +that bridge. + +Add a per-BSS option for the interface to use for the exchange, for +example the VLAN interface of the access points' VLAN: + + ft_iface= + +Signed-off-by: Mattias Walström +--- + hostapd/config_file.c | 2 ++ + hostapd/hostapd.conf | 5 +++++ + src/ap/ap_config.h | 1 + + src/ap/wpa_auth_glue.c | 3 ++- + 4 files changed, 10 insertions(+), 1 deletion(-) + +diff -ruN a/hostapd/config_file.c b/hostapd/config_file.c +--- a/hostapd/config_file.c ++++ b/hostapd/config_file.c +@@ -2419,6 +2419,8 @@ + sizeof(conf->bss[0]->iface)); + } else if (os_strcmp(buf, "bridge") == 0) { + os_strlcpy(bss->bridge, pos, sizeof(bss->bridge)); ++ } else if (os_strcmp(buf, "ft_iface") == 0) { ++ os_strlcpy(bss->ft_iface, pos, sizeof(bss->ft_iface)); + } else if (os_strcmp(buf, "bridge_hairpin") == 0) { + bss->bridge_hairpin = atoi(pos); + } else if (os_strcmp(buf, "vlan_bridge") == 0) { +diff -ruN a/hostapd/hostapd.conf b/hostapd/hostapd.conf +--- a/hostapd/hostapd.conf ++++ b/hostapd/hostapd.conf +@@ -19,6 +19,11 @@ + # has been started to change the interface mode). If needed, the bridge + # interface is also created. + #bridge=br0 ++ ++# Interface to send and receive the 802.11r R0KH/R1KH exchange on. Defaults ++# to the bridge, or to the BSS interface when no bridge is configured. Use ++# it to name the VLAN interface of the access points on a VLAN aware bridge. ++#ft_iface=br0.1 + + # Driver interface type (wired/none/nl80211/bsd); + # default: nl80211). nl80211 is used with all Linux mac80211 drivers. +diff -ruN a/src/ap/ap_config.h b/src/ap/ap_config.h +--- a/src/ap/ap_config.h ++++ b/src/ap/ap_config.h +@@ -290,6 +290,7 @@ + struct hostapd_bss_config { + char iface[IFNAMSIZ + 1]; + char bridge[IFNAMSIZ + 1]; ++ char ft_iface[IFNAMSIZ + 1]; + char vlan_bridge[IFNAMSIZ + 1]; + char wds_bridge[IFNAMSIZ + 1]; + int bridge_hairpin; /* hairpin_mode on bridge members */ +diff -ruN a/src/ap/wpa_auth_glue.c b/src/ap/wpa_auth_glue.c +--- a/src/ap/wpa_auth_glue.c ++++ b/src/ap/wpa_auth_glue.c +@@ -1918,7 +1918,8 @@ + wpa_key_mgmt_ft(hapd->conf->wpa_key_mgmt)) { + const char *ft_iface; + +- ft_iface = hapd->conf->bridge[0] ? hapd->conf->bridge : ++ ft_iface = hapd->conf->ft_iface[0] ? hapd->conf->ft_iface : ++ hapd->conf->bridge[0] ? hapd->conf->bridge : + hapd->conf->iface; + hapd->l2 = l2_packet_init(ft_iface, NULL, ETH_P_RRB, + hostapd_rrb_receive, hapd, 1); From 528a264984ca1a37799e59b9dd7569e8edbeadd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20Walstr=C3=B6m?= Date: Wed, 7 Oct 2026 09:49:12 +0200 Subject: [PATCH 32/38] confd: wifi: Exchange 802.11r keys between access points MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fast transition to an access point on another device failed for WPA3 clients with status 53, invalid PMKID: their PMK comes from the SAE handshake, so the target cannot regenerate it from the passphrase like ft_psk_generate_local does for WPA2. Give every access point of the SSID wildcard R0KH/R1KH entries with a key derived from the mobility domain and the passphrase, so the target fetches the PMK-R1 from the access point the client came from, and tell hostapd about the bridge so that exchange reaches the other devices. Signed-off-by: Mattias Walström --- doc/ChangeLog.md | 2 ++ doc/wifi.md | 7 ++++ src/confd/src/hardware.c | 73 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 82 insertions(+) diff --git a/doc/ChangeLog.md b/doc/ChangeLog.md index 47df4b1d0..284b3caa0 100644 --- a/doc/ChangeLog.md +++ b/doc/ChangeLog.md @@ -65,6 +65,8 @@ All notable changes to the project are documented in this file. - WebUI: a YANG module file cut short in the schema cache, for example by a restart during download, crashed the WebUI at every start until the cache was removed by hand +- Fast roaming (802.11r) between WiFi access points on different devices + failed for WPA3 clients, which fell back to a full reconnect - Changing the MAC address of a WiFi access point could leave it, and the other access points on the same radio, down until the WiFi service was restarted diff --git a/doc/wifi.md b/doc/wifi.md index a1e37eff4..c69be4907 100644 --- a/doc/wifi.md +++ b/doc/wifi.md @@ -612,6 +612,13 @@ admin@example:/config/interface/wifi0/> set wifi access-point roaming dot11r mob - All APs in roaming group must have **identical** SSID - All APs must have **identical** passphrase (same keystore secret) - All APs must use the **same mobility-domain** identifier +- APs on different devices must be ports of bridges that are connected + to each other, over a cable, a mesh or a WDS backhaul. The APs hand a + roaming client's keys to each other over that network, which WPA3 + clients need for a fast transition. On a bridge with VLAN filtering + the keys travel in the APs' VLAN, so each device needs a VLAN + interface on the bridge for that VLAN, the one carrying its IP address + there is enough **Mobility Domain Options:** - Explicit 4-character hex value (e.g., `4f57`) - default if not specified diff --git a/src/confd/src/hardware.c b/src/confd/src/hardware.c index adba498bd..c0ae221dc 100644 --- a/src/confd/src/hardware.c +++ b/src/confd/src/hardware.c @@ -221,6 +221,46 @@ static const char *resolve_mobility_domain(const char *mobility_domain, const ch return hash_result; } +/* + * Key for the 802.11r key holder exchange between access points, hex + * encoded SHA-256 over the mobility domain and the passphrase. Every AP + * of the SSID derives the same key, so the wildcard R0KH/R1KH entries + * let any of them fetch a roaming client's PMK-R1 from the AP it came + * from, which is what fast transition needs for WPA3 (SAE) clients: their + * PMK comes from the SAE handshake and cannot be regenerated locally the + * way a WPA2 PSK can. + */ +static int wifi_ft_key(const char *mobility_domain, const unsigned char *secret, char *out, size_t len) +{ + unsigned char digest[EVP_MAX_MD_SIZE]; + unsigned int dlen = 0; + EVP_MD_CTX *ctx; + size_t i; + + if (len < 2 * 32 + 1) + return -1; + + ctx = EVP_MD_CTX_new(); + if (!ctx) + return -1; + + if (EVP_DigestInit_ex(ctx, EVP_sha256(), NULL) != 1 || + EVP_DigestUpdate(ctx, "infix-ft:", 9) != 1 || + EVP_DigestUpdate(ctx, mobility_domain, strlen(mobility_domain)) != 1 || + EVP_DigestUpdate(ctx, ":", 1) != 1 || + EVP_DigestUpdate(ctx, secret, strlen((const char *)secret)) != 1 || + EVP_DigestFinal_ex(ctx, digest, &dlen) != 1 || dlen < 32) { + EVP_MD_CTX_free(ctx); + return -1; + } + EVP_MD_CTX_free(ctx); + + for (i = 0; i < 32; i++) + snprintf(out + 2 * i, 3, "%02x", digest[i]); + + return 0; +} + /* * Find an AP interface on a higher-band radio (5/6 GHz) advertising the * same SSID as the caller's 2.4 GHz BSS, for no_probe_resp_if_seen_on=. @@ -519,6 +559,9 @@ static void wifi_gen_ssid_config(FILE *hostapd, struct lyd_node *cif, struct lyd /* 802.11r: Fast BSS Transition */ if (enable_80211r) { + const char *bridge = lydx_get_cattr(lydx_get_child(cif, "bridge-port"), "bridge"); + char ft_key[65]; + fprintf(hostapd, "# Fast BSS Transition (802.11r)\n"); fprintf(hostapd, "mobility_domain=%s\n", mobility_domain); /* Over-the-air FT: the client authenticates directly with the @@ -527,6 +570,36 @@ static void wifi_gen_ssid_config(FILE *hostapd, struct lyd_node *cif, struct lyd fprintf(hostapd, "ft_over_ds=0\n"); fprintf(hostapd, "ft_psk_generate_local=1\n"); fprintf(hostapd, "nas_identifier=%s\n", nas_identifier_cfg); + /* The key holders of all APs on the SSID reach each other over + * the network the APs are bridged to, see wifi_ft_key(). On a + * VLAN filtering bridge that is the APs' VLAN: use its VLAN + * interface when there is one, the bridge device itself has + * no say in which VLAN its frames end up in. */ + if (secret && !wifi_ft_key(mobility_domain, secret, ft_key, sizeof(ft_key))) { + if (bridge) { + const char *pvid = lydx_get_cattr(lydx_get_child(cif, "bridge-port"), "pvid"); + const char *ft_iface = bridge; + + if (pvid) { + struct lyd_node *vif; + + vif = lydx_get_xpathf(config, "/interfaces/interface[vlan/id='%s' and vlan/lower-layer-if='%s']/name", + pvid, bridge); + if (vif) + ft_iface = lyd_get_value(vif); + } + fprintf(hostapd, "ft_iface=%s\n", ft_iface); + } + fprintf(hostapd, "r0kh=ff:ff:ff:ff:ff:ff * %s\n", ft_key); + fprintf(hostapd, "r1kh=00:00:00:00:00:00 00:00:00:00:00:00 %s\n", ft_key); + /* A client roaming away from a node that is going down + * asks for a key that node can no longer hand out. + * Give up on the fetch quickly and reject, the client + * then logs in the normal way; waiting in silence makes + * it blacklist the target instead. */ + fprintf(hostapd, "rkh_pull_timeout=300\n"); + fprintf(hostapd, "rkh_pull_retries=1\n"); + } } /* 802.11k: Radio Resource Management */ From ddd0ee4e8d0e42e1a205e0f3c7de4fbb7771d45f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20Walstr=C3=B6m?= Date: Wed, 7 Oct 2026 09:41:42 +0200 Subject: [PATCH 33/38] confd: wifi: Hand clients over before hostapd stops MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A client with a good signal has no reason to roam, so when its access point goes away it only notices once the beacons stop, and then scans for a new network. Run hostapd through a wrapper that, when stopped, sends every station an 802.11v BSS transition request with disassociation imminent and waits for them to leave, so clients that support it roam while the radio is still up. Give finit ten seconds before SIGKILL to make room for that. Signed-off-by: Mattias Walström --- .../rootfs/usr/libexec/infix/hostapd.sh | 76 +++++++++++++++++++ doc/ChangeLog.md | 4 + doc/wifi.md | 7 ++ src/confd/src/hardware.c | 7 +- .../interfaces/wifi_mesh_roaming/test.adoc | 5 ++ .../case/interfaces/wifi_mesh_roaming/test.py | 35 ++++++++- 6 files changed, 131 insertions(+), 3 deletions(-) create mode 100755 board/common/rootfs/usr/libexec/infix/hostapd.sh diff --git a/board/common/rootfs/usr/libexec/infix/hostapd.sh b/board/common/rootfs/usr/libexec/infix/hostapd.sh new file mode 100755 index 000000000..d02cac354 --- /dev/null +++ b/board/common/rootfs/usr/libexec/infix/hostapd.sh @@ -0,0 +1,76 @@ +#!/bin/sh +# Run hostapd and, when stopped, hand the clients over first. +# +# A client with a good signal has no reason to roam, so when its access +# point goes away it only notices once the beacons stop, and then has to +# scan for a new network. An 802.11v BSS transition request with +# disassociation imminent makes it roam to another access point while +# the radio is still up. Clients without 802.11v are deauthenticated +# by hostapd on exit, as before. + +# The request names no candidate, a node does not know the other nodes' +# access points, so the client has to scan for one: a few seconds on a +# real radio across three bands. A client that roams never tells the old +# access point either, hostapd drops it from its station list when the +# timer runs out, so wait a little longer than that before giving up. +TIMER=50 # beacon intervals (100 ms) until hostapd disassociates a client that stays +WAIT=35 # polls, 200 ms apart, for the clients to leave + +bsses() +{ + for sock in /run/hostapd/*; do + [ -S "$sock" ] && echo "${sock##*/}" + done +} + +stations() +{ + for bss in $(bsses); do + hostapd_cli -i "$bss" list_sta 2>/dev/null + done +} + +handover() +{ + num=0 + for bss in $(bsses); do + for sta in $(hostapd_cli -i "$bss" list_sta 2>/dev/null); do + hostapd_cli -i "$bss" disassoc_imminent "$sta" $TIMER >/dev/null 2>&1 + num=$((num + 1)) + done + done + if [ $num -eq 0 ]; then + logger -t hostapd -p daemon.notice "stop: no stations on $(bsses | tr '\n' ' ')" + return 0 + fi + + i=0 + while [ $i -lt $WAIT ]; do + [ -z "$(stations)" ] && break + sleep 0.2 + i=$((i + 1)) + done + + left=$(stations | grep -c .) + if [ "$left" -eq 0 ]; then + logger -t hostapd -p daemon.notice "stop: asked $num station(s) to move, all left" + else + logger -t hostapd -p daemon.notice "stop: asked $num station(s) to move, $left still here" + fi +} + +stop() +{ + handover + kill -TERM "$pid" 2>/dev/null +} + +hostapd "$@" & +pid=$! +trap stop TERM INT +rc=0 +while kill -0 "$pid" 2>/dev/null; do + wait "$pid" + rc=$? +done +exit $rc diff --git a/doc/ChangeLog.md b/doc/ChangeLog.md index 284b3caa0..2c7cb7842 100644 --- a/doc/ChangeLog.md +++ b/doc/ChangeLog.md @@ -26,6 +26,10 @@ All notable changes to the project are documented in this file. domain and is applied as soon as it is set, so a radio without an interface is in the right regulatory domain too. Existing configurations are migrated, the first radio's code wins +- A WiFi access point asks its clients to move to another access point, + with 802.11v, before it stops for a reboot, an upgrade, or a + configuration change, so clients that support it roam without losing + their connection - WiFi channel survey is now on request: `show hardware survey` in the CLI, a Scan channels button per radio on the WebUI WiFi page, or the `channel-survey` action on the radio. The survey covers every channel the diff --git a/doc/wifi.md b/doc/wifi.md index c69be4907..097160ddf 100644 --- a/doc/wifi.md +++ b/doc/wifi.md @@ -672,6 +672,13 @@ admin@example:/config/interface/wifi0/> set wifi access-point roaming dot11v Allows APs to suggest better APs to clients, improving roaming decisions. +An access point about to stop, because the device reboots, is upgraded, +or its WiFi configuration changes, also uses 802.11v to ask its clients +to move first. Clients that support it roam to another access point +with the same SSID while the radio is still up, instead of noticing the +loss afterwards and scanning for a new network. Clients without +802.11v are disconnected as before. + #### Band Steering (MBO) Enabling `dot11v` also turns on MBO (Multi-Band Operation), advertised in diff --git a/src/confd/src/hardware.c b/src/confd/src/hardware.c index c0ae221dc..485a793bd 100644 --- a/src/confd/src/hardware.c +++ b/src/confd/src/hardware.c @@ -1570,8 +1570,11 @@ int hardware_change(sr_session_ctx_t *session, struct lyd_node *config, struct l rc = SR_ERR_INTERNAL; } else { fprintf(fp, "# Generated by confd, do not edit.\n"); - fprintf(fp, "service name:hostapd \\\n"); - fprintf(fp, "\t[2345] hostapd -g /run/hostapd.global -P /run/hostapd.pid"); + /* The wrapper hands the clients over to another + * access point before hostapd stops, give it time. */ + fprintf(fp, "service name:hostapd kill:10 \\\n"); + fprintf(fp, "\t[2345] /usr/libexec/infix/hostapd.sh" + " -g /run/hostapd.global -P /run/hostapd.pid"); for (i = 0; i < gl.gl_pathc; i++) fprintf(fp, " %s", gl.gl_pathv[i]); fprintf(fp, " \\\n\t-- Wi-Fi Access Points\n"); diff --git a/test/case/interfaces/wifi_mesh_roaming/test.adoc b/test/case/interfaces/wifi_mesh_roaming/test.adoc index 77290bed3..630bb2fd8 100644 --- a/test/case/interfaces/wifi_mesh_roaming/test.adoc +++ b/test/case/interfaces/wifi_mesh_roaming/test.adoc @@ -60,5 +60,10 @@ image::topology.svg[WiFi Mesh backhaul with roaming Access Points topology, alig . 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 +. Stop the WiFi service on the client's current node +. Verify the client roams to the remaining node's AP +. Verify the client was asked to move and roamed without disconnecting +. Verify connectivity is restored after the handover +. Start the WiFi service again on the stopped node diff --git a/test/case/interfaces/wifi_mesh_roaming/test.py b/test/case/interfaces/wifi_mesh_roaming/test.py index c85c3c1ff..7a166cd24 100755 --- a/test/case/interfaces/wifi_mesh_roaming/test.py +++ b/test/case/interfaces/wifi_mesh_roaming/test.py @@ -110,13 +110,18 @@ def gw_config(mesh_mac, ap_mac, uplink=None): # 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( + gw1, gw2, gw3, client, gw1sh, gw2sh, gw3sh, clientsh = parallel( lambda: env.attach("gw1", "mgmt"), lambda: env.attach("gw2", "mgmt"), lambda: env.attach("gw3", "mgmt"), lambda: env.attach("client", "mgmt"), + lambda: env.attach("gw1", "mgmt", "ssh"), + lambda: env.attach("gw2", "mgmt", "ssh"), + lambda: env.attach("gw3", "mgmt", "ssh"), + lambda: env.attach("client", "mgmt", "ssh"), ) gw_duts = [gw1, gw2, gw3] + shells = {"gw1": gw1sh, "gw2": gw2sh, "gw3": gw3sh} gws = [(name, dut, mesh, ap) for (name, mesh, ap), dut in zip(GWS, gw_duts)] wifi.skip_unless_supported(test, client, *gw_duts) @@ -198,4 +203,32 @@ def gw_config(mesh_mac, ap_mac, uplink=None): with test.step("Verify connectivity is restored after roaming"): ns.must_reach(CLIENT_IP) + # A node going down for a reboot or an upgrade asks its clients + # to move first, so a client with a strong signal roams instead + # of waiting for the beacons to stop. + second_bssid = wifi.station_bssid(client) + second_ap, _ = aps[second_bssid] + + with test.step("Stop the WiFi service on the client's current node"): + shells[second_ap].runsh("initctl stop hostapd") + + with test.step("Verify the client roams to the remaining node's AP"): + until(lambda: wifi.station_bssid(client) in aps and + wifi.station_bssid(client) not in (first_bssid, second_bssid), + attempts=30, interval=1) + third_ap, _ = aps[wifi.station_bssid(client)] + print(f"client roamed from {second_ap} to {third_ap}") + + with test.step("Verify the client was asked to move and roamed without disconnecting"): + def log(): + return clientsh.runsh("sed -n '/WNM: Disassociation Imminent/,$p' /var/log/syslog").stdout + until(lambda: "Disassociation Imminent" in log(), attempts=10, interval=1) + assert "CTRL-EVENT-DISCONNECTED" not in log(), log() + + with test.step("Verify connectivity is restored after the handover"): + ns.must_reach(CLIENT_IP) + + with test.step("Start the WiFi service again on the stopped node"): + shells[second_ap].runsh("initctl start hostapd") + test.succeed() From bb1f134fa312db79f5e5ceef3e05274c61dfcf18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20Walstr=C3=B6m?= Date: Wed, 7 Oct 2026 10:03:07 +0200 Subject: [PATCH 34/38] test: Regenerate the OSPF BFD specifications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Mattias Walström --- test/case/routing/ospf_bfd/ospfv2.adoc | 6 +++--- test/case/routing/ospf_bfd/ospfv3.adoc | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/test/case/routing/ospf_bfd/ospfv2.adoc b/test/case/routing/ospf_bfd/ospfv2.adoc index 78e548c64..a5e31f327 100644 --- a/test/case/routing/ospf_bfd/ospfv2.adoc +++ b/test/case/routing/ospf_bfd/ospfv2.adoc @@ -12,9 +12,9 @@ This can typically happen when one logical link, from OSPF's perspective, is made up of multiple physical links containing media converters without link fault forwarding. -Note: OSPFv3 next-hops are IPv6 link-local addresses, so the active path is -verified with traceroute rather than by matching a RIB next-hop, and its BFD -peers are only known by their session count. +Note: OSPFv3 next-hops and BFD peers are IPv6 link-local addresses, unknown in +advance, so both versions verify the active path with traceroute rather than by +matching a RIB next-hop, and count BFD sessions rather than name their peers. ==== Topology diff --git a/test/case/routing/ospf_bfd/ospfv3.adoc b/test/case/routing/ospf_bfd/ospfv3.adoc index 65aa96443..5fbe3a0de 100644 --- a/test/case/routing/ospf_bfd/ospfv3.adoc +++ b/test/case/routing/ospf_bfd/ospfv3.adoc @@ -12,9 +12,9 @@ This can typically happen when one logical link, from OSPF's perspective, is made up of multiple physical links containing media converters without link fault forwarding. -Note: OSPFv3 next-hops are IPv6 link-local addresses, so the active path is -verified with traceroute rather than by matching a RIB next-hop, and its BFD -peers are only known by their session count. +Note: OSPFv3 next-hops and BFD peers are IPv6 link-local addresses, unknown in +advance, so both versions verify the active path with traceroute rather than by +matching a RIB next-hop, and count BFD sessions rather than name their peers. ==== Topology From 70d4425f239d9fc9e58312532e729b970df2621a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20Walstr=C3=B6m?= Date: Wed, 7 Oct 2026 10:24:42 +0200 Subject: [PATCH 35/38] gitignore: Ignore __pycache__ directories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Mattias Walström --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 40975fc54..8af2d4f84 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ AGENTS.md /test/.log /local.mk /test/spec/Readme.adoc +__pycache__/ From 6f2e5540af594a9562dccda44d92b55efca24b44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20Walstr=C3=B6m?= Date: Wed, 7 Oct 2026 11:16:04 +0200 Subject: [PATCH 36/38] confd: wifi: Steer dual-band clients to the higher band MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Withholding probe responses only sways a client that is choosing a network, a client already connected on 2.4 GHz stays there. Ask such clients to move with an 802.11v request naming the higher-band twin, from a steering loop the hostapd wrapper runs per pair, as long as the twin is up and the client's 2.4 GHz signal makes the move worthwhile; a client that shrugs off a couple of requests is left alone for an hour. Refusing authentication on 2.4 GHz for clients the twin has seen was tried and dropped: it locks a client out when the twin cannot take it. Keep the seen-on list to one minute. Signed-off-by: Mattias Walström --- .../rootfs/usr/libexec/infix/hostapd.sh | 36 ++++++- .../rootfs/usr/libexec/infix/wifi-steer.sh | 96 +++++++++++++++++++ doc/ChangeLog.md | 3 + doc/wifi.md | 27 +++++- src/confd/src/hardware.c | 25 +++-- src/confd/yang/confd/infix-if-wifi.yang | 6 ++ .../interfaces/wifi_band_steering/test.adoc | 5 + .../interfaces/wifi_band_steering/test.py | 36 ++++++- 8 files changed, 217 insertions(+), 17 deletions(-) create mode 100755 board/common/rootfs/usr/libexec/infix/wifi-steer.sh diff --git a/board/common/rootfs/usr/libexec/infix/hostapd.sh b/board/common/rootfs/usr/libexec/infix/hostapd.sh index d02cac354..307f313a4 100755 --- a/board/common/rootfs/usr/libexec/infix/hostapd.sh +++ b/board/common/rootfs/usr/libexec/infix/hostapd.sh @@ -1,5 +1,6 @@ #!/bin/sh -# Run hostapd and, when stopped, hand the clients over first. +# Run hostapd, steer dual-band clients to 5 GHz while it runs, and hand +# the clients over when stopped. # # A client with a good signal has no reason to roam, so when its access # point goes away it only notices once the beacons stop, and then has to @@ -59,15 +60,48 @@ handover() fi } +# Band steering pairs, a 2.4 GHz BSS and the 5/6 GHz twin it defers to, +# from the no_probe_resp_if_seen_on directives in the radio configs. +pairs() +{ + for conf in "$@"; do + case $conf in + *.conf) ;; + *) continue ;; + esac + awk -F= '/^(interface|bss)=/ { cur = $2 } + /^no_probe_resp_if_seen_on=/ { print cur, $2 }' "$conf" + done +} + +steer() +{ + pairs "$@" | while read -r bss twin; do + i=0 + while [ ! -S /run/hostapd/$bss ] && [ $i -lt 50 ]; do + sleep 0.2 + i=$((i + 1)) + done + [ -S /run/hostapd/$bss ] || continue + /usr/libexec/infix/wifi-steer.sh "$bss" "$twin" & + echo $! >> /run/wifi-steer/pids + done +} + stop() { + [ -f /run/wifi-steer/pids ] && kill $(cat /run/wifi-steer/pids) 2>/dev/null + rm -rf /run/wifi-steer handover kill -TERM "$pid" 2>/dev/null } +rm -rf /run/wifi-steer +mkdir -p /run/wifi-steer hostapd "$@" & pid=$! trap stop TERM INT +steer "$@" & rc=0 while kill -0 "$pid" 2>/dev/null; do wait "$pid" diff --git a/board/common/rootfs/usr/libexec/infix/wifi-steer.sh b/board/common/rootfs/usr/libexec/infix/wifi-steer.sh new file mode 100755 index 000000000..0f9cd64db --- /dev/null +++ b/board/common/rootfs/usr/libexec/infix/wifi-steer.sh @@ -0,0 +1,96 @@ +#!/bin/sh +# Move dual-band clients from a 2.4 GHz access point to its 5/6 GHz twin. +# +# Usage: wifi-steer.sh <2.4 GHz bss> +# +# hostapd keeps a list of the clients each radio has seen lately. A +# client connected on 2.4 GHz that the twin has seen is dual-band and in +# range of the twin, so ask it to move with an 802.11v BSS transition +# request naming the twin. Clients that ignore the request stay: a +# client is asked again only after a cooldown, and one that has shrugged +# off a couple of requests is left alone for an hour. A client with a +# weak 2.4 GHz signal is left alone too, 5 GHz would be worse, and +# nobody is sent to a twin that is down or still checking for radar. +bss=$1 +twin=$2 + +PERIOD=15 # seconds between rounds +COOLDOWN=120 # seconds before asking the same client again +GIVEUP=2 # requests a client may shrug off before it is left alone ... +LONG=3600 # ... for this long +MIN_SIGNAL=-65 # dBm on 2.4 GHz below which a client is left alone + +state=/run/wifi-steer/$bss +mkdir -p "$state" + +# Neighbor report candidate for the twin: BSSID, BSSID information, +# operating class, channel and PHY type. Taken from the twin's own +# neighbor entry when it keeps one (802.11k), else built from its status +# with the 20 MHz operating class of the channel, enough for the client +# to find the BSS and learn the rest from its beacons. +candidate() +{ + bssid=$(hostapd_cli -i "$twin" get_config 2>/dev/null | sed -n 's/^bssid=//p') + [ -n "$bssid" ] || return 1 + + nr=$(hostapd_cli -i "$twin" show_neighbor 2>/dev/null | \ + awk -v b="$bssid" 'tolower($1) == tolower(b) { for (i = 2; i <= NF; i++) if ($i ~ /^nr=/) print substr($i, 4) }') + if [ ${#nr} -ge 26 ]; then + info=$(echo "$nr" | cut -c13-20) + info=$(printf '%d' "0x$(echo "$info" | cut -c7-8)$(echo "$info" | cut -c5-6)$(echo "$info" | cut -c3-4)$(echo "$info" | cut -c1-2)") + op=$(printf '%d' "0x$(echo "$nr" | cut -c21-22)") + chan=$(printf '%d' "0x$(echo "$nr" | cut -c23-24)") + phy=$(printf '%d' "0x$(echo "$nr" | cut -c25-26)") + echo "$bssid,$info,$op,$chan,$phy" + return 0 + fi + + status=$(hostapd_cli -i "$twin" status 2>/dev/null) + freq=$(echo "$status" | sed -n 's/^freq=//p') + chan=$(echo "$status" | sed -n 's/^channel=//p') + [ -n "$freq" ] && [ -n "$chan" ] || return 1 + if [ "$freq" -ge 5925 ]; then + op=131 + elif [ "$freq" -ge 5745 ]; then + op=124 + elif [ "$freq" -ge 5500 ]; then + op=121 + elif [ "$freq" -ge 5260 ]; then + op=118 + elif [ "$freq" -ge 5180 ]; then + op=115 + else + op=81 + fi + # BSSID information: AP reachable, same security and key scope. + echo "$bssid,1151,$op,$chan,9" +} + +while sleep $PERIOD; do + stas=$(hostapd_cli -i "$bss" list_sta 2>/dev/null) + [ -n "$stas" ] || continue + # Nothing to move to while the twin is down or checking for radar + hostapd_cli -i "$twin" status 2>/dev/null | grep -q '^state=ENABLED' || continue + # hostapd_cli has no shorthand for the seen-on list, ask hostapd directly + seen=$(hostapd_cli -i "$twin" raw TRACK_STA_LIST 2>/dev/null) + [ -n "$seen" ] || continue + cand=$(candidate) || continue + now=$(date +%s) + + for sta in $stas; do + echo "$seen" | grep -qi "^$sta " || continue + read -r last tries < "$state/$sta" 2>/dev/null || { last=0; tries=0; } + wait=$COOLDOWN + [ "${tries:-0}" -lt $GIVEUP ] || wait=$LONG + [ $((now - last)) -ge $wait ] || continue + [ "${tries:-0}" -lt $GIVEUP ] || tries=0 + signal=$(hostapd_cli -i "$bss" sta "$sta" 2>/dev/null | sed -n 's/^signal=//p') + if [ -n "$signal" ] && [ "$signal" -lt $MIN_SIGNAL ]; then + continue + fi + hostapd_cli -i "$bss" bss_tm_req "$sta" pref=1 abridged=1 valid_int=255 \ + "neighbor=$cand" >/dev/null 2>&1 + echo "$now $((tries + 1))" > "$state/$sta" + logger -t hostapd -p daemon.notice "$bss: asked $sta to move to $twin" + done +done diff --git a/doc/ChangeLog.md b/doc/ChangeLog.md index 2c7cb7842..22effeacc 100644 --- a/doc/ChangeLog.md +++ b/doc/ChangeLog.md @@ -26,6 +26,9 @@ All notable changes to the project are documented in this file. domain and is applied as soon as it is set, so a radio without an interface is in the right regulatory domain too. Existing configurations are migrated, the first radio's code wins +- WiFi band steering now also moves dual-band clients that are already + connected on 2.4 GHz to the 5 or 6 GHz access point of the same SSID, + with an 802.11v request - A WiFi access point asks its clients to move to another access point, with 802.11v, before it stops for a reboot, an upgrade, or a configuration change, so clients that support it roam without losing diff --git a/doc/wifi.md b/doc/wifi.md index 097160ddf..9d4234b04 100644 --- a/doc/wifi.md +++ b/doc/wifi.md @@ -687,11 +687,28 @@ the same SSID exists on another band and decide for itself when to move, while 802.11v BSS Transition Management lets the AP suggest a better target. -On top of the client-cooperative hints, the AP applies active steering: -on a 2.4 GHz access-point it suppresses probe responses to clients that -were recently seen on the same SSID on the 5/6 GHz band, nudging -dual-band clients onto the higher band. MBO is **enabled by default** -whenever `dot11v` is enabled: +On top of these hints, the device steers dual-band clients to 5 or 6 GHz +itself. Each radio remembers the clients it has seen during the last +minute. A 2.4 GHz access point whose SSID also exists on a higher band +of the same device treats a client that higher band has seen as +dual-band and in range of it: + +- it does not answer the client's probe requests, so a client choosing + a network tends to pick the higher band, +- a client that is connected on 2.4 GHz anyway, for example because it + joined while the higher band was down, is asked to move with an + 802.11v request naming the higher band, as long as its 2.4 GHz signal + is good enough for the move to make sense and the higher band is up. + A client that shrugs off a couple of requests is left alone for an + hour. + +A client is never refused on 2.4 GHz, so one that cannot get in on the +higher band still has a way in. Clients that only support 2.4 GHz are +never seen on the higher band and are not affected, and clients that +ignore 802.11v requests stay where they are. Band +steering is **enabled by default** whenever `dot11v` is enabled. The +setting is read on the 2.4 GHz access point, the one that defers and +moves clients; on a 5 or 6 GHz access point it has no effect: ``` admin@example:/config/interface/wifi0/> set wifi access-point roaming dot11v diff --git a/src/confd/src/hardware.c b/src/confd/src/hardware.c index 485a793bd..212ccc423 100644 --- a/src/confd/src/hardware.c +++ b/src/confd/src/hardware.c @@ -633,16 +633,14 @@ static void wifi_gen_ssid_config(FILE *hostapd, struct lyd_node *cif, struct lyd fprintf(hostapd, "mbo=1\n"); - /* Required for no_probe_resp_if_seen_on below: without it - * hostapd keeps no sta_track list, so the twin radio never - * knows which clients it has seen. Radio-level, emit once - * in the main section, never per BSS. */ - if (!is_bss) - fprintf(hostapd, "track_sta_max_num=100\n"); - - /* Active band steering: on a 2.4 GHz BSS, suppress probe - * responses to clients recently seen on the same-SSID 5/6 - * GHz BSS, nudging dual-band clients to the higher band. */ + /* Band steering on a 2.4 GHz BSS with a same-SSID twin on 5/6 + * GHz: a client the twin has seen lately gets no probe response + * here, so it tends to join the twin instead. The seen-on list + * is kept per radio, see the radio section. Clients already + * connected are moved by wifi-steer.sh, which finds the pairs + * by this directive. Refusing authentication as well would + * lock a client out when the twin cannot take it, e.g. during + * its radar check, so that is deliberately not done. */ twin = wifi_find_higher_band_twin(config, band, ssid); if (twin) fprintf(hostapd, "no_probe_resp_if_seen_on=%s\n", twin); @@ -1029,6 +1027,13 @@ static void wifi_gen_radio_config(FILE *hostapd, const char *radio_name, /* Use short preamble for better throughput on modern clients */ fprintf(hostapd, "preamble=1\n"); + /* Remember the clients this radio has seen, for band steering on + * the other radios. A dual-band client is refused on 2.4 GHz + * while it is on this list, so keep the list short-lived: that is + * the longest a client that cannot get in on 5 GHz has to wait. */ + fprintf(hostapd, "track_sta_max_num=100\n"); + fprintf(hostapd, "track_sta_max_age=60\n"); + if (band) { if (!strcmp(band, "2.4GHz")) { /* hw_mode=g: 2.4GHz with 802.11g (OFDM) as baseline */ diff --git a/src/confd/yang/confd/infix-if-wifi.yang b/src/confd/yang/confd/infix-if-wifi.yang index 8af593ad3..ece455d0f 100644 --- a/src/confd/yang/confd/infix-if-wifi.yang +++ b/src/confd/yang/confd/infix-if-wifi.yang @@ -608,6 +608,12 @@ submodule infix-if-wifi { encouraging dual-band devices to prefer 5GHz over 2.4GHz when signal quality permits. + Acts on a 2.4GHz access point whose SSID is also + served on 5 or 6GHz by this device: it defers + dual-band clients to that access point, and moves + the ones already connected. On a 5 or 6GHz + access point the setting has no effect. + MBO steers clients via 802.11v BSS Transition Management, hence its placement under dot11v; enabling dot11v enables band steering by default. diff --git a/test/case/interfaces/wifi_band_steering/test.adoc b/test/case/interfaces/wifi_band_steering/test.adoc index af7edd2dd..5f0242b12 100644 --- a/test/case/interfaces/wifi_band_steering/test.adoc +++ b/test/case/interfaces/wifi_band_steering/test.adoc @@ -45,5 +45,10 @@ image::topology.svg[WiFi Band Steering across a dual-band Access Point topology, . 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 +. Disable the 5GHz BSS +. Verify the client falls back to the 2.4GHz BSS +. Enable the 5GHz BSS again +. Verify a scanning client is steered back to the 5GHz BSS +. Verify the client still has its address after the move diff --git a/test/case/interfaces/wifi_band_steering/test.py b/test/case/interfaces/wifi_band_steering/test.py index cb93b3a58..4f43f3aba 100755 --- a/test/case/interfaces/wifi_band_steering/test.py +++ b/test/case/interfaces/wifi_band_steering/test.py @@ -72,9 +72,10 @@ def ap_bss(name, radio_name, bssid): 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( + ap, client, clientsh = parallel( lambda: env.attach("ap", "mgmt"), lambda: env.attach("client", "mgmt"), + lambda: env.attach("client", "mgmt", "ssh"), ) wifi.skip_unless_supported(test, ap, client) @@ -135,4 +136,37 @@ def ap_bss(name, radio_name, bssid): iface.address_exist(client, "wifi0", POOL_END), attempts=60, interval=2) + # The 5GHz BSS goes away: the client falls back to 2.4GHz once the + # 5GHz radio has not seen it for a minute. When the 5GHz BSS comes + # back, the client is connected on the wrong band. A client that + # scans is seen on 5GHz again and gets asked to move there. Real + # clients scan in the background on their own, this one is told to. + with test.step("Disable the 5GHz BSS"): + ap.put_config_dicts({"ietf-interfaces": {"interfaces": { + "interface": [{"name": "wifi1", "enabled": False}]}}}) + + with test.step("Verify the client falls back to the 2.4GHz BSS"): + until(lambda: CLIENT_MAC in wifi.ap_stations(ap, "wifi0"), + attempts=90, interval=2) + + with test.step("Enable the 5GHz BSS again"): + ap.put_config_dicts({"ietf-interfaces": {"interfaces": { + "interface": [{"name": "wifi1", "enabled": True}]}}}) + until(lambda: iface.is_oper_up(ap, "wifi1"), attempts=60, interval=2) + + with test.step("Verify a scanning client is steered back to the 5GHz BSS"): + def steered(): + if CLIENT_MAC in wifi.ap_stations(ap, "wifi1"): + return True + clientsh.runsh("sudo wpa_cli -i wifi0 scan >/dev/null") + return False + until(steered, attempts=20, interval=10) + assert CLIENT_MAC not in wifi.ap_stations(ap, "wifi0"), \ + "client still associated on 2.4GHz" + + with test.step("Verify the client still has its address after the move"): + until(lambda: iface.address_exist(client, "wifi0", POOL_START) or + iface.address_exist(client, "wifi0", POOL_END), + attempts=30, interval=2) + test.succeed() From 88b9496febe20d5c6926958f03014d5d9e810890 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20Walstr=C3=B6m?= Date: Wed, 7 Oct 2026 15:33:25 +0200 Subject: [PATCH 37/38] confd: wifi: Tell the other nodes about our access points MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A client asked to move needs to know where to, wpa_supplicant ignores a transition request without candidates, and a node does not know the other nodes' access points. Every 30 seconds a node announces its access points in one frame per network they are bridged to, the network the 802.11r key exchange uses, and listens for the other nodes' frames. What it hears goes to hostapd as 802.11k neighbors of every access point with the same SSID, and into the handover request. No radio leaves its channel for it. Signed-off-by: Mattias Walström --- .../rootfs/usr/libexec/infix/hostapd.sh | 70 +++-- .../usr/libexec/infix/wifi-neighbors.py | 245 ++++++++++++++++++ doc/ChangeLog.md | 4 +- doc/wifi.md | 16 +- .../interfaces/wifi_mesh_roaming/test.adoc | 2 +- .../case/interfaces/wifi_mesh_roaming/test.py | 17 +- 6 files changed, 328 insertions(+), 26 deletions(-) create mode 100755 board/common/rootfs/usr/libexec/infix/wifi-neighbors.py diff --git a/board/common/rootfs/usr/libexec/infix/hostapd.sh b/board/common/rootfs/usr/libexec/infix/hostapd.sh index 307f313a4..9b68f31a4 100755 --- a/board/common/rootfs/usr/libexec/infix/hostapd.sh +++ b/board/common/rootfs/usr/libexec/infix/hostapd.sh @@ -1,6 +1,6 @@ #!/bin/sh -# Run hostapd, steer dual-band clients to 5 GHz while it runs, and hand -# the clients over when stopped. +# Run hostapd, learn the other nodes' access points, steer dual-band +# clients to 5 GHz while it runs, and hand the clients over when stopped. # # A client with a good signal has no reason to roam, so when its access # point goes away it only notices once the beacons stop, and then has to @@ -9,11 +9,11 @@ # the radio is still up. Clients without 802.11v are deauthenticated # by hostapd on exit, as before. -# The request names no candidate, a node does not know the other nodes' -# access points, so the client has to scan for one: a few seconds on a -# real radio across three bands. A client that roams never tells the old -# access point either, hostapd drops it from its station list when the -# timer runs out, so wait a little longer than that before giving up. +# A client given candidates roams in well under a second, one without +# has to scan first, a few seconds on a real radio across three bands. +# A client that roams never tells the old access point either, hostapd +# drops it from its station list when the timer runs out, so wait a +# little longer than that before giving up. TIMER=50 # beacon intervals (100 ms) until hostapd disassociates a client that stays WAIT=35 # polls, 200 ms apart, for the clients to leave @@ -31,12 +31,34 @@ stations() done } +# Ask a station to leave, naming the other access points of the SSID +# wifi-neighbors.py has heard of: a client does not look for a new +# access point on a request without candidates. +ask_to_move() +{ + bss=$1 + sta=$2 + cands="" + if [ -s /run/wifi-neighbors/$bss ]; then + while read -r cand; do + cands="$cands neighbor=$cand" + done < /run/wifi-neighbors/$bss + fi + if [ -n "$cands" ]; then + # shellcheck disable=SC2086 + hostapd_cli -i "$bss" bss_tm_req "$sta" disassoc_imminent=1 disassoc_timer=$TIMER \ + pref=1 abridged=1 $cands >/dev/null 2>&1 + else + hostapd_cli -i "$bss" disassoc_imminent "$sta" $TIMER >/dev/null 2>&1 + fi +} + handover() { num=0 for bss in $(bsses); do for sta in $(hostapd_cli -i "$bss" list_sta 2>/dev/null); do - hostapd_cli -i "$bss" disassoc_imminent "$sta" $TIMER >/dev/null 2>&1 + ask_to_move "$bss" "$sta" num=$((num + 1)) done done @@ -74,15 +96,31 @@ pairs() done } -steer() +# Wait for the control socket of a BSS, up to ten seconds +wait_bss() +{ + i=0 + while [ ! -S /run/hostapd/$1 ] && [ $i -lt 50 ]; do + sleep 0.2 + i=$((i + 1)) + done + [ -S /run/hostapd/$1 ] +} + +helpers() { + # Exchange access point lists with the other nodes once hostapd is up + for conf in "$@"; do + case $conf in *.conf) ;; *) continue ;; esac + wait_bss "$(sed -n 's/^interface=//p' "$conf")" || continue + /usr/libexec/infix/wifi-neighbors.py "$@" & + echo $! >> /run/wifi-steer/pids + break + done + + # Steer dual-band clients to the higher band, one loop per pair pairs "$@" | while read -r bss twin; do - i=0 - while [ ! -S /run/hostapd/$bss ] && [ $i -lt 50 ]; do - sleep 0.2 - i=$((i + 1)) - done - [ -S /run/hostapd/$bss ] || continue + wait_bss "$bss" || continue /usr/libexec/infix/wifi-steer.sh "$bss" "$twin" & echo $! >> /run/wifi-steer/pids done @@ -101,7 +139,7 @@ mkdir -p /run/wifi-steer hostapd "$@" & pid=$! trap stop TERM INT -steer "$@" & +helpers "$@" & rc=0 while kill -0 "$pid" 2>/dev/null; do wait "$pid" diff --git a/board/common/rootfs/usr/libexec/infix/wifi-neighbors.py b/board/common/rootfs/usr/libexec/infix/wifi-neighbors.py new file mode 100755 index 000000000..69871265e --- /dev/null +++ b/board/common/rootfs/usr/libexec/infix/wifi-neighbors.py @@ -0,0 +1,245 @@ +#!/usr/bin/env python3 +""" +Tell the other nodes about our access points, learn about theirs. + +Usage: wifi-neighbors.py ... + +A client asked to move needs to know where to, and a node knows nothing +about the other nodes' access points. Every 30 seconds each node sends +one frame per network its access points are bridged to, listing them: +BSSID, frequency, PHY type and SSID. The network is the one the 802.11r +key exchange uses, ft_iface in the hostapd config, so the announcements +reach exactly the nodes a client can roam to. Bridges flood the frames +over the backhaul like any multicast, no IP address is needed and no +radio ever leaves its channel. + +What is heard is kept for 90 seconds and handed to hostapd as 802.11k +neighbors of every access point with the same SSID, for its neighbor +reports and for the transition requests sent by hostapd.sh and +wifi-steer.sh, which read the candidate list of a BSS from +/run/wifi-neighbors/, one bss_tm_req neighbor= argument per line. +""" +import os +import re +import select +import socket +import struct +import subprocess +import sys +import time + +ETHERTYPE = 0x88B5 # IEEE 802 local experimental 1 +GROUP = bytes.fromhex('034b4b000001') # locally administered group address +MAGIC = b'infix-wifi 1' +PERIOD = 30 +EXPIRE = 95 +DIR = '/run/wifi-neighbors' + + +def log(msg): + subprocess.run(['logger', '-t', 'hostapd', '-p', 'daemon.notice', msg], + capture_output=True) + + +def hostapd_cli(bss, *args): + try: + res = subprocess.run(['hostapd_cli', '-i', bss, *args], + capture_output=True, text=True, timeout=5) + return res.stdout if res.returncode == 0 else '' + except (OSError, subprocess.SubprocessError): + return '' + + +def parse_configs(paths): + """{bss: ft_iface} for every BSS with a key exchange interface.""" + bsses = {} + for path in paths: + if not path.endswith('.conf'): + continue + cur = None + try: + lines = open(path).read().splitlines() + except OSError: + continue + for line in lines: + if line.startswith(('interface=', 'bss=')): + cur = line.split('=', 1)[1].strip() + bsses.setdefault(cur, None) + elif line.startswith('ft_iface=') and cur: + bsses[cur] = line.split('=', 1)[1].strip() + return bsses + + +def opclass(freq): + """20 MHz operating class of a frequency.""" + if freq >= 5925: + return 131 + if freq >= 5745: + return 124 + if freq >= 5500: + return 121 + if freq >= 5260: + return 118 + if freq >= 5180: + return 115 + return 81 + + +def channel(freq): + if freq >= 5925: + return (freq - 5950) // 5 + if freq >= 5000: + return (freq - 5000) // 5 + if freq == 2484: + return 14 + return (freq - 2407) // 5 + + +def own_bsses(bsses): + """{bss: (bssid, freq, phy, ssid)} from hostapd, for the BSSes up.""" + out = {} + for bss in bsses: + cfg = hostapd_cli(bss, 'get_config') + status = hostapd_cli(bss, 'status') + bssid = re.search(r'^bssid=(\S+)', cfg, re.M) + ssid = re.search(r'^ssid=(.*)$', cfg, re.M) + freq = re.search(r'^freq=(\d+)', status, re.M) + if not (bssid and ssid and freq): + continue + if 'ieee80211ax=1' in status: + phy = 9 + elif 'ieee80211ac=1' in status: + phy = 8 + elif 'ieee80211n=1' in status: + phy = 7 + else: + phy = 4 + out[bss] = (bssid.group(1).lower(), int(freq.group(1)), phy, ssid.group(1)) + return out + + +def announcement(host, own): + lines = [MAGIC + b' ' + host.encode()] + for bssid, freq, phy, ssid in own.values(): + lines.append(f'{bssid} {freq} {phy} {ssid}'.encode()) + return b'\n'.join(lines) + b'\n' + + +def parse_announcement(data): + """[(bssid, freq, phy, ssid)] or None when not ours.""" + try: + text = data.decode() + except UnicodeDecodeError: + return None + lines = text.split('\n') + if not lines or not lines[0].startswith(MAGIC.decode() + ' '): + return None + out = [] + for line in lines[1:]: + parts = line.split(' ', 3) + if len(parts) < 4: + continue + try: + out.append((parts[0].lower(), int(parts[1]), int(parts[2]), parts[3])) + except ValueError: + continue + return out + + +def candidate(bssid, freq, phy): + # BSSID information: AP reachable, same security and key scope + return f'{bssid},1151,{opclass(freq)},{channel(freq)},{phy}' + + +def nr_hex(bssid, freq, phy): + info = 1151 + return (bssid.replace(':', '') + struct.pack(' (bssid, freq, phy, ssid, last seen) + published = {} + next_send = 0.0 + own = {} + while True: + now = time.monotonic() + if now >= next_send: + own = own_bsses(bsses) + frame = GROUP + b'\0' * 6 + struct.pack('!H', ETHERTYPE) + announcement(host, own) + for s, ifc in socks.items(): + try: + mac = s.getsockname()[4] + s.send(GROUP + mac + frame[12:]) + except OSError: + pass + next_send = now + PERIOD + + ready, _, _ = select.select(list(socks), [], [], max(0.1, next_send - now)) + for s in ready: + try: + data = s.recv(2048) + except OSError: + continue + got = parse_announcement(data[14:]) + if not got: + continue + stamp = time.monotonic() + for bssid, freq, phy, ssid in got: + if any(bssid == o[0] for o in own.values()): + continue + neighbors[bssid] = (bssid, freq, phy, ssid, stamp) + + cutoff = time.monotonic() - EXPIRE + for bssid in [b for b, n in neighbors.items() if n[4] < cutoff]: + del neighbors[bssid] + if own: + publish(own, {b: n[:4] for b, n in neighbors.items()}, published) + + +if __name__ == '__main__': + try: + main() + except KeyboardInterrupt: + pass diff --git a/doc/ChangeLog.md b/doc/ChangeLog.md index 22effeacc..58225971d 100644 --- a/doc/ChangeLog.md +++ b/doc/ChangeLog.md @@ -32,7 +32,9 @@ All notable changes to the project are documented in this file. - A WiFi access point asks its clients to move to another access point, with 802.11v, before it stops for a reboot, an upgrade, or a configuration change, so clients that support it roam without losing - their connection + their connection. Devices tell each other about their access points, + over the network the access points are bridged to, and list the other + devices' access points in their 802.11k neighbor reports - WiFi channel survey is now on request: `show hardware survey` in the CLI, a Scan channels button per radio on the WebUI WiFi page, or the `channel-survey` action on the radio. The survey covers every channel the diff --git a/doc/wifi.md b/doc/wifi.md index 9d4234b04..0754c2816 100644 --- a/doc/wifi.md +++ b/doc/wifi.md @@ -662,6 +662,14 @@ admin@example:/config/interface/wifi0/> set wifi access-point roaming dot11k Enables neighbor reports and beacon reports, allowing clients to discover nearby APs before roaming. + +Devices with access points on the same network tell each other about +them, over that network, so every access point knows the other access +points of its SSID on the other devices and lists them in its neighbor +reports. A client asking where else its network exists gets the real +answer, and a client asked to move, see below, is told where to. A +device that comes up later is known to the others within a minute. + ### 802.11v - BSS Transition Management Enable 802.11v for network-assisted roaming: @@ -674,10 +682,10 @@ Allows APs to suggest better APs to clients, improving roaming decisions. An access point about to stop, because the device reboots, is upgraded, or its WiFi configuration changes, also uses 802.11v to ask its clients -to move first. Clients that support it roam to another access point -with the same SSID while the radio is still up, instead of noticing the -loss afterwards and scanning for a new network. Clients without -802.11v are disconnected as before. +to move first, naming the other access points of the SSID it knows of. +Clients that support it roam to one of them while the radio is still +up, instead of noticing the loss afterwards and scanning for a new +network. Clients without 802.11v are disconnected as before. #### Band Steering (MBO) diff --git a/test/case/interfaces/wifi_mesh_roaming/test.adoc b/test/case/interfaces/wifi_mesh_roaming/test.adoc index 630bb2fd8..b74b724f2 100644 --- a/test/case/interfaces/wifi_mesh_roaming/test.adoc +++ b/test/case/interfaces/wifi_mesh_roaming/test.adoc @@ -62,7 +62,7 @@ image::topology.svg[WiFi Mesh backhaul with roaming Access Points topology, alig . Verify connectivity is restored after roaming . Stop the WiFi service on the client's current node . Verify the client roams to the remaining node's AP -. Verify the client was asked to move and roamed without disconnecting +. Verify the client was asked to move, given candidates, and roamed without disconnecting . Verify connectivity is restored after the handover . Start the WiFi service again on the stopped node diff --git a/test/case/interfaces/wifi_mesh_roaming/test.py b/test/case/interfaces/wifi_mesh_roaming/test.py index 7a166cd24..eedb8054e 100755 --- a/test/case/interfaces/wifi_mesh_roaming/test.py +++ b/test/case/interfaces/wifi_mesh_roaming/test.py @@ -186,8 +186,16 @@ def gw_config(mesh_mac, ap_mac, uplink=None): with infamy.IsolatedMacVlan(hlan) as ns: ns.addip(HOST_IP) + # Give a ping a few tries, the radios have just come up. + def reaches(addr): + try: + ns.ping(addr) + return True + except Exception: + return False + with test.step("Verify the client is reachable across the mesh"): - ns.must_reach(CLIENT_IP) + until(lambda: reaches(CLIENT_IP), attempts=10, interval=2) with test.step("Take down the client's current AP to force a roam"): first_dut.put_config_dicts({"ietf-interfaces": {"interfaces": { @@ -201,7 +209,7 @@ def gw_config(mesh_mac, ap_mac, uplink=None): print(f"client roamed from {first_ap} to {new_ap}") with test.step("Verify connectivity is restored after roaming"): - ns.must_reach(CLIENT_IP) + until(lambda: reaches(CLIENT_IP), attempts=15, interval=2) # A node going down for a reboot or an upgrade asks its clients # to move first, so a client with a strong signal roams instead @@ -219,14 +227,15 @@ def gw_config(mesh_mac, ap_mac, uplink=None): third_ap, _ = aps[wifi.station_bssid(client)] print(f"client roamed from {second_ap} to {third_ap}") - with test.step("Verify the client was asked to move and roamed without disconnecting"): + with test.step("Verify the client was asked to move, given candidates, and roamed without disconnecting"): def log(): return clientsh.runsh("sed -n '/WNM: Disassociation Imminent/,$p' /var/log/syslog").stdout until(lambda: "Disassociation Imminent" in log(), attempts=10, interval=1) + assert "Preferred List Available" in log(), log() assert "CTRL-EVENT-DISCONNECTED" not in log(), log() with test.step("Verify connectivity is restored after the handover"): - ns.must_reach(CLIENT_IP) + until(lambda: reaches(CLIENT_IP), attempts=15, interval=2) with test.step("Start the WiFi service again on the stopped node"): shells[second_ap].runsh("initctl start hostapd") From 6c4f6f2b9c5966a2ae0d6b6c5e4edb1736bd4aa5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20Walstr=C3=B6m?= Date: Wed, 7 Oct 2026 16:52:32 +0200 Subject: [PATCH 38/38] board: bpi-r3: Disable WED again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With WED on, the access points of the built-in radio accept a client, complete the key handshake, and then drop every data frame it sends: the netdev counts a single received packet while the client sends hundreds, so the frames never reach the bridge. Seen on the 5 GHz radio of a BPI-R3 with the access points as ports of a VLAN filtering bridge, and gone with WED off. Keep the option in place for when it works. Signed-off-by: Mattias Walström --- .../rootfs/etc/modprobe.d/mt7915e-wed.conf | 8 ++++++-- doc/ChangeLog.md | 3 --- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/board/aarch64/bananapi-bpi-r3/rootfs/etc/modprobe.d/mt7915e-wed.conf b/board/aarch64/bananapi-bpi-r3/rootfs/etc/modprobe.d/mt7915e-wed.conf index f6575644f..867e1d534 100644 --- a/board/aarch64/bananapi-bpi-r3/rootfs/etc/modprobe.d/mt7915e-wed.conf +++ b/board/aarch64/bananapi-bpi-r3/rootfs/etc/modprobe.d/mt7915e-wed.conf @@ -1,2 +1,6 @@ -# Wireless Ethernet Dispatch, lets the PPE forward offloaded flows to the radios -options mt7915e wed_enable=1 +# Wireless Ethernet Dispatch, lets the PPE forward offloaded flows to the +# radios. Off: with it on, the built-in radio's access points drop every +# data frame from their clients when the access points are ports of a +# VLAN filtering bridge, the association and the key handshake succeed +# and nothing else arrives. Seen on the BPI-R3 with the 5 GHz radio. +options mt7915e wed_enable=0 diff --git a/doc/ChangeLog.md b/doc/ChangeLog.md index 58225971d..9e677fd74 100644 --- a/doc/ChangeLog.md +++ b/doc/ChangeLog.md @@ -57,9 +57,6 @@ All notable changes to the project are documented in this file. 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