ProtoCore v0.0.2
Deterministic, zero-heap network stack for embedded targets
Loading...
Searching...
No Matches
roaming.cpp
Go to the documentation of this file.
1// Copyright (C) 2026 Douglas Quigg (dstroy0) <dquigg123@gmail.com>
2// SPDX-License-Identifier: AGPL-3.0-or-later
3
4/**
5 * @file roaming.cpp
6 * @brief Wi-Fi roaming decision layer - see roaming.h.
7 */
8
10
11#if PC_ENABLE_ROAMING
12
13#include <string.h>
14
15namespace
16{
17bool mac_eq(const uint8_t *a, const uint8_t *b)
18{
19 return memcmp(a, b, 6) == 0;
20}
21
22// Index of the strongest candidate that is not the current AP, or -1 if there is none.
23int best_other(const uint8_t *current, const pc_roam_neighbor *nb, uint8_t n)
24{
25 int best = -1;
26 for (uint8_t i = 0; i < n; i++)
27 {
28 if (mac_eq(nb[i].bssid, current))
29 {
30 continue;
31 }
32 if (best < 0 || nb[i].rssi_dbm > nb[best].rssi_dbm)
33 {
34 best = (int)i;
35 }
36 }
37 return best;
38}
39
40// Index of the candidate whose BSSID equals target, or -1.
41int find_bssid(const uint8_t *target, const pc_roam_neighbor *nb, uint8_t n)
42{
43 for (uint8_t i = 0; i < n; i++)
44 {
45 if (mac_eq(nb[i].bssid, target))
46 {
47 return (int)i;
48 }
49 }
50 return -1;
51}
52
53void pick(pc_roam_decision *out, const pc_roam_neighbor *nb, int idx, pc_roam_reason reason)
54{
55 out->roam = true;
56 memcpy(out->target_bssid, nb[idx].bssid, 6);
57 out->target_channel = nb[idx].channel;
58 out->reason = reason;
59}
60} // namespace
61
62void pc_roam_decide(const uint8_t current_bssid[6], int8_t current_rssi_dbm, const pc_roam_neighbor *neighbors,
63 uint8_t n, const pc_roam_btm *btm, const pc_roam_policy *policy, pc_roam_decision *out)
64{
65 if (!out)
66 {
67 return;
68 }
69 out->roam = false;
70 memset(out->target_bssid, 0, 6);
71 out->target_channel = 0;
72 out->reason = PC_ROAM_NONE;
73 if (!current_bssid || (n && !neighbors))
74 {
75 return;
76 }
77
78 // A conservative default when the caller passes no policy.
79 int8_t threshold = policy ? policy->roam_rssi_threshold_dbm : (int8_t)-75;
80 uint8_t hysteresis = policy ? policy->hysteresis_db : (uint8_t)8;
81
82 int best = best_other(current_bssid, neighbors, n);
83
84 // 1. A disassociation-imminent BTM forces us off: roam to the preferred candidate if present, else the
85 // strongest one. (If there is nowhere to go, we cannot roam.)
86 if (btm && btm->present && btm->disassoc_imminent)
87 {
88 int target = -1;
89 if (btm->has_preferred)
90 {
91 target = find_bssid(btm->preferred_bssid, neighbors, n);
92 }
93 if (target < 0)
94 {
95 target = best;
96 }
97 if (target >= 0)
98 {
99 pick(out, neighbors, target, PC_ROAM_BTM_IMMINENT);
100 }
101 return;
102 }
103
104 // 2. A non-imminent BTM steering hint: honour it when the preferred AP is a real candidate that is not
105 // weaker than the current link (do not chase the network into a worse AP).
106 if (btm && btm->present && btm->has_preferred)
107 {
108 int target = find_bssid(btm->preferred_bssid, neighbors, n);
109 if (target >= 0 && neighbors[target].rssi_dbm >= current_rssi_dbm)
110 {
111 pick(out, neighbors, target, PC_ROAM_BTM_SUGGESTED);
112 return;
113 }
114 }
115
116 // 3. RSSI-driven: only when the current link is at/below the threshold and the strongest candidate beats
117 // it by at least the hysteresis margin (so we do not ping-pong on a marginal difference).
118 if (best >= 0 && current_rssi_dbm <= threshold &&
119 (int)neighbors[best].rssi_dbm >= (int)current_rssi_dbm + (int)hysteresis)
120 {
121 pick(out, neighbors, best, PC_ROAM_LOW_RSSI);
122 return;
123 }
124
125 // 4. Otherwise stay put.
126}
127
128uint8_t pc_roam_parse_neighbor_report(const uint8_t *elems, size_t len, pc_roam_neighbor *out, uint8_t max)
129{
130 if (!elems || !out)
131 {
132 return 0;
133 }
134 // Each Neighbor Report element (id 52) carries: BSSID(6) | BSSID Info(4) | Operating Class(1) |
135 // Channel(1) | PHY Type(1) | optional subelements - so a fixed 13-octet body, plus any subelements.
136 const uint8_t NR_BODY_MIN = 13;
137 uint8_t count = 0;
138 size_t off = 0;
139 while (off + 2 <= len && count < max) // element header: id + length
140 {
141 uint8_t id = elems[off];
142 uint8_t elen = elems[off + 1];
143 if (off + 2 + elen > len) // a truncated element ends the walk
144 {
145 break;
146 }
147 if (id == PC_ROAM_NR_ELEM_ID && elen >= NR_BODY_MIN)
148 {
149 const uint8_t *b = elems + off + 2;
150 memcpy(out[count].bssid, b, 6);
151 out[count].channel = b[11]; // after BSSID(6) + BSSID Info(4) + Operating Class(1)
152 out[count].rssi_dbm = PC_ROAM_RSSI_UNKNOWN;
153 count++;
154 }
155 off += (size_t)2 + elen; // skip this element (and any non-52 / short one)
156 }
157 return count;
158}
159
160bool pc_roam_parse_btm_request(const uint8_t *frame, size_t len, pc_roam_btm *out)
161{
162 if (!out)
163 {
164 return false;
165 }
166 memset(out, 0, sizeof(*out));
167 // Fixed fields: Category | Action | Dialog Token | Request Mode | Disassoc Timer(2) | Validity(1) = 7.
168 if (!frame || len < 7 || frame[0] != PC_ROAM_WNM_CATEGORY || frame[1] != PC_ROAM_BTM_REQ_ACTION)
169 {
170 return false;
171 }
172 uint8_t mode = frame[3];
173 out->present = true;
174 out->disassoc_imminent = (mode & PC_ROAM_BTM_DISASSOC) != 0;
175
176 // Walk past the optional fields to reach the candidate list.
177 size_t off = 7;
178 if (mode & PC_ROAM_BTM_TERM_INCL) // BSS Termination Duration is 12 octets
179 {
180 off += 12;
181 }
182 if (mode & PC_ROAM_BTM_ESS_DISASSOC) // Session Information URL: 1-octet length + URL
183 {
184 if (off >= len)
185 {
186 return true; // truncated optional tail; the flags above are still valid
187 }
188 off += (size_t)1 + frame[off];
189 }
190 // Preferred candidate list: the first Neighbor Report element (id 52) is the top-preference target.
191 if ((mode & PC_ROAM_BTM_PREF_LIST) && off + 2 + 6 <= len && frame[off] == PC_ROAM_NR_ELEM_ID &&
192 frame[off + 1] >= 13)
193 {
194 memcpy(out->preferred_bssid, frame + off + 2, 6);
195 out->has_preferred = true;
196 }
197 return true;
198}
199
200#endif // PC_ENABLE_ROAMING
Wi-Fi roaming decision layer (PC_ENABLE_ROAMING) - the policy that picks a roam target.