1use 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
37static USED_MUX: AtomicBool = AtomicBool::new(false);
40
41const DEFAULT_TIMEOUT: Duration = Duration::from_secs(2);
43
44#[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 #[must_use]
69 pub fn port(mut self, path: impl Into<String>) -> Self {
70 self.port = Some(path.into());
71 self
72 }
73
74 #[must_use]
77 pub fn timeout(mut self, timeout: Duration) -> Self {
78 self.timeout = Some(timeout);
79 self
80 }
81
82 #[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 #[must_use]
96 pub fn auto_configure(mut self, enabled: bool) -> Self {
97 self.auto_configure = enabled;
98 self
99 }
100
101 #[must_use]
106 pub fn keepalive(mut self, enabled: bool) -> Self {
107 self.keepalive = enabled;
108 self
109 }
110}
111
112impl ConnectOptions {
115 #[must_use]
117 pub fn new() -> Self {
118 Self { keepalive: true, ..Self::default() }
119 }
120}
121
122#[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#[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
161pub async fn connect() -> ClientResult<Dongle> {
170 connect_with(ConnectOptions::new()).await
171}
172
173pub async fn connect_with(opts: ConnectOptions) -> ClientResult<Dongle> {
175 let timeout = opts.timeout.unwrap_or(DEFAULT_TIMEOUT);
176
177 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 if USED_MUX.load(Ordering::Relaxed) {
188 return connect_mux_sticky(&opts, timeout).await;
189 }
190
191 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 #[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 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
216pub async fn try_connect() -> ClientResult<Dongle> {
219 try_connect_with(ConnectOptions::new()).await
220}
221
222pub 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
265pub async fn connect_mux_auto() -> ClientResult<Dongle> {
268 connect_mux_auto_with(ConnectOptions::new()).await
269}
270
271pub 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#[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
304pub 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
317async 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 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
396pub(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; const SX127X_SF: u16 = 0x1FC0; const SUB_GHZ_BW: u16 = 0x03FF; #[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 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, 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}