From 7127aa96afadc77b62bab02f431217d9783d03e5 Mon Sep 17 00:00:00 2001 From: rdmitry0911 Date: Sat, 5 Sep 2026 10:59:12 +0300 Subject: [PATCH 1/6] policy: add same-AP cascading down-steering for weak upper bands usteer's band steering only moves clients UP (2.4 -> 5/6 GHz). A client that camps on a weak 6 GHz signal is never moved down, even when the same AP's 5 GHz would be far stronger, because roaming only steers to candidates whose per-STA signal usteer has measured, and a client glued to 6 GHz is not heard on the lower bands. Add an opt-in fallback: when the roam state machine reaches SCAN_DONE with no measured better candidate and the client sits on an upper band (freq > 4000) below the new band_downsteer_snr threshold, send a directed BSS-TM request to the next-lower band of the SAME AP (6->5, then 5->2.4). Measured roaming to another AP keeps priority; down-steering only runs when usteer would otherwise give up. min_snr / min_connect_snr on the upper band prevent an immediate bounce back. Also wire the new option through the OpenWrt init script and document it in the sample config and README. Verified on a 4x Airoha AN7581 (MT7996) mesh with an iPhone that otherwise clung to 6 GHz at -85 dBm: the client is moved to the same AP's 5 GHz and streams normally. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FT9j3wHzwrFbMaxRdn5x3T --- README.md | 1 + band_steering.c | 26 ++++++++++++++++++++++++++ main.c | 1 + openwrt/usteer/files/etc/config/usteer | 11 +++++++++++ openwrt/usteer/files/etc/init.d/usteer | 2 +- policy.c | 15 +++++++++++++++ ubus.c | 1 + usteer.h | 2 ++ 8 files changed, 58 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 502ac0d..3a94a25 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ Its goal is to optimize roaming behavior of wireless clients (STAs) in a ESS con - Policy-based decisions for probe- / association- / authentication requests received from STAs - Requesting clients to roam to a different BSS based on SNR / signal-level - Channel-load based client steering to different BSS + - Same-AP down-steering of clients stuck on a weak upper band (6->5->2.4) when no better AP is available ## Installation diff --git a/band_steering.c b/band_steering.c index 9f28603..3a6ec0a 100644 --- a/band_steering.c +++ b/band_steering.c @@ -18,6 +18,32 @@ #include "usteer.h" #include "node.h" +/* Return a node of the SAME AP (local node) on the next-lower band (6->5->2.4). + * Used as a fallback when the roam state machine finds no measured better + * candidate: the same-AP lower band is guaranteed reachable and physically + * stronger than a weak upper band, even though its per-STA signal is unknown. */ +struct usteer_node *usteer_band_downsteer_target(struct usteer_local_node *ln) +{ + struct usteer_node *cur = &ln->node; + struct usteer_node *node, *best = NULL; + + for_each_local_node(node) { + if (node == cur) + continue; + if (strcmp(cur->ssid, node->ssid) != 0) + continue; + if (node->freq >= cur->freq) + continue; + if (!usteer_policy_node_below_max_assoc(node)) + continue; + /* Highest freq strictly below current => one band down. */ + if (!best || node->freq > best->freq) + best = node; + } + + return best; +} + void usteer_band_steering_sta_update(struct sta_info *si) { if (si->connected == STA_NOT_CONNECTED) { diff --git a/main.c b/main.c index bd6ae4b..6e7df13 100644 --- a/main.c +++ b/main.c @@ -103,6 +103,7 @@ void usteer_init_defaults(void) config.band_steering_interval = 30000; config.band_steering_min_snr = -60; + config.band_downsteer_snr = 0; config.band_steering_signal_threshold = 5; config.link_measurement_interval = 30000; diff --git a/openwrt/usteer/files/etc/config/usteer b/openwrt/usteer/files/etc/config/usteer index dc06b7c..0c382e5 100644 --- a/openwrt/usteer/files/etc/config/usteer +++ b/openwrt/usteer/files/etc/config/usteer @@ -143,6 +143,17 @@ config usteer # steered to a higher frequency band #option band_steering_min_snr -60 + # Signal level (dBm) below which a client on an upper band (5/6 GHz) is + # down-steered to the next-lower band of the SAME AP (6->5->2.4), but only + # when the roaming policy has found no better measured candidate on another + # AP. Fixes clients (notably iOS) that cling to a weak 6 GHz signal while a + # stronger lower band of the same AP is available. A value of 0 disables it. + # Pair with min_snr to make the move durable: min_snr blocks re-association + # to a band below the threshold, so a client cannot bounce straight back up. + # This is safe with the cascade - if every upper band is below min_snr the + # client simply lands on 2.4 GHz, which reaches furthest and stays above it. + #option band_downsteer_snr 0 + # SNR difference that the signal must be better compared to signal was on connection to node. # Avoids conflicts between roaming and band-steering policies. # A value of 0 disables threshold. diff --git a/openwrt/usteer/files/etc/init.d/usteer b/openwrt/usteer/files/etc/init.d/usteer index 5c99bec..8a6a9fd 100755 --- a/openwrt/usteer/files/etc/init.d/usteer +++ b/openwrt/usteer/files/etc/init.d/usteer @@ -86,7 +86,7 @@ uci_usteer() { roam_kick_delay roam_scan_tries roam_scan_timeout \ roam_scan_snr roam_scan_interval roam_trigger_snr roam_trigger_interval \ link_measurement_interval \ - band_steering_interval band_steering_min_snr band_steering_signal_threshold \ + band_steering_interval band_steering_min_snr band_downsteer_snr band_steering_signal_threshold \ load_kick_threshold load_kick_delay load_kick_min_clients \ load_kick_reason_code do diff --git a/policy.c b/policy.c index 16c4363..b1f9e00 100644 --- a/policy.c +++ b/policy.c @@ -374,6 +374,21 @@ usteer_roam_trigger_sm(struct usteer_local_node *ln, struct sta_info *si) candidate = usteer_roam_sm_found_better_node(si, &ev, ROAM_TRIGGER_SCAN_DONE); /* Kick back in case no better node is found */ if (!candidate) { + /* No measured better route. Fall back to same-AP down-steering: + * if the client sits on an upper band (5/6 GHz) below + * band_downsteer_snr, push it to the next-lower band of the same + * AP. min_snr / min_connect_snr then refuse a bounce back up to a + * band below the threshold, so the move is durable. */ + if (config.band_downsteer_snr && si->node->freq > 4000 && + si->signal < usteer_snr_to_signal(si->node, config.band_downsteer_snr)) { + struct usteer_node *lower = usteer_band_downsteer_target(ln); + if (lower) { + uint32_t vp = 10000 / usteer_local_node_get_beacon_interval(ln); + MSG(VERBOSE, "down-steer " MAC_ADDR_FMT " to %s (signal %d)\n", + MAC_ADDR_DATA(si->sta->addr), usteer_node_name(lower), si->signal); + usteer_ubus_bss_transition_request(si, 1, true, 0, true, vp, lower); + } + } usteer_roam_set_state(si, ROAM_TRIGGER_IDLE, &ev); break; } diff --git a/ubus.c b/ubus.c index dca6f9a..f106fcc 100644 --- a/ubus.c +++ b/ubus.c @@ -186,6 +186,7 @@ struct cfg_item { _cfg(U32, load_kick_reason_code), \ _cfg(U32, band_steering_interval), \ _cfg(I32, band_steering_min_snr), \ + _cfg(I32, band_downsteer_snr), \ _cfg(U32, link_measurement_interval), \ _cfg(ARRAY_CB, interfaces), \ _cfg(STRING_CB, node_up_script), \ diff --git a/usteer.h b/usteer.h index 407d02d..6041f3d 100644 --- a/usteer.h +++ b/usteer.h @@ -203,6 +203,7 @@ struct usteer_config { uint32_t band_steering_interval; int32_t band_steering_min_snr; + int32_t band_downsteer_snr; uint32_t band_steering_signal_threshold; uint32_t link_measurement_interval; @@ -350,6 +351,7 @@ bool usteer_policy_can_perform_roam(struct sta_info *si); void usteer_band_steering_perform_steer(struct usteer_local_node *ln); void usteer_band_steering_sta_update(struct sta_info *si); bool usteer_band_steering_is_target(struct usteer_local_node *ln, struct usteer_node *node); +struct usteer_node *usteer_band_downsteer_target(struct usteer_local_node *ln); void usteer_ubus_init(struct ubus_context *ctx); void usteer_ubus_kick_client(struct sta_info *si, uint32_t kick_reason_code); From 298cd7fdaf229ba1491751eb782950db24d97b5e Mon Sep 17 00:00:00 2001 From: rdmitry0911 Date: Sat, 5 Sep 2026 11:13:29 +0300 Subject: [PATCH 2/6] policy: add band_downsteer_hold hysteresis after down-steer On a fluctuating signal a down-steered client could bounce straight back up to the weak band as soon as its signal briefly peaked above the threshold. Add a time-based hold: for band_downsteer_hold ms after a down-steer, refuse probe/assoc for any band higher than the one the client was moved to. This is hysteresis independent of the instantaneous signal, so brief peaks no longer pull the client back up. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FT9j3wHzwrFbMaxRdn5x3T --- main.c | 1 + openwrt/usteer/files/etc/config/usteer | 12 ++++++++---- openwrt/usteer/files/etc/init.d/usteer | 2 +- policy.c | 13 +++++++++++++ ubus.c | 1 + usteer.h | 5 +++++ 6 files changed, 29 insertions(+), 5 deletions(-) diff --git a/main.c b/main.c index 6e7df13..a55514e 100644 --- a/main.c +++ b/main.c @@ -104,6 +104,7 @@ void usteer_init_defaults(void) config.band_steering_interval = 30000; config.band_steering_min_snr = -60; config.band_downsteer_snr = 0; + config.band_downsteer_hold = 30 * 1000; config.band_steering_signal_threshold = 5; config.link_measurement_interval = 30000; diff --git a/openwrt/usteer/files/etc/config/usteer b/openwrt/usteer/files/etc/config/usteer index 0c382e5..2578c02 100644 --- a/openwrt/usteer/files/etc/config/usteer +++ b/openwrt/usteer/files/etc/config/usteer @@ -148,12 +148,16 @@ config usteer # when the roaming policy has found no better measured candidate on another # AP. Fixes clients (notably iOS) that cling to a weak 6 GHz signal while a # stronger lower band of the same AP is available. A value of 0 disables it. - # Pair with min_snr to make the move durable: min_snr blocks re-association - # to a band below the threshold, so a client cannot bounce straight back up. - # This is safe with the cascade - if every upper band is below min_snr the - # client simply lands on 2.4 GHz, which reaches furthest and stays above it. + # Pair with band_downsteer_hold (below) for durability: min_snr-style kicking + # tends to fight the client's own band preference and cause an assoc/kick + # storm, whereas the directed BSS-TM plus a hold moves the client gently. #option band_downsteer_snr 0 + # Time (ms) after a down-steer during which the client is not allowed back + # up to a higher band than it was moved to. Time-based hysteresis that stops + # bouncing on a fluctuating signal. 0 disables the hold. + #option band_downsteer_hold 30000 + # SNR difference that the signal must be better compared to signal was on connection to node. # Avoids conflicts between roaming and band-steering policies. # A value of 0 disables threshold. diff --git a/openwrt/usteer/files/etc/init.d/usteer b/openwrt/usteer/files/etc/init.d/usteer index 8a6a9fd..f41c9a1 100755 --- a/openwrt/usteer/files/etc/init.d/usteer +++ b/openwrt/usteer/files/etc/init.d/usteer @@ -86,7 +86,7 @@ uci_usteer() { roam_kick_delay roam_scan_tries roam_scan_timeout \ roam_scan_snr roam_scan_interval roam_trigger_snr roam_trigger_interval \ link_measurement_interval \ - band_steering_interval band_steering_min_snr band_downsteer_snr band_steering_signal_threshold \ + band_steering_interval band_steering_min_snr band_downsteer_snr band_downsteer_hold band_steering_signal_threshold \ load_kick_threshold load_kick_delay load_kick_min_clients \ load_kick_reason_code do diff --git a/policy.c b/policy.c index b1f9e00..98b62ec 100644 --- a/policy.c +++ b/policy.c @@ -182,6 +182,17 @@ usteer_check_request(struct sta_info *si, enum usteer_event_type type) if (type == EVENT_TYPE_AUTH) goto out; + /* Down-steer hold-down: for band_downsteer_hold ms after a down-steer, + * refuse probe/assoc for a band higher than the one the client was moved + * to. Time-based hysteresis stops a client bouncing back up on brief signal + * peaks. */ + if (config.band_downsteer_hold && si->sta->downsteer_hold_until && + current_time < si->sta->downsteer_hold_until && + si->node->freq > si->sta->downsteer_to_freq) { + ret = false; + goto out; + } + if (type == EVENT_TYPE_ASSOC) { /* Check if assoc request has lower signal than min_signal. * If this is the case, block assoc even when assoc steering is enabled. @@ -387,6 +398,8 @@ usteer_roam_trigger_sm(struct usteer_local_node *ln, struct sta_info *si) MSG(VERBOSE, "down-steer " MAC_ADDR_FMT " to %s (signal %d)\n", MAC_ADDR_DATA(si->sta->addr), usteer_node_name(lower), si->signal); usteer_ubus_bss_transition_request(si, 1, true, 0, true, vp, lower); + si->sta->downsteer_hold_until = current_time + config.band_downsteer_hold; + si->sta->downsteer_to_freq = lower->freq; } } usteer_roam_set_state(si, ROAM_TRIGGER_IDLE, &ev); diff --git a/ubus.c b/ubus.c index f106fcc..33deee8 100644 --- a/ubus.c +++ b/ubus.c @@ -187,6 +187,7 @@ struct cfg_item { _cfg(U32, band_steering_interval), \ _cfg(I32, band_steering_min_snr), \ _cfg(I32, band_downsteer_snr), \ + _cfg(U32, band_downsteer_hold), \ _cfg(U32, link_measurement_interval), \ _cfg(ARRAY_CB, interfaces), \ _cfg(STRING_CB, node_up_script), \ diff --git a/usteer.h b/usteer.h index 6041f3d..f047eb2 100644 --- a/usteer.h +++ b/usteer.h @@ -204,6 +204,7 @@ struct usteer_config { uint32_t band_steering_interval; int32_t band_steering_min_snr; int32_t band_downsteer_snr; + uint32_t band_downsteer_hold; uint32_t band_steering_signal_threshold; uint32_t link_measurement_interval; @@ -304,6 +305,10 @@ struct sta { uint32_t aggressiveness; + /* Down-steer hold: block upsteer above downsteer_to_freq until this time. */ + uint64_t downsteer_hold_until; + uint32_t downsteer_to_freq; + uint8_t addr[6]; }; From f722270fd7647c6a084cab585622aeb9a8d82908 Mon Sep 17 00:00:00 2001 From: rdmitry0911 Date: Sat, 5 Sep 2026 12:29:20 +0300 Subject: [PATCH 3/6] policy: add assoc_min_snr band-aware admission floor Refuse association to an upper band (>2.4 GHz) on ANY node when the client signal is below assoc_min_snr; 2.4 GHz is exempt so a client that is weak on all upper bands still has a landing band and cannot be locked out. Enforced independently of assoc_steering. Admission-only: never kicks a connected client, so unlike min_snr it cannot cause an assoc/kick storm. Closes the cross-AP gap that per-AP band_downsteer_hold cannot: the hold stops bounce-back to the SAME AP upper band, assoc_min_snr stops a jump to another node upper band that is equally weak. --- main.c | 1 + openwrt/usteer/files/etc/config/usteer | 16 +++++++++++++++- openwrt/usteer/files/etc/init.d/usteer | 2 +- policy.c | 16 ++++++++++++++++ ubus.c | 1 + usteer.h | 3 +++ 6 files changed, 37 insertions(+), 2 deletions(-) diff --git a/main.c b/main.c index a55514e..1bef1a7 100644 --- a/main.c +++ b/main.c @@ -105,6 +105,7 @@ void usteer_init_defaults(void) config.band_steering_min_snr = -60; config.band_downsteer_snr = 0; config.band_downsteer_hold = 30 * 1000; + config.assoc_min_snr = 0; config.band_steering_signal_threshold = 5; config.link_measurement_interval = 30000; diff --git a/openwrt/usteer/files/etc/config/usteer b/openwrt/usteer/files/etc/config/usteer index 2578c02..85cdd68 100644 --- a/openwrt/usteer/files/etc/config/usteer +++ b/openwrt/usteer/files/etc/config/usteer @@ -155,9 +155,23 @@ config usteer # Time (ms) after a down-steer during which the client is not allowed back # up to a higher band than it was moved to. Time-based hysteresis that stops - # bouncing on a fluctuating signal. 0 disables the hold. + # bouncing on a fluctuating signal. 0 disables the hold. NOTE: the hold state + # is per-AP (not synced between nodes), so it stops bounce-back to the SAME + # AP's upper band but not a jump to another AP's upper band. Use assoc_min_snr + # (below) to close that cross-AP gap. #option band_downsteer_hold 30000 + # Admission floor (dBm): refuse a client's association to an UPPER band + # (5/6 GHz) on ANY AP when its signal is below this value; 2.4 GHz is always + # exempt. This closes cross-AP bouncing onto weak 5/6 GHz that the per-AP + # band_downsteer_hold cannot: a client that is weak everywhere on the upper + # bands can only land on 2.4 GHz, so it settles instead of thrashing. + # Unlike min_snr this is ADMISSION-ONLY -- it never kicks a connected client, + # so it cannot cause the assoc/kick storm that min_snr does; and because + # 2.4 GHz is exempt the client always has a landing band (no lockout). + # Enforced independently of assoc_steering. 0 disables it. + #option assoc_min_snr 0 + # SNR difference that the signal must be better compared to signal was on connection to node. # Avoids conflicts between roaming and band-steering policies. # A value of 0 disables threshold. diff --git a/openwrt/usteer/files/etc/init.d/usteer b/openwrt/usteer/files/etc/init.d/usteer index f41c9a1..ebfb04e 100755 --- a/openwrt/usteer/files/etc/init.d/usteer +++ b/openwrt/usteer/files/etc/init.d/usteer @@ -86,7 +86,7 @@ uci_usteer() { roam_kick_delay roam_scan_tries roam_scan_timeout \ roam_scan_snr roam_scan_interval roam_trigger_snr roam_trigger_interval \ link_measurement_interval \ - band_steering_interval band_steering_min_snr band_downsteer_snr band_downsteer_hold band_steering_signal_threshold \ + band_steering_interval band_steering_min_snr band_downsteer_snr band_downsteer_hold assoc_min_snr band_steering_signal_threshold \ load_kick_threshold load_kick_delay load_kick_min_clients \ load_kick_reason_code do diff --git a/policy.c b/policy.c index 98b62ec..c21e269 100644 --- a/policy.c +++ b/policy.c @@ -194,6 +194,22 @@ usteer_check_request(struct sta_info *si, enum usteer_event_type type) } if (type == EVENT_TYPE_ASSOC) { + /* Band-aware admission floor (assoc_min_snr): refuse association to an + * upper band (> 2.4 GHz) when the client's signal is below the floor, + * regardless of assoc_steering. This closes cross-node bouncing onto + * weak 5/6 GHz on ANY AP. 2.4 GHz is exempt, so the client always has a + * landing band and cannot be locked out. Admission-only: a connected + * client is never kicked, so unlike min_snr this cannot cause a kick + * storm. */ + if (config.assoc_min_snr && si->node->freq > 4000 && + si->signal < usteer_snr_to_signal(si->node, config.assoc_min_snr)) { + ev.reason = UEV_REASON_LOW_SIGNAL; + ev.threshold.cur = si->signal; + ev.threshold.ref = usteer_snr_to_signal(si->node, config.assoc_min_snr); + ret = false; + goto out; + } + /* Check if assoc request has lower signal than min_signal. * If this is the case, block assoc even when assoc steering is enabled. * diff --git a/ubus.c b/ubus.c index 33deee8..aaf5966 100644 --- a/ubus.c +++ b/ubus.c @@ -188,6 +188,7 @@ struct cfg_item { _cfg(I32, band_steering_min_snr), \ _cfg(I32, band_downsteer_snr), \ _cfg(U32, band_downsteer_hold), \ + _cfg(I32, assoc_min_snr), \ _cfg(U32, link_measurement_interval), \ _cfg(ARRAY_CB, interfaces), \ _cfg(STRING_CB, node_up_script), \ diff --git a/usteer.h b/usteer.h index f047eb2..d79c5f6 100644 --- a/usteer.h +++ b/usteer.h @@ -205,6 +205,9 @@ struct usteer_config { int32_t band_steering_min_snr; int32_t band_downsteer_snr; uint32_t band_downsteer_hold; + /* Admission floor: refuse assoc to an upper band (>2.4 GHz) below this + * SNR, regardless of assoc_steering; 2.4 GHz exempt. Admission-only. */ + int32_t assoc_min_snr; uint32_t band_steering_signal_threshold; uint32_t link_measurement_interval; From a1d2a6f6c23fe647a1faed2ba9434d2a80cbc925 Mon Sep 17 00:00:00 2001 From: rdmitry0911 Date: Sat, 5 Sep 2026 22:48:29 +0300 Subject: [PATCH 4/6] policy: add band_downsteer_disassoc (forced down-steer) After a down-steer BSS-TM, if band_downsteer_disassoc (ms) is set and the client is still on the upper band after that delay, kick it. Handles clients (notably iOS) that ignore the gentle BSS-TM and cling to a dying upper band. Safe with assoc_min_snr: the kicked client cannot reassociate to the weak upper band, so it lands on 2.4/5 GHz and cannot bounce back -- no kick storm (unlike min_snr). 0 = gentle (suggestion only, previous behaviour). --- main.c | 1 + openwrt/usteer/files/etc/config/usteer | 9 +++++++++ openwrt/usteer/files/etc/init.d/usteer | 2 +- policy.c | 19 +++++++++++++++---- ubus.c | 1 + usteer.h | 3 +++ 6 files changed, 30 insertions(+), 5 deletions(-) diff --git a/main.c b/main.c index 1bef1a7..b47e59c 100644 --- a/main.c +++ b/main.c @@ -106,6 +106,7 @@ void usteer_init_defaults(void) config.band_downsteer_snr = 0; config.band_downsteer_hold = 30 * 1000; config.assoc_min_snr = 0; + config.band_downsteer_disassoc = 0; config.band_steering_signal_threshold = 5; config.link_measurement_interval = 30000; diff --git a/openwrt/usteer/files/etc/config/usteer b/openwrt/usteer/files/etc/config/usteer index 85cdd68..943d3e2 100644 --- a/openwrt/usteer/files/etc/config/usteer +++ b/openwrt/usteer/files/etc/config/usteer @@ -172,6 +172,15 @@ config usteer # Enforced independently of assoc_steering. 0 disables it. #option assoc_min_snr 0 + # Forced down-steer (ms): a plain down-steer is a BSS-TM suggestion that some + # clients (notably iOS) ignore, clinging to a dying upper band. If set, then + # this many ms after down-steering a client that is still on the upper band + # is kicked (deauth). Paired with assoc_min_snr this is safe: the kicked + # client cannot reassociate to the weak upper band, so it lands on 2.4/5 GHz + # and cannot bounce back -- no kick storm (the failure mode of blind min_snr). + # 0 = gentle (suggestion only). Typical: 8000 (give the client ~8s to comply). + #option band_downsteer_disassoc 0 + # SNR difference that the signal must be better compared to signal was on connection to node. # Avoids conflicts between roaming and band-steering policies. # A value of 0 disables threshold. diff --git a/openwrt/usteer/files/etc/init.d/usteer b/openwrt/usteer/files/etc/init.d/usteer index ebfb04e..7367cf9 100755 --- a/openwrt/usteer/files/etc/init.d/usteer +++ b/openwrt/usteer/files/etc/init.d/usteer @@ -86,7 +86,7 @@ uci_usteer() { roam_kick_delay roam_scan_tries roam_scan_timeout \ roam_scan_snr roam_scan_interval roam_trigger_snr roam_trigger_interval \ link_measurement_interval \ - band_steering_interval band_steering_min_snr band_downsteer_snr band_downsteer_hold assoc_min_snr band_steering_signal_threshold \ + band_steering_interval band_steering_min_snr band_downsteer_snr band_downsteer_hold assoc_min_snr band_downsteer_disassoc band_steering_signal_threshold \ load_kick_threshold load_kick_delay load_kick_min_clients \ load_kick_reason_code do diff --git a/policy.c b/policy.c index c21e269..429b1e2 100644 --- a/policy.c +++ b/policy.c @@ -410,12 +410,23 @@ usteer_roam_trigger_sm(struct usteer_local_node *ln, struct sta_info *si) si->signal < usteer_snr_to_signal(si->node, config.band_downsteer_snr)) { struct usteer_node *lower = usteer_band_downsteer_target(ln); if (lower) { - uint32_t vp = 10000 / usteer_local_node_get_beacon_interval(ln); - MSG(VERBOSE, "down-steer " MAC_ADDR_FMT " to %s (signal %d)\n", - MAC_ADDR_DATA(si->sta->addr), usteer_node_name(lower), si->signal); - usteer_ubus_bss_transition_request(si, 1, true, 0, true, vp, lower); + uint32_t bi = usteer_local_node_get_beacon_interval(ln); + uint32_t vp = 10000 / bi; + uint32_t dtimer = config.band_downsteer_disassoc ? + config.band_downsteer_disassoc / bi : 0; + MSG(VERBOSE, "down-steer " MAC_ADDR_FMT " to %s (signal %d%s)\n", + MAC_ADDR_DATA(si->sta->addr), usteer_node_name(lower), si->signal, + config.band_downsteer_disassoc ? ", forced" : ""); + usteer_ubus_bss_transition_request(si, 1, true, dtimer, true, vp, lower); si->sta->downsteer_hold_until = current_time + config.band_downsteer_hold; si->sta->downsteer_to_freq = lower->freq; + /* If the client ignores the BSS-TM (iOS often does) and is + * still on this upper band after band_downsteer_disassoc ms, + * kick it. assoc_min_snr then refuses reassoc to a weak upper + * band, so it lands on 2.4/5 and cannot bounce back -> no + * kick storm (unlike blind min_snr). */ + if (config.band_downsteer_disassoc && !si->kick_time) + si->kick_time = current_time + config.band_downsteer_disassoc; } } usteer_roam_set_state(si, ROAM_TRIGGER_IDLE, &ev); diff --git a/ubus.c b/ubus.c index aaf5966..a69ec06 100644 --- a/ubus.c +++ b/ubus.c @@ -189,6 +189,7 @@ struct cfg_item { _cfg(I32, band_downsteer_snr), \ _cfg(U32, band_downsteer_hold), \ _cfg(I32, assoc_min_snr), \ + _cfg(U32, band_downsteer_disassoc), \ _cfg(U32, link_measurement_interval), \ _cfg(ARRAY_CB, interfaces), \ _cfg(STRING_CB, node_up_script), \ diff --git a/usteer.h b/usteer.h index d79c5f6..98ccfcf 100644 --- a/usteer.h +++ b/usteer.h @@ -208,6 +208,9 @@ struct usteer_config { /* Admission floor: refuse assoc to an upper band (>2.4 GHz) below this * SNR, regardless of assoc_steering; 2.4 GHz exempt. Admission-only. */ int32_t assoc_min_snr; + /* Forced down-steer: ms after a down-steer BSS-TM to kick (deauth) a + * client still on the upper band. 0 = gentle (BSS-TM suggestion only). */ + uint32_t band_downsteer_disassoc; uint32_t band_steering_signal_threshold; uint32_t link_measurement_interval; From 57598f379a3622cab5f8e01624f674a71c16260b Mon Sep 17 00:00:00 2001 From: rdmitry0911 Date: Sun, 6 Sep 2026 15:19:47 +0300 Subject: [PATCH 5/6] local_node: preserve kick_time when BSS-TM is rejected (PR openwrt/usteer#16) When a client rejects a BSS-TM (status_code != 0), keep the already-scheduled fallback kick instead of cancelling it. Cancelling left stubborn clients (notably iPhones) stuck for minutes on soft-steering retries and defeated the forced band-downsteer, which relies on kick_time. Ask nicely first, deauth if the client refuses. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JWdWhnrgq2Mq8nuuMmtsNL --- local_node.c | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/local_node.c b/local_node.c index d90e4a5..6e935c5 100644 --- a/local_node.c +++ b/local_node.c @@ -182,11 +182,13 @@ usteer_handle_bss_tm_response(struct usteer_local_node *ln, struct blob_attr *ms si->bss_transition_response.status_code = blobmsg_get_u8(tb[BSS_TM_RESPONSE_STATUS_CODE]); si->bss_transition_response.timestamp = current_time; - if (si->bss_transition_response.status_code && si->kick_time && si->sta->aggressiveness) { - /* Cancel imminent kick in case BSS transition was rejected */ - si->kick_time = 0; - MSG(VERBOSE, "Kick canceled because transition rejected by station " MAC_ADDR_FMT "\n", MAC_ADDR_DATA(si->sta->addr)); - } + /* PR#16 (openwrt/usteer): KEEP the already-scheduled fallback kick even if + * the client REJECTS the BSS-TM (status_code != 0). Cancelling it here left + * stubborn clients (notably iPhones) stuck for many minutes on soft-steering + * retries and DEFEATED the forced band-downsteer (which schedules kick_time). + * Ask nicely via BSS-TM first; if the client refuses, kick_time still deauths + * it (roam_kick_delay / band_downsteer_disassoc), then assoc_min_snr keeps it + * off the weak upper band. */ return 0; } From 68a4d23e635abb9ad45087ceea3ab425bb119e23 Mon Sep 17 00:00:00 2001 From: rdmitry0911 Date: Sun, 6 Sep 2026 15:39:09 +0300 Subject: [PATCH 6/6] ubus: expose band_steering_signal_threshold in set_config The band_steering_signal_threshold field existed in the config struct, was referenced by band_steering.c, listed in the init.d option loop and documented in config/usteer -- but it was MISSING from the ubus.c _cfg config table. So set_config silently dropped it: uci could never change it and the daemon always used the hardcoded main.c default of 5. With threshold=5 the up-steer (band_steering) only fires when a client's signal has risen >5 dB above its tracked minimum (i.e. it is physically approaching the AP); a client that connected already-strong on 2.4 GHz and stays put is never steered up. Exposing the knob lets it be set to 0, which disables that "signal must be improving" gate and up-steers any BSS-TM-capable client whose 2.4 GHz signal is >= band_steering_min_snr. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JWdWhnrgq2Mq8nuuMmtsNL --- ubus.c | 1 + 1 file changed, 1 insertion(+) diff --git a/ubus.c b/ubus.c index a69ec06..074ad1a 100644 --- a/ubus.c +++ b/ubus.c @@ -186,6 +186,7 @@ struct cfg_item { _cfg(U32, load_kick_reason_code), \ _cfg(U32, band_steering_interval), \ _cfg(I32, band_steering_min_snr), \ + _cfg(U32, band_steering_signal_threshold), \ _cfg(I32, band_downsteer_snr), \ _cfg(U32, band_downsteer_hold), \ _cfg(I32, assoc_min_snr), \