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::{Info, LoRaConfig, 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 prepared = prepare_config(&info, m)?;
384                let result = session.set_config(prepared, timeout).await?;
385                Some(result.current)
386            }
387            None => None,
388        }
389    } else {
390        None
391    };
392
393    Ok(Dongle::new(session, info, kind, applied, opts.keepalive))
394}
395
396/// Validate and auto-adjust `modulation` against the device's advertised caps.
397///
398/// Per-field policy:
399///
400/// * `tx_power_dbm`: clamped into `[tx_power_min_dbm, tx_power_max_dbm]`.
401///   A clamp is logged at INFO — "give me max power" quietly returning
402///   less is the universally-expected behaviour and not worth a hard error.
403/// * `freq_hz`: rejected with [`ClientError::ConfigNotSupported`] when
404///   outside `[freq_min_hz, freq_max_hz]`. Silently shifting a 915 MHz
405///   request to 868 MHz (or vice versa) would cross regulatory boundaries.
406/// * `sf`, `bw`: rejected with [`ClientError::ConfigNotSupported`] when
407///   the corresponding capability bit isn't set. These change airtime and
408///   sensitivity dramatically; silent substitution is more confusing
409///   than helpful.
410///
411/// Non-LoRa modulations pass through untouched — the firmware rejects
412/// unsupported modulation IDs with `EMODULATION` on its own.
413pub(crate) fn prepare_config(info: &Info, modulation: Modulation) -> ClientResult<Modulation> {
414    let Modulation::LoRa(cfg) = modulation else {
415        return Ok(modulation);
416    };
417    Ok(Modulation::LoRa(prepare_lora_config(info, cfg)?))
418}
419
420fn prepare_lora_config(info: &Info, mut cfg: LoRaConfig) -> ClientResult<LoRaConfig> {
421    if cfg.freq_hz < info.freq_min_hz || cfg.freq_hz > info.freq_max_hz {
422        return Err(ClientError::ConfigNotSupported {
423            reason: format!(
424                "frequency {} Hz outside device range [{}, {}] Hz",
425                cfg.freq_hz, info.freq_min_hz, info.freq_max_hz
426            ),
427        });
428    }
429
430    if info.supported_sf_bitmap & (1u16 << cfg.sf) == 0 {
431        let supported: Vec<u8> = (0u8..16).filter(|i| info.supported_sf_bitmap & (1u16 << i) != 0).collect();
432        return Err(ClientError::ConfigNotSupported {
433            reason: format!("SF{} not supported by this device (supports SF{:?})", cfg.sf, supported),
434        });
435    }
436
437    let bw_bit = cfg.bw.as_u8();
438    if info.supported_bw_bitmap & (1u16 << bw_bit) == 0 {
439        return Err(ClientError::ConfigNotSupported {
440            reason: format!(
441                "bandwidth {:?} (bit {}) not in supported_bw_bitmap 0x{:04X}",
442                cfg.bw, bw_bit, info.supported_bw_bitmap
443            ),
444        });
445    }
446
447    if cfg.tx_power_dbm > info.tx_power_max_dbm {
448        info!(requested = cfg.tx_power_dbm, device_max = info.tx_power_max_dbm, "clamping tx_power_dbm to device max");
449        cfg.tx_power_dbm = info.tx_power_max_dbm;
450    } else if cfg.tx_power_dbm < info.tx_power_min_dbm {
451        info!(requested = cfg.tx_power_dbm, device_min = info.tx_power_min_dbm, "clamping tx_power_dbm to device min");
452        cfg.tx_power_dbm = info.tx_power_min_dbm;
453    }
454
455    Ok(cfg)
456}
457
458#[cfg(test)]
459#[allow(clippy::unwrap_used, clippy::panic)]
460mod tests {
461    use super::*;
462    use donglora_protocol::{
463        FskConfig, LoRaBandwidth, LoRaCodingRate, LoRaHeaderMode, MAX_MCU_UID_LEN, MAX_RADIO_UID_LEN, RadioChipId,
464    };
465
466    fn info(tx_min: i8, tx_max: i8, freq_min: u32, freq_max: u32, sf_bm: u16, bw_bm: u16) -> Info {
467        Info {
468            proto_major: 1,
469            proto_minor: 0,
470            fw_major: 0,
471            fw_minor: 0,
472            fw_patch: 0,
473            radio_chip_id: RadioChipId::Sx1262.as_u16(),
474            capability_bitmap: donglora_protocol::cap::LORA,
475            supported_sf_bitmap: sf_bm,
476            supported_bw_bitmap: bw_bm,
477            max_payload_bytes: 255,
478            rx_queue_capacity: 32,
479            tx_queue_capacity: 1,
480            freq_min_hz: freq_min,
481            freq_max_hz: freq_max,
482            tx_power_min_dbm: tx_min,
483            tx_power_max_dbm: tx_max,
484            mcu_uid_len: 0,
485            mcu_uid: [0u8; MAX_MCU_UID_LEN],
486            radio_uid_len: 0,
487            radio_uid: [0u8; MAX_RADIO_UID_LEN],
488        }
489    }
490
491    fn lora(freq_hz: u32, sf: u8, bw: LoRaBandwidth, tx_power_dbm: i8) -> LoRaConfig {
492        LoRaConfig {
493            freq_hz,
494            sf,
495            bw,
496            cr: LoRaCodingRate::Cr4_5,
497            preamble_len: 8,
498            sync_word: 0x3444,
499            tx_power_dbm,
500            header_mode: LoRaHeaderMode::Explicit,
501            payload_crc: true,
502            iq_invert: false,
503        }
504    }
505
506    const SUB_GHZ_ALL_SF: u16 = 0x1FE0; // SF5..SF12
507    const SX127X_SF: u16 = 0x1FC0; // SF6..SF12
508    const SUB_GHZ_BW: u16 = 0x03FF; // BW 0..9
509
510    #[test]
511    fn tx_power_above_max_clamps_down() {
512        let i = info(-9, 20, 863_000_000, 928_000_000, SUB_GHZ_ALL_SF, SUB_GHZ_BW);
513        let cfg = lora(915_000_000, 7, LoRaBandwidth::Khz125, 30);
514        let Modulation::LoRa(out) = prepare_config(&i, Modulation::LoRa(cfg)).unwrap() else {
515            panic!("expected LoRa");
516        };
517        assert_eq!(out.tx_power_dbm, 20);
518        assert_eq!(out.freq_hz, 915_000_000);
519    }
520
521    #[test]
522    fn tx_power_below_min_clamps_up() {
523        let i = info(2, 20, 863_000_000, 928_000_000, SUB_GHZ_ALL_SF, SUB_GHZ_BW);
524        let cfg = lora(915_000_000, 7, LoRaBandwidth::Khz125, -30);
525        let Modulation::LoRa(out) = prepare_config(&i, Modulation::LoRa(cfg)).unwrap() else {
526            panic!("expected LoRa");
527        };
528        assert_eq!(out.tx_power_dbm, 2);
529    }
530
531    #[test]
532    fn tx_power_in_range_unchanged() {
533        let i = info(-9, 22, 863_000_000, 928_000_000, SUB_GHZ_ALL_SF, SUB_GHZ_BW);
534        let cfg = lora(915_000_000, 7, LoRaBandwidth::Khz125, 17);
535        let Modulation::LoRa(out) = prepare_config(&i, Modulation::LoRa(cfg)).unwrap() else {
536            panic!("expected LoRa");
537        };
538        assert_eq!(out.tx_power_dbm, 17);
539    }
540
541    #[test]
542    fn freq_out_of_range_rejected() {
543        let i = info(-9, 22, 863_000_000, 928_000_000, SUB_GHZ_ALL_SF, SUB_GHZ_BW);
544        let cfg = lora(300_000_000, 7, LoRaBandwidth::Khz125, 14);
545        let err = prepare_config(&i, Modulation::LoRa(cfg)).unwrap_err();
546        assert!(matches!(err, ClientError::ConfigNotSupported { ref reason } if reason.contains("frequency")));
547    }
548
549    #[test]
550    fn sf5_rejected_on_sx127x_bitmap() {
551        let i = info(2, 20, 863_000_000, 1_020_000_000, SX127X_SF, SUB_GHZ_BW);
552        let cfg = lora(915_000_000, 5, LoRaBandwidth::Khz125, 14);
553        let err = prepare_config(&i, Modulation::LoRa(cfg)).unwrap_err();
554        assert!(matches!(err, ClientError::ConfigNotSupported { ref reason } if reason.contains("SF5")));
555    }
556
557    #[test]
558    fn bw_not_in_bitmap_rejected() {
559        let i = info(-9, 22, 863_000_000, 928_000_000, SUB_GHZ_ALL_SF, SUB_GHZ_BW);
560        // Bw200 is bit 10 — SX128x only. Sub-GHz bitmap rejects it.
561        let cfg = lora(915_000_000, 7, LoRaBandwidth::Khz200, 14);
562        let err = prepare_config(&i, Modulation::LoRa(cfg)).unwrap_err();
563        assert!(matches!(err, ClientError::ConfigNotSupported { ref reason } if reason.contains("bandwidth")));
564    }
565
566    #[test]
567    fn non_lora_modulation_passes_through() {
568        let i = info(-9, 22, 863_000_000, 928_000_000, SUB_GHZ_ALL_SF, SUB_GHZ_BW);
569        let fsk = FskConfig {
570            freq_hz: 50_000_000, // well outside info range — must not be inspected
571            bitrate_bps: 50_000,
572            freq_dev_hz: 25_000,
573            rx_bw: 0,
574            preamble_len: 16,
575            sync_word_len: 0,
576            sync_word: [0u8; 8],
577        };
578        let out = prepare_config(&i, Modulation::FskGfsk(fsk)).unwrap();
579        assert!(matches!(out, Modulation::FskGfsk(_)));
580    }
581}