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