Skip to main content

ebus_discovery/
search.rs

1//! The broker search state machine (_mdns_core.py:216-295; ebus.py:798-825).
2
3use alloc::collections::BTreeSet;
4use alloc::vec::Vec;
5use core::fmt;
6
7use crate::broker::{BrokerEndpoint, Choice, Wanted, choose, log_choice, rank_brokers};
8use crate::instance::{InstanceKey, InstanceTable, Sighting};
9use crate::{BROKER_TYPES, BrokerMode, BrokerType, Instant, TCP_BROKER_TYPES};
10
11/// The fast requeries: 3 s apart, python's `RetrySchedule` (ebus.py:801-825).
12const REQUERY_FAST_MS: u64 = 3_000;
13/// Requeries after the fast ones.
14const REQUERY_SLOW_MS: u64 = 30_000;
15/// The number of browse attempts, the first included, before the slow interval.
16const REQUERY_FAST_ATTEMPTS: u32 = 3;
17
18/// How a host finds its broker, from its defaults overridden by its REST
19/// "MQTT broker configuration" (framework.md:93, 607).
20#[derive(Clone, Debug, PartialEq, Eq)]
21#[non_exhaustive]
22pub struct BrokerConfig {
23    /// The broker mode; `None`: `ConfiguredOnly` with a URL, else `DiscoveryOnly`.
24    pub mode: Option<BrokerMode>,
25    /// The configured broker.
26    pub url: Option<BrokerEndpoint>,
27    /// The particular discovered broker wanted; not with a URL.
28    pub wanted: Option<Wanted>,
29    /// TLS required; also implied by an `mqtts://` URL.
30    pub tls: bool,
31    /// The accepted broker types in the client's preference order, most
32    /// preferred first; a type listed twice keeps its first position. `None`:
33    /// `[SecureMqtt]` with TLS, else [`TCP_BROKER_TYPES`], both in the
34    /// specification's order. A list is used as given whatever `tls` says, so
35    /// a TLS client listing a plain type can select a plain broker.
36    pub accept: Option<Vec<BrokerType>>,
37    /// In `DiscoveryWithFallback` with a URL, accept a discovered broker that
38    /// does not match it (ebus.py:776-781).
39    pub allow_unmatched: bool,
40    /// How long after the first usable broker is seen the result is set.
41    pub settle_ms: u64,
42    /// In `DiscoveryWithFallback` with a URL, how long after the start the
43    /// configured broker is used when no discovered broker matched.
44    pub fallback_after_ms: u64,
45}
46
47impl Default for BrokerConfig {
48    fn default() -> Self {
49        Self {
50            mode: None,
51            url: None,
52            wanted: None,
53            tls: false,
54            accept: None,
55            allow_unmatched: false,
56            settle_ms: 3_000,
57            fallback_after_ms: 15_000,
58        }
59    }
60}
61
62impl BrokerConfig {
63    /// The mode in effect: `mode`, else `ConfiguredOnly` with a URL, else
64    /// `DiscoveryOnly`.
65    pub fn effective_mode(&self) -> BrokerMode {
66        self.mode.unwrap_or(if self.url.is_some() {
67            BrokerMode::ConfiguredOnly
68        } else {
69            BrokerMode::DiscoveryOnly
70        })
71    }
72
73    /// True when TLS is required: `tls`, or a TLS URL.
74    pub fn requires_tls(&self) -> bool {
75        self.tls || self.url.as_ref().is_some_and(|u| u.broker_type.uses_tls())
76    }
77
78    /// The broker types accepted, most preferred first: `accept` without
79    /// repeats, else `_secure-mqtt._tcp` alone when TLS is required, else
80    /// [`TCP_BROKER_TYPES`] (_mdns_core.py:247-253).
81    pub fn accepted(&self) -> Vec<BrokerType> {
82        match &self.accept {
83            Some(a) => {
84                let mut order = Vec::with_capacity(a.len());
85                for t in a {
86                    if !order.contains(t) {
87                        order.push(*t);
88                    }
89                }
90                order
91            }
92            None if self.requires_tls() => alloc::vec![BrokerType::SecureMqtt],
93            None => TCP_BROKER_TYPES.to_vec(),
94        }
95    }
96
97    /// The configuration errors [`BrokerSearch::new`] rejects.
98    pub fn check(&self) -> Result<(), BrokerConfigError> {
99        if self.wanted.is_some() && self.url.is_some() {
100            return Err(BrokerConfigError::WantedWithUrl);
101        }
102        if self.effective_mode() == BrokerMode::ConfiguredOnly && self.url.is_none() {
103            return Err(BrokerConfigError::NoUrl);
104        }
105        Ok(())
106    }
107
108    /// The accepted brokers among `endpoints`, with `wanted` applied, ranked
109    /// in the [`accepted`](Self::accepted) order. `endpoints` is every broker
110    /// endpoint seen, of every type: [`Wanted::DeviceId`] reads the
111    /// `device_id` of endpoints whose type is not accepted too.
112    pub fn rank(&self, endpoints: impl IntoIterator<Item = BrokerEndpoint>) -> Vec<BrokerEndpoint> {
113        let order = self.accepted();
114        let sighted: Vec<BrokerEndpoint> = endpoints.into_iter().collect();
115        let wanted = self.wanted.as_ref();
116        rank_brokers(
117            sighted
118                .iter()
119                .filter(|e| wanted.is_none_or(|w| w.matches(e, &sighted)))
120                .cloned(),
121            &order,
122        )
123    }
124}
125
126/// A broker configuration a search cannot run with.
127#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
128#[non_exhaustive]
129pub enum BrokerConfigError {
130    /// `ConfiguredOnly` without a URL.
131    NoUrl,
132    /// `wanted` together with a URL.
133    WantedWithUrl,
134}
135
136impl fmt::Display for BrokerConfigError {
137    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
138        f.write_str(match self {
139            Self::NoUrl => "configured-only needs a broker URL",
140            Self::WantedWithUrl => "a wanted broker cannot be combined with a broker URL",
141        })
142    }
143}
144
145impl core::error::Error for BrokerConfigError {}
146
147/// One broker search, with no I/O: the backend's sightings and the time go
148/// in; requeries and, once, the result come out.
149///
150/// The browse covers every broker type, so a broker of a type not accepted is
151/// logged with `reason=brokerTransportNotAccepted`, and a broker wanted by
152/// device id is found under an accepted transport when only another of its
153/// transports carries the id. The result is set `settle_ms` after a usable
154/// broker is first seen, so each broker's every transport has been heard and
155/// the one earliest in [`BrokerConfig::accepted`] is chosen; in
156/// `DiscoveryWithFallback` with a URL, the configured broker becomes the
157/// result `fallback_after_ms` after the start when no usable broker is
158/// pending. While no usable broker is seen,
159/// [`BrokerSearch::poll_requery`] asks for a requery 3 s, 6 s and 36 s after
160/// the start, then every 30 s. Once set, the result never changes.
161#[derive(Clone, Debug)]
162pub struct BrokerSearch {
163    cfg: BrokerConfig,
164    mode: BrokerMode,
165    table: InstanceTable,
166    examined: BTreeSet<InstanceKey>,
167    settle_at: Option<Instant>,
168    fallback_at: Option<Instant>,
169    requery_at: Option<Instant>,
170    attempt: u32,
171    requery: bool,
172    result: Option<BrokerEndpoint>,
173}
174
175impl BrokerSearch {
176    /// A search starting at `now`. In `ConfiguredOnly` the result is the
177    /// configured broker at once.
178    pub fn new(now: Instant, cfg: BrokerConfig) -> Result<Self, BrokerConfigError> {
179        cfg.check()?;
180        let mode = cfg.effective_mode();
181        let result = match mode {
182            BrokerMode::ConfiguredOnly => cfg.url.clone(),
183            _ => None,
184        };
185        let browses = result.is_none();
186        let fallback_at =
187            (browses && mode == BrokerMode::DiscoveryWithFallback && cfg.url.is_some())
188                .then(|| now.add_ms(cfg.fallback_after_ms));
189        Ok(Self {
190            mode,
191            table: InstanceTable::new(),
192            examined: BTreeSet::new(),
193            settle_at: None,
194            fallback_at,
195            requery_at: browses.then(|| now.add_ms(REQUERY_FAST_MS)),
196            attempt: 0,
197            requery: false,
198            result,
199            cfg,
200        })
201    }
202
203    /// False in `ConfiguredOnly`: no browse runs.
204    pub fn browses(&self) -> bool {
205        self.mode != BrokerMode::ConfiguredOnly
206    }
207
208    /// The mode in effect.
209    pub fn mode(&self) -> BrokerMode {
210        self.mode
211    }
212
213    /// The service types to browse: every broker type.
214    pub fn browse_types(&self) -> &'static [BrokerType] {
215        BROKER_TYPES
216    }
217
218    /// Apply a backend report.
219    pub fn handle(&mut self, now: Instant, s: Sighting) {
220        if !self.table.handle(s) || self.result.is_some() {
221            return;
222        }
223        self.log_not_accepted();
224        match self.choice() {
225            Choice::Discovered(..) => {
226                if self.settle_at.is_none() {
227                    self.settle_at = Some(now.add_ms(self.cfg.settle_ms));
228                }
229            }
230            Choice::Configured | Choice::None => self.settle_at = None,
231        }
232    }
233
234    /// Advance to `now`: settle, fall back or schedule a requery when due.
235    pub fn handle_timeout(&mut self, now: Instant) {
236        if self.result.is_some() {
237            return;
238        }
239        if let Some(at) = self.settle_at {
240            if now < at {
241                return;
242            }
243            self.settle_at = None;
244            let ranked = self.candidates();
245            let choice = choose(
246                self.mode,
247                self.cfg.url.as_ref(),
248                &ranked,
249                self.cfg.allow_unmatched,
250            );
251            if let Choice::Discovered(_, ep) = &choice {
252                log_choice(&choice, self.cfg.url.as_ref(), &ranked);
253                crate::log::info!(
254                    "brokerDiscovered",
255                    url = ep.url(),
256                    attempt = self.attempt + 1
257                );
258                self.result = Some(ep.clone());
259                return;
260            }
261        }
262        if let (Some(at), Some(url)) = (self.fallback_at, &self.cfg.url) {
263            if now >= at {
264                crate::log::info!("brokerFallbackToConfigured", url = url.url());
265                self.result = Some(url.clone());
266                return;
267            }
268        }
269        if let Some(at) = self.requery_at {
270            if now >= at {
271                if self.candidates().is_empty() {
272                    crate::log::info!("brokerNotFound", attempt = self.attempt + 1);
273                }
274                self.attempt += 1;
275                self.requery = true;
276                let next = if self.attempt + 1 < REQUERY_FAST_ATTEMPTS {
277                    REQUERY_FAST_MS
278                } else {
279                    REQUERY_SLOW_MS
280                };
281                self.requery_at = Some(now.add_ms(next));
282            }
283        }
284    }
285
286    /// True once per requery the search asks the backend for.
287    pub fn poll_requery(&mut self) -> bool {
288        core::mem::take(&mut self.requery)
289    }
290
291    /// The next instant [`BrokerSearch::handle_timeout`] has work at.
292    pub fn poll_timeout(&self) -> Option<Instant> {
293        if self.result.is_some() {
294            return None;
295        }
296        if self.settle_at.is_some() {
297            return self.settle_at;
298        }
299        match (self.fallback_at, self.requery_at) {
300            (Some(a), Some(b)) => Some(a.min(b)),
301            (a, b) => a.or(b),
302        }
303    }
304
305    /// The broker chosen, set once and then fixed.
306    pub fn result(&self) -> Option<&BrokerEndpoint> {
307        self.result.as_ref()
308    }
309
310    /// The accepted brokers now seen, with `wanted` applied, ranked.
311    pub fn candidates(&self) -> Vec<BrokerEndpoint> {
312        self.cfg.rank(
313            self.table
314                .instances()
315                .filter_map(|i| BrokerEndpoint::from_instance(&i)),
316        )
317    }
318
319    fn choice(&self) -> Choice {
320        choose(
321            self.mode,
322            self.cfg.url.as_ref(),
323            &self.candidates(),
324            self.cfg.allow_unmatched,
325        )
326    }
327
328    /// Log each newly seen broker whose type is not accepted and whose host
329    /// offers no accepted type (_mdns_core.py:270-278).
330    fn log_not_accepted(&mut self) {
331        let accept = self.cfg.accepted();
332        let mut usable: Option<Vec<BrokerEndpoint>> = None;
333        for inst in self.table.instances() {
334            if self.examined.contains(&inst.key) {
335                continue;
336            }
337            self.examined.insert(inst.key.clone());
338            let Some(ep) = BrokerEndpoint::from_instance(&inst) else {
339                continue;
340            };
341            if accept.contains(&ep.broker_type) {
342                continue;
343            }
344            let usable = usable.get_or_insert_with(|| {
345                self.cfg.rank(
346                    self.table
347                        .instances()
348                        .filter_map(|i| BrokerEndpoint::from_instance(&i)),
349                )
350            });
351            if !usable.iter().any(|u| crate::same_host(&u.host, &ep.host)) {
352                crate::log::info!(
353                    "brokerTransportNotAccepted",
354                    url = ep.url(),
355                    accept = accept
356                        .iter()
357                        .map(|t| t.as_str())
358                        .collect::<Vec<_>>()
359                        .join("|")
360                );
361            }
362        }
363    }
364}
365
366#[cfg(test)]
367// The tests set fields one by one, as a host must for a `#[non_exhaustive]` struct.
368#[allow(clippy::field_reassign_with_default)]
369mod tests {
370    use super::*;
371    use crate::addr::ScopedAddr;
372    use crate::instance::Slot;
373    use crate::txt::TxtRecord;
374    use alloc::string::String;
375    use alloc::vec;
376
377    fn seen(t: BrokerType, name: &str, server: &str, txt: &[&str], addrs: &[&str]) -> Sighting {
378        Sighting::Seen {
379            key: InstanceKey {
380                service_type: t.as_str().into(),
381                name: name.into(),
382            },
383            slot: Slot::Whole,
384            server: server.into(),
385            port: 0,
386            txt: TxtRecord::from_strings(txt),
387            addrs: addrs
388                .iter()
389                .map(|a| ScopedAddr::new(a.parse().unwrap()))
390                .collect(),
391        }
392    }
393
394    fn gone(t: BrokerType, name: &str) -> Sighting {
395        Sighting::Gone {
396            key: InstanceKey {
397                service_type: t.as_str().into(),
398                name: name.into(),
399            },
400            slot: None,
401        }
402    }
403
404    fn search(cfg: BrokerConfig) -> BrokerSearch {
405        BrokerSearch::new(Instant(0), cfg).unwrap()
406    }
407
408    fn url(u: &str) -> Option<BrokerEndpoint> {
409        Some(BrokerEndpoint::from_url(u).unwrap())
410    }
411
412    fn result(s: &BrokerSearch) -> Option<String> {
413        s.result().map(BrokerEndpoint::url)
414    }
415
416    /// Run timeouts up to `until`, returning the instants requeries were asked at.
417    fn run(s: &mut BrokerSearch, until: u64) -> Vec<u64> {
418        let mut requeries = Vec::new();
419        while let Some(at) = s.poll_timeout() {
420            if at.0 > until {
421                break;
422            }
423            s.handle_timeout(at);
424            if s.poll_requery() {
425                requeries.push(at.0);
426            }
427        }
428        requeries
429    }
430
431    #[test]
432    fn mode_precedence() {
433        let s = search(BrokerConfig::default());
434        assert_eq!(s.mode(), BrokerMode::DiscoveryOnly);
435        assert!(s.browses());
436        let mut cfg = BrokerConfig::default();
437        cfg.url = url("mqtt://b.local");
438        let s = search(cfg.clone());
439        assert_eq!(s.mode(), BrokerMode::ConfiguredOnly);
440        assert!(!s.browses());
441        assert_eq!(result(&s).as_deref(), Some("mqtt://b.local:1883"));
442        assert_eq!(s.poll_timeout(), None);
443        cfg.mode = Some(BrokerMode::DiscoveryOnly);
444        assert_eq!(search(cfg).mode(), BrokerMode::DiscoveryOnly);
445    }
446
447    #[test]
448    fn config_errors() {
449        let mut cfg = BrokerConfig::default();
450        cfg.mode = Some(BrokerMode::ConfiguredOnly);
451        assert_eq!(
452            BrokerSearch::new(Instant(0), cfg).err(),
453            Some(BrokerConfigError::NoUrl)
454        );
455        for mode in [
456            None,
457            Some(BrokerMode::DiscoveryWithFallback),
458            Some(BrokerMode::DiscoveryOnly),
459        ] {
460            let mut cfg = BrokerConfig::default();
461            cfg.mode = mode;
462            cfg.url = url("mqtt://b.local");
463            cfg.wanted = Some(Wanted::Instance("b".into()));
464            assert_eq!(
465                BrokerSearch::new(Instant(0), cfg).err(),
466                Some(BrokerConfigError::WantedWithUrl)
467            );
468        }
469    }
470
471    #[test]
472    fn discovery_only_settles_then_picks_first() {
473        let mut s = search(BrokerConfig::default());
474        s.handle(
475            Instant(100),
476            seen(BrokerType::Mqtt, "b", "b.local.", &[], &[]),
477        );
478        s.handle(
479            Instant(200),
480            seen(BrokerType::Mqtt, "a", "a.local.", &[], &[]),
481        );
482        assert_eq!(s.poll_timeout(), Some(Instant(3_100)));
483        s.handle_timeout(Instant(3_099));
484        assert_eq!(s.result(), None);
485        assert_eq!(run(&mut s, 3_100), []);
486        assert_eq!(result(&s).as_deref(), Some("mqtt://a.local:1883"));
487        assert_eq!(s.poll_timeout(), None);
488    }
489
490    #[test]
491    fn settle_lets_the_secure_transport_win() {
492        let mut s = search(BrokerConfig::default());
493        s.handle(
494            Instant(0),
495            seen(BrokerType::Mqtt, "b", "b.local.", &[], &[]),
496        );
497        s.handle(
498            Instant(2_000),
499            seen(BrokerType::SecureMqtt, "b", "b.local.", &[], &[]),
500        );
501        assert_eq!(s.poll_timeout(), Some(Instant(3_000)));
502        run(&mut s, 3_000);
503        assert_eq!(result(&s).as_deref(), Some("mqtts://b.local:8883"));
504        assert_eq!(s.candidates().len(), 1);
505    }
506
507    fn plain_first() -> BrokerConfig {
508        let mut cfg = BrokerConfig::default();
509        cfg.accept = Some(vec![BrokerType::Mqtt, BrokerType::SecureMqtt]);
510        cfg
511    }
512
513    #[test]
514    fn client_order_picks_the_plain_transport_of_a_dual_transport_broker() {
515        let mut s = search(plain_first());
516        s.handle(
517            Instant(0),
518            seen(BrokerType::SecureMqtt, "b", "b.local.", &[], &[]),
519        );
520        s.handle(
521            Instant(2_000),
522            seen(BrokerType::Mqtt, "b", "b.local.", &[], &[]),
523        );
524        assert_eq!(s.poll_timeout(), Some(Instant(3_000)));
525        run(&mut s, 3_000);
526        assert_eq!(result(&s).as_deref(), Some("mqtt://b.local:1883"));
527        assert_eq!(s.candidates().len(), 1);
528    }
529
530    #[test]
531    fn client_order_picks_the_plain_broker_of_two() {
532        let mut s = search(plain_first());
533        s.handle(
534            Instant(0),
535            seen(BrokerType::SecureMqtt, "a", "a.local.", &[], &[]),
536        );
537        s.handle(
538            Instant(0),
539            seen(BrokerType::Mqtt, "z", "z.local.", &[], &[]),
540        );
541        run(&mut s, 3_000);
542        assert_eq!(result(&s).as_deref(), Some("mqtt://z.local:1883"));
543        let mut s = search(BrokerConfig::default());
544        s.handle(
545            Instant(0),
546            seen(BrokerType::SecureMqtt, "a", "a.local.", &[], &[]),
547        );
548        s.handle(
549            Instant(0),
550            seen(BrokerType::Mqtt, "z", "z.local.", &[], &[]),
551        );
552        run(&mut s, 3_000);
553        assert_eq!(result(&s).as_deref(), Some("mqtts://a.local:8883"));
554    }
555
556    #[test]
557    fn accepted_keeps_the_first_of_a_repeated_type() {
558        let mut cfg = BrokerConfig::default();
559        cfg.accept = Some(vec![
560            BrokerType::Mqtt,
561            BrokerType::SecureMqtt,
562            BrokerType::Mqtt,
563        ]);
564        assert_eq!(cfg.accepted(), [BrokerType::Mqtt, BrokerType::SecureMqtt]);
565        assert_eq!(BrokerConfig::default().accepted(), TCP_BROKER_TYPES);
566    }
567
568    #[test]
569    fn result_stays_fixed() {
570        let mut s = search(BrokerConfig::default());
571        s.handle(
572            Instant(0),
573            seen(BrokerType::Mqtt, "b", "b.local.", &[], &[]),
574        );
575        run(&mut s, 3_000);
576        s.handle(
577            Instant(4_000),
578            seen(BrokerType::SecureMqtt, "a", "a.local.", &[], &[]),
579        );
580        s.handle(Instant(4_000), gone(BrokerType::Mqtt, "b"));
581        run(&mut s, 100_000);
582        assert_eq!(result(&s).as_deref(), Some("mqtt://b.local:1883"));
583        assert_eq!(s.candidates()[0].host, "a.local");
584    }
585
586    #[test]
587    fn repeated_sightings_do_not_extend_the_settle_window() {
588        let mut s = search(BrokerConfig::default());
589        s.handle(
590            Instant(0),
591            seen(BrokerType::Mqtt, "b", "b.local.", &[], &[]),
592        );
593        s.handle(
594            Instant(2_500),
595            seen(BrokerType::Mqtt, "b", "b.local.", &[], &[]),
596        );
597        s.handle(
598            Instant(2_600),
599            seen(BrokerType::Mqtt, "c", "c.local.", &[], &[]),
600        );
601        assert_eq!(s.poll_timeout(), Some(Instant(3_000)));
602    }
603
604    #[test]
605    fn requery_schedule_while_nothing_is_found() {
606        let mut s = search(BrokerConfig::default());
607        assert_eq!(run(&mut s, 100_000), [3_000, 6_000, 36_000, 66_000, 96_000]);
608        assert!(!s.poll_requery());
609    }
610
611    #[test]
612    fn broker_gone_while_settling_resumes_requeries() {
613        let mut s = search(BrokerConfig::default());
614        s.handle(
615            Instant(1_000),
616            seen(BrokerType::Mqtt, "b", "b.local.", &[], &[]),
617        );
618        s.handle(Instant(3_500), gone(BrokerType::Mqtt, "b"));
619        assert_eq!(s.poll_timeout(), Some(Instant(3_000)));
620        assert_eq!(run(&mut s, 7_000), [3_000, 6_000]);
621        assert_eq!(s.result(), None);
622    }
623
624    #[test]
625    fn tls_narrows_accept() {
626        let mut cfg = BrokerConfig::default();
627        cfg.tls = true;
628        let mut s = search(cfg);
629        s.handle(
630            Instant(0),
631            seen(BrokerType::Mqtt, "p", "p.local.", &[], &[]),
632        );
633        assert!(s.candidates().is_empty());
634        assert_eq!(s.poll_timeout(), Some(Instant(3_000)));
635        s.handle(
636            Instant(1_000),
637            seen(BrokerType::SecureMqtt, "s", "s.local.", &[], &[]),
638        );
639        run(&mut s, 4_000);
640        assert_eq!(result(&s).as_deref(), Some("mqtts://s.local:8883"));
641
642        let mut cfg = BrokerConfig::default();
643        cfg.mode = Some(BrokerMode::DiscoveryWithFallback);
644        cfg.url = url("mqtts://x.local");
645        assert_eq!(cfg.accepted(), [BrokerType::SecureMqtt]);
646        cfg.url = url("mqtt://x.local");
647        assert_eq!(cfg.accepted(), TCP_BROKER_TYPES);
648        cfg.accept = Some(vec![BrokerType::MqttWs]);
649        assert_eq!(cfg.accepted(), [BrokerType::MqttWs]);
650        cfg.tls = true;
651        cfg.accept = Some(vec![BrokerType::Mqtt, BrokerType::SecureMqtt]);
652        assert_eq!(cfg.accepted(), [BrokerType::Mqtt, BrokerType::SecureMqtt]);
653    }
654
655    #[test]
656    fn websocket_brokers_are_not_selected_by_default() {
657        let mut s = search(BrokerConfig::default());
658        s.handle(
659            Instant(0),
660            seen(BrokerType::MqttWss, "w", "w.local.", &[], &[]),
661        );
662        assert!(s.candidates().is_empty());
663        assert_eq!(s.poll_timeout(), Some(Instant(3_000)));
664    }
665
666    #[test]
667    fn wanted_by_instance_and_device_id() {
668        for (wanted, expect) in [
669            (Wanted::Instance("B".into()), "mqtt://b.local:1883"),
670            (Wanted::DeviceId("d2".into()), "mqtts://c.local:8883"),
671        ] {
672            let mut cfg = BrokerConfig::default();
673            cfg.wanted = Some(wanted);
674            let mut s = search(cfg);
675            s.handle(
676                Instant(0),
677                seen(BrokerType::Mqtt, "a", "a.local.", &[], &[]),
678            );
679            s.handle(
680                Instant(0),
681                seen(BrokerType::Mqtt, "b", "b.local.", &[], &[]),
682            );
683            s.handle(
684                Instant(0),
685                seen(
686                    BrokerType::SecureMqtt,
687                    "c",
688                    "c.local.",
689                    &["device_id=d1,d2"],
690                    &[],
691                ),
692            );
693            run(&mut s, 3_000);
694            assert_eq!(result(&s).as_deref(), Some(expect));
695        }
696    }
697
698    fn device_id_cfg(id: &str, accept: &[BrokerType]) -> BrokerConfig {
699        let mut cfg = BrokerConfig::default();
700        cfg.wanted = Some(Wanted::DeviceId(id.into()));
701        cfg.accept = Some(accept.to_vec());
702        cfg
703    }
704
705    #[test]
706    fn device_id_finds_a_plain_broker_by_its_secure_sibling() {
707        let mut s = search(device_id_cfg("p1", &[BrokerType::Mqtt]));
708        s.handle(
709            Instant(0),
710            seen(
711                BrokerType::SecureMqtt,
712                "c",
713                "c.local.",
714                &["broker=cert.example.com", "device_id=p1"],
715                &[],
716            ),
717        );
718        s.handle(
719            Instant(0),
720            seen(BrokerType::Mqtt, "other", "o.local.", &[], &[]),
721        );
722        s.handle(
723            Instant(0),
724            seen(BrokerType::Mqtt, "c", "c.local.", &[], &["10.0.0.5"]),
725        );
726        run(&mut s, 3_000);
727        assert_eq!(result(&s).as_deref(), Some("mqtt://c.local:1883"));
728    }
729
730    #[test]
731    fn device_id_on_a_plain_record() {
732        let mut s = search(device_id_cfg("p1", &[BrokerType::Mqtt]));
733        s.handle(
734            Instant(0),
735            seen(BrokerType::Mqtt, "a", "a.local.", &[], &[]),
736        );
737        s.handle(
738            Instant(0),
739            seen(BrokerType::Mqtt, "p", "p.local.", &["device_id=p0,p1"], &[]),
740        );
741        run(&mut s, 3_000);
742        assert_eq!(result(&s).as_deref(), Some("mqtt://p.local:1883"));
743    }
744
745    #[test]
746    fn device_id_on_a_websocket_sibling() {
747        let mut s = search(device_id_cfg("p1", &[BrokerType::Mqtt]));
748        s.handle(
749            Instant(0),
750            seen(BrokerType::Mqtt, "c", "c.local.", &[], &[]),
751        );
752        s.handle(
753            Instant(0),
754            seen(BrokerType::MqttWs, "c", "c.local.", &["device_id=p1"], &[]),
755        );
756        run(&mut s, 3_000);
757        assert_eq!(result(&s).as_deref(), Some("mqtt://c.local:1883"));
758    }
759
760    #[test]
761    fn device_id_not_listed_keeps_searching() {
762        let mut s = search(device_id_cfg("p9", &[BrokerType::Mqtt]));
763        s.handle(
764            Instant(0),
765            seen(
766                BrokerType::SecureMqtt,
767                "c",
768                "c.local.",
769                &["device_id=p1"],
770                &[],
771            ),
772        );
773        s.handle(
774            Instant(0),
775            seen(BrokerType::Mqtt, "c", "c.local.", &[], &[]),
776        );
777        assert_eq!(run(&mut s, 7_000), [3_000, 6_000]);
778        assert_eq!(s.result(), None);
779    }
780
781    #[test]
782    fn device_id_never_selects_a_type_not_accepted() {
783        let mut s = search(device_id_cfg("p1", &[BrokerType::Mqtt]));
784        s.handle(
785            Instant(0),
786            seen(
787                BrokerType::SecureMqtt,
788                "c",
789                "c.local.",
790                &["device_id=p1"],
791                &[],
792            ),
793        );
794        // No settle window opens: the next timeout is the first requery.
795        assert_eq!(s.poll_timeout(), Some(Instant(3_000)));
796        assert!(s.candidates().is_empty());
797        assert_eq!(run(&mut s, 7_000), [3_000, 6_000]);
798        assert_eq!(s.result(), None);
799        // The accepted sibling arrives later and opens the window then.
800        s.handle(
801            Instant(7_000),
802            seen(BrokerType::Mqtt, "c", "c.local.", &[], &[]),
803        );
804        assert_eq!(s.poll_timeout(), Some(Instant(10_000)));
805        run(&mut s, 10_000);
806        assert_eq!(result(&s).as_deref(), Some("mqtt://c.local:1883"));
807    }
808
809    #[test]
810    fn device_id_sibling_opens_the_settle_window_when_it_arrives() {
811        let mut s = search(device_id_cfg("p1", &[BrokerType::Mqtt]));
812        s.handle(
813            Instant(0),
814            seen(BrokerType::Mqtt, "c", "c.local.", &[], &[]),
815        );
816        assert!(s.candidates().is_empty());
817        assert_eq!(s.poll_timeout(), Some(Instant(3_000)));
818        s.handle(
819            Instant(1_000),
820            seen(
821                BrokerType::SecureMqtt,
822                "c",
823                "c.local.",
824                &["device_id=p1"],
825                &[],
826            ),
827        );
828        assert_eq!(s.poll_timeout(), Some(Instant(4_000)));
829        assert_eq!(run(&mut s, 4_000), []);
830        assert_eq!(result(&s).as_deref(), Some("mqtt://c.local:1883"));
831    }
832
833    #[test]
834    fn device_id_finds_the_secure_record_of_a_dual_transport_broker() {
835        let mut cfg = BrokerConfig::default();
836        cfg.wanted = Some(Wanted::DeviceId("d1".into()));
837        let mut s = search(cfg);
838        s.handle(
839            Instant(0),
840            seen(BrokerType::Mqtt, "c", "c.local.", &[], &[]),
841        );
842        s.handle(
843            Instant(0),
844            seen(BrokerType::Mqtt, "plain-only", "p.local.", &[], &[]),
845        );
846        s.handle(
847            Instant(0),
848            seen(
849                BrokerType::SecureMqtt,
850                "c",
851                "c.local.",
852                &["device_id=d1"],
853                &[],
854            ),
855        );
856        run(&mut s, 3_000);
857        assert_eq!(result(&s).as_deref(), Some("mqtts://c.local:8883"));
858    }
859
860    #[test]
861    fn wanted_absent_keeps_searching() {
862        let mut cfg = BrokerConfig::default();
863        cfg.mode = Some(BrokerMode::DiscoveryWithFallback);
864        cfg.wanted = Some(Wanted::Instance("z".into()));
865        let mut s = search(cfg);
866        s.handle(
867            Instant(0),
868            seen(BrokerType::Mqtt, "a", "a.local.", &[], &[]),
869        );
870        assert_eq!(run(&mut s, 40_000), [3_000, 6_000, 36_000]);
871        assert_eq!(s.result(), None);
872    }
873
874    fn fallback_cfg(u: &str) -> BrokerConfig {
875        let mut cfg = BrokerConfig::default();
876        cfg.mode = Some(BrokerMode::DiscoveryWithFallback);
877        cfg.url = url(u);
878        cfg
879    }
880
881    #[test]
882    fn fallback_to_configured() {
883        let mut s = search(fallback_cfg("mqtt://x.local"));
884        s.handle(
885            Instant(0),
886            seen(BrokerType::Mqtt, "a", "a.local.", &[], &[]),
887        );
888        assert_eq!(run(&mut s, 14_999), [3_000, 6_000]);
889        assert_eq!(s.result(), None);
890        run(&mut s, 15_000);
891        assert_eq!(result(&s).as_deref(), Some("mqtt://x.local:1883"));
892    }
893
894    #[test]
895    fn allow_unmatched() {
896        let mut cfg = fallback_cfg("mqtt://x.local");
897        cfg.allow_unmatched = true;
898        let mut s = search(cfg);
899        s.handle(
900            Instant(0),
901            seen(BrokerType::Mqtt, "a", "a.local.", &[], &[]),
902        );
903        run(&mut s, 3_000);
904        assert_eq!(result(&s).as_deref(), Some("mqtt://a.local:1883"));
905    }
906
907    #[test]
908    fn fallback_matches_by_name_and_address() {
909        let mut s = search(fallback_cfg("mqtt://192.168.1.8"));
910        s.handle(
911            Instant(0),
912            seen(BrokerType::Mqtt, "a", "a.local.", &[], &["192.168.1.7"]),
913        );
914        s.handle(
915            Instant(0),
916            seen(BrokerType::Mqtt, "p", "p.local.", &[], &["192.168.1.8"]),
917        );
918        run(&mut s, 3_000);
919        let r = s.result().unwrap();
920        assert_eq!(
921            (r.host.as_str(), r.instance.as_deref()),
922            ("192.168.1.8", Some("p"))
923        );
924
925        let mut s = search(fallback_cfg("mqtts://192.168.1.8:9999"));
926        s.handle(
927            Instant(0),
928            seen(
929                BrokerType::SecureMqtt,
930                "s",
931                "s.local.",
932                &[],
933                &["192.168.1.8"],
934            ),
935        );
936        run(&mut s, 3_000);
937        let r = s.result().unwrap();
938        assert_eq!((r.host.as_str(), r.port), ("s.local", 8883));
939
940        let mut s = search(fallback_cfg("mqtt://B.LOCAL"));
941        s.handle(
942            Instant(0),
943            seen(BrokerType::Mqtt, "b", "b.local.", &[], &[]),
944        );
945        run(&mut s, 3_000);
946        assert_eq!(s.result().unwrap().instance.as_deref(), Some("b"));
947    }
948
949    #[test]
950    fn a_pending_settle_outlasts_the_fallback_deadline() {
951        let mut s = search(fallback_cfg("mqtt://b.local"));
952        s.handle(
953            Instant(14_000),
954            seen(BrokerType::Mqtt, "b", "b.local.", &[], &[]),
955        );
956        assert_eq!(s.poll_timeout(), Some(Instant(17_000)));
957        run(&mut s, 17_000);
958        assert_eq!(s.result().unwrap().instance.as_deref(), Some("b"));
959    }
960
961    #[test]
962    fn device_id_merges_transports_across_hosts() {
963        let mut s = search(BrokerConfig::default());
964        s.handle(
965            Instant(0),
966            seen(
967                BrokerType::SecureMqtt,
968                "x",
969                "x.local.",
970                &["broker=cert.example.com", "device_id=p1"],
971                &[],
972            ),
973        );
974        s.handle(
975            Instant(0),
976            seen(
977                BrokerType::SecureMqtt,
978                "y",
979                "y.local.",
980                &["device_id=p1"],
981                &[],
982            ),
983        );
984        assert_eq!(s.candidates().len(), 1);
985    }
986}