From: Felix Fietkau Date: Fri, 10 Jul 2026 10:31:37 +0000 (+0200) Subject: hostapd: ucode: apply channel/width changes via CSA instead of restart X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=55b9263596e2a35aa2b7389b926aff2128a8dc35;p=thirdparty%2Fopenwrt.git hostapd: ucode: apply channel/width changes via CSA instead of restart A config change that only alters the channel or channel width previously failed the radio comparison in iface_reload_config() and forced a full interface restart, disconnecting every client. Classify the radio diff: apply a channel/width-only change through iface.switch_channel() (CSA) so clients stay associated, keep the full restart for any other radio change. When a co-located STA on the same band owns the runtime channel (#channel_owned_by_sta), the change is a no-op on the AP side, which keeps following the STA via apsta_state. MLD APs and DFS targets fall back to a full restart, as a single iface-level CSA cannot handle them. Signed-off-by: Felix Fietkau --- diff --git a/package/network/services/hostapd/files/hostapd.uc b/package/network/services/hostapd/files/hostapd.uc index 943b8810492..2eea5d731be 100644 --- a/package/network/services/hostapd/files/hostapd.uc +++ b/package/network/services/hostapd/files/hostapd.uc @@ -525,13 +525,118 @@ function get_config_bss(name, config, idx) return if_bss[ifname]; } +const radio_chan_fields = [ + "op_class", + "vht_oper_chwidth", "vht_oper_centr_freq_seg0_idx", + "he_oper_chwidth", "he_oper_centr_freq_seg0_idx", + "eht_oper_chwidth", "eht_oper_centr_freq_seg0_idx", +]; + +function radio_line_is_chan(line) +{ + return index(radio_chan_fields, split(line, "=", 2)[0]) >= 0; +} + +// The HT40+/- direction in ht_capab is channel-derived and re-applied through +// the CSA secondary-channel offset, so strip it when comparing the base config; +// the remaining ht_capab bits are device capabilities that require a restart. +function radio_base(radio) +{ + return map(filter(radio.data, (line) => !radio_line_is_chan(line)), (line) => { + if (substr(line, 0, 9) != "ht_capab=") + return line; + return replace(replace(line, "[HT40+]", ""), "[HT40-]", ""); + }); +} + +function radio_reload_class(old_radio, new_radio) +{ + if (is_equal(old_radio, new_radio)) + return "same"; + + // anything beyond channel/width, or the channel-follow flag flipping, + // needs a full restart + if (!is_equal(radio_base(old_radio), radio_base(new_radio)) || + (!old_radio.channel_follow) != (!new_radio.channel_follow)) + return "restart"; + + // base identical and apsta flag unchanged: only treat this as a channel + // switch if the channel or its derived width lines actually differ (the + // derived frequency field alone appearing is not a real change) + if (old_radio.channel == new_radio.channel && + is_equal(filter(old_radio.data, radio_line_is_chan), + filter(new_radio.data, radio_line_is_chan))) + return "same"; + + return "channel"; +} + +function iface_channel_is_dfs(radio) +{ + let freq = radio.frequency; + + /* 5 GHz DFS sub-bands; 2.4 GHz and 6 GHz have no radar channels */ + return freq && ((freq >= 5250 && freq <= 5330) || + (freq >= 5490 && freq <= 5730)); +} + +function iface_channel_switch(name, config) +{ + let radio = config.radio; + + /* + * The runtime channel is followed from a co-located supplicant + * interface (STA/mesh/adhoc) via apsta_state; the AP never picks its + * own channel here. Adopt the new fallback channel without touching the + * running BSSes. + */ + if (radio.channel_follow) + return true; + + /* + * A single iface-level CSA cannot coordinate the links of an MLD AP + * that span multiple radios, and DFS targets need CAC before use. + * Fall back to a full restart for both. + */ + for (let bss in config.bss) + if (bss.mld_ap) + return false; + + if (!radio.frequency || iface_channel_is_dfs(radio)) + return false; + + let iface = hostapd.interfaces[name]; + if (!iface || iface.state() != "ENABLED") + return false; + + let freq_info = iface_freq_info(iface, config, { frequency: radio.frequency }); + if (!freq_info) + return false; + + freq_info.csa_count = 10; + if (!iface.switch_channel(freq_info)) + return false; + + hostapd.printf(`Channel switch to ${radio.channel} on phy ${name}`); + return true; +} + function iface_reload_config(name, phydev, config, old_config) { let phy = phydev.name; - if (!old_config || !is_equal(old_config.radio, config.radio)) + if (!old_config) return false; + switch (radio_reload_class(old_config.radio, config.radio)) { + case "restart": + return false; + case "channel": + if (!iface_channel_switch(name, config)) + return false; + break; + } + if (is_equal(old_config.bss, config.bss)) return true;