Skip to main content

hick_smoltcp/
smoltcp_io.rs

1//! [`UdpIo`] over smoltcp UDP sockets, via the family-aware [`DualStack`].
2//!
3//! The engine fans every multicast to BOTH the v4 and v6 groups, so the transport
4//! must be family-aware: a datagram of one family must reach a socket of THAT
5//! family, and an absent family must report [`SendError::Unsupported`] so the
6//! engine confirms on the family it has. A bare `udp::Socket` cannot express its
7//! family — a port-only `bind(5353)` is family-ambiguous, and smoltcp's
8//! `send_slice` does not reject a wrong-family destination (it enqueues, then
9//! `iface.poll` panics on mixed IP versions or silently drops while the engine has
10//! already confirmed the send). So this crate does NOT implement `UdpIo` for a raw
11//! `udp::Socket`; use [`DualStack`], naming the v4 and/or v6 socket handle
12//! explicitly (leave one `None` for a single-stack node). This is a bare poll loop
13//! / RTIC transport; `hick-embassy` provides the embassy equivalent.
14
15use core::net::SocketAddr;
16
17use smoltcp::{
18  iface::{SocketHandle, SocketSet},
19  socket::udp,
20  wire::IpEndpoint,
21};
22
23use crate::udpio::{RecvMeta, SendError, UdpIo};
24
25/// Pull one datagram from `socket` into `buf`. `None` when the receive queue is
26/// empty; a zero-length [`RecvMeta`] marks a datagram smoltcp dropped as oversized
27/// (so the engine still counts it against the per-pump RX cap — see below).
28fn recv_from(socket: &mut udp::Socket<'_>, buf: &mut [u8]) -> Option<RecvMeta> {
29  match socket.recv_slice(buf) {
30    Ok((len, meta)) => {
31      #[cfg(feature = "defmt")]
32      defmt::trace!("smoltcp recv_from: {} bytes", len);
33      Some(RecvMeta {
34        src: meta.endpoint.into(),
35        local: meta.local_address.map(Into::into),
36        // smoltcp's `udp::Socket` UDP metadata (`UdpMetadata`) exposes no received
37        // hop-limit — verified against 0.13.1 (only `endpoint` / `local_address` /
38        // `meta`) — so this is `None` and the engine's §11 RECEIVE gate falls back to
39        // the source-subnet heuristic (`onlink::on_link`). The §11 TRANSMIT invariant
40        // (TTL 255 out) is enforced separately in `send_from`, not here.
41        hop_limit: None,
42        len,
43      })
44    }
45    Err(udp::RecvError::Exhausted) => None,
46    // An oversized datagram was DROPPED (recv_slice already dequeued it). Surface a
47    // zero-length marker rather than looping here to find the next fitting datagram:
48    // the drop then counts against the engine's MAX_RX_PER_PUMP cap, so a flood
49    // of oversized packets cannot drain the whole socket backlog in one uncapped pass.
50    // A real mDNS datagram is never zero-length (>= 12-byte header), so the engine
51    // treats len 0 as "nothing to deliver".
52    Err(udp::RecvError::Truncated) => Some(RecvMeta {
53      src: SocketAddr::new(core::net::IpAddr::V4(core::net::Ipv4Addr::UNSPECIFIED), 0),
54      local: None,
55      hop_limit: None,
56      len: 0,
57    }),
58  }
59}
60
61/// Enqueue one datagram on `socket` for `dst` — which [`DualStack`] has already
62/// routed to this socket's family, so there is no wrong-family hazard here. Maps a
63/// payload larger than the TX buffer to [`SendError::TooLarge`] (permanent, so the
64/// engine retires the producer rather than retrying) versus a momentarily
65/// full queue to [`SendError::Busy`] (transient).
66fn send_from(socket: &mut udp::Socket<'_>, buf: &[u8], dst: SocketAddr) -> Result<(), SendError> {
67  if buf.len() > socket.payload_send_capacity() {
68    return Err(SendError::TooLarge);
69  }
70  #[cfg(feature = "defmt")]
71  defmt::trace!("smoltcp send_from: {} bytes", buf.len());
72  // RFC 6762 §11: EVERY outgoing mDNS packet MUST leave with IP TTL / hop-limit 255,
73  // and a conformant receiver rejects anything else (it is the multicast on-link
74  // guard — the same gate `onlink::on_link` applies on RX). smoltcp dispatches a UDP
75  // datagram with the socket's configured hop-limit and DEFAULTS it to 64 when unset,
76  // so a probe/announcement/goodbye would otherwise egress at 64 and compliant peers
77  // would silently drop it — the API would appear to transmit while no peer listens.
78  // Force 255 here at the single send choke point (idempotent — a plain field set on
79  // the value smoltcp reads at egress) rather than trusting every caller to have
80  // configured both sockets; `hick-embassy`'s driver enforces the equivalent
81  // `set_hop_limit(Some(255))` once at startup (it holds the sockets by `&mut` there),
82  // and the smoltcp sockets are reached only through this transport, so enforce per-send.
83  socket.set_hop_limit(Some(255));
84  match socket.send_slice(buf, IpEndpoint::from(dst)) {
85    Ok(()) => Ok(()),
86    Err(udp::SendError::BufferFull) => Err(SendError::Busy),
87    Err(udp::SendError::Unaddressable) => Err(SendError::Unsupported),
88  }
89}
90
91/// A family-aware [`UdpIo`] over a v4 and/or v6 smoltcp UDP socket living in a
92/// shared [`SocketSet`] — the smoltcp transport (single- OR dual-stack), mirroring
93/// `hick-embassy`'s `DualUdp`.
94///
95/// The engine fans every multicast to BOTH groups, so each datagram must reach the
96/// socket of ITS OWN family rather than be enqueued on a mismatched one (which
97/// would panic or be silently dropped). `try_send` routes by the destination's
98/// family and reports [`SendError::Unsupported`] for an absent family (the engine
99/// then confirms on the family it has); `try_recv` alternates the two sockets
100/// round-robin so a sustained backlog on one family cannot starve the other under
101/// the engine's per-pump RX cap. For a SINGLE-stack node, leave the absent family's
102/// handle `None` — the family-explicit replacement for naively pumping one raw socket.
103///
104/// `pump` borrows the [`SocketSet`] for the duration of one step; build a fresh
105/// `DualStack` each step (it is a thin view) with [`DualStack::new`], e.g.
106/// `engine.pump(now, &mut DualStack::new(sockets, Some(h4), Some(h6)), buf)`.
107pub struct DualStack<'set, 'sockets> {
108  sockets: &'set mut SocketSet<'sockets>,
109  v4: Option<SocketHandle>,
110  v6: Option<SocketHandle>,
111  /// Which family to poll FIRST on the next `try_recv`, toggled each call so the two
112  /// families are drained round-robin. Per-pump (a fresh `DualStack` resets it)
113  /// is enough: the RX cap is per-pump, so alternating within each pump serves both.
114  take_v6_first: bool,
115}
116
117impl<'set, 'sockets> DualStack<'set, 'sockets> {
118  /// Build the transport view for one pump step over a v4 and/or v6 socket living in
119  /// `sockets`. Pass `None` for an absent family on a single-stack node.
120  pub fn new(
121    sockets: &'set mut SocketSet<'sockets>,
122    v4: Option<SocketHandle>,
123    v6: Option<SocketHandle>,
124  ) -> Self {
125    Self {
126      sockets,
127      v4,
128      v6,
129      take_v6_first: false,
130    }
131  }
132}
133
134impl UdpIo for DualStack<'_, '_> {
135  fn try_recv(&mut self, buf: &mut [u8]) -> Option<RecvMeta> {
136    // Alternate which family is polled first so a sustained backlog on one cannot
137    // monopolize the engine's per-pump RX cap and starve the other.
138    let take_v6_first = self.take_v6_first;
139    self.take_v6_first = !take_v6_first;
140    let (first, second) = if take_v6_first {
141      (self.v6, self.v4)
142    } else {
143      (self.v4, self.v6)
144    };
145    if let Some(handle) = first
146      && let Some(meta) = recv_from(self.sockets.get_mut::<udp::Socket<'_>>(handle), buf)
147    {
148      return Some(meta);
149    }
150    if let Some(handle) = second {
151      return recv_from(self.sockets.get_mut::<udp::Socket<'_>>(handle), buf);
152    }
153    None
154  }
155
156  fn try_send(&mut self, buf: &[u8], dst: SocketAddr) -> Result<(), SendError> {
157    let handle = if dst.is_ipv4() { self.v4 } else { self.v6 };
158    match handle {
159      Some(handle) => send_from(self.sockets.get_mut::<udp::Socket<'_>>(handle), buf, dst),
160      // No socket for this family — it will never queue here. Report unsupported so
161      // the engine confirms on the family it has rather than retrying forever.
162      None => Err(SendError::Unsupported),
163    }
164  }
165}
166
167#[cfg(test)]
168#[allow(clippy::unwrap_used, clippy::expect_used)]
169mod tests;