The full 1.0 client surface, matching the protocol v2 wire definitions from donglora-protocol 1.0.0.
* Public API is now async. The blocking Client<T: Transport> is replaced by Dongle (from donglora_client::connect / connect_with / ConnectOptions).
* src/client.rs and src/codec.rs are removed in favor of src/dongle.rs (high-level surface), src/session.rs (per-client tag/slot state), src/retry.rs (RetryPolicy + tx_with_retry), src/errors.rs (thiserror-based ClientError / ClientResult). src/protocol.rs is replaced by direct donglora-protocol imports.
* Transport is now tokio-serial for USB and tokio::net::{UnixStream, TcpStream} for mux, replacing the blocking serial + shim model.
* Dongle::tx_with_retry(data, &RetryPolicy) implements PROTOCOL.md §6.10 randomized backoff for CHANNEL_BUSY / EBUSY, returning a TxOutcome with per-attempt trace.
* Background keepalive task pings every 500 ms on host idle, tracking the 1 s inactivity timeout from §3.4 with 2x margin. Disable via ConnectOptions::keepalive(false).
* Auto-recovery from ERR(ENOTCONFIGURED): the Dongle caches the last SET_CONFIG and silently re-applies on session drop, retrying once.
* Error taxonomy switches from anyhow to thiserror. Transport-level conditions (Timeout, TransportClosed, ReaderExited, BadFrame) are first-class.
* Examples rewritten for the async API: examples/ping.rs, examples/tx.rs, examples/rx.rs all target the MeshCore US preset (910.525 MHz / BW 62.5 / SF 7 / CR 4/5 / sync 0x1424 / 20 dBm). examples/common/mod.rs holds the shared helper.
* tests/vectors.rs gains protocol-level roundtrip coverage anchored to the published donglora-protocol 1.0.0 types.
208 lines
7.7 KiB
Rust
208 lines
7.7 KiB
Rust
//! Async transport abstractions.
|
|
//!
|
|
//! The [`Transport`] marker trait unifies tokio-based byte streams so the
|
|
//! [`Session`](crate::session::Session) can drive any of them. Three
|
|
//! concrete transports are provided:
|
|
//!
|
|
//! - [`SerialTransport`] — `tokio-serial` USB CDC-ACM.
|
|
//! - [`UnixSocketTransport`] — mux daemon over `tokio::net::UnixStream`.
|
|
//! - [`TcpTransport`] — mux daemon over `tokio::net::TcpStream`.
|
|
//!
|
|
//! Most callers use [`AnyTransport`] so the concrete variant is decided
|
|
//! at `connect()` time without leaking type parameters into user code.
|
|
|
|
use std::io;
|
|
use std::pin::Pin;
|
|
use std::task::{Context, Poll};
|
|
use std::time::Duration;
|
|
|
|
use tokio::io::{AsyncRead, AsyncWrite, ReadBuf};
|
|
use tokio::net::TcpStream;
|
|
#[cfg(unix)]
|
|
use tokio::net::UnixStream;
|
|
use tokio_serial::{SerialPortBuilderExt, SerialStream};
|
|
|
|
use crate::errors::{ClientError, ClientResult};
|
|
|
|
/// Marker for any async byte-stream we can carry DongLoRa Protocol frames over.
|
|
///
|
|
/// This is intentionally a blanket trait so the [`Session`](crate::session::Session)
|
|
/// can accept `SerialStream`, `UnixStream`, `TcpStream`, or the
|
|
/// type-erased [`AnyTransport`] uniformly.
|
|
pub trait Transport: AsyncRead + AsyncWrite + Unpin + Send + 'static {}
|
|
impl<T: AsyncRead + AsyncWrite + Unpin + Send + 'static> Transport for T {}
|
|
|
|
// ── Serial transport ───────────────────────────────────────────────
|
|
|
|
/// Direct USB serial port connection (tokio-serial).
|
|
pub struct SerialTransport {
|
|
inner: SerialStream,
|
|
}
|
|
|
|
impl SerialTransport {
|
|
/// Open a serial port. Baud rate is irrelevant for USB CDC-ACM but
|
|
/// `tokio-serial` still demands a value; 115200 is conventional.
|
|
pub fn open(path: &str) -> ClientResult<Self> {
|
|
let inner = tokio_serial::new(path, 115_200)
|
|
.open_native_async()
|
|
.map_err(|e| ClientError::Other(format!("failed to open serial port {path}: {e}")))?;
|
|
Ok(Self { inner })
|
|
}
|
|
}
|
|
|
|
impl AsyncRead for SerialTransport {
|
|
fn poll_read(mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll<io::Result<()>> {
|
|
Pin::new(&mut self.inner).poll_read(cx, buf)
|
|
}
|
|
}
|
|
|
|
impl AsyncWrite for SerialTransport {
|
|
fn poll_write(mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &[u8]) -> Poll<io::Result<usize>> {
|
|
Pin::new(&mut self.inner).poll_write(cx, buf)
|
|
}
|
|
|
|
fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
|
|
Pin::new(&mut self.inner).poll_flush(cx)
|
|
}
|
|
|
|
fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
|
|
Pin::new(&mut self.inner).poll_shutdown(cx)
|
|
}
|
|
}
|
|
|
|
// ── Unix socket transport ──────────────────────────────────────────
|
|
|
|
/// Mux daemon connection over a Unix domain socket.
|
|
#[cfg(unix)]
|
|
pub struct UnixSocketTransport {
|
|
inner: UnixStream,
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
impl UnixSocketTransport {
|
|
/// Connect to the mux daemon at `path`.
|
|
pub async fn connect(path: &str) -> ClientResult<Self> {
|
|
let inner = UnixStream::connect(path)
|
|
.await
|
|
.map_err(|e| ClientError::Other(format!("failed to connect to mux socket {path}: {e}")))?;
|
|
Ok(Self { inner })
|
|
}
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
impl AsyncRead for UnixSocketTransport {
|
|
fn poll_read(mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll<io::Result<()>> {
|
|
Pin::new(&mut self.inner).poll_read(cx, buf)
|
|
}
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
impl AsyncWrite for UnixSocketTransport {
|
|
fn poll_write(mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &[u8]) -> Poll<io::Result<usize>> {
|
|
Pin::new(&mut self.inner).poll_write(cx, buf)
|
|
}
|
|
|
|
fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
|
|
Pin::new(&mut self.inner).poll_flush(cx)
|
|
}
|
|
|
|
fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
|
|
Pin::new(&mut self.inner).poll_shutdown(cx)
|
|
}
|
|
}
|
|
|
|
// ── TCP transport ──────────────────────────────────────────────────
|
|
|
|
/// Mux daemon connection over TCP. Uses `TCP_NODELAY` because DongLoRa Protocol frames
|
|
/// are small and latency-sensitive.
|
|
pub struct TcpTransport {
|
|
inner: TcpStream,
|
|
}
|
|
|
|
impl TcpTransport {
|
|
/// Connect to `host:port`. A failed connect attempt returns an error
|
|
/// immediately; no internal retries (callers decide policy).
|
|
pub async fn connect(host: &str, port: u16, timeout: Duration) -> ClientResult<Self> {
|
|
let addr = format!("{host}:{port}");
|
|
let fut = TcpStream::connect(&addr);
|
|
let stream = tokio::time::timeout(timeout, fut)
|
|
.await
|
|
.map_err(|_| ClientError::Timeout { what: "tcp connect" })?
|
|
.map_err(|e| ClientError::Other(format!("failed to connect to {addr}: {e}")))?;
|
|
stream.set_nodelay(true).map_err(|e| ClientError::Other(format!("failed to set TCP_NODELAY: {e}")))?;
|
|
Ok(Self { inner: stream })
|
|
}
|
|
}
|
|
|
|
impl AsyncRead for TcpTransport {
|
|
fn poll_read(mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll<io::Result<()>> {
|
|
Pin::new(&mut self.inner).poll_read(cx, buf)
|
|
}
|
|
}
|
|
|
|
impl AsyncWrite for TcpTransport {
|
|
fn poll_write(mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &[u8]) -> Poll<io::Result<usize>> {
|
|
Pin::new(&mut self.inner).poll_write(cx, buf)
|
|
}
|
|
|
|
fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
|
|
Pin::new(&mut self.inner).poll_flush(cx)
|
|
}
|
|
|
|
fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
|
|
Pin::new(&mut self.inner).poll_shutdown(cx)
|
|
}
|
|
}
|
|
|
|
// ── AnyTransport ───────────────────────────────────────────────────
|
|
|
|
/// Type-erased transport returned by [`crate::connect`]. One of the three
|
|
/// concrete variants depending on which transport the auto-discovery chain
|
|
/// succeeded on.
|
|
pub enum AnyTransport {
|
|
Serial(SerialTransport),
|
|
#[cfg(unix)]
|
|
Unix(UnixSocketTransport),
|
|
Tcp(TcpTransport),
|
|
}
|
|
|
|
impl AsyncRead for AnyTransport {
|
|
fn poll_read(self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll<io::Result<()>> {
|
|
match self.get_mut() {
|
|
Self::Serial(t) => Pin::new(t).poll_read(cx, buf),
|
|
#[cfg(unix)]
|
|
Self::Unix(t) => Pin::new(t).poll_read(cx, buf),
|
|
Self::Tcp(t) => Pin::new(t).poll_read(cx, buf),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl AsyncWrite for AnyTransport {
|
|
fn poll_write(self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &[u8]) -> Poll<io::Result<usize>> {
|
|
match self.get_mut() {
|
|
Self::Serial(t) => Pin::new(t).poll_write(cx, buf),
|
|
#[cfg(unix)]
|
|
Self::Unix(t) => Pin::new(t).poll_write(cx, buf),
|
|
Self::Tcp(t) => Pin::new(t).poll_write(cx, buf),
|
|
}
|
|
}
|
|
|
|
fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
|
|
match self.get_mut() {
|
|
Self::Serial(t) => Pin::new(t).poll_flush(cx),
|
|
#[cfg(unix)]
|
|
Self::Unix(t) => Pin::new(t).poll_flush(cx),
|
|
Self::Tcp(t) => Pin::new(t).poll_flush(cx),
|
|
}
|
|
}
|
|
|
|
fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
|
|
match self.get_mut() {
|
|
Self::Serial(t) => Pin::new(t).poll_shutdown(cx),
|
|
#[cfg(unix)]
|
|
Self::Unix(t) => Pin::new(t).poll_shutdown(cx),
|
|
Self::Tcp(t) => Pin::new(t).poll_shutdown(cx),
|
|
}
|
|
}
|
|
}
|