Skip to main content

prns_interfaces_embassy/
tcp.rs

1use ::core::time::Duration as CoreDuration;
2use embassy_futures::select::{select, select4, Either, Either4};
3use embassy_net::tcp::{Error as TcpIoError, TcpSocket};
4use embassy_net::{IpEndpoint, Stack};
5use embassy_time::{with_timeout, Duration, Instant, Timer};
6use embedded_io_async_07::Write;
7
8use prns_core::engine::InstantMillis;
9use prns_core::interfaces::rns_serial_framing::{self, RnsSerialDecoder};
10use prns_core::interfaces::{
11    tcp, BitrateBps, ConnectionState, InterfaceDescriptor, InterfaceId, InterfaceKind,
12};
13use prns_runtime::manifold::airtime::{frame_airtime_us, AirtimeLedger};
14use prns_runtime::manifold::driver::EmbassyInterfaceStatus;
15use prns_runtime::manifold::interface_seam::{Interface, InterfaceSeam, EMBEDDED_MAX_LINK_MTU};
16use prns_runtime::manifold::reconnect::ReconnectPolicy;
17use prns_runtime::manifold::throughput::ThroughputLedger;
18
19pub const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
20/// A connection idle past [`SOCKET_TIMEOUT`] is dropped for reconnect, while [`KEEP_ALIVE`] prevents a quiet live link from reaching that timeout.
21pub const SOCKET_TIMEOUT: Duration = Duration::from_secs(24);
22pub const KEEP_ALIVE: Duration = Duration::from_secs(5);
23pub const TCP_DNS_HOSTNAME_MAX_BYTES: usize = 253;
24
25#[derive(Clone, Copy, Debug, Eq, PartialEq)]
26pub enum TcpClientExitCause {
27    PeerClosed,
28    ReadFailure(TcpIoError),
29    WriteFailure(TcpIoError),
30    Timeout,
31    NetworkUnavailable,
32    Disabled,
33}
34
35enum TcpConnectionAttempt {
36    Initial,
37    Retry,
38}
39
40#[derive(Clone, Copy, Debug, Eq, PartialEq)]
41enum TcpNetworkFamily {
42    Ipv4,
43    Ipv6,
44}
45
46impl TcpConnectionAttempt {
47    const fn connection_state(&self) -> ConnectionState {
48        match self {
49            TcpConnectionAttempt::Initial => ConnectionState::Initializing,
50            TcpConnectionAttempt::Retry => ConnectionState::Reconnecting,
51        }
52    }
53}
54
55pub struct TcpSocketBuffers<'a> {
56    pub rx: &'a mut [u8],
57    pub tx: &'a mut [u8],
58}
59
60pub struct TcpClientInput<'a> {
61    pub stack: Stack<'a>,
62    pub target: TcpClientTarget,
63    pub channel_tag: &'a [u8],
64    pub bitrate: BitrateBps,
65    pub reconnect_policy: ReconnectPolicy,
66    pub socket_buffers: TcpSocketBuffers<'a>,
67    pub status: &'a EmbassyInterfaceStatus,
68}
69
70#[derive(Debug)]
71pub struct TcpClientTarget {
72    endpoint: Option<IpEndpoint>,
73    #[cfg(feature = "tcp-dns")]
74    hostname: heapless::String<TCP_DNS_HOSTNAME_MAX_BYTES>,
75    #[cfg(feature = "tcp-dns")]
76    port: u16,
77}
78
79impl TcpClientTarget {
80    #[must_use]
81    pub fn endpoint(endpoint: IpEndpoint) -> Self {
82        Self {
83            endpoint: Some(endpoint),
84            #[cfg(feature = "tcp-dns")]
85            hostname: heapless::String::new(),
86            #[cfg(feature = "tcp-dns")]
87            port: endpoint.port,
88        }
89    }
90
91    fn network_family(&self) -> TcpNetworkFamily {
92        match self.endpoint.map(|endpoint| endpoint.addr) {
93            Some(embassy_net::IpAddress::Ipv4(_)) | None => TcpNetworkFamily::Ipv4,
94            Some(embassy_net::IpAddress::Ipv6(_)) => TcpNetworkFamily::Ipv6,
95        }
96    }
97
98    #[cfg(feature = "tcp-dns")]
99    #[must_use]
100    pub fn dns(hostname: heapless::String<TCP_DNS_HOSTNAME_MAX_BYTES>, port: u16) -> Self {
101        Self {
102            endpoint: None,
103            hostname,
104            port,
105        }
106    }
107}
108
109pub struct TcpClient<'a> {
110    id: InterfaceId,
111    stack: Stack<'a>,
112    target: TcpClientTarget,
113    tag: &'a [u8],
114    bitrate: BitrateBps,
115    reconnect_policy: ReconnectPolicy,
116    rx_buffer: &'a mut [u8],
117    tx_buffer: &'a mut [u8],
118    status: &'a EmbassyInterfaceStatus,
119}
120
121impl<'a> TcpClient<'a> {
122    #[must_use]
123    pub fn interface_id(tag: &[u8]) -> InterfaceId {
124        InterfaceId::from_channel_tag(InterfaceKind::TcpClient, tag)
125    }
126
127    #[must_use]
128    pub fn new(input: TcpClientInput<'a>) -> Self {
129        let TcpClientInput {
130            stack,
131            target,
132            channel_tag,
133            bitrate,
134            reconnect_policy,
135            socket_buffers,
136            status,
137        } = input;
138        Self {
139            id: Self::interface_id(channel_tag),
140            stack,
141            target,
142            tag: channel_tag,
143            bitrate,
144            reconnect_policy,
145            rx_buffer: socket_buffers.rx,
146            tx_buffer: socket_buffers.tx,
147            status,
148        }
149    }
150
151    #[must_use]
152    pub fn id(&self) -> InterfaceId {
153        self.id
154    }
155}
156
157impl Interface for TcpClient<'_> {
158    const HW_MTU: usize = EMBEDDED_MAX_LINK_MTU;
159    const KIND: InterfaceKind = InterfaceKind::TcpClient;
160
161    fn descriptor(&self) -> InterfaceDescriptor {
162        tcp::descriptor(self.id, tcp::policy_for_bitrate(self.bitrate))
163    }
164
165    fn channel_tag(&self) -> &[u8] {
166        self.tag
167    }
168
169    async fn run<Seam: InterfaceSeam>(self, mut seam: Seam) {
170        let TcpClient {
171            id: _,
172            stack,
173            target,
174            tag: _,
175            bitrate,
176            reconnect_policy,
177            rx_buffer,
178            tx_buffer,
179            status,
180        } = self;
181        let mut decoder = RnsSerialDecoder::<{ tcp::EMBEDDED_FRAME_CAP }>::new();
182        let mut read_buf = [0u8; tcp::EMBEDDED_READ_BUF_LEN];
183        let mut frame_buf = [0u8; tcp::EMBEDDED_FRAMED_LEN];
184        let mut airtime = AirtimeLedger::new();
185        let mut throughput = ThroughputLedger::new();
186        let started = Instant::now();
187        let mut reconnect = reconnect_policy.schedule();
188        let mut connection_attempt = TcpConnectionAttempt::Initial;
189
190        loop {
191            if !status.is_enabled() {
192                status.set_connection(ConnectionState::Disabled);
193                status.wait_until_enabled().await;
194                continue;
195            }
196            status.set_connection(connection_attempt.connection_state());
197            connection_attempt = TcpConnectionAttempt::Retry;
198            let network_family = target.network_family();
199            let network_ready = select(
200                wait_until_network_ready(stack, network_family),
201                status.wait_until_disabled(),
202            )
203            .await;
204            if matches!(network_ready, Either::Second(())) {
205                status.set_connection(ConnectionState::Disabled);
206                continue;
207            }
208            crate::diagnostic_log::info!("tcp-client [configured]: resolving target={target:?}");
209            let resolved_target = select(
210                with_timeout(CONNECT_TIMEOUT, resolve_target(stack, &target)),
211                status.wait_until_disabled(),
212            )
213            .await;
214            let resolved_target = match resolved_target {
215                Either::First(Ok(Some(resolved_target))) => {
216                    crate::diagnostic_log::info!(
217                        "tcp-client [configured]: resolved target={target:?} endpoint={resolved_target:?}"
218                    );
219                    resolved_target
220                }
221                Either::First(Ok(None)) => {
222                    crate::diagnostic_log::warn!(
223                        "tcp-client [configured]: resolution failed target={target:?}"
224                    );
225                    status.set_connection(ConnectionState::Disconnected);
226                    let reconnect_delay = reconnect.next_delay(|bytes| seam.fill_entropy(bytes));
227                    crate::diagnostic_log::info!(
228                        "tcp-client [configured]: target={target:?} retry_delay_ms={}",
229                        reconnect_delay.as_millis()
230                    );
231                    let _ = select(
232                        Timer::after(Duration::from_millis(reconnect_delay.as_millis() as u64)),
233                        status.wait_until_disabled(),
234                    )
235                    .await;
236                    continue;
237                }
238                Either::First(Err(_)) => {
239                    crate::diagnostic_log::warn!(
240                        "tcp-client [configured]: resolution failed target={target:?} cause={:?}",
241                        TcpClientExitCause::Timeout
242                    );
243                    status.set_connection(ConnectionState::Disconnected);
244                    let reconnect_delay = reconnect.next_delay(|bytes| seam.fill_entropy(bytes));
245                    crate::diagnostic_log::info!(
246                        "tcp-client [configured]: target={target:?} retry_delay_ms={}",
247                        reconnect_delay.as_millis()
248                    );
249                    let _ = select(
250                        Timer::after(Duration::from_millis(reconnect_delay.as_millis() as u64)),
251                        status.wait_until_disabled(),
252                    )
253                    .await;
254                    continue;
255                }
256                Either::Second(()) => {
257                    status.set_connection(ConnectionState::Disabled);
258                    crate::diagnostic_log::info!(
259                        "tcp-client [configured]: target={target:?} exit={:?}",
260                        TcpClientExitCause::Disabled
261                    );
262                    continue;
263                }
264            };
265            let mut socket = TcpSocket::new(stack, &mut *rx_buffer, &mut *tx_buffer);
266            socket.set_timeout(Some(SOCKET_TIMEOUT));
267            socket.set_keep_alive(Some(KEEP_ALIVE));
268            crate::diagnostic_log::info!(
269                "tcp-client [configured]: connecting target={target:?} endpoint={resolved_target:?}"
270            );
271            let connected = select(
272                with_timeout(CONNECT_TIMEOUT, socket.connect(resolved_target)),
273                status.wait_until_disabled(),
274            )
275            .await;
276            match connected {
277                Either::First(Ok(Ok(()))) => {
278                    let connected_at = Instant::now();
279                    reset_decoder_for_connection(&mut decoder);
280                    status.set_connection(ConnectionState::Connected);
281                    crate::diagnostic_log::info!(
282                        "tcp-client [configured]: connected target={target:?} endpoint={resolved_target:?}"
283                    );
284                    let exit = serve(
285                        &mut socket,
286                        &mut seam,
287                        status,
288                        &mut decoder,
289                        &mut read_buf,
290                        &mut frame_buf,
291                        &mut airtime,
292                        &mut throughput,
293                        bitrate,
294                        started,
295                        stack,
296                        network_family,
297                    )
298                    .await;
299                    let lifetime_ms = connected_at.elapsed().as_millis();
300                    reconnect.record_connection_lifetime(CoreDuration::from_millis(lifetime_ms));
301                    crate::diagnostic_log::info!(
302                        "tcp-client [configured]: target={target:?} endpoint={resolved_target:?} exit={exit:?} lifetime_ms={lifetime_ms}"
303                    );
304                }
305                Either::First(Ok(Err(error))) => {
306                    crate::diagnostic_log::warn!(
307                        "tcp-client [configured]: connect failed target={target:?} endpoint={resolved_target:?} error={error:?}"
308                    );
309                }
310                Either::First(Err(_)) => {
311                    crate::diagnostic_log::warn!(
312                        "tcp-client [configured]: connect failed target={target:?} endpoint={resolved_target:?} cause={:?}",
313                        TcpClientExitCause::Timeout
314                    );
315                }
316                Either::Second(()) => {
317                    crate::diagnostic_log::info!(
318                        "tcp-client [configured]: connect stopped target={target:?} endpoint={resolved_target:?} exit={:?}",
319                        TcpClientExitCause::Disabled
320                    );
321                }
322            }
323            socket.abort();
324            // Skip reconnect delay after disable so status changes immediately.
325            if status.is_enabled() {
326                status.set_connection(ConnectionState::Disconnected);
327                let reconnect_delay = reconnect.next_delay(|bytes| seam.fill_entropy(bytes));
328                crate::diagnostic_log::info!(
329                    "tcp-client [configured]: target={target:?} retry_delay_ms={}",
330                    reconnect_delay.as_millis()
331                );
332                let _ = select(
333                    Timer::after(Duration::from_millis(reconnect_delay.as_millis() as u64)),
334                    status.wait_until_disabled(),
335                )
336                .await;
337            } else {
338                status.set_connection(ConnectionState::Disabled);
339            }
340        }
341    }
342}
343
344fn reset_decoder_for_connection(decoder: &mut RnsSerialDecoder<{ tcp::EMBEDDED_FRAME_CAP }>) {
345    decoder.reset();
346}
347
348async fn resolve_target(_stack: Stack<'_>, target: &TcpClientTarget) -> Option<IpEndpoint> {
349    if let Some(endpoint) = target.endpoint {
350        return Some(endpoint);
351    }
352    #[cfg(feature = "tcp-dns")]
353    {
354        use embassy_net::dns::DnsQueryType;
355        use embassy_net::IpAddress;
356
357        return _stack
358            .dns_query(target.hostname.as_str(), DnsQueryType::A)
359            .await
360            .ok()?
361            .into_iter()
362            .find_map(|address| match address {
363                IpAddress::Ipv4(address) => {
364                    Some(IpEndpoint::new(IpAddress::Ipv4(address), target.port))
365                }
366                IpAddress::Ipv6(_) => None,
367            });
368    }
369    #[cfg(not(feature = "tcp-dns"))]
370    None
371}
372
373fn network_ready(stack: Stack<'_>, family: TcpNetworkFamily) -> bool {
374    if !stack.is_link_up() {
375        return false;
376    }
377    match family {
378        TcpNetworkFamily::Ipv4 => stack.config_v4().is_some(),
379        TcpNetworkFamily::Ipv6 => stack.config_v6().is_some(),
380    }
381}
382
383async fn wait_until_network_ready(stack: Stack<'_>, family: TcpNetworkFamily) {
384    while !network_ready(stack, family) {
385        Timer::after(Duration::from_millis(100)).await;
386    }
387}
388
389async fn wait_until_network_unavailable(stack: Stack<'_>, family: TcpNetworkFamily) {
390    while network_ready(stack, family) {
391        Timer::after(Duration::from_millis(100)).await;
392    }
393}
394
395#[expect(
396    clippy::too_many_arguments,
397    reason = "embedded serve-loop internals pass the loop's split-borrowed locals; bundling awaits an on-hardware validation pass"
398)]
399async fn serve<Seam: InterfaceSeam>(
400    socket: &mut TcpSocket<'_>,
401    seam: &mut Seam,
402    status: &EmbassyInterfaceStatus,
403    decoder: &mut RnsSerialDecoder<{ tcp::EMBEDDED_FRAME_CAP }>,
404    read_buf: &mut [u8],
405    frame_buf: &mut [u8],
406    airtime: &mut AirtimeLedger,
407    throughput: &mut ThroughputLedger,
408    bitrate: BitrateBps,
409    started: Instant,
410    stack: Stack<'_>,
411    network_family: TcpNetworkFamily,
412) -> TcpClientExitCause {
413    let (mut reader, mut writer) = socket.split();
414    loop {
415        match select4(
416            reader.read(read_buf),
417            seam.next_outbound(),
418            status.wait_until_disabled(),
419            wait_until_network_unavailable(stack, network_family),
420        )
421        .await
422        {
423            Either4::Fourth(()) => return TcpClientExitCause::NetworkUnavailable,
424            Either4::Third(()) => return TcpClientExitCause::Disabled,
425            Either4::First(read) => {
426                let read = match read {
427                    Ok(0) => return TcpClientExitCause::PeerClosed,
428                    Err(error) => return TcpClientExitCause::ReadFailure(error),
429                    Ok(read) => read,
430                };
431                status.add_rx(read as u64);
432                let now = InstantMillis(started.elapsed().as_millis());
433                throughput.record_rx(now, read as u64);
434                status.set_transfer_rates(throughput.rates());
435                let mut offset = 0;
436                let chunk = &read_buf[..read];
437                while offset < chunk.len() {
438                    match decoder.feed_slice_next(chunk, &mut offset) {
439                        Ok(Some(frame)) => {
440                            if !frame.is_empty() {
441                                seam.next_inbound(frame).await;
442                            }
443                        }
444                        Ok(None) => break,
445                        Err(error) => crate::diagnostic_log::warn!(
446                            "tcp-client [configured]: decode failed error={error:?}"
447                        ),
448                    }
449                }
450            }
451            Either4::Second(outbound) => match rns_serial_framing::encode(outbound, frame_buf) {
452                Ok(framed) => {
453                    if let Err(error) = writer.write_all(&frame_buf[..framed]).await {
454                        return TcpClientExitCause::WriteFailure(error);
455                    }
456                    status.add_tx(framed as u64);
457                    let now = InstantMillis(started.elapsed().as_millis());
458                    throughput.record_tx(now, framed as u64);
459                    status.set_transfer_rates(throughput.rates());
460                    let frame_airtime = frame_airtime_us(framed, bitrate);
461                    status.set_airtime(airtime.record_tx(now, frame_airtime));
462                }
463                Err(error) => crate::diagnostic_log::warn!(
464                    "tcp-client [configured]: encode failed error={error:?}"
465                ),
466            },
467        }
468    }
469}
470
471#[cfg(test)]
472mod tests {
473    use super::*;
474
475    #[test]
476    fn a_new_connection_discards_partial_decoder_state() {
477        let mut decoder = RnsSerialDecoder::<{ tcp::EMBEDDED_FRAME_CAP }>::new();
478        let mut encoded = [0u8; tcp::EMBEDDED_FRAMED_LEN];
479        let len = rns_serial_framing::encode(b"second connection", &mut encoded).unwrap();
480        let mut offset = 0;
481        let _ = decoder.feed_slice_next(&encoded[..len / 2], &mut offset);
482
483        reset_decoder_for_connection(&mut decoder);
484        offset = 0;
485        let decoded = decoder
486            .feed_slice_next(&encoded[..len], &mut offset)
487            .unwrap()
488            .expect("the complete frame decodes after reset");
489
490        assert_eq!(decoded, b"second connection");
491    }
492
493    #[test]
494    fn connection_attempts_distinguish_initialization_from_retries() {
495        assert_eq!(
496            TcpConnectionAttempt::Initial.connection_state(),
497            ConnectionState::Initializing
498        );
499        assert_eq!(
500            TcpConnectionAttempt::Retry.connection_state(),
501            ConnectionState::Reconnecting
502        );
503    }
504
505    #[test]
506    fn tcp_targets_select_their_required_network_family() {
507        let v4 = TcpClientTarget::endpoint(IpEndpoint::new(
508            embassy_net::Ipv4Address::new(192, 0, 2, 1).into(),
509            4242,
510        ));
511        let v6 = TcpClientTarget::endpoint(IpEndpoint::new(
512            embassy_net::Ipv6Address::LOCALHOST.into(),
513            4242,
514        ));
515        assert_eq!(v4.network_family(), TcpNetworkFamily::Ipv4);
516        assert_eq!(v6.network_family(), TcpNetworkFamily::Ipv6);
517    }
518}