umsh_ulcp_runtime/
ble_privacy.rs

1//! Public BLE discovery policy. No hardware or host-stack dependencies.
2
3pub 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
8/// Bluetooth company identifier for devices without an assigned one.
9pub const COMPANY_ID: u16 = 0xFFFF;
10
11/// Hardware models named in pairing advertisements, as assigned in
12/// `docs/hardware/boards.json`. Zero is unspecified; `0xFF00` and above are
13/// private use.
14pub 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
27/// Choose the window duration from the retained bond count when it opens.
28pub 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/// Avoid a tight controller rebuild loop while keeping retries short enough
37/// for background reconnection. A stable run resets consecutive-failure delay.
38#[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    /// Both controller interval bounds use the same pairing-policy snapshot
55    /// as the payload, including when a pairing window opens or closes.
56    pub interval_us: u64,
57    /// Absolute boot uptime when this advertisement needs reconfiguration.
58    /// Stack recreation must not extend the startup fast-advertising period.
59    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
77/// Service discovery remains public; the model and name are disclosed only
78/// during pairing.
79pub 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        // Hosts that report only the primary packet title the device by its
108        // model: manufacturer data, company identifier first.
109        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        // The name travels only in the scan response. A prefix in the primary
114        // packet would be reported, and cached, in place of the full name.
115        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/// A deadline survives stack recreation; only an explicit open extends it.
131#[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        // The board list keeps one board per line, `id` then `model_id`.
160        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            // Repeated construction models internal stack restart/re-enable:
208            // the boot deadline and payload privacy remain unchanged.
209            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            // Manufacturer data: company identifier, then the model.
261            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); // Complete Local Name
266            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        // Clearing all hosts gives the next window the initial setup duration.
318        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, // explicit close
349                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            // Reopening advertises the current name and the service again.
364            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}