Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7e93ab56b
|
||
|
|
5cec7dcd58
|
||
|
|
75d70df294 | ||
|
|
b2e4bb0476 |
No files matched your search
@@ -1,5 +1,35 @@
|
||||
# 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
|
||||
|
||||
Initial release.
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
# Add non-blocking `connect` variant
|
||||
|
||||
## Problem
|
||||
|
||||
`connect(None, timeout)` in `src/connect.rs` has a three-step fallback:
|
||||
|
||||
1. `DONGLORA_MUX_TCP` env var -> TCP mux
|
||||
2. Unix socket mux (if socket file exists)
|
||||
3. Direct USB serial via `find_port().unwrap_or_else(wait_for_device)`
|
||||
|
||||
Step 3 calls `wait_for_device()` which polls `find_port()` every 500ms
|
||||
**indefinitely** until a USB device appears. This makes `connect(None, ...)`
|
||||
unsuitable for callers that need to retry with backoff or report disconnected
|
||||
status (e.g. donglora-bridge's reconnect loop).
|
||||
|
||||
Today, donglora-bridge works around this by manually reimplementing the
|
||||
fallback chain:
|
||||
|
||||
```rust
|
||||
if let Ok(c) = connect_mux_auto(timeout) {
|
||||
(c, "mux".to_string())
|
||||
} else {
|
||||
let port_path = find_port()
|
||||
.ok_or_else(|| anyhow!("no mux or USB dongle found"))?;
|
||||
let c = connect(Some(&port_path), timeout)?;
|
||||
(c, shorten_path(&port_path))
|
||||
}
|
||||
```
|
||||
|
||||
This duplicates logic from `connect()` (steps 1-2 via `connect_mux_auto`, step
|
||||
3 minus the blocking wait) and won't pick up any future changes to the
|
||||
`connect()` fallback order.
|
||||
|
||||
## Proposed solution
|
||||
|
||||
Add a `try_connect` (or similar) that runs the same mux -> serial fallback as
|
||||
`connect(None, timeout)` but replaces `wait_for_device()` with a single
|
||||
`find_port()` call, returning an error immediately if no device is found:
|
||||
|
||||
```rust
|
||||
/// Like [`connect`] but returns an error instead of blocking when no USB
|
||||
/// device is present. Suitable for callers with their own retry logic.
|
||||
pub fn try_connect(timeout: Duration) -> Result<Client<AnyTransport>> {
|
||||
// Steps 1-2: identical to connect() — try TCP mux, then Unix socket mux
|
||||
|
||||
// Step 3: non-blocking USB scan
|
||||
let port_path = discovery::find_port()
|
||||
.ok_or_else(|| anyhow!("no DongLoRa device found (no mux, no USB device)"))?;
|
||||
let transport = SerialTransport::open(&port_path, timeout)?;
|
||||
Ok(Client::new(AnyTransport::Serial(transport)))
|
||||
}
|
||||
```
|
||||
|
||||
This would let donglora-bridge collapse its connection code to:
|
||||
|
||||
```rust
|
||||
let (mut client, device) = if let Some(port) = port {
|
||||
let c = connect(Some(port), timeout)?;
|
||||
(c, shorten_path(port))
|
||||
} else {
|
||||
let c = try_connect(timeout)?;
|
||||
let device = match c.transport() {
|
||||
AnyTransport::Mux(_) => "mux".to_string(),
|
||||
AnyTransport::Serial(_) => find_port()
|
||||
.map(|p| shorten_path(&p))
|
||||
.unwrap_or_else(|| "serial".to_string()),
|
||||
};
|
||||
(c, device)
|
||||
};
|
||||
```
|
||||
|
||||
### Device name from transport
|
||||
|
||||
The bridge also needs to know *what* it connected to for display purposes. Two
|
||||
options:
|
||||
|
||||
1. Match on `AnyTransport` variant after connecting (shown above) -- works but
|
||||
requires a redundant `find_port()` call for the display name since
|
||||
`SerialTransport` has private fields.
|
||||
2. Have `try_connect` return the port path alongside the client, e.g.
|
||||
`-> Result<(Client<AnyTransport>, ConnectionInfo)>` where `ConnectionInfo`
|
||||
is an enum like `Mux { socket_path }` | `Serial { port_path }`. Cleaner
|
||||
but a larger API change.
|
||||
|
||||
Either works. Option 2 would also benefit `connect()` callers who want to log
|
||||
what they connected to.
|
||||
Generated
+1
-1
@@ -44,7 +44,7 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||
|
||||
[[package]]
|
||||
name = "donglora-client"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serialport",
|
||||
|
||||
+5
-2
@@ -1,13 +1,16 @@
|
||||
[package]
|
||||
name = "donglora-client"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.85"
|
||||
authors = ["Stephen Waits <steve@waits.net>"]
|
||||
license = "MIT"
|
||||
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"]
|
||||
categories = ["hardware-support"]
|
||||
exclude = ["CLAUDE_TODO.md", "deny.toml", "justfile", "mise.toml", "rustfmt.toml", ".jj"]
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
|
||||
@@ -1,24 +1,21 @@
|
||||
# DongLoRa Rust Client
|
||||
|
||||
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`)
|
||||
- `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)
|
||||
```toml
|
||||
[dependencies]
|
||||
donglora-client = "0.2"
|
||||
```
|
||||
|
||||
## Usage
|
||||
## Quick Start
|
||||
|
||||
```rust
|
||||
use donglora_client::*;
|
||||
|
||||
let mut client = connect_default()?;
|
||||
client.ping()?;
|
||||
client.set_config(RadioConfig::default())?;
|
||||
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
|
||||
|
||||
- `ucobs` — COBS framing (same implementation as the firmware)
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# cargo-deny configuration (v0.19+)
|
||||
# Run with: cargo deny check
|
||||
|
||||
[licenses]
|
||||
version = 2
|
||||
|
||||
# Permissive licenses only. No copyleft.
|
||||
allow = [
|
||||
"MIT",
|
||||
"MIT-0",
|
||||
"Apache-2.0",
|
||||
"BSD-1-Clause",
|
||||
"BSD-2-Clause",
|
||||
"BSD-3-Clause",
|
||||
"BSL-1.0",
|
||||
"ISC",
|
||||
"Zlib",
|
||||
"Unlicense",
|
||||
"CC0-1.0",
|
||||
"CDLA-Permissive-2.0",
|
||||
"Unicode-3.0",
|
||||
"MPL-2.0",
|
||||
]
|
||||
|
||||
[advisories]
|
||||
|
||||
[bans]
|
||||
multiple-versions = "warn"
|
||||
wildcards = "warn"
|
||||
|
||||
[sources]
|
||||
unknown-registry = "deny"
|
||||
unknown-git = "warn"
|
||||
allow-git = []
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -2,10 +2,41 @@ set shell := ["bash", "-c"]
|
||||
|
||||
default: check
|
||||
|
||||
# Run clippy and tests
|
||||
check:
|
||||
cargo clippy -- -D warnings && cargo test
|
||||
[private]
|
||||
_ensure_tools:
|
||||
@mise trust --yes . 2>/dev/null; mise install --quiet
|
||||
|
||||
# Run all checks (fmt, clippy, test, audit)
|
||||
check: fmt-check clippy test audit
|
||||
|
||||
# Format code
|
||||
fmt: _ensure_tools
|
||||
cargo fmt
|
||||
|
||||
# Check formatting without changing files
|
||||
fmt-check: _ensure_tools
|
||||
cargo fmt --check
|
||||
|
||||
# Lint with clippy
|
||||
clippy: _ensure_tools
|
||||
cargo clippy --all-targets -- -D warnings
|
||||
|
||||
# Run tests
|
||||
test: _ensure_tools
|
||||
cargo test
|
||||
|
||||
# Security + license audit
|
||||
audit: _ensure_tools
|
||||
cargo deny check
|
||||
|
||||
# Build release
|
||||
build:
|
||||
build: _ensure_tools
|
||||
cargo build --release
|
||||
|
||||
# Check for outdated dependencies
|
||||
outdated: _ensure_tools
|
||||
cargo outdated
|
||||
|
||||
# Clean build artifacts
|
||||
clean:
|
||||
cargo clean
|
||||
@@ -0,0 +1,3 @@
|
||||
edition = "2024"
|
||||
max_width = 120
|
||||
use_small_heuristics = "Max"
|
||||
+37
-27
@@ -5,9 +5,10 @@
|
||||
//! while waiting for solicited responses).
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::time::Duration;
|
||||
|
||||
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;
|
||||
|
||||
/// 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.
|
||||
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.
|
||||
///
|
||||
/// 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> {
|
||||
/// Create a new client wrapping the given transport.
|
||||
pub fn new(transport: T) -> Self {
|
||||
Self {
|
||||
transport,
|
||||
rx_buffer: VecDeque::with_capacity(64),
|
||||
}
|
||||
Self { transport, rx_buffer: VecDeque::with_capacity(64) }
|
||||
}
|
||||
|
||||
/// 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)?;
|
||||
|
||||
for _ in 0..MAX_UNSOLICITED_BEFORE_TIMEOUT {
|
||||
let data = read_frame(&mut self.transport)?
|
||||
.ok_or_else(|| anyhow::anyhow!("timeout waiting for response"))?;
|
||||
let resp = Response::from_bytes(&data)
|
||||
.ok_or_else(|| {
|
||||
let hex: Vec<String> = data.iter().map(|b| format!("{b:02x}")).collect();
|
||||
anyhow::anyhow!("malformed response ({} bytes): [{}]", data.len(), hex.join(" "))
|
||||
})?;
|
||||
let data =
|
||||
read_frame(&mut self.transport)?.ok_or_else(|| anyhow::anyhow!("timeout waiting for response"))?;
|
||||
let resp = Response::from_bytes(&data).ok_or_else(|| {
|
||||
let hex: Vec<String> = data.iter().map(|b| format!("{b:02x}")).collect();
|
||||
anyhow::anyhow!("malformed response ({} bytes): [{}]", data.len(), hex.join(" "))
|
||||
})?;
|
||||
if resp.is_rx_packet() {
|
||||
self.buffer_rx(resp);
|
||||
continue;
|
||||
@@ -69,8 +69,8 @@ impl<T: Transport> Client<T> {
|
||||
let Some(data) = read_frame(&mut self.transport)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let resp = Response::from_bytes(&data)
|
||||
.ok_or_else(|| anyhow::anyhow!("malformed response ({} bytes)", data.len()))?;
|
||||
let resp =
|
||||
Response::from_bytes(&data).ok_or_else(|| anyhow::anyhow!("malformed response ({} bytes)", data.len()))?;
|
||||
if resp.is_rx_packet() {
|
||||
Ok(Some(resp))
|
||||
} else {
|
||||
@@ -87,8 +87,7 @@ impl<T: Transport> Client<T> {
|
||||
let mut packets: Vec<Response> = self.rx_buffer.drain(..).collect();
|
||||
|
||||
let old_timeout = self.transport.timeout();
|
||||
self.transport
|
||||
.set_timeout(std::time::Duration::from_millis(10))?;
|
||||
self.transport.set_timeout(std::time::Duration::from_millis(10))?;
|
||||
|
||||
let result: anyhow::Result<()> = (|| {
|
||||
loop {
|
||||
@@ -103,12 +102,27 @@ impl<T: Transport> Client<T> {
|
||||
}
|
||||
})();
|
||||
|
||||
// Always restore timeout, even if the loop errored
|
||||
self.transport.set_timeout(old_timeout)?;
|
||||
// Always restore timeout, even if the loop errored.
|
||||
// Preserve the original error if both fail.
|
||||
let restore_result = self.transport.set_timeout(old_timeout);
|
||||
result?;
|
||||
restore_result?;
|
||||
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.
|
||||
pub fn ping(&mut self) -> anyhow::Result<()> {
|
||||
match self.send(Command::Ping)? {
|
||||
@@ -145,15 +159,11 @@ impl<T: Transport> Client<T> {
|
||||
}
|
||||
|
||||
/// Transmit a LoRa packet.
|
||||
pub fn transmit(
|
||||
&mut self,
|
||||
payload: &[u8],
|
||||
config: Option<RadioConfig>,
|
||||
) -> anyhow::Result<()> {
|
||||
let cmd = Command::Transmit {
|
||||
config,
|
||||
payload: payload.to_vec(),
|
||||
};
|
||||
pub fn transmit(&mut self, payload: &[u8], config: Option<RadioConfig>) -> anyhow::Result<()> {
|
||||
if payload.len() > MAX_PAYLOAD {
|
||||
anyhow::bail!("payload too large ({} bytes, max {MAX_PAYLOAD})", payload.len());
|
||||
}
|
||||
let cmd = Command::Transmit { config, payload: payload.to_vec() };
|
||||
match self.send(cmd)? {
|
||||
Response::TxDone => Ok(()),
|
||||
Response::Error(ErrorCode::TxTimeout) => anyhow::bail!("transmit timed out"),
|
||||
|
||||
+9
-27
@@ -47,9 +47,7 @@ pub struct FrameReader {
|
||||
impl FrameReader {
|
||||
/// Create a new empty frame reader.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
buf: Vec::with_capacity(512),
|
||||
}
|
||||
Self { buf: Vec::with_capacity(512) }
|
||||
}
|
||||
|
||||
/// 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]);
|
||||
}
|
||||
Err(e)
|
||||
if matches!(
|
||||
e.kind(),
|
||||
std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock
|
||||
) =>
|
||||
{
|
||||
Err(e) if matches!(e.kind(), std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock) => {
|
||||
return Ok(None);
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
decode_frame(&buf)
|
||||
.map(Some)
|
||||
.ok_or_else(|| anyhow::anyhow!("COBS decode error"))
|
||||
decode_frame(&buf).map(Some).ok_or_else(|| anyhow::anyhow!("COBS decode error"))
|
||||
}
|
||||
|
||||
// ── Tests ──────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -154,7 +146,7 @@ mod tests {
|
||||
// This is equivalent to an empty inter-frame gap and is correctly
|
||||
// skipped by both FrameReader and read_frame.
|
||||
let encoded = encode_frame(b"");
|
||||
assert_eq!(*encoded.last().unwrap(), 0x00);
|
||||
assert_eq!(encoded.last(), Some(&0x00));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -286,10 +278,7 @@ mod tests {
|
||||
// Exact Config response bytes: tag=1, freq=910525000, bw=62.5k, sf=7,
|
||||
// cr=5, sync=0x3444, tx=22dBm, preamble=16, cad=1
|
||||
// Note: preamble high byte is 0x00 — tests COBS with embedded zero.
|
||||
let data = vec![
|
||||
0x01, 0x48, 0x82, 0x45, 0x36, 0x06, 0x07, 0x05,
|
||||
0x44, 0x34, 0x16, 0x10, 0x00, 0x01,
|
||||
];
|
||||
let data = vec![0x01, 0x48, 0x82, 0x45, 0x36, 0x06, 0x07, 0x05, 0x44, 0x34, 0x16, 0x10, 0x00, 0x01];
|
||||
assert_eq!(data.len(), 14);
|
||||
let encoded = encode_frame(&data);
|
||||
let decoded = decode_frame(&encoded[..encoded.len() - 1]);
|
||||
@@ -299,10 +288,7 @@ mod tests {
|
||||
#[test]
|
||||
fn config_response_cobs_roundtrip_cad_zero() {
|
||||
// Same but with cad=0 — two consecutive 0x00 bytes at end.
|
||||
let data = vec![
|
||||
0x01, 0x48, 0x82, 0x45, 0x36, 0x06, 0x07, 0x05,
|
||||
0x44, 0x34, 0x16, 0x10, 0x00, 0x00,
|
||||
];
|
||||
let data = vec![0x01, 0x48, 0x82, 0x45, 0x36, 0x06, 0x07, 0x05, 0x44, 0x34, 0x16, 0x10, 0x00, 0x00];
|
||||
assert_eq!(data.len(), 14);
|
||||
let encoded = encode_frame(&data);
|
||||
let decoded = decode_frame(&encoded[..encoded.len() - 1]);
|
||||
@@ -316,15 +302,11 @@ mod tests {
|
||||
use std::time::Duration;
|
||||
|
||||
let (mut a, _b) = UnixStream::pair().unwrap();
|
||||
a.set_read_timeout(Some(Duration::from_millis(10)))
|
||||
.unwrap();
|
||||
a.set_read_timeout(Some(Duration::from_millis(10))).unwrap();
|
||||
|
||||
// No data written to _b, so read on `a` will time out with WouldBlock on Linux.
|
||||
let result = read_frame(&mut a);
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"socket timeout should be Ok(None), got: {result:?}"
|
||||
);
|
||||
assert!(result.is_ok(), "socket timeout should be Ok(None), got: {result:?}");
|
||||
assert!(result.unwrap().is_none());
|
||||
}
|
||||
}
|
||||
+77
-27
@@ -53,27 +53,23 @@ fn find_mux_socket() -> Option<String> {
|
||||
|
||||
/// Connect to the mux daemon via Unix domain socket.
|
||||
#[cfg(unix)]
|
||||
pub fn mux_connect(
|
||||
path: Option<&str>,
|
||||
timeout: Duration,
|
||||
) -> anyhow::Result<Client<MuxTransport>> {
|
||||
pub fn mux_connect(path: Option<&str>, timeout: Duration) -> anyhow::Result<Client<MuxTransport>> {
|
||||
let path = match path {
|
||||
Some(p) => p.to_string(),
|
||||
None => find_mux_socket()
|
||||
.ok_or_else(|| anyhow::anyhow!("no mux socket found"))?,
|
||||
None => find_mux_socket().ok_or_else(|| anyhow::anyhow!("no mux socket found"))?,
|
||||
};
|
||||
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.
|
||||
pub fn mux_tcp_connect(
|
||||
host: &str,
|
||||
port: u16,
|
||||
timeout: Duration,
|
||||
) -> anyhow::Result<Client<MuxTransport>> {
|
||||
pub fn mux_tcp_connect(host: &str, port: u16, timeout: Duration) -> anyhow::Result<Client<MuxTransport>> {
|
||||
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.
|
||||
@@ -84,15 +80,14 @@ pub fn mux_tcp_connect(
|
||||
/// 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.
|
||||
pub fn connect(
|
||||
port: Option<&str>,
|
||||
timeout: Duration,
|
||||
) -> anyhow::Result<Client<AnyTransport>> {
|
||||
pub fn connect(port: Option<&str>, timeout: Duration) -> anyhow::Result<Client<AnyTransport>> {
|
||||
// If explicit port given, go direct
|
||||
if let Some(port) = port {
|
||||
debug!("opening serial port {port}");
|
||||
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
|
||||
@@ -100,7 +95,9 @@ pub fn connect(
|
||||
&& let Some(transport) = try_tcp_mux(&tcp, timeout)
|
||||
{
|
||||
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
|
||||
@@ -109,7 +106,11 @@ pub fn connect(
|
||||
match MuxTransport::unix(&path, timeout) {
|
||||
Ok(transport) => {
|
||||
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(_) => {
|
||||
// Stale socket or connection refused — fall through
|
||||
@@ -119,11 +120,12 @@ pub fn connect(
|
||||
}
|
||||
|
||||
// Direct USB serial — auto-detect or wait
|
||||
let port_path = discovery::find_port()
|
||||
.unwrap_or_else(discovery::wait_for_device);
|
||||
let port_path = discovery::find_port().unwrap_or_else(discovery::wait_for_device);
|
||||
debug!("opening serial port {port_path}");
|
||||
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.
|
||||
@@ -144,23 +146,71 @@ pub fn connect_mux_auto(timeout: Duration) -> anyhow::Result<Client<AnyTransport
|
||||
&& let Some(transport) = try_tcp_mux(&tcp, timeout)
|
||||
{
|
||||
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.
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let path = find_mux_socket()
|
||||
.ok_or_else(|| anyhow::anyhow!("no mux socket found"))?;
|
||||
let path = find_mux_socket().ok_or_else(|| anyhow::anyhow!("no mux socket found"))?;
|
||||
let transport = MuxTransport::unix(&path, timeout)?;
|
||||
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))]
|
||||
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> {
|
||||
let (host, port) = if let Some((h, p)) = addr.rsplit_once(':') {
|
||||
let host = if h.is_empty() { "localhost" } else { h };
|
||||
|
||||
+24
-1
@@ -13,18 +13,41 @@ pub const USB_VID: u16 = 0x1209;
|
||||
/// DongLoRa USB Product ID.
|
||||
pub const USB_PID: u16 = 0x5741;
|
||||
|
||||
/// Known USB-UART bridge VID:PIDs found on some board revisions.
|
||||
const BRIDGE_VID_PIDS: &[(u16, u16)] = &[
|
||||
(0x10C4, 0xEA60), // CP2102 (Silicon Labs)
|
||||
(0x1A86, 0x55D4), // CH9102
|
||||
(0x1A86, 0x7523), // CH340
|
||||
(0x0403, 0x6001), // FT232R (FTDI)
|
||||
];
|
||||
|
||||
/// Find the DongLoRa serial port by USB VID:PID.
|
||||
///
|
||||
/// Checks for native USB CDC-ACM first (VID:PID 1209:5741), then falls
|
||||
/// back to known USB-UART bridge chips found on some board revisions.
|
||||
/// Returns the first matching port path, or `None` if no device is found.
|
||||
pub fn find_port() -> Option<String> {
|
||||
let ports = serialport::available_ports().ok()?;
|
||||
|
||||
// Prefer native USB (our custom VID:PID)
|
||||
if let Some(port) = ports.iter().find(|p| {
|
||||
matches!(
|
||||
&p.port_type,
|
||||
serialport::SerialPortType::UsbPort(info)
|
||||
if info.vid == USB_VID && info.pid == USB_PID
|
||||
)
|
||||
}) {
|
||||
return Some(port.port_name.clone());
|
||||
}
|
||||
|
||||
// Fall back to known USB-UART bridge chips
|
||||
ports
|
||||
.into_iter()
|
||||
.find(|p| {
|
||||
matches!(
|
||||
&p.port_type,
|
||||
serialport::SerialPortType::UsbPort(info)
|
||||
if info.vid == USB_VID && info.pid == USB_PID
|
||||
if BRIDGE_VID_PIDS.contains(&(info.vid, info.pid))
|
||||
)
|
||||
})
|
||||
.map(|p| p.port_name)
|
||||
|
||||
+6
-8
@@ -1,7 +1,7 @@
|
||||
//! DongLoRa host library — connect, configure, send/receive LoRa packets.
|
||||
//!
|
||||
//! 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
|
||||
//!
|
||||
@@ -9,7 +9,6 @@
|
||||
//! use donglora_client::*;
|
||||
//!
|
||||
//! let mut client = connect_default()?;
|
||||
//! client.ping()?;
|
||||
//! client.set_config(RadioConfig::default())?;
|
||||
//! client.start_rx()?;
|
||||
//!
|
||||
@@ -30,13 +29,12 @@ pub mod transport;
|
||||
|
||||
// Flat re-exports for convenience
|
||||
pub use client::Client;
|
||||
pub use codec::{decode_frame, encode_frame, read_frame, FrameReader};
|
||||
pub use connect::{connect, connect_default, connect_mux_auto, default_socket_path};
|
||||
pub use discovery::{find_port, wait_for_device, USB_PID, USB_VID};
|
||||
pub use codec::{FrameReader, decode_frame, encode_frame, read_frame};
|
||||
pub use connect::{connect, connect_default, connect_mux_auto, default_socket_path, try_connect};
|
||||
pub use discovery::{USB_PID, USB_VID, find_port, wait_for_device};
|
||||
pub use protocol::{
|
||||
Bandwidth, Command, ErrorCode, RadioConfig, Response, CMD_TAG_SET_CONFIG, CMD_TAG_START_RX,
|
||||
CMD_TAG_STOP_RX, ERROR_INVALID_CONFIG, MAX_PAYLOAD, PREAMBLE_DEFAULT, RADIO_CONFIG_SIZE,
|
||||
RESP_TAG_ERROR, RESP_TAG_OK, RESP_TAG_RX_PACKET, TX_POWER_MAX,
|
||||
Bandwidth, Command, ErrorCode, MAX_PAYLOAD, PREAMBLE_DEFAULT, RADIO_CONFIG_SIZE, RadioConfig, Response,
|
||||
TX_POWER_MAX,
|
||||
};
|
||||
pub use transport::{AnyTransport, MuxTransport, SerialTransport, Transport};
|
||||
|
||||
|
||||
+15
-49
@@ -1,7 +1,7 @@
|
||||
//! Wire protocol types and fixed-size little-endian serialization.
|
||||
//!
|
||||
//! 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.
|
||||
pub const MAX_PAYLOAD: usize = 256;
|
||||
@@ -156,10 +156,7 @@ pub enum Command {
|
||||
SetConfig(RadioConfig),
|
||||
StartRx,
|
||||
StopRx,
|
||||
Transmit {
|
||||
config: Option<RadioConfig>,
|
||||
payload: Vec<u8>,
|
||||
},
|
||||
Transmit { config: Option<RadioConfig>, payload: Vec<u8> },
|
||||
DisplayOn,
|
||||
DisplayOff,
|
||||
GetMac,
|
||||
@@ -241,10 +238,7 @@ impl Command {
|
||||
if rest.len() < data_start + len || len > MAX_PAYLOAD {
|
||||
return None;
|
||||
}
|
||||
Some(Self::Transmit {
|
||||
config,
|
||||
payload: rest[data_start..data_start + len].to_vec(),
|
||||
})
|
||||
Some(Self::Transmit { config, payload: rest[data_start..data_start + len].to_vec() })
|
||||
}
|
||||
6 => Some(Self::DisplayOn),
|
||||
7 => Some(Self::DisplayOff),
|
||||
@@ -283,6 +277,8 @@ impl ErrorCode {
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ErrorCode {}
|
||||
|
||||
impl std::fmt::Display for ErrorCode {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
@@ -303,11 +299,7 @@ impl std::fmt::Display for ErrorCode {
|
||||
pub enum Response {
|
||||
Pong,
|
||||
Config(RadioConfig),
|
||||
RxPacket {
|
||||
rssi: i16,
|
||||
snr: i16,
|
||||
payload: Vec<u8>,
|
||||
},
|
||||
RxPacket { rssi: i16, snr: i16, payload: Vec<u8> },
|
||||
TxDone,
|
||||
Ok,
|
||||
Error(ErrorCode),
|
||||
@@ -378,11 +370,7 @@ impl Response {
|
||||
if rest.len() < 6 + len || len > MAX_PAYLOAD {
|
||||
return None;
|
||||
}
|
||||
Some(Self::RxPacket {
|
||||
rssi,
|
||||
snr,
|
||||
payload: rest[6..6 + len].to_vec(),
|
||||
})
|
||||
Some(Self::RxPacket { rssi, snr, payload: rest[6..6 + len].to_vec() })
|
||||
}
|
||||
3 => Some(Self::TxDone),
|
||||
4 => Some(Self::Ok),
|
||||
@@ -403,6 +391,7 @@ impl Response {
|
||||
// ── Tests ──────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -494,30 +483,21 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn command_transmit_no_config_roundtrip() {
|
||||
let cmd = Command::Transmit {
|
||||
config: None,
|
||||
payload: b"hello".to_vec(),
|
||||
};
|
||||
let cmd = Command::Transmit { config: None, payload: b"hello".to_vec() };
|
||||
let bytes = cmd.to_bytes();
|
||||
assert_eq!(Command::from_bytes(&bytes), Some(cmd));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_transmit_with_config_roundtrip() {
|
||||
let cmd = Command::Transmit {
|
||||
config: Some(make_config()),
|
||||
payload: b"test".to_vec(),
|
||||
};
|
||||
let cmd = Command::Transmit { config: Some(make_config()), payload: b"test".to_vec() };
|
||||
let bytes = cmd.to_bytes();
|
||||
assert_eq!(Command::from_bytes(&bytes), Some(cmd));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_transmit_empty_payload() {
|
||||
let cmd = Command::Transmit {
|
||||
config: None,
|
||||
payload: vec![],
|
||||
};
|
||||
let cmd = Command::Transmit { config: None, payload: vec![] };
|
||||
let bytes = cmd.to_bytes();
|
||||
assert_eq!(Command::from_bytes(&bytes), Some(cmd));
|
||||
}
|
||||
@@ -572,22 +552,14 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn response_rx_packet_roundtrip() {
|
||||
let resp = Response::RxPacket {
|
||||
rssi: -80,
|
||||
snr: 10,
|
||||
payload: b"data".to_vec(),
|
||||
};
|
||||
let resp = Response::RxPacket { rssi: -80, snr: 10, payload: b"data".to_vec() };
|
||||
let bytes = resp.to_bytes();
|
||||
assert_eq!(Response::from_bytes(&bytes), Some(resp));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_rx_packet_empty_payload() {
|
||||
let resp = Response::RxPacket {
|
||||
rssi: -120,
|
||||
snr: -5,
|
||||
payload: vec![],
|
||||
};
|
||||
let resp = Response::RxPacket { rssi: -120, snr: -5, payload: vec![] };
|
||||
let bytes = resp.to_bytes();
|
||||
assert_eq!(Response::from_bytes(&bytes), Some(resp));
|
||||
}
|
||||
@@ -619,10 +591,7 @@ mod tests {
|
||||
fn response_tags() {
|
||||
assert_eq!(Response::Pong.tag(), 0);
|
||||
assert_eq!(Response::Config(make_config()).tag(), 1);
|
||||
assert_eq!(
|
||||
Response::RxPacket { rssi: 0, snr: 0, payload: vec![] }.tag(),
|
||||
2
|
||||
);
|
||||
assert_eq!(Response::RxPacket { rssi: 0, snr: 0, payload: vec![] }.tag(), 2);
|
||||
assert_eq!(Response::TxDone.tag(), 3);
|
||||
assert_eq!(Response::Ok.tag(), 4);
|
||||
assert_eq!(Response::Error(ErrorCode::RadioBusy).tag(), 5);
|
||||
@@ -685,9 +654,6 @@ mod tests {
|
||||
let cmd = Command::SetConfig(cfg);
|
||||
let bytes = cmd.to_bytes();
|
||||
// Expected: 02 C0 CA 89 36 07 07 05 24 14 80 00 00 01
|
||||
assert_eq!(
|
||||
bytes,
|
||||
[0x02, 0xC0, 0xCA, 0x89, 0x36, 0x07, 0x07, 0x05, 0x24, 0x14, 0x80, 0x00, 0x00, 0x01]
|
||||
);
|
||||
assert_eq!(bytes, [0x02, 0xC0, 0xCA, 0x89, 0x36, 0x07, 0x07, 0x05, 0x24, 0x14, 0x80, 0x00, 0x00, 0x01]);
|
||||
}
|
||||
}
|
||||
+7
-23
@@ -61,9 +61,7 @@ impl Write for SerialTransport {
|
||||
|
||||
impl Transport for SerialTransport {
|
||||
fn set_timeout(&mut self, timeout: Duration) -> anyhow::Result<()> {
|
||||
self.port
|
||||
.set_timeout(timeout)
|
||||
.map_err(|e| anyhow::anyhow!("failed to set serial timeout: {e}"))?;
|
||||
self.port.set_timeout(timeout).map_err(|e| anyhow::anyhow!("failed to set serial timeout: {e}"))?;
|
||||
self.timeout = timeout;
|
||||
Ok(())
|
||||
}
|
||||
@@ -99,10 +97,7 @@ impl MuxTransport {
|
||||
stream
|
||||
.set_write_timeout(Some(timeout))
|
||||
.map_err(|e| anyhow::anyhow!("failed to set socket write timeout: {e}"))?;
|
||||
Ok(Self {
|
||||
stream: MuxStream::Unix(stream),
|
||||
timeout,
|
||||
})
|
||||
Ok(Self { stream: MuxStream::Unix(stream), timeout })
|
||||
}
|
||||
|
||||
/// Connect to the mux daemon via TCP.
|
||||
@@ -117,16 +112,9 @@ impl MuxTransport {
|
||||
.ok_or_else(|| anyhow::anyhow!("no addresses found for {addr}"))?;
|
||||
let stream = std::net::TcpStream::connect_timeout(&sock_addr, timeout)
|
||||
.map_err(|e| anyhow::anyhow!("failed to connect to mux at {addr}: {e}"))?;
|
||||
stream
|
||||
.set_read_timeout(Some(timeout))
|
||||
.map_err(|e| anyhow::anyhow!("failed to set TCP read timeout: {e}"))?;
|
||||
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,
|
||||
})
|
||||
stream.set_read_timeout(Some(timeout)).map_err(|e| anyhow::anyhow!("failed to set TCP read timeout: {e}"))?;
|
||||
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;
|
||||
let result = match &self.stream {
|
||||
#[cfg(unix)]
|
||||
MuxStream::Unix(s) => s
|
||||
.set_read_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))),
|
||||
MuxStream::Unix(s) => s.set_read_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}"))
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user