feat!: v0.2.0 — ping-on-connect validation, try_connect, publish polish

- Add ping-on-connect handshake: all connect functions validate the device
  within 200ms, rejecting non-DongLoRa serial devices immediately
- Add try_connect() — non-blocking connect that errors instead of blocking
  forever when no USB device is present
- Add payload size validation in transmit() (max 256 bytes)
- Add ErrorCode: impl std::error::Error
- Fix drain_rx error handling (no longer swallows original error)
- Fix repository URL to github.com/donglora/client-rs
- Remove wire-level tag constants from top-level re-exports
- Add rust-version (MSRV 1.85), homepage, exclude fields to Cargo.toml
- Add examples/ping.rs
- Allow clippy unwrap/expect in test modules
This commit is contained in:
swaits committed 2026-04-07 15:52:37 -06:00
1 parent 5cec7dcd58
commit a7e93ab56b
11 files changed
+242 -174

No files matched your search

+30
View File
@@ -1,5 +1,35 @@
# Changelog # Changelog
## 0.2.0 — 2026-04-07
### Features
- **Ping-on-connect validation** — all connect functions automatically ping the
device after connecting and reject non-DongLoRa devices within 200ms. No more
accidentally talking LoRa protocol to an Arduino.
- **`try_connect(timeout)`** — non-blocking alternative to `connect()`. Returns
an error immediately if no DongLoRa device is found instead of blocking
indefinitely.
- **USB-UART bridge chip fallback** — `find_port()` now detects boards using
CP2102, CH9102, CH340, or FT232R bridge chips (validated via ping).
- **Payload size validation** — `transmit()` rejects payloads exceeding 256
bytes with a clear error before hitting the wire.
- **`ErrorCode` implements `std::error::Error`** — usable in error chains.
### Breaking changes
- Wire-level tag constants (`CMD_TAG_*`, `RESP_TAG_*`, `ERROR_INVALID_CONFIG`)
removed from the top-level re-exports. Access them via
`donglora_client::protocol::*` if needed.
- `connect()` and friends now validate the device on connect. Code that
previously connected to non-DongLoRa serial devices will now get an error.
### Fixes
- Fixed repository URL in crate metadata.
- `drain_rx` no longer swallows the original error if timeout restore also fails.
- Removed dead `firmware/PROTOCOL.md` reference from docs.
## 0.1.0 — 2026-04-06 ## 0.1.0 — 2026-04-06
Initial release. Initial release.
Generated
+1 -1
View File
@@ -44,7 +44,7 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]] [[package]]
name = "donglora-client" name = "donglora-client"
version = "0.1.0" version = "0.2.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"serialport", "serialport",
+5 -2
View File
@@ -1,13 +1,16 @@
[package] [package]
name = "donglora-client" name = "donglora-client"
version = "0.1.0" version = "0.2.0"
edition = "2024" edition = "2024"
rust-version = "1.85"
authors = ["Stephen Waits <steve@waits.net>"] authors = ["Stephen Waits <steve@waits.net>"]
license = "MIT" license = "MIT"
description = "DongLoRa host library — connect, configure, send/receive LoRa packets" description = "DongLoRa host library — connect, configure, send/receive LoRa packets"
repository = "https://github.com/swaits/donglora" repository = "https://github.com/donglora/client-rs"
homepage = "https://github.com/donglora/client-rs"
keywords = ["lora", "radio", "usb", "serial"] keywords = ["lora", "radio", "usb", "serial"]
categories = ["hardware-support"] categories = ["hardware-support"]
exclude = ["CLAUDE_TODO.md", "deny.toml", "justfile", "mise.toml", "rustfmt.toml", ".jj"]
[dependencies] [dependencies]
anyhow = "1" anyhow = "1"
+30 -10
View File
@@ -1,24 +1,21 @@
# DongLoRa Rust Client # DongLoRa Rust Client
Rust client library for talking to a DongLoRa device — either directly Rust client library for talking to a DongLoRa device — either directly
over USB or through the [mux daemon](../../mux/). over USB or through a mux daemon.
## What's in Here ## Install
- `src/protocol.rs` — wire protocol types (`RadioConfig`, `Command`, `Response`, `ErrorCode`) ```toml
- `src/codec.rs` — COBS framing, frame accumulator [dependencies]
- `src/discovery.rs` — USB VID:PID device discovery donglora-client = "0.2"
- `src/transport.rs` — serial and mux socket transports ```
- `src/client.rs` — high-level `Client<T>` with send/recv
- `src/connect.rs` — auto-detection (mux socket, TCP, direct USB)
## Usage ## Quick Start
```rust ```rust
use donglora_client::*; use donglora_client::*;
let mut client = connect_default()?; let mut client = connect_default()?;
client.ping()?;
client.set_config(RadioConfig::default())?; client.set_config(RadioConfig::default())?;
client.start_rx()?; client.start_rx()?;
@@ -29,6 +26,29 @@ loop {
} }
``` ```
All `connect` functions automatically validate the device by pinging it,
so you'll get a clear error immediately if the port isn't a real DongLoRa.
## What's in Here
- `src/protocol.rs` — wire protocol types (`RadioConfig`, `Command`, `Response`, `ErrorCode`)
- `src/codec.rs` — COBS framing, frame accumulator
- `src/discovery.rs` — USB VID:PID device discovery
- `src/transport.rs` — serial and mux socket transports
- `src/client.rs` — high-level `Client<T>` with send/recv
- `src/connect.rs` — auto-detection (mux socket, TCP, direct USB)
## Connection Priority
[`connect`] and [`try_connect`] try these in order:
1. **TCP mux** — via `DONGLORA_MUX_TCP` env var
2. **Unix socket mux** — checks `$XDG_RUNTIME_DIR/donglora/mux.sock` or `/tmp/donglora-mux.sock`
3. **Direct USB serial** — auto-detects by VID:PID
`try_connect` returns an error immediately if no device is found.
`connect` blocks until a USB device appears.
## Dependencies ## Dependencies
- `ucobs` — COBS framing (same implementation as the firmware) - `ucobs` — COBS framing (same implementation as the firmware)
+25
View File
@@ -0,0 +1,25 @@
//! Connect to a DongLoRa device, ping it, and print the MAC address.
//!
//! Run with: cargo run --example ping
use donglora_client::{RadioConfig, try_connect};
use std::time::Duration;
fn main() -> anyhow::Result<()> {
let mut client = try_connect(Duration::from_secs(2))?;
println!("connected!");
client.ping()?;
println!("ping ok");
let mac = client.get_mac()?;
println!("MAC: {:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}", mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
let config = client.get_config()?;
println!("current config: {config:?}");
client.set_config(RadioConfig::default())?;
println!("config set to defaults");
Ok(())
}
+37 -27
View File
@@ -5,9 +5,10 @@
//! while waiting for solicited responses). //! while waiting for solicited responses).
use std::collections::VecDeque; use std::collections::VecDeque;
use std::time::Duration;
use crate::codec::{encode_frame, read_frame}; use crate::codec::{encode_frame, read_frame};
use crate::protocol::{Command, ErrorCode, RadioConfig, Response}; use crate::protocol::{Command, ErrorCode, MAX_PAYLOAD, RadioConfig, Response};
use crate::transport::Transport; use crate::transport::Transport;
/// Maximum number of buffered RxPackets before oldest are dropped. /// Maximum number of buffered RxPackets before oldest are dropped.
@@ -16,6 +17,9 @@ const RX_BUFFER_CAP: usize = 256;
/// Maximum frames to read while waiting for a solicited response before giving up. /// Maximum frames to read while waiting for a solicited response before giving up.
const MAX_UNSOLICITED_BEFORE_TIMEOUT: usize = 50; const MAX_UNSOLICITED_BEFORE_TIMEOUT: usize = 50;
/// Timeout for the ping-on-connect handshake.
const HANDSHAKE_TIMEOUT: Duration = Duration::from_millis(200);
/// High-level DongLoRa client, generic over transport. /// High-level DongLoRa client, generic over transport.
/// ///
/// Works with any [`Transport`]: direct USB serial, Unix socket mux, or TCP mux. /// Works with any [`Transport`]: direct USB serial, Unix socket mux, or TCP mux.
@@ -27,10 +31,7 @@ pub struct Client<T: Transport> {
impl<T: Transport> Client<T> { impl<T: Transport> Client<T> {
/// Create a new client wrapping the given transport. /// Create a new client wrapping the given transport.
pub fn new(transport: T) -> Self { pub fn new(transport: T) -> Self {
Self { Self { transport, rx_buffer: VecDeque::with_capacity(64) }
transport,
rx_buffer: VecDeque::with_capacity(64),
}
} }
/// Send a command and wait for the solicited response. /// Send a command and wait for the solicited response.
@@ -43,13 +44,12 @@ impl<T: Transport> Client<T> {
std::io::Write::flush(&mut self.transport)?; std::io::Write::flush(&mut self.transport)?;
for _ in 0..MAX_UNSOLICITED_BEFORE_TIMEOUT { for _ in 0..MAX_UNSOLICITED_BEFORE_TIMEOUT {
let data = read_frame(&mut self.transport)? let data =
.ok_or_else(|| anyhow::anyhow!("timeout waiting for response"))?; read_frame(&mut self.transport)?.ok_or_else(|| anyhow::anyhow!("timeout waiting for response"))?;
let resp = Response::from_bytes(&data) let resp = Response::from_bytes(&data).ok_or_else(|| {
.ok_or_else(|| { let hex: Vec<String> = data.iter().map(|b| format!("{b:02x}")).collect();
let hex: Vec<String> = data.iter().map(|b| format!("{b:02x}")).collect(); anyhow::anyhow!("malformed response ({} bytes): [{}]", data.len(), hex.join(" "))
anyhow::anyhow!("malformed response ({} bytes): [{}]", data.len(), hex.join(" ")) })?;
})?;
if resp.is_rx_packet() { if resp.is_rx_packet() {
self.buffer_rx(resp); self.buffer_rx(resp);
continue; continue;
@@ -69,8 +69,8 @@ impl<T: Transport> Client<T> {
let Some(data) = read_frame(&mut self.transport)? else { let Some(data) = read_frame(&mut self.transport)? else {
return Ok(None); return Ok(None);
}; };
let resp = Response::from_bytes(&data) let resp =
.ok_or_else(|| anyhow::anyhow!("malformed response ({} bytes)", data.len()))?; Response::from_bytes(&data).ok_or_else(|| anyhow::anyhow!("malformed response ({} bytes)", data.len()))?;
if resp.is_rx_packet() { if resp.is_rx_packet() {
Ok(Some(resp)) Ok(Some(resp))
} else { } else {
@@ -87,8 +87,7 @@ impl<T: Transport> Client<T> {
let mut packets: Vec<Response> = self.rx_buffer.drain(..).collect(); let mut packets: Vec<Response> = self.rx_buffer.drain(..).collect();
let old_timeout = self.transport.timeout(); let old_timeout = self.transport.timeout();
self.transport self.transport.set_timeout(std::time::Duration::from_millis(10))?;
.set_timeout(std::time::Duration::from_millis(10))?;
let result: anyhow::Result<()> = (|| { let result: anyhow::Result<()> = (|| {
loop { loop {
@@ -103,12 +102,27 @@ impl<T: Transport> Client<T> {
} }
})(); })();
// Always restore timeout, even if the loop errored // Always restore timeout, even if the loop errored.
self.transport.set_timeout(old_timeout)?; // Preserve the original error if both fail.
let restore_result = self.transport.set_timeout(old_timeout);
result?; result?;
restore_result?;
Ok(packets) Ok(packets)
} }
/// Validate the connection by pinging the device.
///
/// Uses a short timeout to fail fast on non-DongLoRa devices (e.g. USB-UART
/// bridges that happen to match known VID:PIDs). Called automatically by the
/// [`connect`](crate::connect::connect) family of functions.
pub fn validate(&mut self) -> anyhow::Result<()> {
let saved = self.transport.timeout();
self.transport.set_timeout(HANDSHAKE_TIMEOUT)?;
let result = self.ping();
self.transport.set_timeout(saved)?;
result.map_err(|_| anyhow::anyhow!("device did not respond to ping — not a DongLoRa device"))
}
/// Send a Ping and verify the Pong response. /// Send a Ping and verify the Pong response.
pub fn ping(&mut self) -> anyhow::Result<()> { pub fn ping(&mut self) -> anyhow::Result<()> {
match self.send(Command::Ping)? { match self.send(Command::Ping)? {
@@ -145,15 +159,11 @@ impl<T: Transport> Client<T> {
} }
/// Transmit a LoRa packet. /// Transmit a LoRa packet.
pub fn transmit( pub fn transmit(&mut self, payload: &[u8], config: Option<RadioConfig>) -> anyhow::Result<()> {
&mut self, if payload.len() > MAX_PAYLOAD {
payload: &[u8], anyhow::bail!("payload too large ({} bytes, max {MAX_PAYLOAD})", payload.len());
config: Option<RadioConfig>, }
) -> anyhow::Result<()> { let cmd = Command::Transmit { config, payload: payload.to_vec() };
let cmd = Command::Transmit {
config,
payload: payload.to_vec(),
};
match self.send(cmd)? { match self.send(cmd)? {
Response::TxDone => Ok(()), Response::TxDone => Ok(()),
Response::Error(ErrorCode::TxTimeout) => anyhow::bail!("transmit timed out"), Response::Error(ErrorCode::TxTimeout) => anyhow::bail!("transmit timed out"),
+9 -27
View File
@@ -47,9 +47,7 @@ pub struct FrameReader {
impl FrameReader { impl FrameReader {
/// Create a new empty frame reader. /// Create a new empty frame reader.
pub fn new() -> Self { pub fn new() -> Self {
Self { Self { buf: Vec::with_capacity(512) }
buf: Vec::with_capacity(512),
}
} }
/// Feed raw bytes and return all complete decoded frames. /// Feed raw bytes and return all complete decoded frames.
@@ -107,12 +105,7 @@ pub fn read_frame(reader: &mut dyn std::io::Read) -> anyhow::Result<Option<Vec<u
} }
buf.push(byte[0]); buf.push(byte[0]);
} }
Err(e) Err(e) if matches!(e.kind(), std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock) => {
if matches!(
e.kind(),
std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock
) =>
{
return Ok(None); return Ok(None);
} }
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue, Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
@@ -124,14 +117,13 @@ pub fn read_frame(reader: &mut dyn std::io::Read) -> anyhow::Result<Option<Vec<u
return Ok(None); return Ok(None);
} }
decode_frame(&buf) decode_frame(&buf).map(Some).ok_or_else(|| anyhow::anyhow!("COBS decode error"))
.map(Some)
.ok_or_else(|| anyhow::anyhow!("COBS decode error"))
} }
// ── Tests ────────────────────────────────────────────────────────── // ── Tests ──────────────────────────────────────────────────────────
#[cfg(test)] #[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests { mod tests {
use super::*; use super::*;
@@ -154,7 +146,7 @@ mod tests {
// This is equivalent to an empty inter-frame gap and is correctly // This is equivalent to an empty inter-frame gap and is correctly
// skipped by both FrameReader and read_frame. // skipped by both FrameReader and read_frame.
let encoded = encode_frame(b""); let encoded = encode_frame(b"");
assert_eq!(*encoded.last().unwrap(), 0x00); assert_eq!(encoded.last(), Some(&0x00));
} }
#[test] #[test]
@@ -286,10 +278,7 @@ mod tests {
// Exact Config response bytes: tag=1, freq=910525000, bw=62.5k, sf=7, // Exact Config response bytes: tag=1, freq=910525000, bw=62.5k, sf=7,
// cr=5, sync=0x3444, tx=22dBm, preamble=16, cad=1 // cr=5, sync=0x3444, tx=22dBm, preamble=16, cad=1
// Note: preamble high byte is 0x00 — tests COBS with embedded zero. // Note: preamble high byte is 0x00 — tests COBS with embedded zero.
let data = vec![ let data = vec![0x01, 0x48, 0x82, 0x45, 0x36, 0x06, 0x07, 0x05, 0x44, 0x34, 0x16, 0x10, 0x00, 0x01];
0x01, 0x48, 0x82, 0x45, 0x36, 0x06, 0x07, 0x05,
0x44, 0x34, 0x16, 0x10, 0x00, 0x01,
];
assert_eq!(data.len(), 14); assert_eq!(data.len(), 14);
let encoded = encode_frame(&data); let encoded = encode_frame(&data);
let decoded = decode_frame(&encoded[..encoded.len() - 1]); let decoded = decode_frame(&encoded[..encoded.len() - 1]);
@@ -299,10 +288,7 @@ mod tests {
#[test] #[test]
fn config_response_cobs_roundtrip_cad_zero() { fn config_response_cobs_roundtrip_cad_zero() {
// Same but with cad=0 — two consecutive 0x00 bytes at end. // Same but with cad=0 — two consecutive 0x00 bytes at end.
let data = vec![ let data = vec![0x01, 0x48, 0x82, 0x45, 0x36, 0x06, 0x07, 0x05, 0x44, 0x34, 0x16, 0x10, 0x00, 0x00];
0x01, 0x48, 0x82, 0x45, 0x36, 0x06, 0x07, 0x05,
0x44, 0x34, 0x16, 0x10, 0x00, 0x00,
];
assert_eq!(data.len(), 14); assert_eq!(data.len(), 14);
let encoded = encode_frame(&data); let encoded = encode_frame(&data);
let decoded = decode_frame(&encoded[..encoded.len() - 1]); let decoded = decode_frame(&encoded[..encoded.len() - 1]);
@@ -316,15 +302,11 @@ mod tests {
use std::time::Duration; use std::time::Duration;
let (mut a, _b) = UnixStream::pair().unwrap(); let (mut a, _b) = UnixStream::pair().unwrap();
a.set_read_timeout(Some(Duration::from_millis(10))) a.set_read_timeout(Some(Duration::from_millis(10))).unwrap();
.unwrap();
// No data written to _b, so read on `a` will time out with WouldBlock on Linux. // No data written to _b, so read on `a` will time out with WouldBlock on Linux.
let result = read_frame(&mut a); let result = read_frame(&mut a);
assert!( assert!(result.is_ok(), "socket timeout should be Ok(None), got: {result:?}");
result.is_ok(),
"socket timeout should be Ok(None), got: {result:?}"
);
assert!(result.unwrap().is_none()); assert!(result.unwrap().is_none());
} }
} }
+77 -27
View File
@@ -53,27 +53,23 @@ fn find_mux_socket() -> Option<String> {
/// Connect to the mux daemon via Unix domain socket. /// Connect to the mux daemon via Unix domain socket.
#[cfg(unix)] #[cfg(unix)]
pub fn mux_connect( pub fn mux_connect(path: Option<&str>, timeout: Duration) -> anyhow::Result<Client<MuxTransport>> {
path: Option<&str>,
timeout: Duration,
) -> anyhow::Result<Client<MuxTransport>> {
let path = match path { let path = match path {
Some(p) => p.to_string(), Some(p) => p.to_string(),
None => find_mux_socket() None => find_mux_socket().ok_or_else(|| anyhow::anyhow!("no mux socket found"))?,
.ok_or_else(|| anyhow::anyhow!("no mux socket found"))?,
}; };
let transport = MuxTransport::unix(&path, timeout)?; let transport = MuxTransport::unix(&path, timeout)?;
Ok(Client::new(transport)) let mut client = Client::new(transport);
client.validate()?;
Ok(client)
} }
/// Connect to the mux daemon via TCP. /// Connect to the mux daemon via TCP.
pub fn mux_tcp_connect( pub fn mux_tcp_connect(host: &str, port: u16, timeout: Duration) -> anyhow::Result<Client<MuxTransport>> {
host: &str,
port: u16,
timeout: Duration,
) -> anyhow::Result<Client<MuxTransport>> {
let transport = MuxTransport::tcp(host, port, timeout)?; let transport = MuxTransport::tcp(host, port, timeout)?;
Ok(Client::new(transport)) let mut client = Client::new(transport);
client.validate()?;
Ok(client)
} }
/// Auto-detect and connect to a DongLoRa device. /// Auto-detect and connect to a DongLoRa device.
@@ -84,15 +80,14 @@ pub fn mux_tcp_connect(
/// 3. Direct USB serial (auto-detect by VID:PID, blocks until device appears) /// 3. Direct USB serial (auto-detect by VID:PID, blocks until device appears)
/// ///
/// If `port` is `Some`, skips mux detection and connects directly to that serial port. /// If `port` is `Some`, skips mux detection and connects directly to that serial port.
pub fn connect( pub fn connect(port: Option<&str>, timeout: Duration) -> anyhow::Result<Client<AnyTransport>> {
port: Option<&str>,
timeout: Duration,
) -> anyhow::Result<Client<AnyTransport>> {
// If explicit port given, go direct // If explicit port given, go direct
if let Some(port) = port { if let Some(port) = port {
debug!("opening serial port {port}"); debug!("opening serial port {port}");
let transport = SerialTransport::open(port, timeout)?; let transport = SerialTransport::open(port, timeout)?;
return Ok(Client::new(AnyTransport::Serial(transport))); let mut client = Client::new(AnyTransport::Serial(transport));
client.validate()?;
return Ok(client);
} }
// Try TCP mux via environment variable // Try TCP mux via environment variable
@@ -100,7 +95,9 @@ pub fn connect(
&& let Some(transport) = try_tcp_mux(&tcp, timeout) && let Some(transport) = try_tcp_mux(&tcp, timeout)
{ {
debug!("connected to TCP mux at {tcp}"); debug!("connected to TCP mux at {tcp}");
return Ok(Client::new(AnyTransport::Mux(transport))); let mut client = Client::new(AnyTransport::Mux(transport));
client.validate()?;
return Ok(client);
} }
// Try Unix socket mux // Try Unix socket mux
@@ -109,7 +106,11 @@ pub fn connect(
match MuxTransport::unix(&path, timeout) { match MuxTransport::unix(&path, timeout) {
Ok(transport) => { Ok(transport) => {
debug!("connected to mux socket at {path}"); debug!("connected to mux socket at {path}");
return Ok(Client::new(AnyTransport::Mux(transport))); let mut client = Client::new(AnyTransport::Mux(transport));
if client.validate().is_ok() {
return Ok(client);
}
debug!("mux at {path} did not validate, falling back to USB");
} }
Err(_) => { Err(_) => {
// Stale socket or connection refused — fall through // Stale socket or connection refused — fall through
@@ -119,11 +120,12 @@ pub fn connect(
} }
// Direct USB serial — auto-detect or wait // Direct USB serial — auto-detect or wait
let port_path = discovery::find_port() let port_path = discovery::find_port().unwrap_or_else(discovery::wait_for_device);
.unwrap_or_else(discovery::wait_for_device);
debug!("opening serial port {port_path}"); debug!("opening serial port {port_path}");
let transport = SerialTransport::open(&port_path, timeout)?; let transport = SerialTransport::open(&port_path, timeout)?;
Ok(Client::new(AnyTransport::Serial(transport))) let mut client = Client::new(AnyTransport::Serial(transport));
client.validate()?;
Ok(client)
} }
/// Convenience: connect with default timeout. /// Convenience: connect with default timeout.
@@ -144,23 +146,71 @@ pub fn connect_mux_auto(timeout: Duration) -> anyhow::Result<Client<AnyTransport
&& let Some(transport) = try_tcp_mux(&tcp, timeout) && let Some(transport) = try_tcp_mux(&tcp, timeout)
{ {
debug!("connected to TCP mux at {tcp}"); debug!("connected to TCP mux at {tcp}");
return Ok(Client::new(AnyTransport::Mux(transport))); let mut client = Client::new(AnyTransport::Mux(transport));
client.validate()?;
return Ok(client);
} }
// Try Unix socket mux. // Try Unix socket mux.
#[cfg(unix)] #[cfg(unix)]
{ {
let path = find_mux_socket() let path = find_mux_socket().ok_or_else(|| anyhow::anyhow!("no mux socket found"))?;
.ok_or_else(|| anyhow::anyhow!("no mux socket found"))?;
let transport = MuxTransport::unix(&path, timeout)?; let transport = MuxTransport::unix(&path, timeout)?;
debug!("connected to mux socket at {path}"); debug!("connected to mux socket at {path}");
Ok(Client::new(AnyTransport::Mux(transport))) let mut client = Client::new(AnyTransport::Mux(transport));
client.validate()?;
Ok(client)
} }
#[cfg(not(unix))] #[cfg(not(unix))]
anyhow::bail!("mux-only mode requires Unix socket support or DONGLORA_MUX_TCP") anyhow::bail!("mux-only mode requires Unix socket support or DONGLORA_MUX_TCP")
} }
/// Like [`connect`] but returns an error instead of blocking when no USB
/// device is present. Suitable for callers with their own retry logic.
///
/// Runs the same fallback chain as `connect(None, timeout)` — TCP mux, Unix
/// socket mux, then direct USB serial — but uses a single non-blocking scan
/// instead of polling indefinitely for a USB device.
pub fn try_connect(timeout: Duration) -> anyhow::Result<Client<AnyTransport>> {
// Try TCP mux via environment variable
if let Ok(tcp) = std::env::var("DONGLORA_MUX_TCP")
&& let Some(transport) = try_tcp_mux(&tcp, timeout)
{
debug!("connected to TCP mux at {tcp}");
let mut client = Client::new(AnyTransport::Mux(transport));
client.validate()?;
return Ok(client);
}
// Try Unix socket mux
#[cfg(unix)]
if let Some(path) = find_mux_socket() {
match MuxTransport::unix(&path, timeout) {
Ok(transport) => {
debug!("connected to mux socket at {path}");
let mut client = Client::new(AnyTransport::Mux(transport));
if client.validate().is_ok() {
return Ok(client);
}
debug!("mux socket at {path} did not validate, falling back to USB");
}
Err(_) => {
debug!("mux socket at {path} not reachable, falling back to USB");
}
}
}
// Direct USB serial — single non-blocking scan
let port_path =
discovery::find_port().ok_or_else(|| anyhow::anyhow!("no DongLoRa device found (no mux, no USB device)"))?;
debug!("opening serial port {port_path}");
let transport = SerialTransport::open(&port_path, timeout)?;
let mut client = Client::new(AnyTransport::Serial(transport));
client.validate()?;
Ok(client)
}
fn try_tcp_mux(addr: &str, timeout: Duration) -> Option<MuxTransport> { fn try_tcp_mux(addr: &str, timeout: Duration) -> Option<MuxTransport> {
let (host, port) = if let Some((h, p)) = addr.rsplit_once(':') { let (host, port) = if let Some((h, p)) = addr.rsplit_once(':') {
let host = if h.is_empty() { "localhost" } else { h }; let host = if h.is_empty() { "localhost" } else { h };
+6 -8
View File
@@ -1,7 +1,7 @@
//! DongLoRa host library — connect, configure, send/receive LoRa packets. //! DongLoRa host library — connect, configure, send/receive LoRa packets.
//! //!
//! Implements the DongLoRa USB protocol (COBS-framed fixed-size LE). //! Implements the DongLoRa USB protocol (COBS-framed fixed-size LE).
//! See `firmware/PROTOCOL.md` for the full specification. //! See the [`protocol`] module for wire types and constants.
//! //!
//! # Quick start //! # Quick start
//! //!
@@ -9,7 +9,6 @@
//! use donglora_client::*; //! use donglora_client::*;
//! //!
//! let mut client = connect_default()?; //! let mut client = connect_default()?;
//! client.ping()?;
//! client.set_config(RadioConfig::default())?; //! client.set_config(RadioConfig::default())?;
//! client.start_rx()?; //! client.start_rx()?;
//! //!
@@ -30,13 +29,12 @@ pub mod transport;
// Flat re-exports for convenience // Flat re-exports for convenience
pub use client::Client; pub use client::Client;
pub use codec::{decode_frame, encode_frame, read_frame, FrameReader}; pub use codec::{FrameReader, decode_frame, encode_frame, read_frame};
pub use connect::{connect, connect_default, connect_mux_auto, default_socket_path}; pub use connect::{connect, connect_default, connect_mux_auto, default_socket_path, try_connect};
pub use discovery::{find_port, wait_for_device, USB_PID, USB_VID}; pub use discovery::{USB_PID, USB_VID, find_port, wait_for_device};
pub use protocol::{ pub use protocol::{
Bandwidth, Command, ErrorCode, RadioConfig, Response, CMD_TAG_SET_CONFIG, CMD_TAG_START_RX, Bandwidth, Command, ErrorCode, MAX_PAYLOAD, PREAMBLE_DEFAULT, RADIO_CONFIG_SIZE, RadioConfig, Response,
CMD_TAG_STOP_RX, ERROR_INVALID_CONFIG, MAX_PAYLOAD, PREAMBLE_DEFAULT, RADIO_CONFIG_SIZE, TX_POWER_MAX,
RESP_TAG_ERROR, RESP_TAG_OK, RESP_TAG_RX_PACKET, TX_POWER_MAX,
}; };
pub use transport::{AnyTransport, MuxTransport, SerialTransport, Transport}; pub use transport::{AnyTransport, MuxTransport, SerialTransport, Transport};
+15 -49
View File
@@ -1,7 +1,7 @@
//! Wire protocol types and fixed-size little-endian serialization. //! Wire protocol types and fixed-size little-endian serialization.
//! //!
//! Mirrors the firmware's `protocol.rs` using std types. Every integer is //! Mirrors the firmware's `protocol.rs` using std types. Every integer is
//! fixed-width LE. See `firmware/PROTOCOL.md` for the full specification. //! fixed-width LE.
/// Maximum LoRa payload size in bytes. /// Maximum LoRa payload size in bytes.
pub const MAX_PAYLOAD: usize = 256; pub const MAX_PAYLOAD: usize = 256;
@@ -156,10 +156,7 @@ pub enum Command {
SetConfig(RadioConfig), SetConfig(RadioConfig),
StartRx, StartRx,
StopRx, StopRx,
Transmit { Transmit { config: Option<RadioConfig>, payload: Vec<u8> },
config: Option<RadioConfig>,
payload: Vec<u8>,
},
DisplayOn, DisplayOn,
DisplayOff, DisplayOff,
GetMac, GetMac,
@@ -241,10 +238,7 @@ impl Command {
if rest.len() < data_start + len || len > MAX_PAYLOAD { if rest.len() < data_start + len || len > MAX_PAYLOAD {
return None; return None;
} }
Some(Self::Transmit { Some(Self::Transmit { config, payload: rest[data_start..data_start + len].to_vec() })
config,
payload: rest[data_start..data_start + len].to_vec(),
})
} }
6 => Some(Self::DisplayOn), 6 => Some(Self::DisplayOn),
7 => Some(Self::DisplayOff), 7 => Some(Self::DisplayOff),
@@ -283,6 +277,8 @@ impl ErrorCode {
} }
} }
impl std::error::Error for ErrorCode {}
impl std::fmt::Display for ErrorCode { impl std::fmt::Display for ErrorCode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self { match self {
@@ -303,11 +299,7 @@ impl std::fmt::Display for ErrorCode {
pub enum Response { pub enum Response {
Pong, Pong,
Config(RadioConfig), Config(RadioConfig),
RxPacket { RxPacket { rssi: i16, snr: i16, payload: Vec<u8> },
rssi: i16,
snr: i16,
payload: Vec<u8>,
},
TxDone, TxDone,
Ok, Ok,
Error(ErrorCode), Error(ErrorCode),
@@ -378,11 +370,7 @@ impl Response {
if rest.len() < 6 + len || len > MAX_PAYLOAD { if rest.len() < 6 + len || len > MAX_PAYLOAD {
return None; return None;
} }
Some(Self::RxPacket { Some(Self::RxPacket { rssi, snr, payload: rest[6..6 + len].to_vec() })
rssi,
snr,
payload: rest[6..6 + len].to_vec(),
})
} }
3 => Some(Self::TxDone), 3 => Some(Self::TxDone),
4 => Some(Self::Ok), 4 => Some(Self::Ok),
@@ -403,6 +391,7 @@ impl Response {
// ── Tests ────────────────────────────────────────────────────────── // ── Tests ──────────────────────────────────────────────────────────
#[cfg(test)] #[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests { mod tests {
use super::*; use super::*;
@@ -494,30 +483,21 @@ mod tests {
#[test] #[test]
fn command_transmit_no_config_roundtrip() { fn command_transmit_no_config_roundtrip() {
let cmd = Command::Transmit { let cmd = Command::Transmit { config: None, payload: b"hello".to_vec() };
config: None,
payload: b"hello".to_vec(),
};
let bytes = cmd.to_bytes(); let bytes = cmd.to_bytes();
assert_eq!(Command::from_bytes(&bytes), Some(cmd)); assert_eq!(Command::from_bytes(&bytes), Some(cmd));
} }
#[test] #[test]
fn command_transmit_with_config_roundtrip() { fn command_transmit_with_config_roundtrip() {
let cmd = Command::Transmit { let cmd = Command::Transmit { config: Some(make_config()), payload: b"test".to_vec() };
config: Some(make_config()),
payload: b"test".to_vec(),
};
let bytes = cmd.to_bytes(); let bytes = cmd.to_bytes();
assert_eq!(Command::from_bytes(&bytes), Some(cmd)); assert_eq!(Command::from_bytes(&bytes), Some(cmd));
} }
#[test] #[test]
fn command_transmit_empty_payload() { fn command_transmit_empty_payload() {
let cmd = Command::Transmit { let cmd = Command::Transmit { config: None, payload: vec![] };
config: None,
payload: vec![],
};
let bytes = cmd.to_bytes(); let bytes = cmd.to_bytes();
assert_eq!(Command::from_bytes(&bytes), Some(cmd)); assert_eq!(Command::from_bytes(&bytes), Some(cmd));
} }
@@ -572,22 +552,14 @@ mod tests {
#[test] #[test]
fn response_rx_packet_roundtrip() { fn response_rx_packet_roundtrip() {
let resp = Response::RxPacket { let resp = Response::RxPacket { rssi: -80, snr: 10, payload: b"data".to_vec() };
rssi: -80,
snr: 10,
payload: b"data".to_vec(),
};
let bytes = resp.to_bytes(); let bytes = resp.to_bytes();
assert_eq!(Response::from_bytes(&bytes), Some(resp)); assert_eq!(Response::from_bytes(&bytes), Some(resp));
} }
#[test] #[test]
fn response_rx_packet_empty_payload() { fn response_rx_packet_empty_payload() {
let resp = Response::RxPacket { let resp = Response::RxPacket { rssi: -120, snr: -5, payload: vec![] };
rssi: -120,
snr: -5,
payload: vec![],
};
let bytes = resp.to_bytes(); let bytes = resp.to_bytes();
assert_eq!(Response::from_bytes(&bytes), Some(resp)); assert_eq!(Response::from_bytes(&bytes), Some(resp));
} }
@@ -619,10 +591,7 @@ mod tests {
fn response_tags() { fn response_tags() {
assert_eq!(Response::Pong.tag(), 0); assert_eq!(Response::Pong.tag(), 0);
assert_eq!(Response::Config(make_config()).tag(), 1); assert_eq!(Response::Config(make_config()).tag(), 1);
assert_eq!( assert_eq!(Response::RxPacket { rssi: 0, snr: 0, payload: vec![] }.tag(), 2);
Response::RxPacket { rssi: 0, snr: 0, payload: vec![] }.tag(),
2
);
assert_eq!(Response::TxDone.tag(), 3); assert_eq!(Response::TxDone.tag(), 3);
assert_eq!(Response::Ok.tag(), 4); assert_eq!(Response::Ok.tag(), 4);
assert_eq!(Response::Error(ErrorCode::RadioBusy).tag(), 5); assert_eq!(Response::Error(ErrorCode::RadioBusy).tag(), 5);
@@ -685,9 +654,6 @@ mod tests {
let cmd = Command::SetConfig(cfg); let cmd = Command::SetConfig(cfg);
let bytes = cmd.to_bytes(); let bytes = cmd.to_bytes();
// Expected: 02 C0 CA 89 36 07 07 05 24 14 80 00 00 01 // Expected: 02 C0 CA 89 36 07 07 05 24 14 80 00 00 01
assert_eq!( assert_eq!(bytes, [0x02, 0xC0, 0xCA, 0x89, 0x36, 0x07, 0x07, 0x05, 0x24, 0x14, 0x80, 0x00, 0x00, 0x01]);
bytes,
[0x02, 0xC0, 0xCA, 0x89, 0x36, 0x07, 0x07, 0x05, 0x24, 0x14, 0x80, 0x00, 0x00, 0x01]
);
} }
} }
+7 -23
View File
@@ -61,9 +61,7 @@ impl Write for SerialTransport {
impl Transport for SerialTransport { impl Transport for SerialTransport {
fn set_timeout(&mut self, timeout: Duration) -> anyhow::Result<()> { fn set_timeout(&mut self, timeout: Duration) -> anyhow::Result<()> {
self.port self.port.set_timeout(timeout).map_err(|e| anyhow::anyhow!("failed to set serial timeout: {e}"))?;
.set_timeout(timeout)
.map_err(|e| anyhow::anyhow!("failed to set serial timeout: {e}"))?;
self.timeout = timeout; self.timeout = timeout;
Ok(()) Ok(())
} }
@@ -99,10 +97,7 @@ impl MuxTransport {
stream stream
.set_write_timeout(Some(timeout)) .set_write_timeout(Some(timeout))
.map_err(|e| anyhow::anyhow!("failed to set socket write timeout: {e}"))?; .map_err(|e| anyhow::anyhow!("failed to set socket write timeout: {e}"))?;
Ok(Self { Ok(Self { stream: MuxStream::Unix(stream), timeout })
stream: MuxStream::Unix(stream),
timeout,
})
} }
/// Connect to the mux daemon via TCP. /// Connect to the mux daemon via TCP.
@@ -117,16 +112,9 @@ impl MuxTransport {
.ok_or_else(|| anyhow::anyhow!("no addresses found for {addr}"))?; .ok_or_else(|| anyhow::anyhow!("no addresses found for {addr}"))?;
let stream = std::net::TcpStream::connect_timeout(&sock_addr, timeout) let stream = std::net::TcpStream::connect_timeout(&sock_addr, timeout)
.map_err(|e| anyhow::anyhow!("failed to connect to mux at {addr}: {e}"))?; .map_err(|e| anyhow::anyhow!("failed to connect to mux at {addr}: {e}"))?;
stream stream.set_read_timeout(Some(timeout)).map_err(|e| anyhow::anyhow!("failed to set TCP read timeout: {e}"))?;
.set_read_timeout(Some(timeout)) stream.set_write_timeout(Some(timeout)).map_err(|e| anyhow::anyhow!("failed to set TCP write timeout: {e}"))?;
.map_err(|e| anyhow::anyhow!("failed to set TCP read timeout: {e}"))?; Ok(Self { stream: MuxStream::Tcp(stream), timeout })
stream
.set_write_timeout(Some(timeout))
.map_err(|e| anyhow::anyhow!("failed to set TCP write timeout: {e}"))?;
Ok(Self {
stream: MuxStream::Tcp(stream),
timeout,
})
} }
} }
@@ -163,12 +151,8 @@ impl Transport for MuxTransport {
self.timeout = timeout; self.timeout = timeout;
let result = match &self.stream { let result = match &self.stream {
#[cfg(unix)] #[cfg(unix)]
MuxStream::Unix(s) => s MuxStream::Unix(s) => s.set_read_timeout(Some(timeout)).and_then(|()| s.set_write_timeout(Some(timeout))),
.set_read_timeout(Some(timeout)) MuxStream::Tcp(s) => s.set_read_timeout(Some(timeout)).and_then(|()| s.set_write_timeout(Some(timeout))),
.and_then(|()| s.set_write_timeout(Some(timeout))),
MuxStream::Tcp(s) => s
.set_read_timeout(Some(timeout))
.and_then(|()| s.set_write_timeout(Some(timeout))),
}; };
result.map_err(|e| anyhow::anyhow!("failed to set timeout: {e}")) result.map_err(|e| anyhow::anyhow!("failed to set timeout: {e}"))
} }