1pub const ADVERTISING_INTERVAL_US: u64 = 546_250;
4pub const FAST_ADVERTISING_INTERVAL_US: u64 = 20_000;
5pub const STARTUP_FAST_ADVERTISING_MS: u64 = 30_000;
6pub const RPA_TIMEOUT_SECS: u64 = 900;
7
8pub const COMPANY_ID: u16 = 0xFFFF;
10
11pub mod model {
15 pub const LILYGO_T_ECHO: u16 = 1;
16 pub const SEEED_SENSECAP_T1000_E: u16 = 2;
17 pub const SEEED_SENSECAP_SOLAR_P1: u16 = 3;
18 pub const SEEED_WIO_TRACKER_L1: u16 = 4;
19 pub const SEEED_XIAO_NRF52840_KIT: u16 = 5;
20 pub const HELTEC_LORA32_V2: u16 = 6;
21 pub const HELTEC_LORA32_V3: u16 = 7;
22 pub const LILYGO_T_BEAM_SUPREME: u16 = 8;
23 pub const LILYGO_T_LORA_PAGER: u16 = 9;
24 pub const SEEED_XIAO_ESP32S3_KIT: u16 = 10;
25}
26
27pub const fn pairing_window_ms(bond_count: u8) -> u64 {
29 if bond_count == 0 {
30 5 * 60 * 1000
31 } else {
32 2 * 60 * 1000
33 }
34}
35
36#[derive(Default)]
39pub struct RestartBackoff(u64);
40impl RestartBackoff {
41 pub fn after_exit(&mut self, uptime_ms: u64) -> u64 {
42 if uptime_ms >= 30_000 {
43 self.0 = 0;
44 }
45 self.0 = (self.0 * 2).clamp(1_000, 5_000);
46 self.0
47 }
48 pub fn reset(&mut self) {
49 self.0 = 0;
50 }
51}
52
53pub struct AdvertisementData {
54 pub interval_us: u64,
57 pub refresh_at_ms: Option<u64>,
60 pub advertising: [u8; 31],
61 pub advertising_len: usize,
62 pub scan_response: [u8; 31],
63 pub scan_response_len: usize,
64}
65
66pub fn utf8_prefix_len(bytes: &[u8], limit: usize) -> usize {
67 let Ok(text) = core::str::from_utf8(bytes) else {
68 return 0;
69 };
70 let mut len = bytes.len().min(limit);
71 while !text.is_char_boundary(len) {
72 len -= 1;
73 }
74 len
75}
76
77pub fn advertisement(
80 service_uuid_le: [u8; 16],
81 name: &[u8],
82 model_id: u16,
83 pairing: bool,
84 uptime_ms: u64,
85) -> AdvertisementData {
86 let startup = uptime_ms < STARTUP_FAST_ADVERTISING_MS;
87 let mut data = AdvertisementData {
88 interval_us: if pairing || startup {
89 FAST_ADVERTISING_INTERVAL_US
90 } else {
91 ADVERTISING_INTERVAL_US
92 },
93 refresh_at_ms: if startup && !pairing {
94 Some(STARTUP_FAST_ADVERTISING_MS)
95 } else {
96 None
97 },
98 advertising: [0; 31],
99 advertising_len: 21,
100 scan_response: [0; 31],
101 scan_response_len: 0,
102 };
103 data.advertising[..3].copy_from_slice(&[2, 1, if pairing { 6 } else { 4 }]);
104 data.advertising[3..5].copy_from_slice(&[17, 7]);
105 data.advertising[5..21].copy_from_slice(&service_uuid_le);
106 if pairing {
107 data.advertising[21..23].copy_from_slice(&[5, 0xff]);
110 data.advertising[23..25].copy_from_slice(&COMPANY_ID.to_le_bytes());
111 data.advertising[25..27].copy_from_slice(&model_id.to_be_bytes());
112 data.advertising_len = 27;
113 let len = utf8_prefix_len(name, 29);
116 if len != 0 {
117 data.scan_response[..2]
118 .copy_from_slice(&[(len + 1) as u8, if len == name.len() { 9 } else { 8 }]);
119 data.scan_response[2..2 + len].copy_from_slice(&name[..len]);
120 data.scan_response_len = 2 + len;
121 }
122 }
123 data
124}
125
126pub const fn name_visible(pairing: bool, encrypted: bool, durable_bond: bool) -> bool {
127 pairing || (encrypted && durable_bond)
128}
129
130#[derive(Default, Debug)]
132pub struct PairingDeadline(Option<u64>);
133impl PairingDeadline {
134 pub const fn new() -> Self {
135 Self(None)
136 }
137 pub fn open(&mut self, now_ms: u64, window_ms: u64) {
138 self.0 = Some(now_ms.saturating_add(window_ms));
139 }
140 pub fn close(&mut self) {
141 self.0 = None;
142 }
143 pub fn expired(&self, now_ms: u64) -> bool {
144 self.0.is_some_and(|deadline| now_ms >= deadline)
145 }
146 pub fn at(&self) -> Option<u64> {
147 self.0
148 }
149}
150
151#[cfg(test)]
152mod tests {
153 use super::*;
154
155 const MODEL: u16 = 0x0102;
156
157 #[test]
158 fn model_constants_match_the_board_list() {
159 let boards = include_str!("../../../docs/hardware/boards.json");
161 let models = [
162 ("techo", model::LILYGO_T_ECHO),
163 ("t1000e", model::SEEED_SENSECAP_T1000_E),
164 ("sensecap-solar", model::SEEED_SENSECAP_SOLAR_P1),
165 ("wio-tracker-l1", model::SEEED_WIO_TRACKER_L1),
166 ("xiao-nrf52", model::SEEED_XIAO_NRF52840_KIT),
167 ("heltec-v2", model::HELTEC_LORA32_V2),
168 ("heltec-v3", model::HELTEC_LORA32_V3),
169 ("tbeam-supreme", model::LILYGO_T_BEAM_SUPREME),
170 ("tlora-pager", model::LILYGO_T_LORA_PAGER),
171 ("xiao-esp32s3", model::SEEED_XIAO_ESP32S3_KIT),
172 ];
173 for (board, id) in models {
174 let entry = std::format!("\"id\": \"{board}\", \"model_id\": {id},");
175 assert!(
176 boards.contains(&entry),
177 "{board} disagrees with boards.json"
178 );
179 }
180 assert_eq!(boards.matches("\"model_id\":").count(), models.len());
181 }
182
183 #[test]
184 fn runner_backoff_is_bounded_and_resets_after_stable_operation() {
185 let mut backoff = RestartBackoff::default();
186 for expected in [1_000, 2_000, 4_000, 5_000, 5_000] {
187 assert_eq!(backoff.after_exit(10), expected);
188 }
189 assert_eq!(backoff.after_exit(30_000), 1_000);
190 assert_eq!(backoff.after_exit(10), 2_000);
191 backoff.reset();
192 assert_eq!(backoff.after_exit(10), 1_000);
193 }
194 #[test]
195 fn pairing_and_reconnect_intervals_use_exact_controller_units() {
196 for (pairing, interval_us, controller_units) in [(true, 20_000, 32), (false, 546_250, 874)]
197 {
198 let data = advertisement([0; 16], b"TrackerB2", MODEL, pairing, 30_000);
199 assert_eq!(data.interval_us, interval_us);
200 assert_eq!(data.interval_us % 625, 0);
201 assert_eq!(data.interval_us / 625, controller_units);
202 }
203 }
204 #[test]
205 fn startup_boost_expires_without_changing_privacy_or_restarting_with_the_stack() {
206 for uptime_ms in [0, 10_000, 29_999, 30_000, 30_001, 300_000] {
207 let data = advertisement([0xa5; 16], b"TrackerB2", MODEL, false, uptime_ms);
210 assert_eq!(data.advertising_len, 21);
211 assert_eq!(data.advertising[2], 4);
212 assert_eq!(data.scan_response_len, 0);
213 if uptime_ms < 30_000 {
214 assert_eq!(data.interval_us, 20_000);
215 assert_eq!(data.refresh_at_ms, Some(30_000));
216 } else {
217 assert_eq!(data.interval_us, 546_250);
218 assert_eq!(data.refresh_at_ms, None);
219 }
220 let pairing = advertisement([0xa5; 16], b"TrackerB2", MODEL, true, uptime_ms);
221 assert_eq!(pairing.interval_us, 20_000);
222 assert_eq!(pairing.refresh_at_ms, None);
223 assert_ne!(pairing.scan_response_len, 0);
224 }
225 }
226 #[test]
227 fn reconnect_contains_only_flags_and_service_regardless_of_name() {
228 for (uuid, name) in [
229 ([0xa5; 16], b"Alice's radio 1234".as_slice()),
230 ([0x5a; 16], b"Another radio".as_slice()),
231 ([0; 16], b"".as_slice()),
232 ] {
233 let d = advertisement(uuid, name, MODEL, false, 30_000);
234 assert_eq!(d.advertising_len, 21);
235 assert_eq!(&d.advertising[..5], &[2, 1, 4, 17, 7]);
236 assert_eq!(&d.advertising[5..21], &uuid);
237 assert_eq!(&d.advertising[d.advertising_len..], &[0; 10]);
238 assert_eq!(d.scan_response_len, 0);
239 assert_eq!(d.scan_response, [0; 31]);
240 }
241 }
242 #[test]
243 fn pairing_advertises_service_and_model_even_without_a_usable_name() {
244 for name in [b"".as_slice(), &[0xff]] {
245 let d = advertisement([0xa5; 16], name, MODEL, true, 30_000);
246 assert_eq!(d.advertising_len, 27);
247 assert_eq!(&d.advertising[..5], &[2, 1, 6, 17, 7]);
248 assert_eq!(&d.advertising[5..21], &[0xa5; 16]);
249 assert_eq!(&d.advertising[21..27], &[5, 0xff, 0xff, 0xff, 1, 2]);
250 assert_eq!(d.scan_response_len, 0);
251 }
252 }
253 #[test]
254 fn pairing_name_travels_only_in_the_scan_response() {
255 for name in [b"TrackerB2".as_slice(), b"12345678901234567890123456789"] {
256 let d = advertisement([0xa5; 16], name, MODEL, true, 30_000);
257 assert_eq!(d.advertising_len, 27);
258 assert_eq!(&d.advertising[..5], &[2, 1, 6, 17, 7]);
259 assert_eq!(&d.advertising[5..21], &[0xa5; 16]);
260 assert_eq!(&d.advertising[21..27], &[5, 0xff, 0xff, 0xff, 1, 2]);
262 assert_eq!(&d.advertising[27..], &[0; 4]);
263 assert_eq!(d.scan_response_len, name.len() + 2);
264 assert_eq!(d.scan_response[0], (name.len() + 1) as u8);
265 assert_eq!(d.scan_response[1], 9); assert_eq!(&d.scan_response[2..d.scan_response_len], name);
267 }
268 }
269 #[test]
270 fn pairing_names_obey_payload_and_utf8_limits() {
271 let d = advertisement([0; 16], "1234567é radio".as_bytes(), MODEL, true, 30_000);
272 assert_eq!(d.advertising_len, 27);
273 assert_eq!(d.advertising[2], 6);
274 assert_eq!(&d.advertising[3..5], &[17, 7]);
275 assert_eq!(&d.advertising[5..21], &[0; 16]);
276 assert_eq!(d.scan_response[1], 9);
277 assert_eq!(
278 &d.scan_response[2..d.scan_response_len],
279 "1234567é radio".as_bytes()
280 );
281 let long = "é".repeat(20);
282 let d = advertisement([0; 16], long.as_bytes(), MODEL, true, 30_000);
283 assert_eq!(d.scan_response_len, 30);
284 assert_eq!(d.scan_response[1], 8);
285 assert!(core::str::from_utf8(&d.scan_response[2..30]).is_ok());
286 let closed = advertisement([0; 16], long.as_bytes(), MODEL, false, 30_000);
287 assert_eq!(closed.scan_response_len, 0);
288 }
289 #[test]
290 fn encrypted_and_retained_are_both_required_outside_pairing() {
291 for pairing in [false, true] {
292 for encrypted in [false, true] {
293 for retained in [false, true] {
294 assert_eq!(
295 name_visible(pairing, encrypted, retained),
296 pairing || (encrypted && retained)
297 );
298 }
299 }
300 }
301 }
302 #[test]
303 fn deadline_does_not_extend_when_stack_restarts() {
304 let mut deadline = PairingDeadline::new();
305 deadline.open(1000, pairing_window_ms(0));
306 assert_eq!(deadline.at(), Some(301_000));
307 assert!(!deadline.expired(300_999));
308 assert!(deadline.expired(301_000));
309 deadline.close();
310 assert!(!deadline.expired(600_000));
311 for bonds in 1..=4 {
312 deadline.open(600_000, pairing_window_ms(bonds));
313 assert_eq!(deadline.at(), Some(720_000));
314 assert!(!deadline.expired(719_999));
315 assert!(deadline.expired(720_000));
316 }
317 deadline.open(800_000, pairing_window_ms(0));
319 assert_eq!(deadline.at(), Some(1_100_000));
320 }
321 #[test]
322 fn every_pairing_exit_removes_name_and_retains_service_without_resetting_security() {
323 use crate::ble_security::PairingRuntime;
324 let initial = PairingRuntime {
325 pairing_mode: false,
326 failures: 2,
327 locked_out: false,
328 };
329 for exit in 0..4 {
330 let mut state = initial;
331 let mut deadline = PairingDeadline::new();
332 state.pairing_mode = true;
333 deadline.open(1000, 30000);
334 assert_eq!(
335 advertisement([1; 16], b"radio", MODEL, state.pairing_mode, 30_000).interval_us,
336 20_000
337 );
338 assert_ne!(
339 advertisement([1; 16], b"radio", MODEL, state.pairing_mode, 30_000)
340 .scan_response_len,
341 0
342 );
343 match exit {
344 0 => {
345 assert!(deadline.expired(31000));
346 state.pairing_mode = false;
347 }
348 1 => state.pairing_mode = false, 2 => state = state.pairing_succeeded(),
350 _ => state = state.bonded_reconnect(),
351 }
352 deadline.close();
353 let data = advertisement([1; 16], b"radio", MODEL, state.pairing_mode, 30_000);
354 assert_eq!(data.interval_us, 546_250);
355 assert_eq!(data.advertising_len, 21);
356 assert_eq!(&data.advertising[..5], &[2, 1, 4, 17, 7]);
357 assert_eq!(&data.advertising[5..21], &[1; 16]);
358 assert_eq!(&data.advertising[21..], &[0; 10]);
359 assert_eq!(data.scan_response_len, 0);
360 assert_eq!(data.scan_response, [0; 31]);
361 assert_eq!(deadline.at(), None);
362 assert_eq!(state.failures, if exit == 2 { 0 } else { 2 });
363 let reopened = advertisement([1; 16], b"renamed", MODEL, true, 30_000);
365 assert_eq!(reopened.interval_us, 20_000);
366 assert_eq!(&reopened.advertising[3..5], &[17, 7]);
367 assert_eq!(&reopened.advertising[5..21], &[1; 16]);
368 assert_eq!(
369 &reopened.scan_response[..reopened.scan_response_len],
370 b"\x08\x09renamed"
371 );
372 }
373 }
374}