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
## 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.
Generated
+1 -1
View File
@@ -44,7 +44,7 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "donglora-client"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"anyhow",
"serialport",
+5 -2
View File
@@ -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"
+30 -10
View File
@@ -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)
+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).
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
View File
@@ -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
View File
@@ -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 };
+6 -8
View File
@@ -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
View File
@@ -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
View File
@@ -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}"))
}