Skip to main content

donglora_client/
connect.rs

1//! High-level `connect()` entry points.
2//!
3//! Mirrors the Python client's `connect()` behaviour: explicit port → TCP
4//! mux (env var) → Unix mux (socket file) → direct USB serial. The first
5//! successful mux connection within a process is "sticky": subsequent
6//! calls stay on the mux even if the socket temporarily disappears.
7//!
8//! Public surface:
9//!
10//! - [`ConnectOptions`] — builder for `port`, `timeout`, `config`,
11//!   `auto_configure`, `keepalive`.
12//! - [`connect`] — one-shot convenience: `connect().await?` returns a
13//!   [`Dongle`] with defaults.
14//! - [`connect_with`] — takes a populated [`ConnectOptions`].
15//! - [`try_connect`] — like `connect` but never blocks for a USB device;
16//!   returns an error instead of polling forever.
17//! - [`connect_mux_auto`] — mux-only (TCP env var, then Unix socket);
18//!   never falls back to USB.
19//! - [`mux_unix_connect`] / [`mux_tcp_connect`] — explicit single-transport.
20//! - [`default_socket_path`] / [`find_mux_socket`] — helpers.
21
22use std::path::Path;
23use std::sync::atomic::{AtomicBool, Ordering};
24use std::time::Duration;
25
26use donglora_protocol::Modulation;
27use tracing::{debug, info};
28
29use crate::discovery;
30use crate::dongle::{Dongle, TransportKind};
31use crate::errors::{ClientError, ClientResult};
32use crate::session::Session;
33#[cfg(unix)]
34use crate::transport::UnixSocketTransport;
35use crate::transport::{AnyTransport, SerialTransport, TcpTransport, Transport};
36
37/// Set once this process connects via mux. All future auto-connects stay
38/// on mux rather than falling through to direct USB.
39static USED_MUX: AtomicBool = AtomicBool::new(false);
40
41/// Default per-command timeout when the caller doesn't override it.
42const DEFAULT_TIMEOUT: Duration = Duration::from_secs(2);
43
44/// Builder for [`connect_with`].
45///
46/// ```no_run
47/// # async fn demo() -> Result<(), donglora_client::ClientError> {
48/// use donglora_client::{ConnectOptions, connect_with};
49/// let dongle = connect_with(
50///     ConnectOptions::default()
51///         .port("/dev/ttyACM0")
52///         .keepalive(false),
53/// ).await?;
54/// # drop(dongle); Ok(()) }
55/// ```
56#[derive(Debug, Clone, Default)]
57pub struct ConnectOptions {
58    port: Option<String>,
59    timeout: Option<Duration>,
60    config: Option<Modulation>,
61    auto_configure: bool,
62    keepalive: bool,
63}
64
65impl ConnectOptions {
66    /// Explicit serial port path; skips the mux + auto-discovery chain
67    /// entirely.
68    #[must_use]
69    pub fn port(mut self, path: impl Into<String>) -> Self {
70        self.port = Some(path.into());
71        self
72    }
73
74    /// Per-command timeout for the connect-time PING + GET_INFO +
75    /// SET_CONFIG (if any). Default is 2 s.
76    #[must_use]
77    pub fn timeout(mut self, timeout: Duration) -> Self {
78        self.timeout = Some(timeout);
79        self
80    }
81
82    /// Supply an initial radio config. When paired with `auto_configure`
83    /// (enabled by default via [`Self::auto_configure`]), the config is
84    /// applied immediately after `GET_INFO`. Callers that want full
85    /// manual control over configuration should leave this as `None`.
86    #[must_use]
87    pub fn config(mut self, modulation: Modulation) -> Self {
88        self.config = Some(modulation);
89        self.auto_configure = true;
90        self
91    }
92
93    /// Whether to apply the [`Self::config`] automatically at connect
94    /// time. Default: `true` if a config was supplied, `false` otherwise.
95    #[must_use]
96    pub fn auto_configure(mut self, enabled: bool) -> Self {
97        self.auto_configure = enabled;
98        self
99    }
100
101    /// Enable the background keepalive task. Default: `true`. Disable
102    /// only when the caller manages session liveness themselves (e.g.
103    /// a tight send/recv loop that hits the device more often than the
104    /// 1 s inactivity timer).
105    #[must_use]
106    pub fn keepalive(mut self, enabled: bool) -> Self {
107        self.keepalive = enabled;
108        self
109    }
110}
111
112/// Convenience constructor — equivalent to `ConnectOptions::default()`
113/// but with `keepalive = true`, matching the Python client's defaults.
114impl ConnectOptions {
115    /// Default options with keepalive enabled.
116    #[must_use]
117    pub fn new() -> Self {
118        Self { keepalive: true, ..Self::default() }
119    }
120}
121
122/// Resolve the mux socket path in priority order:
123///
124/// 1. `$DONGLORA_MUX`
125/// 2. `$XDG_RUNTIME_DIR/donglora/mux.sock`
126/// 3. `/tmp/donglora-mux.sock`
127#[must_use]
128pub fn default_socket_path() -> String {
129    if let Ok(env) = std::env::var("DONGLORA_MUX") {
130        return env;
131    }
132    if let Ok(xdg) = std::env::var("XDG_RUNTIME_DIR") {
133        return format!("{xdg}/donglora/mux.sock");
134    }
135    "/tmp/donglora-mux.sock".to_string()
136}
137
138/// Check for a mux socket in the standard places and return the first
139/// one that exists. Returns `None` if no socket is live.
140#[must_use]
141pub fn find_mux_socket() -> Option<String> {
142    if let Ok(env) = std::env::var("DONGLORA_MUX") {
143        if Path::new(&env).exists() {
144            return Some(env);
145        }
146        return None;
147    }
148    if let Ok(xdg) = std::env::var("XDG_RUNTIME_DIR") {
149        let p = format!("{xdg}/donglora/mux.sock");
150        if Path::new(&p).exists() {
151            return Some(p);
152        }
153    }
154    let p = "/tmp/donglora-mux.sock";
155    if Path::new(p).exists() {
156        return Some(p.to_string());
157    }
158    None
159}
160
161// ── Connect entry points ──────────────────────────────────────────
162
163/// One-shot convenience: run the full auto-discovery chain with default
164/// options and return a ready [`Dongle`].
165///
166/// Blocks (asynchronously) waiting for a USB device if no mux is
167/// available and no device is currently present. Use [`try_connect`] if
168/// you want a single non-blocking scan instead.
169pub async fn connect() -> ClientResult<Dongle> {
170    connect_with(ConnectOptions::new()).await
171}
172
173/// Connect with the given [`ConnectOptions`].
174pub async fn connect_with(opts: ConnectOptions) -> ClientResult<Dongle> {
175    let timeout = opts.timeout.unwrap_or(DEFAULT_TIMEOUT);
176
177    // Explicit port always bypasses mux.
178    if let Some(port) = opts.port.as_deref() {
179        debug!("opening serial port {port}");
180        let transport = SerialTransport::open(port)?;
181        return finalize(AnyTransport::Serial(transport), TransportKind::Serial(port.to_string()), &opts, timeout)
182            .await;
183    }
184
185    // Sticky: once we've used a mux, stay on mux (wait if socket
186    // disappeared — a mux restart).
187    if USED_MUX.load(Ordering::Relaxed) {
188        return connect_mux_sticky(&opts, timeout).await;
189    }
190
191    // Try TCP mux via env var.
192    if let Some((transport, endpoint)) = try_tcp_env(timeout).await {
193        USED_MUX.store(true, Ordering::Relaxed);
194        return finalize(AnyTransport::Tcp(transport), TransportKind::MuxTcp(endpoint), &opts, timeout).await;
195    }
196
197    // Try existing Unix mux socket.
198    #[cfg(unix)]
199    if let Some(path) = find_mux_socket() {
200        debug!("connecting to Unix mux at {path}");
201        let transport = UnixSocketTransport::connect(&path).await?;
202        USED_MUX.store(true, Ordering::Relaxed);
203        return finalize(AnyTransport::Unix(transport), TransportKind::MuxUnix(path), &opts, timeout).await;
204    }
205
206    // Direct USB: wait indefinitely for a device.
207    let port = match discovery::find_port() {
208        Some(p) => p,
209        None => discovery::wait_for_device().await,
210    };
211    debug!("opening serial port {port}");
212    let transport = SerialTransport::open(&port)?;
213    finalize(AnyTransport::Serial(transport), TransportKind::Serial(port), &opts, timeout).await
214}
215
216/// Like [`connect`] but returns an error rather than blocking if no USB
217/// device is present. Mux-sticky behaviour still applies.
218pub async fn try_connect() -> ClientResult<Dongle> {
219    try_connect_with(ConnectOptions::new()).await
220}
221
222/// Non-blocking variant of [`connect_with`].
223pub async fn try_connect_with(opts: ConnectOptions) -> ClientResult<Dongle> {
224    let timeout = opts.timeout.unwrap_or(DEFAULT_TIMEOUT);
225
226    if let Some(port) = opts.port.as_deref() {
227        let transport = SerialTransport::open(port)?;
228        return finalize(AnyTransport::Serial(transport), TransportKind::Serial(port.to_string()), &opts, timeout)
229            .await;
230    }
231
232    if USED_MUX.load(Ordering::Relaxed) {
233        let path =
234            find_mux_socket().ok_or_else(|| ClientError::Other("mux not available (waiting for restart)".into()))?;
235        #[cfg(unix)]
236        {
237            let transport = UnixSocketTransport::connect(&path).await?;
238            return finalize(AnyTransport::Unix(transport), TransportKind::MuxUnix(path), &opts, timeout).await;
239        }
240        #[cfg(not(unix))]
241        {
242            let _ = path;
243            return Err(ClientError::Other("Unix mux requires a unix target".into()));
244        }
245    }
246
247    if let Some((transport, endpoint)) = try_tcp_env(timeout).await {
248        USED_MUX.store(true, Ordering::Relaxed);
249        return finalize(AnyTransport::Tcp(transport), TransportKind::MuxTcp(endpoint), &opts, timeout).await;
250    }
251
252    #[cfg(unix)]
253    if let Some(path) = find_mux_socket() {
254        let transport = UnixSocketTransport::connect(&path).await?;
255        USED_MUX.store(true, Ordering::Relaxed);
256        return finalize(AnyTransport::Unix(transport), TransportKind::MuxUnix(path), &opts, timeout).await;
257    }
258
259    let port = discovery::find_port()
260        .ok_or_else(|| ClientError::Other("no DongLoRa device found (no mux, no USB device)".into()))?;
261    let transport = SerialTransport::open(&port)?;
262    finalize(AnyTransport::Serial(transport), TransportKind::Serial(port), &opts, timeout).await
263}
264
265/// Mux-only connect. Never falls back to direct USB; returns an error if
266/// no mux (TCP via env var or Unix socket) is reachable.
267pub async fn connect_mux_auto() -> ClientResult<Dongle> {
268    connect_mux_auto_with(ConnectOptions::new()).await
269}
270
271/// Mux-only connect, with caller-supplied options.
272pub async fn connect_mux_auto_with(opts: ConnectOptions) -> ClientResult<Dongle> {
273    let timeout = opts.timeout.unwrap_or(DEFAULT_TIMEOUT);
274    if let Some((transport, endpoint)) = try_tcp_env(timeout).await {
275        USED_MUX.store(true, Ordering::Relaxed);
276        return finalize(AnyTransport::Tcp(transport), TransportKind::MuxTcp(endpoint), &opts, timeout).await;
277    }
278    #[cfg(unix)]
279    {
280        let path = find_mux_socket().ok_or_else(|| ClientError::Other("no mux socket found".into()))?;
281        let transport = UnixSocketTransport::connect(&path).await?;
282        USED_MUX.store(true, Ordering::Relaxed);
283        finalize(AnyTransport::Unix(transport), TransportKind::MuxUnix(path), &opts, timeout).await
284    }
285    #[cfg(not(unix))]
286    Err(ClientError::Other("mux-only mode requires Unix socket support or DONGLORA_MUX_TCP".into()))
287}
288
289/// Connect to an explicit Unix mux socket. Useful for tests and CLIs
290/// that want to bypass the auto-discovery chain.
291#[cfg(unix)]
292pub async fn mux_unix_connect(path: &str) -> ClientResult<Dongle> {
293    let transport = UnixSocketTransport::connect(path).await?;
294    USED_MUX.store(true, Ordering::Relaxed);
295    finalize(
296        AnyTransport::Unix(transport),
297        TransportKind::MuxUnix(path.to_string()),
298        &ConnectOptions::new(),
299        DEFAULT_TIMEOUT,
300    )
301    .await
302}
303
304/// Connect to an explicit TCP mux endpoint.
305pub async fn mux_tcp_connect(host: &str, port: u16) -> ClientResult<Dongle> {
306    let transport = TcpTransport::connect(host, port, DEFAULT_TIMEOUT).await?;
307    USED_MUX.store(true, Ordering::Relaxed);
308    finalize(
309        AnyTransport::Tcp(transport),
310        TransportKind::MuxTcp(format!("{host}:{port}")),
311        &ConnectOptions::new(),
312        DEFAULT_TIMEOUT,
313    )
314    .await
315}
316
317// ── internals ─────────────────────────────────────────────────────
318
319async fn connect_mux_sticky(opts: &ConnectOptions, timeout: Duration) -> ClientResult<Dongle> {
320    if let Some((transport, endpoint)) = try_tcp_env(timeout).await {
321        return finalize(AnyTransport::Tcp(transport), TransportKind::MuxTcp(endpoint), opts, timeout).await;
322    }
323    #[cfg(unix)]
324    {
325        let path = default_socket_path();
326        let mut warned = false;
327        loop {
328            if Path::new(&path).exists() {
329                let transport = UnixSocketTransport::connect(&path).await?;
330                return finalize(AnyTransport::Unix(transport), TransportKind::MuxUnix(path), opts, timeout).await;
331            }
332            if !warned {
333                info!("waiting for mux at {path} ...");
334                warned = true;
335            }
336            tokio::time::sleep(Duration::from_millis(500)).await;
337        }
338    }
339    #[cfg(not(unix))]
340    Err(ClientError::Other("no mux endpoint available".into()))
341}
342
343async fn try_tcp_env(timeout: Duration) -> Option<(TcpTransport, String)> {
344    let tcp = std::env::var("DONGLORA_MUX_TCP").ok()?;
345    let (host, port) = parse_tcp_endpoint(&tcp)?;
346    match TcpTransport::connect(&host, port, timeout).await {
347        Ok(t) => {
348            debug!("connected to TCP mux at {host}:{port}");
349            Some((t, format!("{host}:{port}")))
350        }
351        Err(e) => {
352            debug!("DONGLORA_MUX_TCP connect failed: {e}");
353            None
354        }
355    }
356}
357
358fn parse_tcp_endpoint(addr: &str) -> Option<(String, u16)> {
359    if let Some((h, p)) = addr.rsplit_once(':') {
360        let host = if h.is_empty() { "localhost".to_string() } else { h.to_string() };
361        let port: u16 = p.parse().ok()?;
362        Some((host, port))
363    } else {
364        let port: u16 = addr.parse().ok()?;
365        Some(("localhost".to_string(), port))
366    }
367}
368
369async fn finalize<T: Transport>(
370    transport: T,
371    kind: TransportKind,
372    opts: &ConnectOptions,
373    timeout: Duration,
374) -> ClientResult<Dongle> {
375    let session = Session::spawn(transport);
376    // Probe: PING validates the connection, GET_INFO caches device info.
377    session.ping(timeout).await?;
378    let info = session.get_info(timeout).await?;
379
380    let applied = if opts.auto_configure {
381        match opts.config {
382            Some(m) => {
383                let result = session.set_config(m, timeout).await?;
384                Some(result.current)
385            }
386            None => None,
387        }
388    } else {
389        None
390    };
391
392    Ok(Dongle::new(session, info, kind, applied, opts.keepalive))
393}