Skip to main content

rs_matter/transport/network/
mdns.rs

1/*
2 *
3 *    Copyright (c) 2025-2026 Project CHIP Authors
4 *
5 *    Licensed under the Apache License, Version 2.0 (the "License");
6 *    you may not use this file except in compliance with the License.
7 *    You may obtain a copy of the License at
8 *
9 *        http://www.apache.org/licenses/LICENSE-2.0
10 *
11 *    Unless required by applicable law or agreed to in writing, software
12 *    distributed under the License is distributed on an "AS IS" BASIS,
13 *    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 *    See the License for the specific language governing permissions and
15 *    limitations under the License.
16 */
17
18use core::fmt::Write;
19use core::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, SocketAddrV6};
20
21use domain::base::name::{Label, ToLabelIter};
22
23use crate::dm::clusters::basic_info::BasicInfoConfig;
24use crate::error::{Error, ErrorCode};
25use crate::tlv::EitherIter;
26use crate::utils::storage::{write_split, Vec, WriteBuf};
27
28use super::{MatterLocalService, MatterRemoteService};
29
30#[cfg(feature = "astro-dnssd")]
31pub mod astro;
32#[cfg(feature = "zbus")]
33pub mod avahi;
34pub mod builtin;
35#[cfg(feature = "zbus")]
36pub mod resolve;
37#[cfg(feature = "zeroconf")]
38pub mod zeroconf;
39
40/// The standard mDNS IPv6 broadcast address
41pub const MDNS_IPV6_BROADCAST_ADDR: Ipv6Addr = Ipv6Addr::new(0xff02, 0, 0, 0, 0, 0, 0, 0x00fb);
42
43/// The standard mDNS IPv4 broadcast address
44pub const MDNS_IPV4_BROADCAST_ADDR: Ipv4Addr = Ipv4Addr::new(224, 0, 0, 251);
45
46/// The standard mDNS port
47pub const MDNS_PORT: u16 = 5353;
48
49/// A default bind address for mDNS sockets. Binds to all available interfaces
50pub const MDNS_SOCKET_DEFAULT_BIND_ADDR: SocketAddr =
51    SocketAddr::V6(SocketAddrV6::new(Ipv6Addr::UNSPECIFIED, MDNS_PORT, 0, 0));
52
53impl MatterLocalService {
54    /// Build a full mDNS service description for this Matter service, including
55    /// the service name, type, protocol, port, subtypes, and TXT records.
56    #[allow(clippy::type_complexity)]
57    pub fn service<'a>(
58        &self,
59        dev_det: &BasicInfoConfig<'_>,
60        matter_port: u16,
61        buf: &'a mut [u8],
62    ) -> Result<
63        (
64            MdnsLocalService<
65                'a,
66                impl Iterator<Item = &'a str> + Clone,
67                impl Iterator<Item = (&'a str, &'a str)> + Clone,
68            >,
69            &'a mut [u8],
70        ),
71        Error,
72    > {
73        match self {
74            Self::Commissioned {
75                compressed_fabric_id,
76                node_id,
77            } => {
78                let mut wb = WriteBuf::new(buf);
79
80                let (name, mut wb) =
81                    write_split!(wb, "{:016X}-{:016X}", compressed_fabric_id, node_id)?;
82
83                // Operational fabric subtype per Matter Core Spec:
84                // `_I<compressed_fabric_id>._sub._matter._tcp.local.` lets a
85                // controller browse for nodes of a given fabric without
86                // already knowing each node's id.
87                let (subtype_i, mut wb) = write_split!(wb, "_I{:016X}", compressed_fabric_id)?;
88                let (txt_sai, mut wb) = if let Some(sai) = dev_det.sai {
89                    write_split!(wb, "{}", sai)?
90                } else {
91                    ("", wb)
92                };
93                let (txt_sii, wb) = if let Some(sii) = dev_det.sii {
94                    write_split!(wb, "{}", sii)?
95                } else {
96                    ("", wb)
97                };
98
99                // Per Matter Core Spec, T is a bitmap:
100                // bit 1 (value 2) = TCP client, bit 2 (value 4) = TCP server
101                let txt_kvs = [
102                    ("SAI", txt_sai),
103                    ("SII", txt_sii),
104                    ("T", if dev_det.tcp_supported { "6" } else { "" }),
105                    // Some mDNS responders do not accept empty TXT records
106                    ("DUMMY", "DUMMY"),
107                ]
108                .into_iter()
109                .filter(|(_, v)| !v.is_empty());
110
111                Ok((
112                    MdnsLocalService {
113                        name,
114                        service: "_matter",
115                        protocol: "_tcp",
116                        service_protocol: "_matter._tcp",
117                        port: matter_port,
118                        service_subtypes: EitherIter::First(core::iter::once(subtype_i)),
119                        txt_kvs: EitherIter::First(txt_kvs),
120                    },
121                    wb.into_buf(),
122                ))
123            }
124            Self::Commissionable {
125                id,
126                discriminator,
127                enhanced,
128            } => {
129                let mut wb = WriteBuf::new(buf);
130
131                let (name, mut wb) = write_split!(wb, "{:016X}", id)?;
132
133                let (subtype_discr, mut wb) = write_split!(wb, "_L{}", *discriminator)?;
134                let (subtype_short_discr, mut wb) = write_split!(
135                    wb,
136                    "_S{}",
137                    Self::compute_short_discriminator(*discriminator)
138                )?;
139                let (subtype_v, mut wb) = write_split!(wb, "_V{}", dev_det.vid)?;
140                let (subtype_t, mut wb) = if let Some(dt) = dev_det.device_type {
141                    write_split!(wb, "_T{}", dt)?
142                } else {
143                    ("", wb)
144                };
145
146                let service_subtypes = [
147                    subtype_discr,
148                    subtype_short_discr,
149                    subtype_v,
150                    subtype_t,
151                    "_CM",
152                ]
153                .into_iter()
154                .filter(|s| !s.is_empty());
155
156                let (txt_discr, mut wb) = write_split!(wb, "{}", *discriminator)?;
157                let (txt_vid_pid, mut wb) = write_split!(wb, "{}+{}", dev_det.vid, dev_det.pid)?;
158                let (txt_sai, mut wb) = if let Some(sai) = dev_det.sai {
159                    write_split!(wb, "{}", sai)?
160                } else {
161                    ("", wb)
162                };
163                let (txt_sii, mut wb) = if let Some(sii) = dev_det.sii {
164                    write_split!(wb, "{}", sii)?
165                } else {
166                    ("", wb)
167                };
168                let (txt_dn, mut wb) = write_split!(wb, "{}", dev_det.device_name)?;
169                let (txt_pi, mut wb) = write_split!(wb, "{}", dev_det.pairing_instruction)?;
170                let (txt_ph, mut wb) = write_split!(wb, "{}", dev_det.pairing_hint.bits())?;
171                let (txt_dt, mut wb) = if let Some(dt) = dev_det.device_type {
172                    write_split!(wb, "{}", dt)?
173                } else {
174                    ("", wb)
175                };
176                let (txt_tcp, wb) = if dev_det.tcp_supported {
177                    write_split!(wb, "6")?
178                } else {
179                    ("", wb)
180                };
181
182                let txt_kvs = [
183                    ("D", txt_discr),
184                    ("CM", if *enhanced { "2" } else { "1" }),
185                    ("VP", txt_vid_pid),
186                    ("SAI", txt_sai),
187                    ("SII", txt_sii),
188                    ("DN", txt_dn),
189                    ("PI", txt_pi),
190                    ("PH", txt_ph),
191                    ("DT", txt_dt),
192                    ("T", txt_tcp),
193                ]
194                .into_iter()
195                .filter(|(_, v)| !v.is_empty());
196
197                Ok((
198                    MdnsLocalService {
199                        name,
200                        service: "_matterc",
201                        protocol: "_udp",
202                        service_protocol: "_matterc._udp",
203                        port: matter_port,
204                        service_subtypes: EitherIter::Second(service_subtypes),
205                        txt_kvs: EitherIter::Second(txt_kvs),
206                    },
207                    wb.into_buf(),
208                ))
209            }
210        }
211    }
212
213    fn compute_short_discriminator(discriminator: u16) -> u16 {
214        const SHORT_DISCRIMINATOR_MASK: u16 = 0xF00;
215        const SHORT_DISCRIMINATOR_SHIFT: u16 = 8;
216
217        (discriminator & SHORT_DISCRIMINATOR_MASK) >> SHORT_DISCRIMINATOR_SHIFT
218    }
219}
220
221impl MatterRemoteService {
222    /// The DNS-SD service type (without domain) this remote service lives under:
223    /// `_matter._tcp` for operational nodes, `_matterc._udp` for commissionable
224    /// ones. Used by OS-backed responders that resolve via a `(name, type, domain)`
225    /// API rather than a fully-qualified instance name.
226    pub fn service_type(&self) -> &'static str {
227        match self {
228            Self::Operational { .. } => "_matter._tcp",
229            Self::Commissionable { .. } => "_matterc._udp",
230        }
231    }
232
233    /// Write the fully-qualified mDNS instance name for this service into `buf`.
234    ///
235    /// This is the name to issue SRV/TXT/A/AAAA queries against when resolving.
236    pub fn instance_name(&self, buf: &mut heapless::String<128>) {
237        buf.clear();
238
239        match self {
240            Self::Operational {
241                compressed_fabric_id,
242                node_id,
243            } => {
244                write_unwrap!(
245                    buf,
246                    "{:016X}-{:016X}._matter._tcp.local",
247                    compressed_fabric_id,
248                    node_id
249                );
250            }
251            Self::Commissionable { id } => {
252                write_unwrap!(buf, "{:016X}._matterc._udp.local", id);
253            }
254        }
255    }
256
257    /// The service-type suffix labels that follow the leading instance label:
258    /// `_matter`/`_tcp`/`local` for operational nodes, `_matterc`/`_udp`/`local`
259    /// for commissionable ones.
260    fn suffix_labels(&self) -> &'static [&'static str] {
261        match self {
262            Self::Operational { .. } => &["_matter", "_tcp", "local"],
263            Self::Commissionable { .. } => &["_matterc", "_udp", "local"],
264        }
265    }
266
267    /// The mDNS instance-name labels for this service: the leading hex id label
268    /// (written into `buf`) followed by the static service-type labels. Used to
269    /// build a query name directly from labels (via `NameSlice`), avoiding a
270    /// full-name buffer + re-parse.
271    pub(crate) fn query_name_labels<'b>(&self, buf: &'b mut heapless::String<33>) -> [&'b str; 4] {
272        buf.clear();
273
274        match self {
275            Self::Operational {
276                compressed_fabric_id,
277                node_id,
278            } => {
279                write_unwrap!(buf, "{:016X}-{:016X}", compressed_fabric_id, node_id);
280                [buf.as_str(), "_matter", "_tcp", "local"]
281            }
282            Self::Commissionable { id } => {
283                write_unwrap!(buf, "{:016X}", id);
284                [buf.as_str(), "_matterc", "_udp", "local"]
285            }
286        }
287    }
288
289    /// Whether the given mDNS instance (as a label iterator) refers to this
290    /// service. Walks the labels directly: the first label must hold the matching
291    /// hex id(s), and the remaining labels the service-type suffix (compared
292    /// case-insensitively). No name is ever rendered into a buffer - it reads the
293    /// `domain` (or OS) name's labels in place.
294    pub fn matches_instance<I: ToLabelIter>(&self, instance: &I) -> bool {
295        // Skip the empty root label that absolute names carry.
296        let mut labels = instance.iter_labels().filter(|l| !l.is_empty());
297
298        let Some(first) = labels.next() else {
299            return false;
300        };
301
302        // The remaining labels must be exactly the service-type suffix.
303        let mut suffix = self.suffix_labels().iter();
304        for label in labels {
305            match suffix.next() {
306                Some(expected) if label.as_slice().eq_ignore_ascii_case(expected.as_bytes()) => {}
307                _ => return false,
308            }
309        }
310        if suffix.next().is_some() {
311            return false;
312        }
313
314        // The first label holds the hex id(s).
315        let Ok(first) = core::str::from_utf8(first.as_slice()) else {
316            return false;
317        };
318
319        match self {
320            Self::Operational {
321                compressed_fabric_id,
322                node_id,
323            } => {
324                let Some((fabric, node)) = first.split_once('-') else {
325                    return false;
326                };
327
328                parse_hex_u64(fabric) == Some(*compressed_fabric_id)
329                    && parse_hex_u64(node) == Some(*node_id)
330            }
331            Self::Commissionable { id } => parse_hex_u64(first) == Some(*id),
332        }
333    }
334}
335
336/// A utility type for expanding a `MatterLocalService` type into a full mDNS service description
337///
338/// Useful as an implementation detail when interfacing with OS-specific mDNS libraries.
339pub struct MdnsLocalService<'a, S, T>
340where
341    S: Iterator<Item = &'a str> + Clone,
342    T: Iterator<Item = (&'a str, &'a str)> + Clone,
343{
344    /// The name of the service, typically the mDNS name
345    pub name: &'a str,
346    /// The service type, e.g. "_matter" or "_matterc"
347    pub service: &'a str,
348    /// The protocol used, e.g. "_tcp" or "_udp"
349    pub protocol: &'a str,
350    /// The service and protocol combined, e.g. "_matter._tcp" or "_matterc._udp"
351    pub service_protocol: &'a str,
352    /// The port number the service is running on
353    pub port: u16,
354    /// Optional service subtypes, e.g. "_L1234" or "_S12"
355    pub service_subtypes: S,
356    /// Key-value pairs for TXT records, e.g. ("D", "1234")
357    pub txt_kvs: T,
358}
359
360/// A borrowed, lazily-evaluated view of a single Matter service discovered over
361/// mDNS - the *query-side* analog of the publish-side [`MdnsLocalService`].
362///
363/// Mirroring `MdnsLocalService`, it carries `addrs` and `txt` as **iterators**
364/// rather than collected buffers, so neither the builtin parser nor the OS-backed
365/// responders need a fixed-size, upper-bounded scratch `Vec`.
366///
367/// Type parameters (bounds applied at the use sites, not here):
368/// - `I`: the instance name as a label iterator (`domain`'s `ToLabelIter`) - a
369///   `ParsedName` straight from the packet for the builtin parser, a [`DottedName`]
370///   over the OS backends' native `&str`. Matched label-by-label, never rendered
371///   to a buffer.
372/// - `A`: an `Iterator<Item = IpAddr>` over the service's addresses.
373/// - `T`: an `Iterator<Item = (&str, &str)>` over the raw TXT key/value pairs.
374#[derive(Debug, Clone, Copy)]
375pub struct MdnsRemoteService<I, A, T> {
376    /// The mDNS instance name, e.g. `ABCD1234._matterc._udp.local` for a
377    /// commissionable node or `<fab>-<node>._matter._tcp.local` for an
378    /// operational one.
379    pub instance_name: I,
380    /// The port from the SRV record, if present.
381    pub port: Option<u16>,
382    /// The service's addresses (A/AAAA records).
383    pub addrs: A,
384    /// The raw TXT key/value pairs.
385    pub txt: T,
386    /// The IPv6 scope (zone) id — the interface index the service was discovered
387    /// on. Required to make a **link-local** (`fe80::/10`) address routable: the
388    /// kernel cannot pick an egress interface for a link-local destination
389    /// without it. `0` (the kernel's own "unscoped" sentinel) when the backend
390    /// can't supply it; only relevant for link-local IPv6 results.
391    pub scope_id: u32,
392}
393
394impl<'a, I, A, T> MdnsRemoteService<I, A, T>
395where
396    T: Iterator<Item = (&'a str, &'a str)> + Clone,
397{
398    /// Parse the peer's MRP/session parameters from this answer's TXT records
399    /// (Matter Core spec), returned as `(SII, SAI, SAT)` in milliseconds
400    /// (session idle interval / active interval / active threshold).
401    pub fn session_params(&self) -> (Option<u32>, Option<u32>, Option<u16>) {
402        let (mut sii, mut sai, mut sat) = (None, None, None);
403
404        for (key, value) in self.txt.clone() {
405            if key.eq_ignore_ascii_case("SII") {
406                sii = value.parse().ok();
407            } else if key.eq_ignore_ascii_case("SAI") {
408                sai = value.parse().ok();
409            } else if key.eq_ignore_ascii_case("SAT") {
410                sat = value.parse().ok();
411            }
412        }
413
414        (sii, sai, sat)
415    }
416}
417
418/// Filter criteria for discovering commissionable devices.
419///
420/// This filter is used by mDNS discovery implementations to narrow down
421/// the search for commissionable Matter devices on the local network.
422///
423/// The mDNS subtype filtering supports discriminator, short discriminator,
424/// vendor ID, device type, and commissioning mode. Product ID filtering
425/// is done post-discovery by checking TXT records.
426#[derive(Debug, Clone, Default, Eq, PartialEq)]
427#[cfg_attr(feature = "defmt", derive(defmt::Format))]
428pub struct CommissionableFilter {
429    /// Filter by long discriminator (12-bit)
430    pub discriminator: Option<u16>,
431    /// Filter by short discriminator (4-bit, derived from long discriminator)
432    pub short_discriminator: Option<u8>,
433    /// Filter by vendor ID
434    pub vendor_id: Option<u16>,
435    /// Filter by product ID (applied post-discovery via TXT record check)
436    pub product_id: Option<u16>,
437    /// Filter by device type (uses `_T{type}` subtype)
438    pub device_type: Option<u32>,
439    /// Filter to only find devices in commissioning mode (uses `_CM` subtype)
440    pub commissioning_mode_only: bool,
441}
442
443impl CommissionableFilter {
444    /// Build the mDNS service type string for browsing commissionable devices.
445    ///
446    /// If the filter specifies a discriminator, short discriminator, vendor ID,
447    /// device type, or commissioning mode, the service type will include the
448    /// appropriate subtype for more efficient discovery.
449    ///
450    /// The priority order for subtypes is:
451    /// 1. Long discriminator (`_L{disc}`)
452    /// 2. Short discriminator (`_S{short}`)
453    /// 3. Vendor ID (`_V{vid}`)
454    /// 4. Device type (`_T{type}`)
455    /// 5. Commissioning mode (`_CM`)
456    ///
457    /// Note: Product ID is not included in the service type because the Matter
458    /// specification only defines it as part of the VP TXT record, not as a subtype.
459    ///
460    /// # Arguments
461    /// * `buf` - A mutable string buffer to write the service type into
462    /// * `include_local` - Whether to append `.local` suffix (needed for raw DNS queries)
463    pub fn service_type(&self, buf: &mut heapless::String<64>, include_local: bool) {
464        buf.clear();
465        let suffix = if include_local { ".local" } else { "" };
466
467        let mut sbuf = heapless::String::<24>::new();
468        if let Some(sub) = self.subtype(&mut sbuf) {
469            write_unwrap!(buf, "{}._sub._matterc._udp{}", sub, suffix);
470        } else {
471            write_unwrap!(buf, "_matterc._udp{}", suffix);
472        }
473    }
474
475    /// The single most-selective browse subtype label this filter offers,
476    /// written into `buf`, in the priority order `_L` > `_S` > `_V` > `_T` >
477    /// `_CM` (see [`CommissionableFilter::service_type`]), or `None` for an
478    /// unfiltered browse. Used to build the browse query name directly from
479    /// labels (via `NameSlice`), and as the single source of the priority logic.
480    pub(crate) fn subtype<'b>(&self, buf: &'b mut heapless::String<24>) -> Option<&'b str> {
481        buf.clear();
482
483        if let Some(disc) = self.discriminator {
484            write_unwrap!(buf, "_L{}", disc);
485        } else if let Some(short_disc) = self.short_discriminator {
486            write_unwrap!(buf, "_S{}", short_disc);
487        } else if let Some(vid) = self.vendor_id {
488            write_unwrap!(buf, "_V{}", vid);
489        } else if let Some(dt) = self.device_type {
490            write_unwrap!(buf, "_T{}", dt);
491        } else if self.commissioning_mode_only {
492            write_unwrap!(buf, "_CM");
493        } else {
494            return None;
495        }
496
497        Some(buf.as_str())
498    }
499
500    /// Whether a discovered [`MdnsRemoteService`] matches this filter (AND over
501    /// all non-`None` fields).
502    pub fn matches<'a, I, A, T>(&self, service: &MdnsRemoteService<I, A, T>) -> bool
503    where
504        T: Iterator<Item = (&'a str, &'a str)> + Clone,
505    {
506        self.matches_txt(service.txt.clone())
507    }
508
509    /// Whether a discovered commissionable node matches **all** of this filter's
510    /// non-`None` fields (AND semantics); an empty filter matches everything.
511    ///
512    /// The single, allocation-free filter primitive - it reads the relevant
513    /// Matter commissionable TXT records (`D`, `VP`, `CM`, `DT`) straight off an
514    /// iterator of `(key, value)` pairs, so both the builtin browse path (via
515    /// [`CommissionableFilter::matches`]) and the OS-backed responders
516    /// (which hand it their native TXT records) share one implementation.
517    fn matches_txt<'a, I>(&self, txt: I) -> bool
518    where
519        I: IntoIterator<Item = (&'a str, &'a str)>,
520    {
521        let mut discriminator: Option<u16> = None;
522        let mut vendor_id: Option<u16> = None;
523        let mut product_id: Option<u16> = None;
524        let mut device_type: Option<u32> = None;
525        let mut commissioning = CommissioningMode::Disabled;
526
527        for (key, value) in txt {
528            if key.eq_ignore_ascii_case("D") {
529                discriminator = value.parse::<u16>().ok().filter(|d| *d <= 0xFFF);
530            } else if key.eq_ignore_ascii_case("VP") {
531                if let Some(plus) = value.find('+') {
532                    vendor_id = value[..plus].parse::<u16>().ok();
533                    product_id = value[plus + 1..].parse::<u16>().ok();
534                } else {
535                    vendor_id = value.parse::<u16>().ok();
536                }
537            } else if key.eq_ignore_ascii_case("CM") {
538                commissioning = CommissioningMode::from_txt_value(value);
539            } else if key.eq_ignore_ascii_case("DT") {
540                device_type = value.parse::<u32>().ok();
541            }
542        }
543
544        if let Some(want) = self.discriminator {
545            if discriminator != Some(want) {
546                return false;
547            }
548        }
549
550        if let Some(want) = self.short_discriminator {
551            // Short discriminator is the upper 4 bits of the 12-bit discriminator.
552            if discriminator.map(|d| (d >> 8) as u8) != Some(want) {
553                return false;
554            }
555        }
556
557        if let Some(want) = self.vendor_id {
558            if vendor_id != Some(want) {
559                return false;
560            }
561        }
562
563        if let Some(want) = self.product_id {
564            if product_id != Some(want) {
565                return false;
566            }
567        }
568
569        if let Some(want) = self.device_type {
570            if device_type != Some(want) {
571                return false;
572            }
573        }
574
575        if self.commissioning_mode_only && !commissioning.is_commissionable() {
576            return false;
577        }
578
579        true
580    }
581}
582
583/// Commissioning mode values for Matter devices.
584///
585/// This indicates whether a device is in commissioning mode and what type
586/// of commissioning window is open.
587#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
588#[cfg_attr(feature = "defmt", derive(defmt::Format))]
589#[repr(u8)]
590enum CommissioningMode {
591    /// Device is not in commissioning mode
592    #[default]
593    Disabled = 0,
594    /// Basic commissioning window is open
595    Basic = 1,
596    /// Enhanced commissioning window is open (with passcode verifier)
597    Enhanced = 2,
598}
599
600impl CommissioningMode {
601    /// Parse a commissioning mode from a string value.
602    fn from_txt_value(value: &str) -> Self {
603        match value {
604            "1" => Self::Basic,
605            "2" => Self::Enhanced,
606            _ => Self::Disabled,
607        }
608    }
609
610    /// Returns true if the device is in any commissioning mode.
611    fn is_commissionable(&self) -> bool {
612        !matches!(self, Self::Disabled)
613    }
614}
615
616/// A textual, dotted mDNS name (e.g. `ABCD1234._matterc._udp.local`) viewed as a
617/// sequence of labels, **borrowing** the string - no allocation.
618///
619/// Lets the OS-backed responders hand their native `&str` instance names to the
620/// shared [`MdnsRemoteService`] machinery (which matches label-by-label via
621/// [`ToLabelIter`]) without rendering anything.
622#[derive(Debug, Clone, Copy)]
623pub struct DottedName<'a>(pub &'a str);
624
625impl ToLabelIter for DottedName<'_> {
626    type LabelIter<'t>
627        = core::iter::Map<core::str::Split<'t, char>, fn(&str) -> &Label>
628    where
629        Self: 't;
630
631    fn iter_labels(&self) -> Self::LabelIter<'_> {
632        /// Reinterpret a single textual label as a `domain` [`Label`]; an over-long
633        /// (invalid) label folds to the empty root label, which won't match anything.
634        fn str_to_label(s: &str) -> &Label {
635            Label::from_slice(s.as_bytes()).unwrap_or_else(|_| Label::root())
636        }
637
638        self.0
639            .trim_end_matches('.')
640            .split('.')
641            .map(str_to_label as fn(&str) -> &Label)
642    }
643}
644
645/// The result of a successful [`Matter::resolve`](crate::Matter::resolve): the
646/// peer's address plus its advertised MRP/session parameters (`SII`/`SAI`/`SAT`
647/// = session idle interval / active interval / active threshold, milliseconds).
648///
649/// The params are carried out so the CASE initiator can seed the new session's
650/// MRP backoff from the peer's advertised values rather than local defaults.
651#[derive(Debug, Clone, Copy)]
652pub struct ResolvedNode {
653    /// The resolved peer address (best-scored address + port).
654    pub addr: SocketAddr,
655    /// Session Idle Interval (`SII`), milliseconds.
656    pub sii: Option<u32>,
657    /// Session Active Interval (`SAI`), milliseconds.
658    pub sai: Option<u32>,
659    /// Session Active Threshold (`SAT`), milliseconds.
660    pub sat: Option<u16>,
661}
662
663/// The state of the single in-flight mDNS resolve "rendezvous" shared between
664/// [`Matter::resolve`](crate::Matter::resolve) callers and the running mDNS
665/// responder.
666///
667/// At most one resolve is in flight at a time; callers serialize on the `Idle`
668/// state. See `Matter::resolve` for the protocol.
669#[derive(Debug, Clone)]
670pub(crate) enum MdnsResolveState {
671    /// No resolve in progress; a caller may place a request.
672    Idle,
673    /// A caller has placed a request; the responder has not yet picked it up.
674    Requested { service: MatterRemoteService },
675    /// The responder picked up the request and sent the query; awaiting an answer.
676    InFlight { service: MatterRemoteService },
677    /// The responder deposited the resolved address + MRP/session params.
678    ///
679    /// No `service` is carried: the rendezvous is single-slot, so the only waiter
680    /// that can observe this is the one whose request the responder resolved.
681    Resolved {
682        ip: IpAddr,
683        port: u16,
684        /// IPv6 scope (zone) id for a link-local `ip`; 0 otherwise. See
685        /// [`MdnsRemoteService::scope_id`].
686        scope_id: u32,
687        sii: Option<u32>,
688        sai: Option<u32>,
689        sat: Option<u16>,
690    },
691}
692
693/// Maximum number of already-tried commissionable instance ids a single
694/// [`Transport::browse_commissionable`](crate::transport::Transport::browse_commissionable)
695/// request can exclude - i.e. how many short-discriminator-collision candidates
696/// a caller can step through before giving up. Small and fixed (no heap).
697pub(crate) const MAX_BROWSE_EXCLUDE: usize = 6;
698
699/// The set of commissionable instance ids to skip on a browse (already tried).
700pub(crate) type BrowseExclude = Vec<u64, MAX_BROWSE_EXCLUDE>;
701
702/// The state of the single in-flight mDNS commissionable-**browse** "rendezvous"
703/// shared between
704/// [`Transport::browse_commissionable`](crate::transport::Transport::browse_commissionable)
705/// callers and the running mDNS responder.
706///
707/// Prototype: at most one browse in flight at a time, returning the *first*
708/// matching node whose id is not in the request's exclude set (so a caller can
709/// step to the "next" candidate on a short-discriminator collision). See
710/// `Transport::browse_commissionable` for the protocol.
711#[derive(Debug, Clone)]
712pub(crate) enum MdnsBrowseState {
713    /// No browse in progress; a caller may place a request.
714    Idle,
715    /// A caller has placed a request (filter + ids to skip); not yet picked up.
716    Requested {
717        filter: CommissionableFilter,
718        exclude: BrowseExclude,
719    },
720    /// The responder picked up the request and sent the browse query; awaiting a match.
721    InFlight {
722        filter: CommissionableFilter,
723        exclude: BrowseExclude,
724    },
725    /// The responder deposited the first matching, non-excluded commissionable node.
726    Found {
727        ip: IpAddr,
728        port: u16,
729        /// IPv6 scope (zone) id for a link-local `ip`; 0 otherwise. See
730        /// [`MdnsRemoteService::scope_id`].
731        scope_id: u32,
732        id: u64,
733    },
734}
735
736/// Score an IP address for prioritization.
737///
738/// Higher scores indicate more preferred addresses. The priority order follows
739/// the Matter specification:
740///
741/// 1. Link-local IPv6 (highest priority) - most likely to work for local discovery
742/// 2. Unique local IPv6 (ULA, fc00::/7) - private network addresses
743/// 3. Global unicast IPv6 - routable addresses
744/// 4. IPv4 (lowest priority)
745///
746/// This prioritization prefers IPv6 over IPv4 and local addresses over global ones,
747/// which aligns with Matter's preference for link-local communication.
748pub fn score_ip_address(addr: &IpAddr) -> u8 {
749    match addr {
750        IpAddr::V6(ipv6) => {
751            if ipv6.is_unicast_link_local() {
752                // Link-local IPv6 (fe80::/10) - highest priority
753                100
754            } else if ipv6.is_unique_local() {
755                // Unique local address (fc00::/7) - second priority
756                80
757            } else if is_ipv6_global_unicast(ipv6) {
758                // Global unicast - third priority
759                60
760            } else {
761                // Other IPv6 (multicast, etc.)
762                40
763            }
764        }
765        IpAddr::V4(_) => {
766            // IPv4 - lowest priority
767            20
768        }
769    }
770}
771
772/// Check if an IPv6 address is global unicast (2000::/3)
773fn is_ipv6_global_unicast(addr: &Ipv6Addr) -> bool {
774    let segments = addr.segments();
775    (segments[0] & 0xe000) == 0x2000
776}
777
778/// The commissionable instance id (the leading label, parsed as hex) of a
779/// discovered instance, or `None` if the first label isn't a single hex id.
780/// Used to dedup/exclude browse candidates by id.
781pub(crate) fn commissionable_instance_id<I: ToLabelIter>(instance: &I) -> Option<u64> {
782    let first = instance.iter_labels().find(|l| !l.is_empty())?;
783    parse_hex_u64(core::str::from_utf8(first.as_slice()).ok()?)
784}
785
786/// Parse a hex string as a `u64` (case-insensitive). `None` on empty/overflow/
787/// non-hex input. Used to read the hex id label out of an mDNS instance name.
788fn parse_hex_u64(s: &str) -> Option<u64> {
789    // `from_str_radix` accepts a leading `+`/`-`; reject those so a malformed
790    // label can't masquerade as a valid id.
791    if s.bytes().all(|b| b.is_ascii_hexdigit()) {
792        u64::from_str_radix(s, 16).ok()
793    } else {
794        None
795    }
796}
797
798#[cfg(test)]
799mod tests {
800    use super::*;
801
802    #[test]
803    fn can_compute_short_discriminator() {
804        let discriminator: u16 = 0b0000_1111_0000_0000;
805        let short = MatterLocalService::compute_short_discriminator(discriminator);
806        assert_eq!(short, 0b1111);
807
808        let discriminator: u16 = 840;
809        let short = MatterLocalService::compute_short_discriminator(discriminator);
810        assert_eq!(short, 3);
811    }
812
813    // --- CommissionableFilter::matches_txt (AND over all non-None fields) ---
814
815    #[test]
816    fn matches_txt_empty_filter_matches_all() {
817        let filter = CommissionableFilter::default();
818        assert!(filter.matches_txt([("D", "1234"), ("VP", "65521+32768"), ("CM", "1")]));
819        assert!(filter.matches_txt(core::iter::empty::<(&str, &str)>())); // even an empty advertisement
820    }
821
822    #[test]
823    fn matches_txt_discriminator_and_short() {
824        let filter = CommissionableFilter {
825            discriminator: Some(1234),
826            ..Default::default()
827        };
828        assert!(filter.matches_txt([("D", "1234")]));
829        assert!(!filter.matches_txt([("D", "5678")]));
830
831        // Short discriminator = top 4 bits of the 12-bit discriminator (840 -> 3).
832        let filter = CommissionableFilter {
833            short_discriminator: Some(3),
834            ..Default::default()
835        };
836        assert!(filter.matches_txt([("D", "840")]));
837        assert!(!filter.matches_txt([("D", "1024")])); // 0x400 -> short 4
838    }
839
840    #[test]
841    fn matches_txt_vendor_product_and_combined() {
842        let filter = CommissionableFilter {
843            vendor_id: Some(0xFFF1),
844            product_id: Some(0x8000),
845            ..Default::default()
846        };
847        assert!(filter.matches_txt([("VP", "65521+32768")]));
848        assert!(!filter.matches_txt([("VP", "65521+1")])); // wrong product
849        assert!(!filter.matches_txt([("VP", "1+32768")])); // wrong vendor
850
851        // All non-None fields must match (AND).
852        let filter = CommissionableFilter {
853            discriminator: Some(1234),
854            vendor_id: Some(0xFFF1),
855            ..Default::default()
856        };
857        assert!(filter.matches_txt([("D", "1234"), ("VP", "65521+1")]));
858        assert!(!filter.matches_txt([("D", "1234"), ("VP", "1+1")]));
859        assert!(!filter.matches_txt([("D", "9999"), ("VP", "65521+1")]));
860    }
861
862    #[test]
863    fn matches_txt_device_type_and_commissioning_mode() {
864        let filter = CommissionableFilter {
865            device_type: Some(257),
866            ..Default::default()
867        };
868        assert!(filter.matches_txt([("DT", "257")]));
869        assert!(!filter.matches_txt([("DT", "256")]));
870
871        let filter = CommissionableFilter {
872            commissioning_mode_only: true,
873            ..Default::default()
874        };
875        assert!(filter.matches_txt([("CM", "1")]));
876        assert!(filter.matches_txt([("CM", "2")]));
877        assert!(!filter.matches_txt([("CM", "0")]));
878        assert!(!filter.matches_txt(core::iter::empty::<(&str, &str)>())); // no CM advertised -> not commissionable
879    }
880
881    // --- CommissionableFilter::service_type (most-selective subtype query) ---
882
883    #[test]
884    fn service_type_no_filter() {
885        let filter = CommissionableFilter::default();
886        let mut buf = heapless::String::<64>::new();
887
888        filter.service_type(&mut buf, false);
889        assert_eq!(buf.as_str(), "_matterc._udp");
890
891        filter.service_type(&mut buf, true);
892        assert_eq!(buf.as_str(), "_matterc._udp.local");
893    }
894
895    #[test]
896    fn service_type_with_discriminator() {
897        let filter = CommissionableFilter {
898            discriminator: Some(1234),
899            ..Default::default()
900        };
901        let mut buf = heapless::String::<64>::new();
902
903        filter.service_type(&mut buf, false);
904        assert_eq!(buf.as_str(), "_L1234._sub._matterc._udp");
905
906        filter.service_type(&mut buf, true);
907        assert_eq!(buf.as_str(), "_L1234._sub._matterc._udp.local");
908    }
909
910    #[test]
911    fn service_type_with_short_discriminator() {
912        let filter = CommissionableFilter {
913            short_discriminator: Some(3),
914            ..Default::default()
915        };
916        let mut buf = heapless::String::<64>::new();
917
918        filter.service_type(&mut buf, false);
919        assert_eq!(buf.as_str(), "_S3._sub._matterc._udp");
920    }
921
922    #[test]
923    fn service_type_with_vendor_id() {
924        let filter = CommissionableFilter {
925            vendor_id: Some(0xFFF1),
926            ..Default::default()
927        };
928        let mut buf = heapless::String::<64>::new();
929
930        filter.service_type(&mut buf, false);
931        assert_eq!(buf.as_str(), "_V65521._sub._matterc._udp");
932    }
933
934    #[test]
935    fn service_type_with_device_type() {
936        let filter = CommissionableFilter {
937            device_type: Some(257),
938            ..Default::default()
939        };
940        let mut buf = heapless::String::<64>::new();
941
942        filter.service_type(&mut buf, false);
943        assert_eq!(buf.as_str(), "_T257._sub._matterc._udp");
944    }
945
946    #[test]
947    fn service_type_with_commissioning_mode_only() {
948        let filter = CommissionableFilter {
949            commissioning_mode_only: true,
950            ..Default::default()
951        };
952        let mut buf = heapless::String::<64>::new();
953
954        filter.service_type(&mut buf, false);
955        assert_eq!(buf.as_str(), "_CM._sub._matterc._udp");
956    }
957
958    #[test]
959    fn service_type_priority_order() {
960        // discriminator > short_discriminator > vendor_id > device_type > CM
961        let mut buf = heapless::String::<64>::new();
962
963        CommissionableFilter {
964            discriminator: Some(1234),
965            short_discriminator: Some(3),
966            vendor_id: Some(0xFFF1),
967            device_type: Some(257),
968            commissioning_mode_only: true,
969            product_id: Some(0x8000),
970        }
971        .service_type(&mut buf, false);
972        assert_eq!(buf.as_str(), "_L1234._sub._matterc._udp");
973
974        CommissionableFilter {
975            short_discriminator: Some(3),
976            vendor_id: Some(0xFFF1),
977            ..Default::default()
978        }
979        .service_type(&mut buf, false);
980        assert_eq!(buf.as_str(), "_S3._sub._matterc._udp");
981
982        CommissionableFilter {
983            vendor_id: Some(0xFFF1),
984            device_type: Some(257),
985            ..Default::default()
986        }
987        .service_type(&mut buf, false);
988        assert_eq!(buf.as_str(), "_V65521._sub._matterc._udp");
989
990        CommissionableFilter {
991            device_type: Some(257),
992            commissioning_mode_only: true,
993            ..Default::default()
994        }
995        .service_type(&mut buf, false);
996        assert_eq!(buf.as_str(), "_T257._sub._matterc._udp");
997
998        // Product ID alone is never a subtype (no VP subtype without vendor).
999        CommissionableFilter {
1000            product_id: Some(0x8000),
1001            ..Default::default()
1002        }
1003        .service_type(&mut buf, false);
1004        assert_eq!(buf.as_str(), "_matterc._udp");
1005    }
1006
1007    // --- small helpers ---
1008
1009    #[test]
1010    fn commissioning_mode_from_txt_value() {
1011        assert_eq!(
1012            CommissioningMode::from_txt_value("0"),
1013            CommissioningMode::Disabled
1014        );
1015        assert_eq!(
1016            CommissioningMode::from_txt_value("1"),
1017            CommissioningMode::Basic
1018        );
1019        assert_eq!(
1020            CommissioningMode::from_txt_value("2"),
1021            CommissioningMode::Enhanced
1022        );
1023        assert_eq!(
1024            CommissioningMode::from_txt_value("x"),
1025            CommissioningMode::Disabled
1026        );
1027
1028        assert!(!CommissioningMode::Disabled.is_commissionable());
1029        assert!(CommissioningMode::Basic.is_commissionable());
1030        assert!(CommissioningMode::Enhanced.is_commissionable());
1031    }
1032
1033    #[test]
1034    fn score_ip_address_priority_order() {
1035        let link_local = IpAddr::V6(Ipv6Addr::new(0xfe80, 0, 0, 0, 0, 0, 0, 1));
1036        let ula = IpAddr::V6(Ipv6Addr::new(0xfd00, 0, 0, 0, 0, 0, 0, 1));
1037        let global = IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1));
1038        let ipv4 = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1));
1039
1040        assert!(score_ip_address(&link_local) > score_ip_address(&ula));
1041        assert!(score_ip_address(&ula) > score_ip_address(&global));
1042        assert!(score_ip_address(&global) > score_ip_address(&ipv4));
1043    }
1044
1045    #[test]
1046    fn is_ipv6_global_unicast_correct() {
1047        assert!(is_ipv6_global_unicast(&Ipv6Addr::new(
1048            0x2001, 0xdb8, 0, 0, 0, 0, 0, 1
1049        )));
1050        assert!(is_ipv6_global_unicast(&Ipv6Addr::new(
1051            0x3fff, 0xffff, 0, 0, 0, 0, 0, 1
1052        )));
1053        assert!(!is_ipv6_global_unicast(&Ipv6Addr::new(
1054            0xfe80, 0, 0, 0, 0, 0, 0, 1
1055        )));
1056        assert!(!is_ipv6_global_unicast(&Ipv6Addr::new(
1057            0xfc00, 0, 0, 0, 0, 0, 0, 1
1058        )));
1059    }
1060}