Skip to main content

ebus_discovery/
lib.rs

1//! eBus DNS-SD contract.
2//!
3//! The service types an eBus entity advertises and browses (framework.md,
4//! "Detail: mDNS Discovery"), the broker transports with the specification's
5//! preference order, the broker modes, and address scopes. With `alloc`: TXT
6//! record decoding, checks and building; the advertisement model; merging the
7//! sightings an mDNS backend reports into instances; and broker ranking and
8//! selection, including the [`BrokerSearch`] state machine.
9//!
10//! The crate performs no I/O and reads no clock: backends in a runtime crate
11//! feed it sightings and the time as an [`Instant`]. Where the specification
12//! leaves a choice open it follows python-service-discovery.
13//!
14//! A search for a plain MQTT broker, fed one sighting by hand where a backend
15//! would report it:
16//!
17//! ```
18//! use ebus_discovery::{
19//!     BrokerConfig, BrokerSearch, Instant, InstanceKey, ScopedAddr, Sighting, Slot, TxtRecord,
20//! };
21//!
22//! let mut search = BrokerSearch::new(Instant(0), BrokerConfig::default())?;
23//! search.handle(
24//!     Instant(0),
25//!     Sighting::Seen {
26//!         key: InstanceKey {
27//!             service_type: "_mqtt._tcp".into(),
28//!             name: "site-broker".into(),
29//!         },
30//!         slot: Slot::Whole,
31//!         server: "broker.local.".into(),
32//!         port: 1883,
33//!         txt: TxtRecord::new(),
34//!         addrs: vec![ScopedAddr::new([192, 168, 1, 10].into())],
35//!     },
36//! );
37//! while let Some(at) = search.poll_timeout() {
38//!     search.handle_timeout(at);
39//!     if search.result().is_some() {
40//!         break;
41//!     }
42//! }
43//! let broker = search.result().expect("a broker");
44//! assert_eq!((broker.host.as_str(), broker.port), ("broker.local", 1883));
45//! # Ok::<(), ebus_discovery::BrokerConfigError>(())
46//! ```
47//!
48//! # Features
49//!
50//! - `std` (default): links `std`; implies `alloc`.
51//! - `alloc`: everything that allocates: TXT records, the advertisement
52//!   model, instances, broker ranking and [`BrokerSearch`].
53//! - `log`: diagnostics through the `log` crate.
54
55#![cfg_attr(not(feature = "std"), no_std)]
56#![cfg_attr(docsrs, feature(doc_cfg))]
57
58#[cfg(feature = "alloc")]
59extern crate alloc;
60
61mod addr;
62#[cfg(feature = "alloc")]
63mod advertise;
64#[cfg(feature = "alloc")]
65mod broker;
66#[cfg(feature = "alloc")]
67mod instance;
68mod log;
69#[cfg(feature = "alloc")]
70mod search;
71#[cfg(feature = "alloc")]
72mod txt;
73
74pub use addr::{AddrScope, ScopedAddr};
75pub use ebus_mqtt::Instant;
76
77#[cfg(feature = "alloc")]
78pub use addr::candidate_addresses;
79#[cfg(feature = "alloc")]
80pub use advertise::{
81    Advertisement, BrokerService, HttpService, Identity, MAX_DEFAULT_INSTANCE_OCTETS,
82    MAX_RENAME_SUFFIX, ServiceRecord,
83};
84#[cfg(feature = "alloc")]
85pub use broker::{BrokerEndpoint, UrlError, Wanted, match_configured, rank_brokers, select_broker};
86#[cfg(feature = "alloc")]
87pub use instance::{Instance, InstanceKey, InstanceTable, Sighting, Slot};
88#[cfg(feature = "alloc")]
89pub use search::{BrokerConfig, BrokerConfigError, BrokerSearch};
90#[cfg(feature = "alloc")]
91pub use txt::{
92    EbusTxt, TXT_STRING_MAX, TXT_STRING_WARN, TXT_TOTAL_WARN, TxtError, TxtRecord, TxtReport,
93    check_txt, parse_ebus_txt,
94};
95
96/// The `_ebus._tcp` service every eBus entity advertises (framework.md:77).
97pub const EBUS_SERVICE: &str = "_ebus._tcp";
98/// The `_device-info._tcp` service every eBus entity advertises (framework.md:77).
99pub const DEVICE_INFO_SERVICE: &str = "_device-info._tcp";
100/// A plain HTTP REST API (framework.md:79).
101pub const HTTP_SERVICE: &str = "_http._tcp";
102/// An HTTPS REST API (framework.md:79).
103pub const HTTPS_SERVICE: &str = "_https._tcp";
104
105/// The framework.md version this crate implements, the default `ebus_version`
106/// TXT value: the `framework` value in `.ebus-spec.json`.
107pub const EBUS_VERSION: &str = "0.9";
108
109/// The `txtvers` value of every record this crate builds.
110pub const TXTVERS: &str = "1";
111
112/// The `device` role (framework.md:248).
113pub const ROLE_DEVICE: &str = "device";
114/// The `controller` role (framework.md:248).
115pub const ROLE_CONTROLLER: &str = "controller";
116/// The `broker-host` role (framework.md:106, 248).
117pub const ROLE_BROKER_HOST: &str = "broker-host";
118
119/// A broker transport, declared in the specification's preference order
120/// (framework.md:330-335), so `Ord` ranks the most preferred first. That
121/// order is the default; a client sets its own with [`BrokerConfig::accept`].
122#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
123#[non_exhaustive]
124pub enum BrokerType {
125    /// `_secure-mqtt._tcp`: MQTT over TLS.
126    SecureMqtt,
127    /// `_mqtt-wss._tcp`: MQTT over secure WebSocket.
128    MqttWss,
129    /// `_mqtt-ws._tcp`: MQTT over WebSocket.
130    MqttWs,
131    /// `_mqtt._tcp`: plain MQTT.
132    Mqtt,
133}
134
135/// Every broker type, in the specification's preference order.
136pub const BROKER_TYPES: &[BrokerType] = &[
137    BrokerType::SecureMqtt,
138    BrokerType::MqttWss,
139    BrokerType::MqttWs,
140    BrokerType::Mqtt,
141];
142
143/// The broker types an MQTT-over-TCP client can use; the accepted types when
144/// no TLS is configured (ebus.py:54).
145pub const TCP_BROKER_TYPES: &[BrokerType] = &[BrokerType::SecureMqtt, BrokerType::Mqtt];
146
147impl BrokerType {
148    /// The DNS-SD service type, without a domain.
149    pub const fn as_str(self) -> &'static str {
150        match self {
151            Self::SecureMqtt => "_secure-mqtt._tcp",
152            Self::MqttWss => "_mqtt-wss._tcp",
153            Self::MqttWs => "_mqtt-ws._tcp",
154            Self::Mqtt => "_mqtt._tcp",
155        }
156    }
157
158    /// The broker type of a service type, ignoring ASCII case and an optional
159    /// `.local` or `.local.` domain; `None` for any other service.
160    pub fn parse(service_type: &str) -> Option<Self> {
161        let t = service_type.strip_suffix('.').unwrap_or(service_type);
162        let t = match t.len().checked_sub(".local".len()) {
163            Some(at) if t.is_char_boundary(at) && t[at..].eq_ignore_ascii_case(".local") => {
164                &t[..at]
165            }
166            _ => t,
167        };
168        BROKER_TYPES
169            .iter()
170            .copied()
171            .find(|b| b.as_str().eq_ignore_ascii_case(t))
172    }
173
174    /// The port used when an advertisement carries none (ebus.py:57-62).
175    pub const fn default_port(self) -> u16 {
176        match self {
177            Self::SecureMqtt => 8883,
178            Self::MqttWss => 9002,
179            Self::MqttWs => 9001,
180            Self::Mqtt => 1883,
181        }
182    }
183
184    /// True for the TLS transports (ebus.py:64-69).
185    pub const fn uses_tls(self) -> bool {
186        matches!(self, Self::SecureMqtt | Self::MqttWss)
187    }
188
189    /// The URL scheme: `mqtts`, `wss`, `ws` or `mqtt` (ebus.py:71-76).
190    pub const fn scheme(self) -> &'static str {
191        match self {
192            Self::SecureMqtt => "mqtts",
193            Self::MqttWss => "wss",
194            Self::MqttWs => "ws",
195            Self::Mqtt => "mqtt",
196        }
197    }
198}
199
200/// How an entity finds its broker (framework.md:102, 614-622).
201#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
202pub enum BrokerMode {
203    /// Use the configured broker; no mDNS discovery.
204    ConfiguredOnly,
205    /// mDNS first; the configured broker if discovery finds no match.
206    DiscoveryWithFallback,
207    /// mDNS only.
208    DiscoveryOnly,
209}
210
211/// A broker mode string that names no mode.
212#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
213pub struct ParseModeError;
214
215impl core::fmt::Display for ParseModeError {
216    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
217        f.write_str(
218            "unknown broker mode; expected configured-only, discovery-with-fallback or discovery-only",
219        )
220    }
221}
222
223impl core::error::Error for ParseModeError {}
224
225impl BrokerMode {
226    /// Every mode, in the specification's order.
227    pub const ALL: [BrokerMode; 3] = [
228        Self::ConfiguredOnly,
229        Self::DiscoveryWithFallback,
230        Self::DiscoveryOnly,
231    ];
232
233    /// The specification's spelling.
234    pub const fn as_str(self) -> &'static str {
235        match self {
236            Self::ConfiguredOnly => "configured-only",
237            Self::DiscoveryWithFallback => "discovery-with-fallback",
238            Self::DiscoveryOnly => "discovery-only",
239        }
240    }
241
242    /// A mode from configuration text: the specification's spellings, with
243    /// surrounding whitespace, ASCII case and `_` for `-` ignored
244    /// (ebus.py:110-127). An empty value is an error, so a host maps "unset" to
245    /// `None` itself and the `BrokerConfig` precedence rules apply.
246    pub fn parse(text: &str) -> Result<Self, ParseModeError> {
247        let text = text.trim();
248        Self::ALL
249            .into_iter()
250            .find(|m| {
251                let want = m.as_str().as_bytes();
252                want.len() == text.len()
253                    && want.iter().zip(text.bytes()).all(|(&w, t)| {
254                        let t = t.to_ascii_lowercase();
255                        w == if t == b'_' { b'-' } else { t }
256                    })
257            })
258            .ok_or(ParseModeError)
259    }
260}
261
262impl core::str::FromStr for BrokerMode {
263    type Err = ParseModeError;
264    fn from_str(s: &str) -> Result<Self, Self::Err> {
265        Self::parse(s)
266    }
267}
268
269/// `name` without one trailing dot (instance.py:18-20).
270pub fn strip_dot(name: &str) -> &str {
271    name.strip_suffix('.').unwrap_or(name)
272}
273
274/// True when two host names are equal ignoring case and one trailing dot each
275/// (ebus.py:661-662).
276pub fn same_host(a: &str, b: &str) -> bool {
277    let (a, b) = (strip_dot(a), strip_dot(b));
278    a.chars()
279        .flat_map(char::to_lowercase)
280        .eq(b.chars().flat_map(char::to_lowercase))
281}
282
283/// True for a name in the `.local` mDNS domain, ignoring ASCII case and a
284/// trailing dot.
285pub fn is_local_name(host: &str) -> bool {
286    let h = strip_dot(host).as_bytes();
287    h.len() > ".local".len() && h[h.len() - ".local".len()..].eq_ignore_ascii_case(b".local")
288}
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293
294    #[test]
295    fn broker_type_order_is_preference() {
296        let mut v = [
297            BrokerType::Mqtt,
298            BrokerType::MqttWs,
299            BrokerType::SecureMqtt,
300            BrokerType::MqttWss,
301        ];
302        v.sort();
303        assert_eq!(v, BROKER_TYPES);
304    }
305
306    #[test]
307    fn broker_type_parse() {
308        for &t in BROKER_TYPES {
309            assert_eq!(BrokerType::parse(t.as_str()), Some(t));
310        }
311        assert_eq!(
312            BrokerType::parse("_Secure-MQTT._tcp.local."),
313            Some(BrokerType::SecureMqtt)
314        );
315        assert_eq!(
316            BrokerType::parse("_mqtt._tcp.local"),
317            Some(BrokerType::Mqtt)
318        );
319        assert_eq!(BrokerType::parse("_mqtt._tcp."), Some(BrokerType::Mqtt));
320        assert_eq!(BrokerType::parse("_ebus._tcp"), None);
321        assert_eq!(BrokerType::parse("_mqtt._udp"), None);
322        assert_eq!(BrokerType::parse(""), None);
323        assert_eq!(BrokerType::parse("é.local"), None);
324    }
325
326    #[test]
327    fn broker_type_properties() {
328        let ports = BROKER_TYPES.iter().map(|t| t.default_port());
329        assert!(ports.eq([8883, 9002, 9001, 1883]));
330        let tls = BROKER_TYPES.iter().map(|t| t.uses_tls());
331        assert!(tls.eq([true, true, false, false]));
332    }
333
334    #[test]
335    fn mode_parse() {
336        assert_eq!(
337            BrokerMode::parse(" Discovery_Only "),
338            Ok(BrokerMode::DiscoveryOnly)
339        );
340        assert_eq!("configured-only".parse(), Ok(BrokerMode::ConfiguredOnly));
341        assert_eq!(BrokerMode::parse(""), Err(ParseModeError));
342        assert_eq!(BrokerMode::parse("discovery"), Err(ParseModeError));
343    }
344
345    #[test]
346    fn host_names() {
347        assert!(same_host("Host.Local.", "host.local"));
348        assert!(!same_host("host.local..", "host.local"));
349        assert!(is_local_name("broker.LOCAL."));
350        assert!(!is_local_name("local"));
351        assert!(!is_local_name("broker.example.com"));
352        assert_eq!(strip_dot("a.local."), "a.local");
353        assert_eq!(strip_dot("a.local.."), "a.local.");
354    }
355}
356
357#[cfg(doctest)]
358#[doc = include_str!("../README.md")]
359struct ReadmeDoctests;