9 Commits
Author SHA1 Message Date
swaits 4fe11526f7 chore: release 1.0.1 2026-04-22 19:34:29 -06:00
swaits c43a78f8bb chore: release 1.0.0 2026-04-22 09:45:43 -06:00
swaits 6937ad47ba feat!: Protocol v2 client rewrite with Dongle/Session model
The full 1.0 client surface, matching the protocol v2 wire definitions from donglora-protocol 1.0.0.

* Public API is now async. The blocking Client<T: Transport> is replaced by Dongle (from donglora_client::connect / connect_with / ConnectOptions).

* src/client.rs and src/codec.rs are removed in favor of src/dongle.rs (high-level surface), src/session.rs (per-client tag/slot state), src/retry.rs (RetryPolicy + tx_with_retry), src/errors.rs (thiserror-based ClientError / ClientResult). src/protocol.rs is replaced by direct donglora-protocol imports.

* Transport is now tokio-serial for USB and tokio::net::{UnixStream, TcpStream} for mux, replacing the blocking serial + shim model.

* Dongle::tx_with_retry(data, &RetryPolicy) implements PROTOCOL.md §6.10 randomized backoff for CHANNEL_BUSY / EBUSY, returning a TxOutcome with per-attempt trace.

* Background keepalive task pings every 500 ms on host idle, tracking the 1 s inactivity timeout from §3.4 with 2x margin. Disable via ConnectOptions::keepalive(false).

* Auto-recovery from ERR(ENOTCONFIGURED): the Dongle caches the last SET_CONFIG and silently re-applies on session drop, retrying once.

* Error taxonomy switches from anyhow to thiserror. Transport-level conditions (Timeout, TransportClosed, ReaderExited, BadFrame) are first-class.

* Examples rewritten for the async API: examples/ping.rs, examples/tx.rs, examples/rx.rs all target the MeshCore US preset (910.525 MHz / BW 62.5 / SF 7 / CR 4/5 / sync 0x1424 / 20 dBm). examples/common/mod.rs holds the shared helper.

* tests/vectors.rs gains protocol-level roundtrip coverage anchored to the published donglora-protocol 1.0.0 types.
2026-04-22 09:45:38 -06:00
swaits 055fc76b87 fix: prevent client from stealing serial port when mux is restarting
Track mux usage with a static AtomicBool. Once a mux connection
succeeds, all future connect() calls in the same process only try
the mux. connect() blocks waiting for the socket to reappear;
try_connect() returns an error so callers can retry with backoff.
2026-04-11 02:50:49 -06:00
swaits 77addfa698 fix: connect() no longer falls through from mux to USB serial
When a mux socket file exists, connect(None, timeout) and try_connect()
now commit to the mux — returning an error if unreachable instead of
silently falling through to USB serial. This prevents a race condition
where a client could steal the serial port from the mux during reconnect.

Callers with explicit port = Some(...) are unaffected.
2026-04-07 18:04:07 -06:00
swaits a7e93ab56b 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
2026-04-07 15:52:37 -06:00
swaits 5cec7dcd58 chore: add consistent mise.toml, justfile, and tooling config 2026-04-07 14:41:26 -06:00
swaits 75d70df294 WIP: get Heltec V3 working 2026-04-07 07:58:57 -06:00
swaits b2e4bb0476 WIP: functional checkpoint! 2026-04-06 21:15:35 -06:00
25 changed files with 3256 additions and 1620 deletions

No files matched your search

+98
View File
@@ -1,5 +1,103 @@
# Changelog
## 1.0.1 — 2026-04-22
### Fixed
- `discovery::find_port` / `wait_for_device` now match the WCH CH340K
USB-UART bridge (`1a86:7522`) and standard CH340 (`1a86:7523`) in
the `BRIDGE_VID_PIDS` list. The Elecrow ThinkNode-M2 board ships
with a CH340K bridge; previously only CP2102, CH9102, and FTDI
bridges were recognized, so `donglora-mux` and any other
`donglora-client` consumer reported "no device" on that board
even when the serial port was clearly present.
## 1.0.0 — 2026-04-22
### Breaking
- **Migrated to the DongLoRa Protocol v2 wire format.** All message
types, tag correlation, and framing now follow `PROTOCOL.md` v1.0. This is a
wire-incompatible break with 0.x clients and firmware. `donglora-client` now
depends on `donglora-protocol` for the normative type definitions.
- **Full tokio-async rewrite.** Public API is `async`; the blocking
`Client<T: Transport>` is gone. Replace with `Dongle` from
`donglora_client::connect()` (or the new `ConnectOptions` builder +
`connect_with`). Serial I/O is now `tokio-serial`; mux connections are
`tokio::net::UnixStream` / `tokio::net::TcpStream`.
- **`thiserror`-based error taxonomy.** `anyhow::Result` on public APIs
is replaced by `donglora_client::ClientResult<T> = Result<T, ClientError>`.
Variants mirror the Python client's `DongloraError` hierarchy plus
transport-level conditions (`Timeout`, `TransportClosed`, `ReaderExited`,
`BadFrame`).
### Added
- **`Dongle::tx_with_retry(data, &RetryPolicy)`** — randomized backoff +
exponential retry for `TX_DONE(CHANNEL_BUSY)` and `ERR(EBUSY)`, per spec
§6.10. Returns a [`TxOutcome`] capturing every per-attempt result so callers
(the bridge TUI especially) can display retry state.
- **Auto-recovery from `ERR(ENOTCONFIGURED)`.** `Dongle` caches the applied
config from the most recent `SET_CONFIG` and silently re-applies it + retries
once when the firmware's inactivity timer expires mid-session.
- **Background keepalive task.** Spawned at connect time, pings the device
every 500 ms when the host has been idle, tracking the 1 s inactivity window
from spec §3.4 with 2× safety margin. Disable via
`ConnectOptions::keepalive(false)`.
- **Rich `RxPayload` metadata.** Bridge-relevant fields (`rssi_tenths_dbm`,
`snr_tenths_db`, `freq_err_hz`, `timestamp_us`, `crc_valid`,
`packets_dropped`, `origin`) are surfaced directly from the wire.
- **New examples:** `cargo run --example tx` / `--example rx` alongside the
existing `ping`, all using the MeshCore US preset
(910.525 MHz / BW 62.5 / SF 7 / CR 4/5 / sync 0x1424 / 20 dBm).
### Removed
- The `Client<T>`, `Response`, `RadioConfig`, `Bandwidth`, and `TX_POWER_MAX`
types. `Modulation` / `LoRaConfig` / `LoRaBandwidth` (re-exported from
`donglora-protocol`) replace them.
## 0.2.1 — 2026-04-07
### Fixed
- **`connect()` no longer falls through from mux to USB serial.** If a mux
socket file exists, `connect(None, timeout)` and `try_connect(timeout)` now
commit to the mux and return an error if the mux is unreachable — instead of
silently bypassing it and grabbing the USB port directly. This prevents a
race condition where a client could steal the serial port from the mux during
reconnect. Callers with explicit `port = Some(...)` are unaffected.
## 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.
+86
View File
@@ -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
+505 -25
View File
@@ -2,6 +2,15 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "aho-corasick"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
dependencies = [
"memchr",
]
[[package]]
name = "anyhow"
version = "1.0.102"
@@ -20,12 +29,30 @@ version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "bytes"
version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "core-foundation"
version = "0.10.1"
@@ -44,14 +71,149 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "donglora-client"
version = "0.1.0"
version = "1.0.1"
dependencies = [
"anyhow",
"serialport",
"donglora-protocol",
"heapless",
"rand",
"thiserror",
"tokio",
"tokio-serial",
"tokio-stream",
"tracing",
"tracing-subscriber",
]
[[package]]
name = "donglora-protocol"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8f2743ac7668845500e3686890917cdf5b0a09d17ec099bde2db4d3fae2a4a4"
dependencies = [
"heapless",
"ucobs",
]
[[package]]
name = "futures"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
dependencies = [
"futures-channel",
"futures-core",
"futures-executor",
"futures-io",
"futures-sink",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-channel"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
dependencies = [
"futures-core",
"futures-sink",
]
[[package]]
name = "futures-core"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
[[package]]
name = "futures-executor"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
dependencies = [
"futures-core",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-io"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
[[package]]
name = "futures-macro"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "futures-sink"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
[[package]]
name = "futures-task"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
[[package]]
name = "futures-util"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
dependencies = [
"futures-channel",
"futures-core",
"futures-io",
"futures-macro",
"futures-sink",
"futures-task",
"memchr",
"pin-project-lite",
"slab",
]
[[package]]
name = "getrandom"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"libc",
"r-efi",
"wasip2",
]
[[package]]
name = "hash32"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606"
dependencies = [
"byteorder",
]
[[package]]
name = "heapless"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1edcd5a338e64688fbdcb7531a846cfd3476a54784dcb918a0844682bc7ada5"
dependencies = [
"hash32",
"stable_deref_trait",
]
[[package]]
name = "io-kit-sys"
version = "0.4.1"
@@ -62,6 +224,12 @@ dependencies = [
"mach2",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "libc"
version = "0.2.184"
@@ -69,24 +237,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af"
[[package]]
name = "libudev"
version = "0.3.0"
name = "log"
version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78b324152da65df7bb95acfcaab55e3097ceaab02fb19b228a9eb74d55f135e0"
dependencies = [
"libc",
"libudev-sys",
]
[[package]]
name = "libudev-sys"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c8469b4a23b962c1396b9b451dda50ef5b283e8dd309d69033475fa9b334324"
dependencies = [
"libc",
"pkg-config",
]
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "mach2"
@@ -97,6 +251,46 @@ dependencies = [
"libc",
]
[[package]]
name = "matchers"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
dependencies = [
"regex-automata",
]
[[package]]
name = "memchr"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "mio"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
dependencies = [
"libc",
"log",
"wasi",
"windows-sys 0.61.2",
]
[[package]]
name = "mio-serial"
version = "5.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "029e1f407e261176a983a6599c084efd322d9301028055c87174beac71397ba3"
dependencies = [
"log",
"mio",
"nix 0.29.0",
"serialport",
"winapi",
]
[[package]]
name = "nix"
version = "0.26.4"
@@ -108,6 +302,27 @@ dependencies = [
"libc",
]
[[package]]
name = "nix"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
dependencies = [
"bitflags 2.11.0",
"cfg-if",
"cfg_aliases",
"libc",
]
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "once_cell"
version = "1.21.4"
@@ -121,10 +336,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "pkg-config"
version = "0.3.32"
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]]
name = "proc-macro2"
@@ -144,6 +362,58 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "rand"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
dependencies = [
"rand_chacha",
"rand_core",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom",
]
[[package]]
name = "regex-automata"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "scopeguard"
version = "1.2.0"
@@ -161,14 +431,50 @@ dependencies = [
"core-foundation",
"core-foundation-sys",
"io-kit-sys",
"libudev",
"mach2",
"nix",
"nix 0.26.4",
"scopeguard",
"unescaper",
"windows-sys",
"windows-sys 0.52.0",
]
[[package]]
name = "sharded-slab"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
dependencies = [
"lazy_static",
]
[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "smallvec"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "socket2"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "syn"
version = "2.0.117"
@@ -200,6 +506,66 @@ dependencies = [
"syn",
]
[[package]]
name = "thread_local"
version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
dependencies = [
"cfg-if",
]
[[package]]
name = "tokio"
version = "1.52.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6"
dependencies = [
"bytes",
"libc",
"mio",
"pin-project-lite",
"socket2",
"tokio-macros",
"windows-sys 0.61.2",
]
[[package]]
name = "tokio-macros"
version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tokio-serial"
version = "5.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa1d5427f11ba7c5e6384521cfd76f2d64572ff29f3f4f7aa0f496282923fdc8"
dependencies = [
"cfg-if",
"futures",
"log",
"mio-serial",
"serialport",
"tokio",
]
[[package]]
name = "tokio-stream"
version = "0.1.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70"
dependencies = [
"futures-core",
"pin-project-lite",
"tokio",
]
[[package]]
name = "tracing"
version = "0.1.44"
@@ -229,6 +595,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [
"once_cell",
"valuable",
]
[[package]]
name = "tracing-log"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
dependencies = [
"log",
"once_cell",
"tracing-core",
]
[[package]]
name = "tracing-subscriber"
version = "0.3.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
dependencies = [
"matchers",
"nu-ansi-term",
"once_cell",
"regex-automata",
"sharded-slab",
"smallvec",
"thread_local",
"tracing",
"tracing-core",
"tracing-log",
]
[[package]]
@@ -252,6 +648,55 @@ version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "valuable"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasip2"
version = "1.0.1+wasi-0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7"
dependencies = [
"wit-bindgen",
]
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.52.0"
@@ -261,6 +706,15 @@ dependencies = [
"windows-targets",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
@@ -324,3 +778,29 @@ name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "wit-bindgen"
version = "0.46.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59"
[[package]]
name = "zerocopy"
version = "0.8.48"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.48"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
+33 -6
View File
@@ -1,19 +1,46 @@
[package]
name = "donglora-client"
version = "0.1.0"
version = "1.0.1"
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"
description = "DongLoRa host library — connect, configure, send/receive LoRa packets (DongLoRa Protocol v2)"
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"
ucobs = "0.3"
serialport = "4"
donglora-protocol = { version = "1.0.0", default-features = false, features = ["std"] }
thiserror = "2"
tokio = { version = "1", features = ["rt", "rt-multi-thread", "sync", "time", "macros", "io-util", "net"] }
tokio-serial = "5"
tokio-stream = "0.1"
tracing = "0.1"
rand = "0.9"
[dev-dependencies]
tokio = { version = "1", features = ["rt", "rt-multi-thread", "sync", "time", "macros", "io-util", "net", "test-util"] }
anyhow = "1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# Direct access to raw wire primitives for tests (frame encoder/decoder
# helpers that are not part of the donglora-client public API).
donglora-protocol = { version = "1.0.0", default-features = false, features = ["std"] }
heapless = "0.9"
[[example]]
name = "ping"
required-features = []
[[example]]
name = "tx"
required-features = []
[[example]]
name = "rx"
required-features = []
[lints.clippy]
unwrap_used = "deny"
+90 -23
View File
@@ -1,37 +1,104 @@
# DongLoRa Rust Client
Rust client library for talking to a DongLoRa device — either directly
over USB or through the [mux daemon](../../mux/).
[![Crates.io](https://img.shields.io/crates/v/donglora-client.svg)](https://crates.io/crates/donglora-client)
[![Docs.rs](https://docs.rs/donglora-client/badge.svg)](https://docs.rs/donglora-client)
## What's in Here
Async Rust client library for talking to a DongLoRa device, either
directly over USB serial or through a mux daemon. Speaks DongLoRa
Protocol v2 via the [`donglora-protocol`][dp] crate.
- `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)
[dp]: https://crates.io/crates/donglora-protocol
## Usage
## Install
```toml
[dependencies]
donglora-client = "1"
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
```
## Quick Start
```rust
use donglora_client::*;
use donglora_client::connect;
use donglora_protocol::{LoRaBandwidth, LoRaCodingRate, LoRaConfig, Modulation};
let mut client = connect_default()?;
client.ping()?;
client.set_config(RadioConfig::default())?;
client.start_rx()?;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let mut dongle = connect(None, std::time::Duration::from_secs(5)).await?;
loop {
if let Some(Response::RxPacket { rssi, snr, payload }) = client.recv()? {
println!("RX rssi={rssi} snr={snr} len={}", payload.len());
dongle.set_config(&Modulation::LoRa(LoRaConfig {
freq_hz: 910_525_000,
bw: LoRaBandwidth::Khz62,
sf: 7,
cr: LoRaCodingRate::Cr4_5,
sync_word: 0x1424,
tx_power_dbm: 20,
..Default::default()
})).await?;
dongle.start_rx().await?;
while let Some(rx) = dongle.next_rx().await? {
println!("RX rssi={} snr={} len={}", rx.rssi_tenths_dbm, rx.snr_tenths_db, rx.data.len());
}
Ok(())
}
```
## Dependencies
All `connect` functions automatically validate the device by pinging
it, so you get a clear error immediately if the port is not a real
DongLoRa.
- `ucobs` — COBS framing (same implementation as the firmware)
- `serialport` — USB serial communication
- `anyhow` — error handling
- `tracing` — logging
## What's in Here
- `src/dongle.rs`: high-level `Dongle` API (connect, set_config,
transmit, start_rx, next_rx, tx_with_retry).
- `src/session.rs`: reader task, tag-allocation, per-command response
routing, async event fan-out.
- `src/retry.rs`: `RetryPolicy` + randomized backoff for
`CHANNEL_BUSY` / `EBUSY` per `PROTOCOL.md §6.10`.
- `src/connect.rs`: auto-detection pipeline (TCP mux env var, unix
socket mux, direct USB serial).
- `src/transport.rs`: tokio transports (`tokio-serial` for USB,
`tokio::net::{UnixStream, TcpStream}` for mux).
- `src/discovery.rs`: USB VID:PID discovery + ping validation.
- `src/errors.rs`: `ClientError` / `ClientResult<T>` (thiserror-based
taxonomy mirroring the Python client's `DongloraError`).
## Connection Priority
`connect(None, timeout)` tries these in order:
1. **TCP mux**: via `DONGLORA_MUX_TCP` env var (host:port).
2. **Unix socket mux**: `$XDG_RUNTIME_DIR/donglora/mux.sock` or
`/tmp/donglora-mux.sock`.
3. **Direct USB serial**: auto-detects by VID:PID (CP210x, CH9102,
CH340, FT232R bridge chips too).
Once a mux path wins, subsequent reconnects commit to that path and
will not fall back to USB, preventing port-steal races during mux
restart.
`try_connect(timeout)` returns an error immediately if no device is
found; `connect` blocks until one appears.
## TX with Retry
```rust
use donglora_client::retry::RetryPolicy;
let policy = RetryPolicy::default(); // 3 attempts, 20-100 ms backoff, 500 ms cap
let outcome = dongle.tx_with_retry(payload, &policy).await?;
println!("sent in {} attempt(s)", outcome.attempts.len());
```
## Keepalive
A background task pings the device every 500 ms when the host has
been idle, tracking the 1 s inactivity window from `PROTOCOL.md §3.4`
with a 2x safety margin. Disable via `ConnectOptions::keepalive(false)`
if you want to manage liveness yourself.
## License
MIT, see [`LICENSE`](LICENSE).
+34
View File
@@ -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 = []
+23
View File
@@ -0,0 +1,23 @@
//! Shared defaults for the example binaries.
//!
//! Mirrors `examples/_common.py::MESHCORE_US`. See that file for the
//! full justification — short version: 910.525 MHz / BW 62.5 / SF 7 /
//! CR 4/5, sync 0x1424 (RADIOLIB_SX126X_SYNC_WORD_PRIVATE), 20 dBm.
use donglora_client::{LoRaBandwidth, LoRaCodingRate, LoRaConfig, LoRaHeaderMode, Modulation};
/// MeshCore's "USA/Canada (Recommended)" preset (October 2025 narrow).
pub fn meshcore_us() -> Modulation {
Modulation::LoRa(LoRaConfig {
freq_hz: 910_525_000,
sf: 7,
bw: LoRaBandwidth::Khz62,
cr: LoRaCodingRate::Cr4_5,
preamble_len: 16,
sync_word: 0x1424,
tx_power_dbm: 20,
header_mode: LoRaHeaderMode::Explicit,
payload_crc: true,
iq_invert: false,
})
}
+30
View File
@@ -0,0 +1,30 @@
//! Connect to a DongLoRa device, ping it, and print its info.
//!
//! Run with: `cargo run --example ping`
use donglora_client::connect;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber_init();
let dongle = connect().await?;
println!("connected!");
dongle.ping().await?;
println!("ping ok");
let info = dongle.info();
println!("firmware: v{}.{}.{} ({:?})", info.fw_major, info.fw_minor, info.fw_patch, info.chip_id());
println!("TX power range: {} .. {} dBm", info.tx_power_min_dbm, info.tx_power_max_dbm);
println!("max OTA payload: {} bytes", info.max_payload_bytes);
dongle.close().await;
Ok(())
}
fn tracing_subscriber_init() {
// Best-effort subscriber; ignore errors if one is already set.
use tracing_subscriber::{EnvFilter, fmt};
let _ = fmt().with_env_filter(EnvFilter::from_default_env()).try_init();
}
+35
View File
@@ -0,0 +1,35 @@
//! Connect, apply the MeshCore USA preset, and print every received
//! packet until interrupted. Run with:
//!
//! ```sh
//! cargo run --example rx
//! ```
#[path = "common/mod.rs"]
mod common;
use donglora_client::{ConnectOptions, connect_with};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let _ = tracing_subscriber::fmt().with_env_filter(tracing_subscriber::EnvFilter::from_default_env()).try_init();
let dongle = connect_with(ConnectOptions::new().config(common::meshcore_us())).await?;
println!("connected; listening...");
while let Some(pkt) = dongle.next_rx().await {
let rssi_dbm = pkt.rssi_tenths_dbm as f32 / 10.0;
let snr_db = pkt.snr_tenths_db as f32 / 10.0;
let text = String::from_utf8_lossy(&pkt.data);
println!(
"RX {:>4} bytes rssi={:6.1} dBm snr={:5.1} dB crc={} \"{}\"",
pkt.data.len(),
rssi_dbm,
snr_db,
if pkt.crc_valid { "ok" } else { "bad" },
text
);
}
Ok(())
}
+32
View File
@@ -0,0 +1,32 @@
//! Connect, apply the MeshCore USA preset, and transmit a greeting
//! every 5 seconds. Run with:
//!
//! ```sh
//! cargo run --example tx
//! ```
#[path = "common/mod.rs"]
mod common;
use std::time::Duration;
use donglora_client::{ConnectOptions, connect_with};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let _ = tracing_subscriber::fmt().with_env_filter(tracing_subscriber::EnvFilter::from_default_env()).try_init();
let dongle = connect_with(ConnectOptions::new().config(common::meshcore_us())).await?;
println!("connected; TX ready");
let mut seq: u32 = 0;
loop {
seq += 1;
let payload = format!("hello #{seq}");
match dongle.tx(payload.as_bytes()).await {
Ok(td) => println!("TX #{seq} sent ({} us airtime)", td.airtime_us),
Err(e) => eprintln!("TX #{seq} failed: {e}"),
}
tokio::time::sleep(Duration::from_secs(5)).await;
}
}
+35 -4
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
[tools]
rust = "1.94"
just = "latest"
+3
View File
@@ -0,0 +1,3 @@
edition = "2024"
max_width = 120
use_small_heuristics = "Max"
-222
View File
@@ -1,222 +0,0 @@
//! High-level DongLoRa client.
//!
//! The [`Client`] wraps a transport and provides ergonomic send/recv methods
//! that handle the command/response discipline (buffering unsolicited RxPackets
//! while waiting for solicited responses).
use std::collections::VecDeque;
use crate::codec::{encode_frame, read_frame};
use crate::protocol::{Command, ErrorCode, RadioConfig, Response};
use crate::transport::Transport;
/// Maximum number of buffered RxPackets before oldest are dropped.
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;
/// High-level DongLoRa client, generic over transport.
///
/// Works with any [`Transport`]: direct USB serial, Unix socket mux, or TCP mux.
pub struct Client<T: Transport> {
transport: T,
rx_buffer: VecDeque<Response>,
}
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),
}
}
/// Send a command and wait for the solicited response.
///
/// Any unsolicited `RxPacket` frames encountered while waiting are buffered
/// and retrievable via [`recv`](Self::recv) or [`drain_rx`](Self::drain_rx).
pub fn send(&mut self, cmd: Command) -> anyhow::Result<Response> {
let frame = encode_frame(&cmd.to_bytes());
std::io::Write::write_all(&mut self.transport, &frame)?;
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(" "))
})?;
if resp.is_rx_packet() {
self.buffer_rx(resp);
continue;
}
return Ok(resp);
}
anyhow::bail!("no solicited response after {MAX_UNSOLICITED_BEFORE_TIMEOUT} frames")
}
/// Return the next RxPacket from the buffer or the wire.
///
/// Returns `Ok(None)` on timeout (no packet available).
pub fn recv(&mut self) -> anyhow::Result<Option<Response>> {
if let Some(pkt) = self.rx_buffer.pop_front() {
return Ok(Some(pkt));
}
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()))?;
if resp.is_rx_packet() {
Ok(Some(resp))
} else {
tracing::warn!("recv: discarding unexpected unsolicited response: {resp:?}");
Ok(None)
}
}
/// Drain all buffered and pending RxPacket frames.
///
/// Temporarily reduces the read timeout to quickly drain any frames still
/// in flight, then restores the original timeout.
pub fn drain_rx(&mut self) -> anyhow::Result<Vec<Response>> {
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))?;
let result: anyhow::Result<()> = (|| {
loop {
let Some(data) = read_frame(&mut self.transport)? else {
return Ok(());
};
if let Some(resp) = Response::from_bytes(&data)
&& resp.is_rx_packet()
{
packets.push(resp);
}
}
})();
// Always restore timeout, even if the loop errored
self.transport.set_timeout(old_timeout)?;
result?;
Ok(packets)
}
/// Send a Ping and verify the Pong response.
pub fn ping(&mut self) -> anyhow::Result<()> {
match self.send(Command::Ping)? {
Response::Pong => Ok(()),
other => anyhow::bail!("unexpected response to Ping: {other:?}"),
}
}
/// Set the radio configuration.
pub fn set_config(&mut self, config: RadioConfig) -> anyhow::Result<()> {
match self.send(Command::SetConfig(config))? {
Response::Ok => Ok(()),
Response::Error(code) => anyhow::bail!("SetConfig failed: {code}"),
other => anyhow::bail!("unexpected response to SetConfig: {other:?}"),
}
}
/// Start receiving LoRa packets.
pub fn start_rx(&mut self) -> anyhow::Result<()> {
match self.send(Command::StartRx)? {
Response::Ok => Ok(()),
Response::Error(code) => anyhow::bail!("StartRx failed: {code}"),
other => anyhow::bail!("unexpected response to StartRx: {other:?}"),
}
}
/// Stop receiving LoRa packets.
pub fn stop_rx(&mut self) -> anyhow::Result<()> {
match self.send(Command::StopRx)? {
Response::Ok => Ok(()),
Response::Error(code) => anyhow::bail!("StopRx failed: {code}"),
other => anyhow::bail!("unexpected response to StopRx: {other:?}"),
}
}
/// 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(),
};
match self.send(cmd)? {
Response::TxDone => Ok(()),
Response::Error(ErrorCode::TxTimeout) => anyhow::bail!("transmit timed out"),
Response::Error(code) => anyhow::bail!("Transmit failed: {code}"),
other => anyhow::bail!("unexpected response to Transmit: {other:?}"),
}
}
/// Get the board's MAC address.
pub fn get_mac(&mut self) -> anyhow::Result<[u8; 6]> {
match self.send(Command::GetMac)? {
Response::MacAddress(mac) => Ok(mac),
Response::Error(code) => anyhow::bail!("GetMac failed: {code}"),
other => anyhow::bail!("unexpected response to GetMac: {other:?}"),
}
}
/// Get the current radio configuration from the device.
pub fn get_config(&mut self) -> anyhow::Result<RadioConfig> {
match self.send(Command::GetConfig)? {
Response::Config(cfg) => Ok(cfg),
Response::Error(code) => anyhow::bail!("GetConfig failed: {code}"),
other => anyhow::bail!("unexpected response to GetConfig: {other:?}"),
}
}
/// Turn on the display (if present).
pub fn display_on(&mut self) -> anyhow::Result<()> {
match self.send(Command::DisplayOn)? {
Response::Ok => Ok(()),
Response::Error(code) => anyhow::bail!("DisplayOn failed: {code}"),
other => anyhow::bail!("unexpected response to DisplayOn: {other:?}"),
}
}
/// Turn off the display (if present).
pub fn display_off(&mut self) -> anyhow::Result<()> {
match self.send(Command::DisplayOff)? {
Response::Ok => Ok(()),
Response::Error(code) => anyhow::bail!("DisplayOff failed: {code}"),
other => anyhow::bail!("unexpected response to DisplayOff: {other:?}"),
}
}
/// Consume the client and return the inner transport.
pub fn into_inner(self) -> T {
self.transport
}
/// Get a reference to the inner transport.
pub fn transport(&self) -> &T {
&self.transport
}
/// Get a mutable reference to the inner transport.
pub fn transport_mut(&mut self) -> &mut T {
&mut self.transport
}
fn buffer_rx(&mut self, resp: Response) {
if self.rx_buffer.len() >= RX_BUFFER_CAP {
self.rx_buffer.pop_front(); // drop oldest
}
self.rx_buffer.push_back(resp);
}
}
-330
View File
@@ -1,330 +0,0 @@
//! COBS framing: encode/decode frames and accumulate partial reads.
//!
//! Each frame is COBS-encoded and terminated with a `0x00` sentinel byte.
//! The sentinel never appears in the encoded data, so it unambiguously
//! marks frame boundaries.
//!
//! Uses the [`ucobs`] crate — the same COBS implementation as the firmware.
use tracing::warn;
/// COBS-encode data and append the `0x00` sentinel.
pub fn encode_frame(data: &[u8]) -> Vec<u8> {
let max_encoded = ucobs::max_encoded_len(data.len());
let mut buf = vec![0u8; max_encoded];
let n = match ucobs::encode(data, &mut buf) {
Some(n) => n,
None => return vec![0x00], // empty/failed encode → just sentinel
};
buf.truncate(n);
buf.push(0x00);
buf
}
/// COBS-decode a frame (without the sentinel). Returns `None` on decode error.
pub fn decode_frame(encoded: &[u8]) -> Option<Vec<u8>> {
if encoded.is_empty() {
return None;
}
let mut buf = vec![0u8; encoded.len()];
let n = ucobs::decode(encoded, &mut buf)?;
buf.truncate(n);
Some(buf)
}
/// Stateful COBS frame accumulator.
///
/// Feed raw byte chunks (from serial reads, socket reads, etc.) and extract
/// complete decoded frames. Handles partial reads, multiple frames per chunk,
/// and empty inter-frame gaps gracefully.
///
/// Used by both the sync client (`read_frame`) and the async mux to accumulate
/// data from serial and client socket streams.
pub struct FrameReader {
buf: Vec<u8>,
}
impl FrameReader {
/// Create a new empty frame reader.
pub fn new() -> Self {
Self {
buf: Vec::with_capacity(512),
}
}
/// Feed raw bytes and return all complete decoded frames.
///
/// COBS decode errors are logged and the bad frame is skipped.
/// Empty inter-frame gaps (consecutive `0x00` bytes) are ignored.
pub fn feed(&mut self, data: &[u8]) -> Vec<Vec<u8>> {
self.buf.extend_from_slice(data);
let mut frames = Vec::new();
while let Some(sentinel_pos) = self.buf.iter().position(|&b| b == 0x00) {
let encoded = &self.buf[..sentinel_pos];
if !encoded.is_empty() {
match decode_frame(encoded) {
Some(decoded) => frames.push(decoded),
None => warn!("bad COBS frame ({} bytes) — skipped", encoded.len()),
}
}
// Remove the consumed bytes including the sentinel
self.buf.drain(..=sentinel_pos);
}
frames
}
/// Return the number of buffered bytes not yet forming a complete frame.
pub fn buffered(&self) -> usize {
self.buf.len()
}
}
impl Default for FrameReader {
fn default() -> Self {
Self::new()
}
}
/// Read one complete COBS frame from a blocking `Read` source.
///
/// Reads byte-by-byte until a `0x00` sentinel is found, then COBS-decodes.
/// Returns `Ok(None)` on timeout (zero-length read from the underlying source).
/// Returns `Err` on I/O errors or COBS decode failures.
pub fn read_frame(reader: &mut dyn std::io::Read) -> anyhow::Result<Option<Vec<u8>>> {
let mut buf = Vec::with_capacity(280);
let mut byte = [0u8; 1];
loop {
match reader.read(&mut byte) {
Ok(0) => {
// Timeout or EOF
return Ok(None);
}
Ok(_) => {
if byte[0] == 0x00 {
break;
}
buf.push(byte[0]);
}
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,
Err(e) => return Err(e.into()),
}
}
if buf.is_empty() {
return Ok(None);
}
decode_frame(&buf)
.map(Some)
.ok_or_else(|| anyhow::anyhow!("COBS decode error"))
}
// ── Tests ──────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn encode_decode_roundtrip() {
let data = b"hello world";
let encoded = encode_frame(data);
// Encoded should end with 0x00 sentinel
assert_eq!(encoded.last(), Some(&0x00));
// Encoded should not contain 0x00 except the trailing sentinel
assert!(!encoded[..encoded.len() - 1].contains(&0x00));
// Decode (without sentinel)
let decoded = decode_frame(&encoded[..encoded.len() - 1]);
assert_eq!(decoded.as_deref(), Some(data.as_slice()));
}
#[test]
fn encode_decode_empty() {
// COBS encoding of empty data produces just the 0x00 sentinel.
// 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);
}
#[test]
fn encode_decode_with_zeros() {
let data = &[0x00, 0x01, 0x00, 0x02, 0x00];
let encoded = encode_frame(data);
let decoded = decode_frame(&encoded[..encoded.len() - 1]);
assert_eq!(decoded.as_deref(), Some(data.as_slice()));
}
#[test]
fn decode_frame_invalid() {
assert!(decode_frame(&[]).is_none());
}
#[test]
fn frame_reader_single_frame() {
let mut reader = FrameReader::new();
let data = b"test";
let frame = encode_frame(data);
let frames = reader.feed(&frame);
assert_eq!(frames.len(), 1);
assert_eq!(frames[0], data);
}
#[test]
fn frame_reader_multiple_frames_at_once() {
let mut reader = FrameReader::new();
let f1 = encode_frame(b"one");
let f2 = encode_frame(b"two");
let mut combined = f1;
combined.extend_from_slice(&f2);
let frames = reader.feed(&combined);
assert_eq!(frames.len(), 2);
assert_eq!(frames[0], b"one");
assert_eq!(frames[1], b"two");
}
#[test]
fn frame_reader_partial_then_complete() {
let mut reader = FrameReader::new();
let frame = encode_frame(b"split");
let mid = frame.len() / 2;
// Feed first half — no complete frames yet
let frames = reader.feed(&frame[..mid]);
assert!(frames.is_empty());
assert!(reader.buffered() > 0);
// Feed second half — now we get the frame
let frames = reader.feed(&frame[mid..]);
assert_eq!(frames.len(), 1);
assert_eq!(frames[0], b"split");
assert_eq!(reader.buffered(), 0);
}
#[test]
fn frame_reader_empty_gaps() {
let mut reader = FrameReader::new();
// Multiple sentinels in a row (empty gaps between frames)
let f = encode_frame(b"data");
let mut input = vec![0x00, 0x00]; // leading empty gaps
input.extend_from_slice(&f);
input.push(0x00); // trailing empty gap
let frames = reader.feed(&input);
assert_eq!(frames.len(), 1);
assert_eq!(frames[0], b"data");
}
#[test]
fn frame_reader_bad_cobs_skipped() {
let mut reader = FrameReader::new();
// Feed a known-bad COBS frame followed by a good one.
// A frame where the first byte claims a run longer than the frame is invalid.
let bad = &[0xFF, 0x01, 0x00]; // 0xFF says next 254 bytes, but only 1 present
let good = encode_frame(b"ok");
let mut input = bad.to_vec();
input.extend_from_slice(&good);
let frames = reader.feed(&input);
// Bad frame skipped, good frame decoded
assert_eq!(frames.len(), 1);
assert_eq!(frames[0], b"ok");
}
#[test]
fn read_frame_from_bytes() {
let data = b"frame";
let encoded = encode_frame(data);
let mut cursor = std::io::Cursor::new(encoded);
let result = read_frame(&mut cursor);
assert!(result.is_ok());
assert_eq!(result.ok().flatten().as_deref(), Some(data.as_slice()));
}
#[test]
fn read_frame_timeout() {
// Empty reader → returns None (timeout)
let mut cursor = std::io::Cursor::new(Vec::<u8>::new());
let result = read_frame(&mut cursor);
assert!(result.is_ok());
assert!(result.ok().flatten().is_none());
}
#[test]
fn read_frame_empty_gap_then_data() {
let data = b"after_gap";
let mut input = vec![0x00]; // empty gap
input.extend_from_slice(&encode_frame(data));
let mut cursor = std::io::Cursor::new(input);
// First read_frame returns None for the empty gap
let r1 = read_frame(&mut cursor);
assert_eq!(r1.ok().flatten(), None);
// Second read returns the actual frame
let r2 = read_frame(&mut cursor);
assert_eq!(r2.ok().flatten().as_deref(), Some(data.as_slice()));
}
#[test]
fn encode_all_byte_values_roundtrip() {
// Test that all possible byte values survive encode/decode
let data: Vec<u8> = (0..=255).collect();
let encoded = encode_frame(&data);
let decoded = decode_frame(&encoded[..encoded.len() - 1]);
assert_eq!(decoded, Some(data));
}
#[test]
fn config_response_cobs_roundtrip() {
// 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,
];
assert_eq!(data.len(), 14);
let encoded = encode_frame(&data);
let decoded = decode_frame(&encoded[..encoded.len() - 1]);
assert_eq!(decoded, Some(data));
}
#[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,
];
assert_eq!(data.len(), 14);
let encoded = encode_frame(&data);
let decoded = decode_frame(&encoded[..encoded.len() - 1]);
assert_eq!(decoded, Some(data));
}
#[cfg(unix)]
#[test]
fn read_frame_socket_timeout_returns_none() {
use std::os::unix::net::UnixStream;
use std::time::Duration;
let (mut a, _b) = UnixStream::pair().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.unwrap().is_none());
}
}
+329 -110
View File
@@ -1,25 +1,130 @@
//! Connection auto-detection and mux client helpers.
//! High-level `connect()` entry points.
//!
//! The [`connect`] function tries mux connections first (TCP via env var, then
//! Unix socket), falling back to direct USB serial. This matches the Python
//! client's `connect()` behavior.
//! Mirrors the Python client's `connect()` behaviour: explicit port → TCP
//! mux (env var) → Unix mux (socket file) → direct USB serial. The first
//! successful mux connection within a process is "sticky": subsequent
//! calls stay on the mux even if the socket temporarily disappears.
//!
//! Public surface:
//!
//! - [`ConnectOptions`] — builder for `port`, `timeout`, `config`,
//! `auto_configure`, `keepalive`.
//! - [`connect`] — one-shot convenience: `connect().await?` returns a
//! [`Dongle`] with defaults.
//! - [`connect_with`] — takes a populated [`ConnectOptions`].
//! - [`try_connect`] — like `connect` but never blocks for a USB device;
//! returns an error instead of polling forever.
//! - [`connect_mux_auto`] — mux-only (TCP env var, then Unix socket);
//! never falls back to USB.
//! - [`mux_unix_connect`] / [`mux_tcp_connect`] — explicit single-transport.
//! - [`default_socket_path`] / [`find_mux_socket`] — helpers.
use std::path::Path;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use tracing::debug;
use donglora_protocol::Modulation;
use tracing::{debug, info};
use crate::client::Client;
use crate::discovery;
use crate::transport::{AnyTransport, MuxTransport, SerialTransport};
use crate::dongle::{Dongle, TransportKind};
use crate::errors::{ClientError, ClientResult};
use crate::session::Session;
#[cfg(unix)]
use crate::transport::UnixSocketTransport;
use crate::transport::{AnyTransport, SerialTransport, TcpTransport, Transport};
/// Default read timeout for connections.
/// Set once this process connects via mux. All future auto-connects stay
/// on mux rather than falling through to direct USB.
static USED_MUX: AtomicBool = AtomicBool::new(false);
/// Default per-command timeout when the caller doesn't override it.
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(2);
/// Resolve the mux socket path in priority order.
/// Builder for [`connect_with`].
///
/// 1. `$DONGLORA_MUX` environment variable
/// ```no_run
/// # async fn demo() -> Result<(), donglora_client::ClientError> {
/// use donglora_client::{ConnectOptions, connect_with};
/// let dongle = connect_with(
/// ConnectOptions::default()
/// .port("/dev/ttyACM0")
/// .keepalive(false),
/// ).await?;
/// # drop(dongle); Ok(()) }
/// ```
#[derive(Debug, Clone, Default)]
pub struct ConnectOptions {
port: Option<String>,
timeout: Option<Duration>,
config: Option<Modulation>,
auto_configure: bool,
keepalive: bool,
}
impl ConnectOptions {
/// Explicit serial port path; skips the mux + auto-discovery chain
/// entirely.
#[must_use]
pub fn port(mut self, path: impl Into<String>) -> Self {
self.port = Some(path.into());
self
}
/// Per-command timeout for the connect-time PING + GET_INFO +
/// SET_CONFIG (if any). Default is 2 s.
#[must_use]
pub fn timeout(mut self, timeout: Duration) -> Self {
self.timeout = Some(timeout);
self
}
/// Supply an initial radio config. When paired with `auto_configure`
/// (enabled by default via [`Self::auto_configure`]), the config is
/// applied immediately after `GET_INFO`. Callers that want full
/// manual control over configuration should leave this as `None`.
#[must_use]
pub fn config(mut self, modulation: Modulation) -> Self {
self.config = Some(modulation);
self.auto_configure = true;
self
}
/// Whether to apply the [`Self::config`] automatically at connect
/// time. Default: `true` if a config was supplied, `false` otherwise.
#[must_use]
pub fn auto_configure(mut self, enabled: bool) -> Self {
self.auto_configure = enabled;
self
}
/// Enable the background keepalive task. Default: `true`. Disable
/// only when the caller manages session liveness themselves (e.g.
/// a tight send/recv loop that hits the device more often than the
/// 1 s inactivity timer).
#[must_use]
pub fn keepalive(mut self, enabled: bool) -> Self {
self.keepalive = enabled;
self
}
}
/// Convenience constructor — equivalent to `ConnectOptions::default()`
/// but with `keepalive = true`, matching the Python client's defaults.
impl ConnectOptions {
/// Default options with keepalive enabled.
#[must_use]
pub fn new() -> Self {
Self { keepalive: true, ..Self::default() }
}
}
/// Resolve the mux socket path in priority order:
///
/// 1. `$DONGLORA_MUX`
/// 2. `$XDG_RUNTIME_DIR/donglora/mux.sock`
/// 3. `/tmp/donglora-mux.sock`
#[must_use]
pub fn default_socket_path() -> String {
if let Ok(env) = std::env::var("DONGLORA_MUX") {
return env;
@@ -30,145 +135,259 @@ pub fn default_socket_path() -> String {
"/tmp/donglora-mux.sock".to_string()
}
/// Find an existing mux socket path, or `None` if no socket file exists.
fn find_mux_socket() -> Option<String> {
/// Check for a mux socket in the standard places and return the first
/// one that exists. Returns `None` if no socket is live.
#[must_use]
pub fn find_mux_socket() -> Option<String> {
if let Ok(env) = std::env::var("DONGLORA_MUX") {
if std::path::Path::new(&env).exists() {
if Path::new(&env).exists() {
return Some(env);
}
return None;
}
if let Ok(xdg) = std::env::var("XDG_RUNTIME_DIR") {
let p = format!("{xdg}/donglora/mux.sock");
if std::path::Path::new(&p).exists() {
if Path::new(&p).exists() {
return Some(p);
}
}
let p = "/tmp/donglora-mux.sock";
if std::path::Path::new(p).exists() {
if Path::new(p).exists() {
return Some(p.to_string());
}
None
}
/// Connect to the mux daemon via Unix domain socket.
#[cfg(unix)]
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"))?,
};
let transport = MuxTransport::unix(&path, timeout)?;
Ok(Client::new(transport))
// ── Connect entry points ──────────────────────────────────────────
/// One-shot convenience: run the full auto-discovery chain with default
/// options and return a ready [`Dongle`].
///
/// Blocks (asynchronously) waiting for a USB device if no mux is
/// available and no device is currently present. Use [`try_connect`] if
/// you want a single non-blocking scan instead.
pub async fn connect() -> ClientResult<Dongle> {
connect_with(ConnectOptions::new()).await
}
/// Connect to the mux daemon via TCP.
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))
}
/// Connect with the given [`ConnectOptions`].
pub async fn connect_with(opts: ConnectOptions) -> ClientResult<Dongle> {
let timeout = opts.timeout.unwrap_or(DEFAULT_TIMEOUT);
/// Auto-detect and connect to a DongLoRa device.
///
/// Priority:
/// 1. `DONGLORA_MUX_TCP` env var → TCP mux connection
/// 2. Unix socket mux (if socket file exists)
/// 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>> {
// If explicit port given, go direct
if let Some(port) = port {
// Explicit port always bypasses mux.
if let Some(port) = opts.port.as_deref() {
debug!("opening serial port {port}");
let transport = SerialTransport::open(port, timeout)?;
return Ok(Client::new(AnyTransport::Serial(transport)));
let transport = SerialTransport::open(port)?;
return finalize(AnyTransport::Serial(transport), TransportKind::Serial(port.to_string()), &opts, timeout)
.await;
}
// 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}");
return Ok(Client::new(AnyTransport::Mux(transport)));
// Sticky: once we've used a mux, stay on mux (wait if socket
// disappeared — a mux restart).
if USED_MUX.load(Ordering::Relaxed) {
return connect_mux_sticky(&opts, timeout).await;
}
// Try Unix socket mux
// Try TCP mux via env var.
if let Some((transport, endpoint)) = try_tcp_env(timeout).await {
USED_MUX.store(true, Ordering::Relaxed);
return finalize(AnyTransport::Tcp(transport), TransportKind::MuxTcp(endpoint), &opts, timeout).await;
}
// Try existing Unix mux socket.
#[cfg(unix)]
if let Some(path) = find_mux_socket() {
match MuxTransport::unix(&path, timeout) {
Ok(transport) => {
debug!("connected to mux socket at {path}");
return Ok(Client::new(AnyTransport::Mux(transport)));
}
Err(_) => {
// Stale socket or connection refused — fall through
debug!("mux socket at {path} not reachable, falling back to USB");
}
debug!("connecting to Unix mux at {path}");
let transport = UnixSocketTransport::connect(&path).await?;
USED_MUX.store(true, Ordering::Relaxed);
return finalize(AnyTransport::Unix(transport), TransportKind::MuxUnix(path), &opts, timeout).await;
}
// Direct USB: wait indefinitely for a device.
let port = match discovery::find_port() {
Some(p) => p,
None => discovery::wait_for_device().await,
};
debug!("opening serial port {port}");
let transport = SerialTransport::open(&port)?;
finalize(AnyTransport::Serial(transport), TransportKind::Serial(port), &opts, timeout).await
}
/// Like [`connect`] but returns an error rather than blocking if no USB
/// device is present. Mux-sticky behaviour still applies.
pub async fn try_connect() -> ClientResult<Dongle> {
try_connect_with(ConnectOptions::new()).await
}
/// Non-blocking variant of [`connect_with`].
pub async fn try_connect_with(opts: ConnectOptions) -> ClientResult<Dongle> {
let timeout = opts.timeout.unwrap_or(DEFAULT_TIMEOUT);
if let Some(port) = opts.port.as_deref() {
let transport = SerialTransport::open(port)?;
return finalize(AnyTransport::Serial(transport), TransportKind::Serial(port.to_string()), &opts, timeout)
.await;
}
if USED_MUX.load(Ordering::Relaxed) {
let path =
find_mux_socket().ok_or_else(|| ClientError::Other("mux not available (waiting for restart)".into()))?;
#[cfg(unix)]
{
let transport = UnixSocketTransport::connect(&path).await?;
return finalize(AnyTransport::Unix(transport), TransportKind::MuxUnix(path), &opts, timeout).await;
}
#[cfg(not(unix))]
{
let _ = path;
return Err(ClientError::Other("Unix mux requires a unix target".into()));
}
}
// Direct USB serial — auto-detect or wait
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)))
}
/// Convenience: connect with default timeout.
pub fn connect_default() -> anyhow::Result<Client<AnyTransport>> {
connect(None, DEFAULT_TIMEOUT)
}
/// Connect to a mux daemon only (TCP via env var, then Unix socket).
///
/// Unlike [`connect`], this **never** falls back to direct USB serial.
/// Returns an error if no mux is reachable — the caller can retry with backoff.
///
/// This is the Rust equivalent of the Python client's "sticky mux" behavior:
/// once you decide to use the mux, you stay on the mux.
pub fn connect_mux_auto(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}");
return Ok(Client::new(AnyTransport::Mux(transport)));
if let Some((transport, endpoint)) = try_tcp_env(timeout).await {
USED_MUX.store(true, Ordering::Relaxed);
return finalize(AnyTransport::Tcp(transport), TransportKind::MuxTcp(endpoint), &opts, timeout).await;
}
// Try Unix socket mux.
#[cfg(unix)]
if let Some(path) = find_mux_socket() {
let transport = UnixSocketTransport::connect(&path).await?;
USED_MUX.store(true, Ordering::Relaxed);
return finalize(AnyTransport::Unix(transport), TransportKind::MuxUnix(path), &opts, timeout).await;
}
let port = discovery::find_port()
.ok_or_else(|| ClientError::Other("no DongLoRa device found (no mux, no USB device)".into()))?;
let transport = SerialTransport::open(&port)?;
finalize(AnyTransport::Serial(transport), TransportKind::Serial(port), &opts, timeout).await
}
/// Mux-only connect. Never falls back to direct USB; returns an error if
/// no mux (TCP via env var or Unix socket) is reachable.
pub async fn connect_mux_auto() -> ClientResult<Dongle> {
connect_mux_auto_with(ConnectOptions::new()).await
}
/// Mux-only connect, with caller-supplied options.
pub async fn connect_mux_auto_with(opts: ConnectOptions) -> ClientResult<Dongle> {
let timeout = opts.timeout.unwrap_or(DEFAULT_TIMEOUT);
if let Some((transport, endpoint)) = try_tcp_env(timeout).await {
USED_MUX.store(true, Ordering::Relaxed);
return finalize(AnyTransport::Tcp(transport), TransportKind::MuxTcp(endpoint), &opts, timeout).await;
}
#[cfg(unix)]
{
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 path = find_mux_socket().ok_or_else(|| ClientError::Other("no mux socket found".into()))?;
let transport = UnixSocketTransport::connect(&path).await?;
USED_MUX.store(true, Ordering::Relaxed);
finalize(AnyTransport::Unix(transport), TransportKind::MuxUnix(path), &opts, timeout).await
}
#[cfg(not(unix))]
anyhow::bail!("mux-only mode requires Unix socket support or DONGLORA_MUX_TCP")
Err(ClientError::Other("mux-only mode requires Unix socket support or DONGLORA_MUX_TCP".into()))
}
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 };
/// Connect to an explicit Unix mux socket. Useful for tests and CLIs
/// that want to bypass the auto-discovery chain.
#[cfg(unix)]
pub async fn mux_unix_connect(path: &str) -> ClientResult<Dongle> {
let transport = UnixSocketTransport::connect(path).await?;
USED_MUX.store(true, Ordering::Relaxed);
finalize(
AnyTransport::Unix(transport),
TransportKind::MuxUnix(path.to_string()),
&ConnectOptions::new(),
DEFAULT_TIMEOUT,
)
.await
}
/// Connect to an explicit TCP mux endpoint.
pub async fn mux_tcp_connect(host: &str, port: u16) -> ClientResult<Dongle> {
let transport = TcpTransport::connect(host, port, DEFAULT_TIMEOUT).await?;
USED_MUX.store(true, Ordering::Relaxed);
finalize(
AnyTransport::Tcp(transport),
TransportKind::MuxTcp(format!("{host}:{port}")),
&ConnectOptions::new(),
DEFAULT_TIMEOUT,
)
.await
}
// ── internals ─────────────────────────────────────────────────────
async fn connect_mux_sticky(opts: &ConnectOptions, timeout: Duration) -> ClientResult<Dongle> {
if let Some((transport, endpoint)) = try_tcp_env(timeout).await {
return finalize(AnyTransport::Tcp(transport), TransportKind::MuxTcp(endpoint), opts, timeout).await;
}
#[cfg(unix)]
{
let path = default_socket_path();
let mut warned = false;
loop {
if Path::new(&path).exists() {
let transport = UnixSocketTransport::connect(&path).await?;
return finalize(AnyTransport::Unix(transport), TransportKind::MuxUnix(path), opts, timeout).await;
}
if !warned {
info!("waiting for mux at {path} ...");
warned = true;
}
tokio::time::sleep(Duration::from_millis(500)).await;
}
}
#[cfg(not(unix))]
Err(ClientError::Other("no mux endpoint available".into()))
}
async fn try_tcp_env(timeout: Duration) -> Option<(TcpTransport, String)> {
let tcp = std::env::var("DONGLORA_MUX_TCP").ok()?;
let (host, port) = parse_tcp_endpoint(&tcp)?;
match TcpTransport::connect(&host, port, timeout).await {
Ok(t) => {
debug!("connected to TCP mux at {host}:{port}");
Some((t, format!("{host}:{port}")))
}
Err(e) => {
debug!("DONGLORA_MUX_TCP connect failed: {e}");
None
}
}
}
fn parse_tcp_endpoint(addr: &str) -> Option<(String, u16)> {
if let Some((h, p)) = addr.rsplit_once(':') {
let host = if h.is_empty() { "localhost".to_string() } else { h.to_string() };
let port: u16 = p.parse().ok()?;
(host.to_string(), port)
Some((host, port))
} else {
let port: u16 = addr.parse().ok()?;
("localhost".to_string(), port)
Some(("localhost".to_string(), port))
}
}
async fn finalize<T: Transport>(
transport: T,
kind: TransportKind,
opts: &ConnectOptions,
timeout: Duration,
) -> ClientResult<Dongle> {
let session = Session::spawn(transport);
// Probe: PING validates the connection, GET_INFO caches device info.
session.ping(timeout).await?;
let info = session.get_info(timeout).await?;
let applied = if opts.auto_configure {
match opts.config {
Some(m) => {
let result = session.set_config(m, timeout).await?;
Some(result.current)
}
None => None,
}
} else {
None
};
MuxTransport::tcp(&host, port, timeout).ok()
Ok(Dongle::new(session, info, kind, applied, opts.keepalive))
}
+45 -14
View File
@@ -1,10 +1,13 @@
//! USB device discovery for DongLoRa dongles.
//!
//! Finds the serial port by matching USB VID:PID using the `serialport` crate.
//! Finds the serial port by matching USB VID:PID. Re-uses
//! `tokio_serial::available_ports` (which re-exports `mio-serial`'s
//! implementation) rather than pulling in `serialport` as a second
//! direct dep.
use std::thread;
use std::time::Duration;
use tokio_serial::{SerialPortType, available_ports};
use tracing::info;
/// DongLoRa USB Vendor ID.
@@ -13,35 +16,63 @@ pub const USB_VID: u16 = 0x1209;
/// DongLoRa USB Product ID.
pub const USB_PID: u16 = 0x5741;
/// Find the DongLoRa serial port by USB VID:PID.
/// 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, 0x7522), // CH340K — Elecrow ThinkNode-M2 ships with this
(0x1A86, 0x7523), // CH340
(0x0403, 0x6001), // FT232R (FTDI)
];
/// How long to wait for USB enumeration to settle after a device
/// appears but before we try to open the port. Empirically 300 ms is
/// enough on Linux and macOS to avoid the first-open race.
const USB_SETTLE: Duration = Duration::from_millis(300);
/// Poll interval for [`wait_for_device`].
const POLL_INTERVAL: Duration = Duration::from_millis(500);
/// Non-blocking one-shot scan. Returns the port path of the first
/// DongLoRa-like device present, or `None`.
///
/// Returns the first matching port path, or `None` if no device is found.
/// Native VID:PID wins over known USB-UART bridges, so if both an
/// original and a bridge-chip board are plugged in, the native one is
/// selected.
pub fn find_port() -> Option<String> {
let ports = serialport::available_ports().ok()?;
let ports = available_ports().ok()?;
if let Some(port) = ports.iter().find(|p| {
matches!(
&p.port_type,
SerialPortType::UsbPort(info) if info.vid == USB_VID && info.pid == USB_PID
)
}) {
return Some(port.port_name.clone());
}
ports
.into_iter()
.find(|p| {
matches!(
&p.port_type,
serialport::SerialPortType::UsbPort(info)
if info.vid == USB_VID && info.pid == USB_PID
SerialPortType::UsbPort(info) if BRIDGE_VID_PIDS.contains(&(info.vid, info.pid))
)
})
.map(|p| p.port_name)
}
/// Block until a DongLoRa device appears on USB.
///
/// Polls [`find_port`] every 500ms and returns the port path once found.
pub fn wait_for_device() -> String {
/// Await a DongLoRa device on USB. Polls [`find_port`] every 500 ms and
/// returns when one appears. Does not time out — the caller is expected
/// to wrap the future with `tokio::time::timeout` if that matters.
pub async fn wait_for_device() -> String {
info!("waiting for DongLoRa device...");
loop {
if let Some(port) = find_port() {
info!("found device at {port}");
// Brief delay for USB enumeration to settle
thread::sleep(Duration::from_millis(300));
tokio::time::sleep(USB_SETTLE).await;
return port;
}
thread::sleep(Duration::from_millis(500));
tokio::time::sleep(POLL_INTERVAL).await;
}
}
+624
View File
@@ -0,0 +1,624 @@
//! The user-facing [`Dongle`] — a connected, configured, ready-to-TX radio.
//!
//! Built on top of the internal [`crate::session::Session`]. Hides the
//! frame codec, tag plumbing, CRC, and keepalive details behind an async
//! method surface. Mirrors `client-py/donglora/dongle.py`.
use std::sync::Arc;
use std::time::Duration;
use donglora_protocol::{Info, Modulation, RxPayload, TxDonePayload};
use tokio::sync::Mutex;
use tokio::task::JoinHandle;
use crate::errors::{ClientError, ClientResult};
use crate::retry::{RetryPolicy, TxAttempt, TxOutcome};
use crate::session::Session;
/// Default keepalive cadence. Spec §3.4 sets the inactivity timer to
/// 1000 ms — we ping at 500 ms for 2x margin. Host commands reset the
/// firmware's timer naturally, so we only emit a PING when the host
/// has been quiet for a full keepalive interval.
pub const KEEPALIVE_INTERVAL: Duration = Duration::from_millis(500);
/// Live DongLoRa radio session.
///
/// Normally constructed via [`crate::connect`]. The happy path:
///
/// ```no_run
/// # async fn demo() -> Result<(), donglora_client::ClientError> {
/// use donglora_client as dl;
///
/// let dongle = dl::connect().await?;
/// dongle.tx(b"Hello").await?;
/// while let Some(pkt) = dongle.recv(std::time::Duration::from_secs(5)).await {
/// println!("{:.1} dBm {:?}", pkt.rssi_tenths_dbm as f32 / 10.0, pkt.data);
/// }
/// # Ok(()) }
/// ```
///
/// Thread-safe: multiple tasks can call `tx` / `recv` / `ping`
/// concurrently. Drop is best-effort — explicit `close().await` is
/// recommended when the caller can afford to await it.
/// Which transport `connect()` ended up using. Useful for logs / UIs
/// that want to distinguish "talking to the dongle directly" from
/// "talking to a mux that talks to the dongle".
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TransportKind {
/// Direct USB serial. String is the device path (e.g. `"/dev/ttyACM0"`).
Serial(String),
/// `donglora-mux` daemon over a Unix domain socket. String is the
/// socket path.
MuxUnix(String),
/// `donglora-mux` daemon over TCP. String is `"host:port"`.
MuxTcp(String),
}
impl TransportKind {
/// Short human-readable label for this transport, suitable for a
/// status line (`"ttyACM0"`, `"mux:unix"`, `"mux:tcp"`).
#[must_use]
pub fn short_label(&self) -> String {
match self {
Self::Serial(p) => {
std::path::Path::new(p).file_name().map_or_else(|| p.clone(), |f| f.to_string_lossy().into_owned())
}
Self::MuxUnix(_) => "mux:unix".to_string(),
Self::MuxTcp(_) => "mux:tcp".to_string(),
}
}
/// True for any mux-backed transport.
#[must_use]
pub const fn is_mux(&self) -> bool {
matches!(self, Self::MuxUnix(_) | Self::MuxTcp(_))
}
}
pub struct Dongle {
inner: Arc<Inner>,
/// Keepalive task handle. `std::sync::Mutex` so `Drop` can abort
/// without an async context.
keepalive_handle: std::sync::Mutex<Option<JoinHandle<()>>>,
}
pub(crate) struct Inner {
pub(crate) session: Session,
info: Info,
transport_kind: TransportKind,
applied_config: Mutex<Option<Modulation>>,
rx_started: Mutex<bool>,
last_write_at: Mutex<tokio::time::Instant>,
closed: Mutex<bool>,
}
impl Dongle {
/// Construct a Dongle on an already-connected session. The caller is
/// responsible for running GET_INFO and (optionally) SET_CONFIG first;
/// [`crate::connect`] does both.
pub(crate) fn new(
session: Session,
info: Info,
transport_kind: TransportKind,
applied_config: Option<Modulation>,
keepalive: bool,
) -> Self {
let inner = Arc::new(Inner {
session,
info,
transport_kind,
applied_config: Mutex::new(applied_config),
rx_started: Mutex::new(false),
last_write_at: Mutex::new(tokio::time::Instant::now()),
closed: Mutex::new(false),
});
let keepalive_handle = if keepalive {
let weak = Arc::downgrade(&inner);
let handle = tokio::spawn(async move {
loop {
tokio::time::sleep(KEEPALIVE_INTERVAL).await;
let Some(inner) = weak.upgrade() else {
return;
};
if *inner.closed.lock().await {
return;
}
let last = *inner.last_write_at.lock().await;
if last.elapsed() < KEEPALIVE_INTERVAL {
continue;
}
match inner.session.ping(Duration::from_secs(2)).await {
Ok(()) => {
*inner.last_write_at.lock().await = tokio::time::Instant::now();
}
Err(ClientError::NotConfigured) => {
let cfg = *inner.applied_config.lock().await;
if let Some(m) = cfg {
match inner.session.set_config(m, Duration::from_secs(2)).await {
Ok(_) => {
*inner.last_write_at.lock().await = tokio::time::Instant::now();
}
Err(e) => {
tracing::debug!(?e, "keepalive recovery set_config failed");
}
}
}
}
Err(e) => {
tracing::debug!(?e, "keepalive ping failed");
}
}
}
});
std::sync::Mutex::new(Some(handle))
} else {
std::sync::Mutex::new(None)
};
Self { inner, keepalive_handle }
}
/// Cached `GET_INFO` snapshot from connect-time.
pub fn info(&self) -> &Info {
&self.inner.info
}
/// Which transport this Dongle is backed by. Lets UIs distinguish
/// a direct USB connection from a mux-backed one.
pub fn transport_kind(&self) -> &TransportKind {
&self.inner.transport_kind
}
/// The most recently applied config (if any).
pub async fn config(&self) -> Option<Modulation> {
*self.inner.applied_config.lock().await
}
/// Transmit *data*. Blocks until `TX_DONE` arrives or an error
/// surfaces. Performs one auto-recovery retry on `NotConfigured`.
///
/// Use [`Dongle::tx_with_retry`] if you want CAD-busy backoff.
pub async fn tx(&self, data: &[u8]) -> ClientResult<TxDonePayload> {
self.tx_with_timeout(data, false, Duration::from_secs(10)).await
}
/// Transmit with a configurable timeout and the `skip_cad` flag.
pub async fn tx_with_timeout(&self, data: &[u8], skip_cad: bool, timeout: Duration) -> ClientResult<TxDonePayload> {
self.check_open().await?;
self.mark_write().await;
match self.inner.session.transmit(data, skip_cad, timeout).await {
Err(ClientError::NotConfigured) => self.recover_and_retry_tx(data, skip_cad, timeout).await,
other => other,
}
}
/// Transmit with automatic retry on `CHANNEL_BUSY` / `EBUSY` per
/// spec §6.10. Returns a [`TxOutcome`] capturing the full per-attempt
/// history (so callers — bridge's TUI especially — can surface retry
/// counts).
pub async fn tx_with_retry(&self, data: &[u8], policy: &RetryPolicy) -> ClientResult<TxOutcome> {
self.check_open().await?;
let mut attempts: Vec<TxAttempt> = Vec::with_capacity(policy.max_attempts as usize);
let mut delay_ms = policy.backoff_ms_min;
for attempt_num in 1..=policy.max_attempts {
self.mark_write().await;
let started = tokio::time::Instant::now();
let result = self.inner.session.transmit(data, policy.skip_cad, policy.per_attempt_timeout).await;
let elapsed = started.elapsed();
match result {
Ok(td) => {
attempts.push(TxAttempt { attempt: attempt_num, result: Ok(td), elapsed });
return Ok(TxOutcome { final_airtime_us: td.airtime_us, attempts });
}
Err(e) => {
let retryable = e.is_retryable();
let attempt_log = TxAttempt { attempt: attempt_num, result: Err(e.clone_kind()), elapsed };
attempts.push(attempt_log);
if !retryable || attempt_num == policy.max_attempts {
return Err(e);
}
let jitter_ms = policy.jitter_ms();
let wait = Duration::from_millis(u64::from(delay_ms) + u64::from(jitter_ms));
tokio::time::sleep(wait).await;
delay_ms = (delay_ms as f32 * policy.backoff_multiplier) as u32;
delay_ms = delay_ms.min(policy.backoff_cap_ms);
}
}
}
// Unreachable — loop either returns on success or errors on the
// final attempt. Surface a clean error just in case.
Err(ClientError::Other("tx_with_retry exited without result".into()))
}
/// Enter continuous receive mode if not already active.
pub async fn rx_start(&self) -> ClientResult<()> {
self.check_open().await?;
self.mark_write().await;
let mut started = self.inner.rx_started.lock().await;
if *started {
return Ok(());
}
match self.inner.session.rx_start(Duration::from_secs(2)).await {
Err(ClientError::NotConfigured) => {
drop(started);
self.reapply_config().await?;
self.inner.session.rx_start(Duration::from_secs(2)).await?;
*self.inner.rx_started.lock().await = true;
}
Err(e) => return Err(e),
Ok(()) => {
*started = true;
}
}
Ok(())
}
/// Exit continuous receive mode.
pub async fn rx_stop(&self) -> ClientResult<()> {
self.check_open().await?;
self.mark_write().await;
self.inner.session.rx_stop(Duration::from_secs(2)).await?;
*self.inner.rx_started.lock().await = false;
Ok(())
}
/// Wait up to *timeout* for the next RX event. Lazily starts continuous
/// RX on the first call. Returns `None` on timeout.
pub async fn recv(&self, timeout: Duration) -> Option<RxPayload> {
if self.check_open().await.is_err() {
return None;
}
if !*self.inner.rx_started.lock().await && self.rx_start().await.is_err() {
return None;
}
self.inner.session.recv_rx(timeout).await
}
/// Long-running listener: yields every RX event until the session
/// closes. Lazily starts continuous RX on first call.
pub async fn next_rx(&self) -> Option<RxPayload> {
if self.check_open().await.is_err() {
return None;
}
if !*self.inner.rx_started.lock().await && self.rx_start().await.is_err() {
return None;
}
self.inner.session.next_rx().await
}
/// Apply a new radio configuration (possibly a different modulation).
pub async fn set_config(&self, modulation: Modulation) -> ClientResult<()> {
self.check_open().await?;
self.mark_write().await;
let result = self.inner.session.set_config(modulation, Duration::from_secs(2)).await?;
// Cache the config for auto-recovery. We store what the firmware
// echoed back as `current` (authoritative), not what we sent.
*self.inner.applied_config.lock().await = Some(result.current);
// SET_CONFIG aborts any continuous RX on the device side.
*self.inner.rx_started.lock().await = false;
Ok(())
}
/// Send a `PING`. Rarely needed directly — the keepalive task covers
/// session liveness unless you constructed the Dongle with `keepalive = false`.
pub async fn ping(&self) -> ClientResult<()> {
self.check_open().await?;
self.mark_write().await;
self.inner.session.ping(Duration::from_secs(2)).await
}
/// Drain any async error frames (`ERR(EFRAME)`, `ERR(ERADIO)` with
/// tag 0, etc.) the reader observed since the last drain.
pub async fn drain_async_errors(&self) -> Vec<ClientError> {
self.inner.session.drain_async_errors().await
}
/// Close the session gracefully. Aborts the keepalive task and the
/// reader task. Idempotent.
pub async fn close(&self) {
let mut closed = self.inner.closed.lock().await;
if *closed {
return;
}
*closed = true;
drop(closed);
self.abort_keepalive();
self.inner.session.close().await;
}
fn abort_keepalive(&self) {
#[allow(clippy::unwrap_used)] // std::sync::Mutex poisoning is unrecoverable
let mut guard = self.keepalive_handle.lock().unwrap();
if let Some(h) = guard.take() {
h.abort();
}
}
// ── internals ─────────────────────────────────────────────────
async fn check_open(&self) -> ClientResult<()> {
if *self.inner.closed.lock().await {
return Err(ClientError::TransportClosed("dongle closed".into()));
}
Ok(())
}
async fn mark_write(&self) {
*self.inner.last_write_at.lock().await = tokio::time::Instant::now();
}
async fn reapply_config(&self) -> ClientResult<()> {
let cfg = (*self.inner.applied_config.lock().await).ok_or(ClientError::NotConfigured)?;
let result = self.inner.session.set_config(cfg, Duration::from_secs(2)).await?;
*self.inner.applied_config.lock().await = Some(result.current);
Ok(())
}
async fn recover_and_retry_tx(
&self,
data: &[u8],
skip_cad: bool,
timeout: Duration,
) -> ClientResult<TxDonePayload> {
tracing::info!("dongle: auto-recovering after inactivity timeout");
self.reapply_config().await?;
self.inner.session.transmit(data, skip_cad, timeout).await
}
}
impl Drop for Dongle {
fn drop(&mut self) {
// Best-effort cleanup. If the caller wanted a clean async
// shutdown they should have called `close().await` first; we
// can only abort the keepalive task here.
self.abort_keepalive();
}
}
// Give ClientError a cheap clone-like helper for recording it into a
// TxAttempt (which is owned), without requiring Clone on the whole
// error type (std::io::Error isn't Clone).
impl ClientError {
pub(crate) fn clone_kind(&self) -> ClientError {
match self {
Self::NotConfigured => Self::NotConfigured,
Self::Busy => Self::Busy,
Self::Param => Self::Param,
Self::Length => Self::Length,
Self::Modulation => Self::Modulation,
Self::UnknownCmd => Self::UnknownCmd,
Self::Radio => Self::Radio,
Self::Frame => Self::Frame,
Self::Internal => Self::Internal,
Self::UnknownCode(c) => Self::UnknownCode(*c),
Self::ChannelBusy => Self::ChannelBusy,
Self::Cancelled => Self::Cancelled,
Self::Timeout { what } => Self::Timeout { what },
Self::TransportClosed(s) => Self::TransportClosed(s.clone()),
Self::ReaderExited => Self::ReaderExited,
Self::BadFrame(s) => Self::BadFrame(s.clone()),
Self::EncodeFailed(s) => Self::EncodeFailed(s.clone()),
Self::Io(e) => Self::Other(format!("io: {e}")),
Self::Other(s) => Self::Other(s.clone()),
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::panic)]
mod tests {
use super::*;
use crate::retry::RetryPolicy;
use donglora_protocol::{
FrameDecoder, FrameResult, LoRaBandwidth, LoRaCodingRate, LoRaHeaderMode, Owner, RadioChipId, SetConfigResult,
SetConfigResultCode, TxResult, commands, encode_frame, events,
};
use tokio::io::{AsyncReadExt, AsyncWriteExt, DuplexStream};
fn lora_cfg() -> Modulation {
Modulation::LoRa(donglora_protocol::LoRaConfig {
freq_hz: 910_525_000,
sf: 7,
bw: LoRaBandwidth::Khz62,
cr: LoRaCodingRate::Cr4_5,
preamble_len: 16,
sync_word: 0x1424,
tx_power_dbm: 20,
header_mode: LoRaHeaderMode::Explicit,
payload_crc: true,
iq_invert: false,
})
}
fn sample_info() -> Info {
Info {
proto_major: 1,
proto_minor: 0,
fw_major: 0,
fw_minor: 1,
fw_patch: 0,
radio_chip_id: RadioChipId::Sx1262.as_u16(),
capability_bitmap: donglora_protocol::cap::LORA | donglora_protocol::cap::CAD_BEFORE_TX,
supported_sf_bitmap: 0x1FE0,
supported_bw_bitmap: 0x03FF,
max_payload_bytes: 255,
rx_queue_capacity: 64,
tx_queue_capacity: 16,
freq_min_hz: 150_000_000,
freq_max_hz: 960_000_000,
tx_power_min_dbm: -9,
tx_power_max_dbm: 22,
mcu_uid_len: 0,
mcu_uid: [0u8; donglora_protocol::MAX_MCU_UID_LEN],
radio_uid_len: 0,
radio_uid: [0u8; donglora_protocol::MAX_RADIO_UID_LEN],
}
}
/// Spawn a fake device task on one side of a tokio duplex and return
/// both (a) a [`Dongle`] wired to the other side and (b) a handle to
/// the device so the test can drive responses on cue.
async fn drain_one_frame(device: &mut DuplexStream) -> (u8, u16, Vec<u8>) {
let mut decoder = FrameDecoder::new();
let mut buf = [0u8; 256];
loop {
let n = device.read(&mut buf).await.unwrap();
let mut out: Option<(u8, u16, Vec<u8>)> = None;
decoder.feed(&buf[..n], |res| match res {
FrameResult::Ok { type_id, tag, payload } => {
out = Some((type_id, tag, payload.to_vec()));
}
FrameResult::Err(e) => panic!("frame decode: {e:?}"),
});
if let Some(t) = out {
return t;
}
}
}
async fn send_frame(device: &mut DuplexStream, type_id: u8, tag: u16, payload: &[u8]) {
let mut wire = [0u8; donglora_protocol::MAX_WIRE_FRAME];
let n = encode_frame(type_id, tag, payload, &mut wire).unwrap();
device.write_all(&wire[..n]).await.unwrap();
device.flush().await.unwrap();
}
#[tokio::test]
async fn tx_success_path() {
let (host, mut device) = tokio::io::duplex(1024);
let session = Session::spawn(host);
let dongle =
Dongle::new(session, sample_info(), TransportKind::Serial("/dev/test".into()), Some(lora_cfg()), false);
let tx_task = tokio::spawn({
let dongle = dongle;
async move {
let td = dongle.tx(b"hello").await;
dongle.close().await;
td
}
});
let (type_id, tag, _p) = drain_one_frame(&mut device).await;
assert_eq!(type_id, commands::TYPE_TX);
send_frame(&mut device, events::TYPE_OK, tag, &[]).await;
tokio::time::sleep(Duration::from_millis(10)).await;
let td = donglora_protocol::TxDonePayload { result: TxResult::Transmitted, airtime_us: 7_777 };
let mut td_buf = [0u8; donglora_protocol::TxDonePayload::WIRE_SIZE];
td.encode(&mut td_buf).unwrap();
send_frame(&mut device, events::TYPE_TX_DONE, tag, &td_buf).await;
let result = tx_task.await.unwrap().unwrap();
assert_eq!(result.airtime_us, 7_777);
}
#[tokio::test]
async fn tx_with_retry_succeeds_after_channel_busy() {
let (host, mut device) = tokio::io::duplex(2048);
let session = Session::spawn(host);
let dongle =
Dongle::new(session, sample_info(), TransportKind::Serial("/dev/test".into()), Some(lora_cfg()), false);
let policy = RetryPolicy {
max_attempts: 3,
backoff_ms_min: 1,
backoff_ms_max: 1,
backoff_multiplier: 1.0,
backoff_cap_ms: 10,
per_attempt_timeout: Duration::from_secs(1),
skip_cad: false,
};
let tx_task = tokio::spawn({
let dongle = dongle;
let p = policy.clone();
async move {
let outcome = dongle.tx_with_retry(b"hi", &p).await;
dongle.close().await;
outcome
}
});
// Attempt 1 — CHANNEL_BUSY.
let (_t1, tag1, _) = drain_one_frame(&mut device).await;
send_frame(&mut device, events::TYPE_OK, tag1, &[]).await;
tokio::time::sleep(Duration::from_millis(5)).await;
let mut td_buf = [0u8; donglora_protocol::TxDonePayload::WIRE_SIZE];
donglora_protocol::TxDonePayload { result: TxResult::ChannelBusy, airtime_us: 0 }.encode(&mut td_buf).unwrap();
send_frame(&mut device, events::TYPE_TX_DONE, tag1, &td_buf).await;
// Attempt 2 — TRANSMITTED.
let (_t2, tag2, _) = drain_one_frame(&mut device).await;
assert_ne!(tag1, tag2, "retry should use a fresh tag");
send_frame(&mut device, events::TYPE_OK, tag2, &[]).await;
tokio::time::sleep(Duration::from_millis(5)).await;
donglora_protocol::TxDonePayload { result: TxResult::Transmitted, airtime_us: 9_000 }
.encode(&mut td_buf)
.unwrap();
send_frame(&mut device, events::TYPE_TX_DONE, tag2, &td_buf).await;
let outcome = tx_task.await.unwrap().unwrap();
assert_eq!(outcome.attempts_used(), 2);
assert!(outcome.had_retries());
assert_eq!(outcome.final_airtime_us, 9_000);
}
#[tokio::test]
async fn tx_auto_recovers_from_not_configured() {
let (host, mut device) = tokio::io::duplex(2048);
let session = Session::spawn(host);
let cfg = lora_cfg();
let dongle = Dongle::new(session, sample_info(), TransportKind::Serial("/dev/test".into()), Some(cfg), false);
let task = tokio::spawn({
let dongle = dongle;
async move {
let r = dongle.tx(b"abc").await;
dongle.close().await;
r
}
});
// First TX → ERR(ENOTCONFIGURED).
let (_t, tag1, _) = drain_one_frame(&mut device).await;
let mut err_buf = [0u8; 2];
donglora_protocol::events::encode_err_payload(donglora_protocol::ErrorCode::ENotConfigured, &mut err_buf)
.unwrap();
send_frame(&mut device, events::TYPE_ERR, tag1, &err_buf).await;
// Auto-recovery: SET_CONFIG goes out. Reply APPLIED/MINE.
let (set_type, tag_set, _) = drain_one_frame(&mut device).await;
assert_eq!(set_type, commands::TYPE_SET_CONFIG);
let result = SetConfigResult { result: SetConfigResultCode::Applied, owner: Owner::Mine, current: cfg };
let mut rbuf = [0u8; donglora_protocol::MAX_SETCONFIG_OK_PAYLOAD];
let n = result.encode(&mut rbuf).unwrap();
send_frame(&mut device, events::TYPE_OK, tag_set, &rbuf[..n]).await;
// Retry TX → succeed.
let (_t, tag2, _) = drain_one_frame(&mut device).await;
send_frame(&mut device, events::TYPE_OK, tag2, &[]).await;
tokio::time::sleep(Duration::from_millis(5)).await;
let mut td_buf = [0u8; donglora_protocol::TxDonePayload::WIRE_SIZE];
donglora_protocol::TxDonePayload { result: TxResult::Transmitted, airtime_us: 1234 }
.encode(&mut td_buf)
.unwrap();
send_frame(&mut device, events::TYPE_TX_DONE, tag2, &td_buf).await;
let td = task.await.unwrap().unwrap();
assert_eq!(td.airtime_us, 1234);
}
#[tokio::test]
async fn drop_aborts_keepalive() {
let (host, _device) = tokio::io::duplex(512);
let session = Session::spawn(host);
let dongle = Dongle::new(session, sample_info(), TransportKind::Serial("/dev/test".into()), None, true);
// Keepalive is running. Dropping the Dongle must abort it.
assert!(dongle.keepalive_handle.lock().unwrap().is_some());
drop(dongle);
// Give the abort a tick to land.
tokio::time::sleep(Duration::from_millis(10)).await;
}
}
+142
View File
@@ -0,0 +1,142 @@
//! Error taxonomy for `donglora-client`.
//!
//! Mirrors the Python client's `DongloraError` hierarchy: one base error
//! with specific variants for each spec-defined failure mode plus a
//! handful of transport-level conditions. Callers who just want to
//! surface failures match on the top-level [`ClientError`]; code that
//! needs to retry on a specific kind (e.g. `ChannelBusy`) pattern-matches
//! that variant.
use donglora_protocol::ErrorCode;
use thiserror::Error;
/// Every fallible operation in `donglora-client` returns this error
/// type. Variants mirror `PROTOCOL.md` §7 plus Rust-side conditions
/// (timeouts, closed transports, bad frames).
#[derive(Debug, Error)]
pub enum ClientError {
/// Firmware returned `ERR(ENOTCONFIGURED)`. Callers usually want to
/// re-apply their config and retry; [`crate::Dongle`] does this
/// automatically via `_with_recovery`.
#[error("device is not configured (ENOTCONFIGURED)")]
NotConfigured,
/// Firmware returned `ERR(EBUSY)` — TX queue full. Host should back
/// off briefly and retry with a new tag.
#[error("firmware TX queue is full (EBUSY)")]
Busy,
/// Firmware returned `ERR(EPARAM)` — a parameter value is out of
/// range or invalid.
#[error("parameter out of range (EPARAM)")]
Param,
/// Firmware returned `ERR(ELENGTH)` — payload length is wrong for
/// the command or modulation.
#[error("payload length wrong (ELENGTH)")]
Length,
/// Firmware returned `ERR(EMODULATION)` — requested modulation not
/// supported on this chip.
#[error("modulation not supported (EMODULATION)")]
Modulation,
/// Firmware returned `ERR(EUNKNOWN_CMD)` — unknown command type
/// byte.
#[error("unknown command (EUNKNOWN_CMD)")]
UnknownCmd,
/// Firmware returned `ERR(ERADIO)` — SPI error or unexpected radio
/// hardware state.
#[error("radio hardware error (ERADIO)")]
Radio,
/// Firmware reported a framing error (`ERR(EFRAME)`), usually
/// because of CRC/COBS corruption on the H→D path. Rare on USB.
#[error("framing error (EFRAME)")]
Frame,
/// Firmware returned `ERR(EINTERNAL)` — firmware bug / invariant
/// violation.
#[error("firmware internal error (EINTERNAL)")]
Internal,
/// Firmware returned an error code this client doesn't recognise.
/// Preserves the raw u16 so forward-compat with minor-version
/// extensions doesn't lose information.
#[error("unknown error code 0x{0:04X}")]
UnknownCode(u16),
/// CAD detected activity and the TX was aborted before airtime.
/// Per spec §6.10, host should randomized-backoff and retry with a
/// **new tag**. Reported as a distinct variant from `Busy` because
/// the retry policy differs.
#[error("channel busy — CAD detected activity")]
ChannelBusy,
/// A queued TX was cancelled by a reconfigure or disconnect before
/// it reached the air. Don't retry — the cancellation is terminal.
#[error("TX cancelled before airtime")]
Cancelled,
/// Command did not complete before its deadline.
#[error("timed out waiting for {what}")]
Timeout { what: &'static str },
/// The underlying transport (USB, socket) closed or errored.
#[error("transport closed: {0}")]
TransportClosed(String),
/// Session reader thread died while waiting for a response.
#[error("session reader exited")]
ReaderExited,
/// An inbound frame failed CRC or COBS decoding and was dropped.
/// The session reader logs these as async events; callers can poll
/// for them via [`crate::Dongle::drain_async_errors`].
#[error("inbound frame corrupted: {0}")]
BadFrame(String),
/// Encoding an outbound frame failed (should not happen in normal
/// use — the protocol crate's limits are enforced at the type level).
#[error("frame encode failed: {0}")]
EncodeFailed(String),
/// Underlying I/O error from `tokio::io` or `tokio-serial`.
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
/// Catch-all for transport initialisation issues that don't have a
/// dedicated variant.
#[error("{0}")]
Other(String),
}
impl ClientError {
/// True if retrying the operation with a fresh tag is spec-sanctioned.
/// Used by [`crate::RetryPolicy`] to decide whether to loop.
#[must_use]
pub fn is_retryable(&self) -> bool {
matches!(self, Self::ChannelBusy | Self::Busy)
}
/// Map an DongLoRa Protocol `ErrorCode` from the wire to the matching variant.
#[must_use]
pub fn from_wire(code: ErrorCode) -> Self {
match code {
ErrorCode::ENotConfigured => Self::NotConfigured,
ErrorCode::EBusy => Self::Busy,
ErrorCode::EParam => Self::Param,
ErrorCode::ELength => Self::Length,
ErrorCode::EModulation => Self::Modulation,
ErrorCode::EUnknownCmd => Self::UnknownCmd,
ErrorCode::ERadio => Self::Radio,
ErrorCode::EFrame => Self::Frame,
ErrorCode::EInternal => Self::Internal,
ErrorCode::Unknown(raw) => Self::UnknownCode(raw),
}
}
}
/// Short alias for the crate's `Result` type.
pub type ClientResult<T> = Result<T, ClientError>;
+61 -30
View File
@@ -1,45 +1,76 @@
//! 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.
//! Implements the DongLoRa Protocol v2 on top of the
//! [`donglora-protocol`](donglora_protocol) wire crate. All I/O is
//! async via tokio; the public surface mirrors the Python client in
//! `client-py/`.
//!
//! # Quick start
//!
//! ```no_run
//! use donglora_client::*;
//! # async fn demo() -> Result<(), donglora_client::ClientError> {
//! use donglora_client::{
//! connect, LoRaBandwidth, LoRaCodingRate, LoRaConfig, LoRaHeaderMode, Modulation,
//! };
//!
//! let mut client = connect_default()?;
//! client.ping()?;
//! client.set_config(RadioConfig::default())?;
//! client.start_rx()?;
//!
//! loop {
//! if let Some(Response::RxPacket { rssi, snr, payload }) = client.recv()? {
//! println!("RX rssi={rssi} snr={snr} len={}", payload.len());
//! }
//! }
//! # Ok::<(), anyhow::Error>(())
//! let dongle = connect().await?;
//! dongle.set_config(Modulation::LoRa(LoRaConfig {
//! freq_hz: 910_525_000,
//! sf: 7,
//! bw: LoRaBandwidth::Khz62,
//! cr: LoRaCodingRate::Cr4_5,
//! preamble_len: 16,
//! sync_word: 0x1424,
//! tx_power_dbm: 20,
//! header_mode: LoRaHeaderMode::Explicit,
//! payload_crc: true,
//! iq_invert: false,
//! })).await?;
//! dongle.tx(b"hello world").await?;
//! # Ok(()) }
//! ```
//!
//! # Module layout
//!
//! - [`connect`] — auto-discovery + [`ConnectOptions`] builder.
//! - [`dongle`] — public [`Dongle`] radio session type.
//! - [`session`] — internal async plumbing (not public).
//! - [`transport`] — tokio-based byte-stream transports.
//! - [`errors`] — [`ClientError`] taxonomy.
//! - [`retry`] — [`RetryPolicy`] + [`TxOutcome`] for `tx_with_retry`.
//! - [`discovery`] — USB VID/PID scan + async `wait_for_device`.
#![forbid(unsafe_code)]
pub mod client;
pub mod codec;
pub mod connect;
pub mod discovery;
pub mod protocol;
pub mod dongle;
pub mod errors;
pub mod retry;
pub mod session;
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 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,
};
pub use transport::{AnyTransport, MuxTransport, SerialTransport, Transport};
// ── Flat re-exports for convenience ─────────────────────────────────
#[cfg(unix)]
pub use connect::mux_connect;
pub use connect::mux_tcp_connect;
pub use connect::mux_unix_connect;
pub use connect::{
ConnectOptions, connect, connect_mux_auto, connect_mux_auto_with, connect_with, default_socket_path,
find_mux_socket, mux_tcp_connect, try_connect, try_connect_with,
};
pub use discovery::{USB_PID, USB_VID, find_port, wait_for_device};
pub use dongle::{Dongle, KEEPALIVE_INTERVAL, TransportKind};
pub use errors::{ClientError, ClientResult};
pub use retry::{RetryPolicy, TxAttempt, TxOutcome};
#[cfg(unix)]
pub use transport::UnixSocketTransport;
pub use transport::{AnyTransport, SerialTransport, TcpTransport, Transport};
// Re-export the protocol crate's user-facing types so callers don't need
// to add `donglora-protocol` as a direct dep.
pub use donglora_protocol::{
Command, DeviceMessage, ErrorCode, FlrcBitrate, FlrcBt, FlrcCodingRate, FlrcConfig, FlrcPreambleLen, FskConfig,
Info, LoRaBandwidth, LoRaCodingRate, LoRaConfig, LoRaHeaderMode, LrFhssBandwidth, LrFhssCodingRate, LrFhssConfig,
LrFhssGrid, Modulation, ModulationId, OkPayload, Owner, RadioChipId, RxOrigin, RxPayload, SetConfigResult,
SetConfigResultCode, TxDonePayload, TxFlags, TxResult, cap,
};
-693
View File
@@ -1,693 +0,0 @@
//! 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.
/// Maximum LoRa payload size in bytes.
pub const MAX_PAYLOAD: usize = 256;
/// RadioConfig wire size (fixed).
pub const RADIO_CONFIG_SIZE: usize = 13;
/// Sentinel value for `tx_power_dbm`: use the board's maximum TX power.
pub const TX_POWER_MAX: i8 = i8::MIN; // -128 on the wire
/// Sentinel value for `preamble_len`: use the firmware default (16 symbols).
pub const PREAMBLE_DEFAULT: u16 = 0;
// ── Tag constants ──────────────────────────────────────────────────
/// Command tag for SetConfig.
pub const CMD_TAG_SET_CONFIG: u8 = 2;
/// Command tag for StartRx.
pub const CMD_TAG_START_RX: u8 = 3;
/// Command tag for StopRx.
pub const CMD_TAG_STOP_RX: u8 = 4;
/// Response tag for RxPacket.
pub const RESP_TAG_RX_PACKET: u8 = 2;
/// Response tag for Ok.
pub const RESP_TAG_OK: u8 = 4;
/// Response tag for Error.
pub const RESP_TAG_ERROR: u8 = 5;
/// Error code for InvalidConfig.
pub const ERROR_INVALID_CONFIG: u8 = 0;
// ── Bandwidth ──────────────────────────────────────────────────────
/// LoRa signal bandwidth.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum Bandwidth {
Khz7 = 0,
Khz10 = 1,
Khz15 = 2,
Khz20 = 3,
Khz31 = 4,
Khz41 = 5,
Khz62 = 6,
Khz125 = 7,
Khz250 = 8,
Khz500 = 9,
}
impl Bandwidth {
/// Convert from raw byte. Returns `None` for invalid values.
pub fn from_u8(v: u8) -> Option<Self> {
match v {
0 => Some(Self::Khz7),
1 => Some(Self::Khz10),
2 => Some(Self::Khz15),
3 => Some(Self::Khz20),
4 => Some(Self::Khz31),
5 => Some(Self::Khz41),
6 => Some(Self::Khz62),
7 => Some(Self::Khz125),
8 => Some(Self::Khz250),
9 => Some(Self::Khz500),
_ => None,
}
}
}
// ── RadioConfig ────────────────────────────────────────────────────
/// Complete LoRa radio configuration.
///
/// Wire layout (13 bytes, all little-endian):
/// ```text
/// [freq_hz:4] [bw:1] [sf:1] [cr:1] [sync_word:2] [tx_power_dbm:1] [preamble_len:2] [cad:1]
/// ```
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RadioConfig {
/// Frequency in Hz (150_000_000 - 960_000_000 for SX1262).
pub freq_hz: u32,
pub bw: Bandwidth,
/// Spreading factor (5-12).
pub sf: u8,
/// Coding rate denominator (5-8). E.g. 5 = CR 4/5, 8 = CR 4/8.
pub cr: u8,
pub sync_word: u16,
/// Transmit power in dBm. Set to [`TX_POWER_MAX`] (-128) for the board's maximum.
pub tx_power_dbm: i8,
/// Preamble length in symbols. Set to [`PREAMBLE_DEFAULT`] (0) for firmware default (16).
pub preamble_len: u16,
/// Channel Activity Detection (listen-before-talk). 0 = disabled, non-zero = enabled.
pub cad: u8,
}
impl RadioConfig {
/// Serialize to fixed-size LE bytes.
pub fn to_bytes(&self) -> [u8; RADIO_CONFIG_SIZE] {
let mut buf = [0u8; RADIO_CONFIG_SIZE];
buf[0..4].copy_from_slice(&self.freq_hz.to_le_bytes());
buf[4] = self.bw as u8;
buf[5] = self.sf;
buf[6] = self.cr;
buf[7..9].copy_from_slice(&self.sync_word.to_le_bytes());
buf[9] = self.tx_power_dbm as u8;
buf[10..12].copy_from_slice(&self.preamble_len.to_le_bytes());
buf[12] = self.cad;
buf
}
/// Deserialize from fixed-size LE bytes. Returns `None` if too short or invalid.
pub fn from_bytes(buf: &[u8]) -> Option<Self> {
if buf.len() < RADIO_CONFIG_SIZE {
return None;
}
Some(Self {
freq_hz: u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]),
bw: Bandwidth::from_u8(buf[4])?,
sf: buf[5],
cr: buf[6],
sync_word: u16::from_le_bytes([buf[7], buf[8]]),
tx_power_dbm: buf[9] as i8,
preamble_len: u16::from_le_bytes([buf[10], buf[11]]),
cad: buf[12],
})
}
}
/// Default: 915 MHz, 125 kHz BW, SF7, CR 4/5, sync 0x1424, max power, default preamble, CAD on.
impl Default for RadioConfig {
fn default() -> Self {
Self {
freq_hz: 915_000_000,
bw: Bandwidth::Khz125,
sf: 7,
cr: 5,
sync_word: 0x1424,
tx_power_dbm: TX_POWER_MAX,
preamble_len: PREAMBLE_DEFAULT,
cad: 1,
}
}
}
// ── Command ────────────────────────────────────────────────────────
/// Host → firmware commands.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Command {
Ping,
GetConfig,
SetConfig(RadioConfig),
StartRx,
StopRx,
Transmit {
config: Option<RadioConfig>,
payload: Vec<u8>,
},
DisplayOn,
DisplayOff,
GetMac,
}
impl Command {
/// Returns the wire tag byte for this command.
pub fn tag(&self) -> u8 {
match self {
Self::Ping => 0,
Self::GetConfig => 1,
Self::SetConfig(_) => 2,
Self::StartRx => 3,
Self::StopRx => 4,
Self::Transmit { .. } => 5,
Self::DisplayOn => 6,
Self::DisplayOff => 7,
Self::GetMac => 8,
}
}
/// Serialize to fixed-size LE bytes.
pub fn to_bytes(&self) -> Vec<u8> {
match self {
Self::Ping => vec![0],
Self::GetConfig => vec![1],
Self::SetConfig(cfg) => {
let mut out = vec![2];
out.extend_from_slice(&cfg.to_bytes());
out
}
Self::StartRx => vec![3],
Self::StopRx => vec![4],
Self::Transmit { config, payload } => {
let mut out = vec![5];
match config {
None => out.push(0),
Some(cfg) => {
out.push(1);
out.extend_from_slice(&cfg.to_bytes());
}
}
out.extend_from_slice(&(payload.len() as u16).to_le_bytes());
out.extend_from_slice(payload);
out
}
Self::DisplayOn => vec![6],
Self::DisplayOff => vec![7],
Self::GetMac => vec![8],
}
}
/// Deserialize from a decoded frame. Returns `None` on malformed input.
pub fn from_bytes(buf: &[u8]) -> Option<Self> {
let tag = *buf.first()?;
let rest = &buf[1..];
match tag {
0 => Some(Self::Ping),
1 => Some(Self::GetConfig),
2 => Some(Self::SetConfig(RadioConfig::from_bytes(rest)?)),
3 => Some(Self::StartRx),
4 => Some(Self::StopRx),
5 => {
if rest.is_empty() {
return None;
}
let (config, pos) = if rest[0] == 0 {
(None, 1)
} else if rest[0] == 1 && rest.len() > RADIO_CONFIG_SIZE {
(Some(RadioConfig::from_bytes(&rest[1..])?), 1 + RADIO_CONFIG_SIZE)
} else {
return None;
};
if rest.len() < pos + 2 {
return None;
}
let len = u16::from_le_bytes([rest[pos], rest[pos + 1]]) as usize;
let data_start = pos + 2;
if rest.len() < data_start + len || len > MAX_PAYLOAD {
return None;
}
Some(Self::Transmit {
config,
payload: rest[data_start..data_start + len].to_vec(),
})
}
6 => Some(Self::DisplayOn),
7 => Some(Self::DisplayOff),
8 => Some(Self::GetMac),
_ => None,
}
}
}
// ── ErrorCode ──────────────────────────────────────────────────────
/// Error codes reported by the firmware.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum ErrorCode {
InvalidConfig = 0,
RadioBusy = 1,
TxTimeout = 2,
CrcError = 3,
NotConfigured = 4,
NoDisplay = 5,
}
impl ErrorCode {
/// Convert from raw byte. Returns `None` for unknown codes.
pub fn from_u8(v: u8) -> Option<Self> {
match v {
0 => Some(Self::InvalidConfig),
1 => Some(Self::RadioBusy),
2 => Some(Self::TxTimeout),
3 => Some(Self::CrcError),
4 => Some(Self::NotConfigured),
5 => Some(Self::NoDisplay),
_ => None,
}
}
}
impl std::fmt::Display for ErrorCode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::InvalidConfig => write!(f, "InvalidConfig"),
Self::RadioBusy => write!(f, "RadioBusy"),
Self::TxTimeout => write!(f, "TxTimeout"),
Self::CrcError => write!(f, "CrcError"),
Self::NotConfigured => write!(f, "NotConfigured"),
Self::NoDisplay => write!(f, "NoDisplay"),
}
}
}
// ── Response ───────────────────────────────────────────────────────
/// Firmware → host responses.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Response {
Pong,
Config(RadioConfig),
RxPacket {
rssi: i16,
snr: i16,
payload: Vec<u8>,
},
TxDone,
Ok,
Error(ErrorCode),
MacAddress([u8; 6]),
}
impl Response {
/// Returns the wire tag byte for this response.
pub fn tag(&self) -> u8 {
match self {
Self::Pong => 0,
Self::Config(_) => 1,
Self::RxPacket { .. } => 2,
Self::TxDone => 3,
Self::Ok => 4,
Self::Error(_) => 5,
Self::MacAddress(_) => 6,
}
}
/// Whether this is an unsolicited RxPacket.
pub fn is_rx_packet(&self) -> bool {
matches!(self, Self::RxPacket { .. })
}
/// Serialize to fixed-size LE bytes.
pub fn to_bytes(&self) -> Vec<u8> {
match self {
Self::Pong => vec![0],
Self::Config(cfg) => {
let mut out = vec![1];
out.extend_from_slice(&cfg.to_bytes());
out
}
Self::RxPacket { rssi, snr, payload } => {
let mut out = vec![2];
out.extend_from_slice(&rssi.to_le_bytes());
out.extend_from_slice(&snr.to_le_bytes());
out.extend_from_slice(&(payload.len() as u16).to_le_bytes());
out.extend_from_slice(payload);
out
}
Self::TxDone => vec![3],
Self::Ok => vec![4],
Self::Error(code) => vec![5, *code as u8],
Self::MacAddress(mac) => {
let mut out = vec![6];
out.extend_from_slice(mac);
out
}
}
}
/// Deserialize from a decoded frame. Returns `None` on malformed input.
pub fn from_bytes(buf: &[u8]) -> Option<Self> {
let tag = *buf.first()?;
let rest = &buf[1..];
match tag {
0 => Some(Self::Pong),
1 => Some(Self::Config(RadioConfig::from_bytes(rest)?)),
2 => {
if rest.len() < 6 {
return None;
}
let rssi = i16::from_le_bytes([rest[0], rest[1]]);
let snr = i16::from_le_bytes([rest[2], rest[3]]);
let len = u16::from_le_bytes([rest[4], rest[5]]) as usize;
if rest.len() < 6 + len || len > MAX_PAYLOAD {
return None;
}
Some(Self::RxPacket {
rssi,
snr,
payload: rest[6..6 + len].to_vec(),
})
}
3 => Some(Self::TxDone),
4 => Some(Self::Ok),
5 => Some(Self::Error(ErrorCode::from_u8(*rest.first()?)?)),
6 => {
if rest.len() < 6 {
return None;
}
let mut mac = [0u8; 6];
mac.copy_from_slice(&rest[..6]);
Some(Self::MacAddress(mac))
}
_ => None,
}
}
}
// ── Tests ──────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
fn make_config() -> RadioConfig {
RadioConfig {
freq_hz: 915_000_000,
bw: Bandwidth::Khz125,
sf: 7,
cr: 5,
sync_word: 0x3444,
tx_power_dbm: 22,
preamble_len: 16,
cad: 1,
}
}
// ── RadioConfig ────────────────────────────────────────────────
#[test]
fn radio_config_roundtrip() {
let cfg = make_config();
let bytes = cfg.to_bytes();
assert_eq!(bytes.len(), RADIO_CONFIG_SIZE);
assert_eq!(RadioConfig::from_bytes(&bytes), Some(cfg));
}
#[test]
fn radio_config_default_roundtrip() {
let cfg = RadioConfig::default();
let bytes = cfg.to_bytes();
assert_eq!(RadioConfig::from_bytes(&bytes), Some(cfg));
}
#[test]
fn radio_config_all_bandwidths() {
for bw_val in 0u8..=9 {
let bw = Bandwidth::from_u8(bw_val);
assert!(bw.is_some(), "bandwidth {bw_val} should be valid");
let cfg = RadioConfig { bw: bw.unwrap(), ..make_config() };
let bytes = cfg.to_bytes();
assert_eq!(RadioConfig::from_bytes(&bytes), Some(cfg));
}
}
#[test]
fn radio_config_invalid_bandwidth() {
let mut buf = make_config().to_bytes();
buf[4] = 255;
assert!(RadioConfig::from_bytes(&buf).is_none());
}
#[test]
fn radio_config_negative_power() {
let cfg = RadioConfig { tx_power_dbm: TX_POWER_MAX, ..make_config() };
let bytes = cfg.to_bytes();
assert_eq!(RadioConfig::from_bytes(&bytes), Some(cfg));
}
#[test]
fn radio_config_short_buffer() {
assert!(RadioConfig::from_bytes(&[0u8; 12]).is_none());
assert!(RadioConfig::from_bytes(&[]).is_none());
}
// ── Command ────────────────────────────────────────────────────
#[test]
fn command_simple_roundtrips() {
for cmd in [
Command::Ping,
Command::GetConfig,
Command::StartRx,
Command::StopRx,
Command::DisplayOn,
Command::DisplayOff,
Command::GetMac,
] {
let bytes = cmd.to_bytes();
assert_eq!(Command::from_bytes(&bytes), Some(cmd));
}
}
#[test]
fn command_set_config_roundtrip() {
let cmd = Command::SetConfig(make_config());
let bytes = cmd.to_bytes();
assert_eq!(Command::from_bytes(&bytes), Some(cmd));
}
#[test]
fn command_transmit_no_config_roundtrip() {
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 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 bytes = cmd.to_bytes();
assert_eq!(Command::from_bytes(&bytes), Some(cmd));
}
#[test]
fn command_transmit_truncated() {
assert!(Command::from_bytes(&[5]).is_none()); // tag only
assert!(Command::from_bytes(&[5, 1]).is_none()); // has_config=1, no config
assert!(Command::from_bytes(&[5, 0]).is_none()); // has_config=0, no length
}
#[test]
fn command_invalid_tag() {
assert!(Command::from_bytes(&[9]).is_none());
assert!(Command::from_bytes(&[255]).is_none());
}
#[test]
fn command_empty_buffer() {
assert!(Command::from_bytes(&[]).is_none());
}
#[test]
fn command_tags() {
assert_eq!(Command::Ping.tag(), 0);
assert_eq!(Command::GetConfig.tag(), 1);
assert_eq!(Command::SetConfig(make_config()).tag(), 2);
assert_eq!(Command::StartRx.tag(), 3);
assert_eq!(Command::StopRx.tag(), 4);
assert_eq!(Command::Transmit { config: None, payload: vec![] }.tag(), 5);
assert_eq!(Command::DisplayOn.tag(), 6);
assert_eq!(Command::DisplayOff.tag(), 7);
assert_eq!(Command::GetMac.tag(), 8);
}
// ── Response ───────────────────────────────────────────────────
#[test]
fn response_simple_roundtrips() {
for resp in [Response::Pong, Response::TxDone, Response::Ok] {
let bytes = resp.to_bytes();
assert_eq!(Response::from_bytes(&bytes), Some(resp));
}
}
#[test]
fn response_config_roundtrip() {
let resp = Response::Config(make_config());
let bytes = resp.to_bytes();
assert_eq!(Response::from_bytes(&bytes), Some(resp));
}
#[test]
fn response_rx_packet_roundtrip() {
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 bytes = resp.to_bytes();
assert_eq!(Response::from_bytes(&bytes), Some(resp));
}
#[test]
fn response_error_codes() {
for code in [
ErrorCode::InvalidConfig,
ErrorCode::RadioBusy,
ErrorCode::TxTimeout,
ErrorCode::CrcError,
ErrorCode::NotConfigured,
ErrorCode::NoDisplay,
] {
let resp = Response::Error(code);
let bytes = resp.to_bytes();
assert_eq!(Response::from_bytes(&bytes), Some(resp));
}
}
#[test]
fn response_mac_address_roundtrip() {
let resp = Response::MacAddress([0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]);
let bytes = resp.to_bytes();
assert_eq!(Response::from_bytes(&bytes), Some(resp));
}
#[test]
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::TxDone.tag(), 3);
assert_eq!(Response::Ok.tag(), 4);
assert_eq!(Response::Error(ErrorCode::RadioBusy).tag(), 5);
assert_eq!(Response::MacAddress([0; 6]).tag(), 6);
}
#[test]
fn response_is_rx_packet() {
assert!(Response::RxPacket { rssi: 0, snr: 0, payload: vec![] }.is_rx_packet());
assert!(!Response::Ok.is_rx_packet());
assert!(!Response::Pong.is_rx_packet());
}
#[test]
fn response_invalid_tag() {
assert!(Response::from_bytes(&[7]).is_none());
assert!(Response::from_bytes(&[255]).is_none());
}
#[test]
fn response_truncated() {
assert!(Response::from_bytes(&[]).is_none());
assert!(Response::from_bytes(&[5]).is_none()); // Error with no code
assert!(Response::from_bytes(&[2, 0, 0]).is_none()); // RxPacket too short
assert!(Response::from_bytes(&[6, 0, 0, 0]).is_none()); // MacAddress too short
}
// ── ErrorCode ──────────────────────────────────────────────────
#[test]
fn error_code_from_u8() {
for v in 0..=5 {
assert!(ErrorCode::from_u8(v).is_some());
}
assert!(ErrorCode::from_u8(6).is_none());
assert!(ErrorCode::from_u8(255).is_none());
}
#[test]
fn error_code_display() {
assert_eq!(ErrorCode::InvalidConfig.to_string(), "InvalidConfig");
assert_eq!(ErrorCode::RadioBusy.to_string(), "RadioBusy");
}
// ── Cross-compatibility with firmware encoding ─────────────────
#[test]
fn firmware_worked_example() {
// From PROTOCOL.md: 915 MHz, 125 kHz BW, SF7, CR 4/5, sync 0x1424, max power
let cfg = RadioConfig {
freq_hz: 915_000_000,
bw: Bandwidth::Khz125,
sf: 7,
cr: 5,
sync_word: 0x1424,
tx_power_dbm: TX_POWER_MAX,
preamble_len: PREAMBLE_DEFAULT,
cad: 1,
};
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]
);
}
}
+105
View File
@@ -0,0 +1,105 @@
//! TX retry policy + outcome reporting.
//!
//! `DongLoRa Protocol v2` surfaces CAD-detected channel activity as a distinct
//! `TX_DONE(CHANNEL_BUSY)` result (`PROTOCOL.md §6.10`). The spec
//! recommends "randomized backoff and retry with a new tag" —
//! [`Dongle::tx_with_retry`](crate::Dongle::tx_with_retry) implements
//! that policy and returns a [`TxOutcome`] capturing every attempt so
//! callers (the bridge TUI especially) can surface retry counts.
use std::time::Duration;
use donglora_protocol::TxDonePayload;
use crate::errors::ClientError;
/// Policy for [`crate::Dongle::tx_with_retry`].
///
/// Defaults match the example in `PROTOCOL.md §C.5.5`: 3 attempts,
/// randomized 20-100 ms backoff on the first retry, doubling up to
/// 500 ms cap. Only `CHANNEL_BUSY` (CAD) and `EBUSY` (TX queue full)
/// trigger a retry — every other error propagates immediately.
#[derive(Debug, Clone)]
pub struct RetryPolicy {
pub max_attempts: u8,
/// Lower bound of the initial randomized backoff (ms).
pub backoff_ms_min: u32,
/// Upper bound of the initial randomized backoff (ms). The jitter
/// range is `[0, backoff_ms_max - backoff_ms_min]`.
pub backoff_ms_max: u32,
/// Multiplier applied to the backoff floor on each subsequent retry
/// (standard exponential backoff).
pub backoff_multiplier: f32,
/// Absolute ceiling on the backoff floor (ms). Prevents runaway
/// delays at high attempt counts.
pub backoff_cap_ms: u32,
/// Per-attempt command deadline. Must accommodate CAD + airtime on
/// the slowest configuration likely to be in play.
pub per_attempt_timeout: Duration,
/// If true, bypass CAD (sends `skip_cad = 1`). Usually false —
/// retrying without CAD defeats the purpose of the retry.
pub skip_cad: bool,
}
impl Default for RetryPolicy {
fn default() -> Self {
Self {
max_attempts: 3,
backoff_ms_min: 20,
backoff_ms_max: 100,
backoff_multiplier: 2.0,
backoff_cap_ms: 500,
per_attempt_timeout: Duration::from_secs(5),
skip_cad: false,
}
}
}
impl RetryPolicy {
/// Sample one jitter value in `[0, backoff_ms_max - backoff_ms_min]`.
pub(crate) fn jitter_ms(&self) -> u32 {
use rand::Rng;
let spread = self.backoff_ms_max.saturating_sub(self.backoff_ms_min);
if spread == 0 {
return 0;
}
rand::rng().random_range(0..=spread)
}
}
/// Result of one attempt within a retry loop.
#[derive(Debug)]
pub struct TxAttempt {
/// 1-indexed attempt number.
pub attempt: u8,
/// `Ok` with the wire `TX_DONE` payload on success, `Err` otherwise.
pub result: Result<TxDonePayload, ClientError>,
/// Wall-clock time this attempt took (including CAD + airtime or
/// timeout).
pub elapsed: Duration,
}
/// Aggregate outcome of a retry loop.
#[derive(Debug)]
pub struct TxOutcome {
/// The final successful attempt's reported airtime. For retries, the
/// earlier attempts' airtimes are 0 (CAD-busy doesn't go on the
/// air), so this is also the total airtime used.
pub final_airtime_us: u32,
/// Every attempt, in order.
pub attempts: Vec<TxAttempt>,
}
impl TxOutcome {
/// How many attempts were needed (equals `attempts.len()`).
#[must_use]
pub fn attempts_used(&self) -> u8 {
self.attempts.len() as u8
}
/// True if this outcome involved at least one retry.
#[must_use]
pub fn had_retries(&self) -> bool {
self.attempts.len() > 1
}
}
+703
View File
@@ -0,0 +1,703 @@
//! Internal session plumbing: tag counter, outstanding-tag correlation,
//! background reader task, async event queue.
//!
//! Direct async port of `client-py/donglora/session.py`. Not part of the
//! public API — [`crate::Dongle`] wraps a [`Session`] and exposes the
//! user-facing surface on top of it.
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use donglora_protocol::{
Command, FrameDecoder, FrameResult, Info, MAX_PAYLOAD_FIELD, MAX_WIRE_FRAME, Modulation, OkPayload, RxPayload,
SetConfigResult, TxDonePayload, TxFlags, TxResult, commands, encode_frame, events,
};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::sync::{Mutex, mpsc, oneshot};
use tokio::task::JoinHandle;
use crate::errors::{ClientError, ClientResult};
use crate::transport::Transport;
/// Result of a single tag-correlated command.
#[derive(Debug)]
pub(crate) enum SessionResponse {
/// `OK` with no payload (PING / TX-enqueue-ack / RX_START / RX_STOP).
Empty,
/// `OK(Info)` from GET_INFO.
Info(Info),
/// `OK(SetConfigResult)` from SET_CONFIG.
SetConfig(SetConfigResult),
/// `TX_DONE` — the async TX completion (TRANSMITTED / CHANNEL_BUSY / CANCELLED).
TxDone(TxDonePayload),
/// `ERR(code)` — command failed.
Err(ClientError),
}
/// State tracked for each outstanding command.
struct Pending {
cmd_type: u8,
/// Completion channel. For TX, we skip the intermediate `OK` and
/// fire this only on `TX_DONE` (or early `ERR`). For every other
/// command, fires on the first `OK`/`ERR`.
waker: oneshot::Sender<SessionResponse>,
}
type PendingMap = Arc<Mutex<HashMap<u16, Pending>>>;
/// Session state.
pub(crate) struct Session {
/// Monotonic tag counter; skips 0.
next_tag: Arc<std::sync::Mutex<u16>>,
pending: PendingMap,
rx_rx: Arc<Mutex<mpsc::UnboundedReceiver<RxPayload>>>,
async_err_rx: Arc<Mutex<mpsc::UnboundedReceiver<ClientError>>>,
/// Half-duplex writer side. Owned exclusively by write calls via
/// the mutex.
writer: Arc<Mutex<Box<dyn AsyncWriteOnly>>>,
reader_handle: Mutex<Option<JoinHandle<()>>>,
closed: Arc<std::sync::atomic::AtomicBool>,
}
/// Object-safe helper — `dyn Transport` isn't object-safe because
/// `Transport` is a blanket impl, so we wrap the writer side in this
/// narrower trait.
pub(crate) trait AsyncWriteOnly: tokio::io::AsyncWrite + Unpin + Send {}
impl<T: tokio::io::AsyncWrite + Unpin + Send> AsyncWriteOnly for T {}
impl Session {
/// Split *transport* into read/write halves and spawn the reader
/// task. The reader dispatches inbound frames into either the
/// per-tag `pending` map (command responses) or the `rx`/`async_err`
/// queues (async events).
pub(crate) fn spawn<T: Transport>(transport: T) -> Self {
let (reader, writer) = tokio::io::split(transport);
let (rx_tx, rx_rx) = mpsc::unbounded_channel();
let (async_err_tx, async_err_rx) = mpsc::unbounded_channel();
let pending: PendingMap = Arc::new(Mutex::new(HashMap::new()));
let closed = Arc::new(std::sync::atomic::AtomicBool::new(false));
let pending_reader = pending.clone();
let closed_reader = closed.clone();
let handle = tokio::spawn(async move {
reader_loop(reader, pending_reader, rx_tx, async_err_tx, closed_reader).await;
});
Self {
next_tag: Arc::new(std::sync::Mutex::new(1)),
pending,
rx_rx: Arc::new(Mutex::new(rx_rx)),
async_err_rx: Arc::new(Mutex::new(async_err_rx)),
writer: Arc::new(Mutex::new(Box::new(writer))),
reader_handle: Mutex::new(Some(handle)),
closed,
}
}
fn alloc_tag(&self) -> u16 {
// `std::sync::Mutex` here: contended for microseconds; not worth
// the await overhead. Tokio's mutex would demand async context.
#[allow(clippy::unwrap_used)] // Mutex poisoning is not recoverable here
let mut t = self.next_tag.lock().unwrap();
let tag = *t;
*t = t.wrapping_add(1);
if *t == 0 {
*t = 1;
}
tag
}
/// Send a command and await the (possibly-deferred) response.
///
/// For most commands the waker fires on the first `OK`/`ERR`. For
/// `TX`, the waker fires on `TX_DONE` (intermediate `OK` is absorbed).
pub(crate) async fn send_command(
&self,
type_id: u8,
payload: &[u8],
timeout: Duration,
) -> ClientResult<SessionResponse> {
if self.closed.load(std::sync::atomic::Ordering::SeqCst) {
return Err(ClientError::TransportClosed("session closed".into()));
}
let tag = self.alloc_tag();
let (tx, rx) = oneshot::channel();
{
let mut map = self.pending.lock().await;
map.insert(tag, Pending { cmd_type: type_id, waker: tx });
}
// Encode + write. If write fails, remove the pending slot so the
// caller sees the transport error rather than a timeout.
if let Err(e) = self.write_frame(type_id, tag, payload).await {
self.pending.lock().await.remove(&tag);
return Err(e);
}
match tokio::time::timeout(timeout, rx).await {
Ok(Ok(resp)) => Ok(resp),
Ok(Err(_)) => {
self.pending.lock().await.remove(&tag);
Err(ClientError::ReaderExited)
}
Err(_) => {
self.pending.lock().await.remove(&tag);
Err(ClientError::Timeout { what: describe_cmd(type_id) })
}
}
}
async fn write_frame(&self, type_id: u8, tag: u16, payload: &[u8]) -> ClientResult<()> {
let mut wire = [0u8; MAX_WIRE_FRAME];
let n =
encode_frame(type_id, tag, payload, &mut wire).map_err(|e| ClientError::EncodeFailed(format!("{e:?}")))?;
let mut writer = self.writer.lock().await;
writer.write_all(&wire[..n]).await.map_err(ClientError::Io)?;
writer.flush().await.map_err(ClientError::Io)?;
Ok(())
}
pub(crate) async fn recv_rx(&self, timeout: Duration) -> Option<RxPayload> {
let mut rx = self.rx_rx.lock().await;
tokio::time::timeout(timeout, rx.recv()).await.ok().flatten()
}
/// Block forever for the next RX event (for long-running listeners).
pub(crate) async fn next_rx(&self) -> Option<RxPayload> {
let mut rx = self.rx_rx.lock().await;
rx.recv().await
}
pub(crate) async fn drain_async_errors(&self) -> Vec<ClientError> {
let mut out = Vec::new();
let mut rx = self.async_err_rx.lock().await;
while let Ok(e) = rx.try_recv() {
out.push(e);
}
out
}
/// Close the session. Wakes any pending commands with
/// `TransportClosed` and aborts the reader task.
pub(crate) async fn close(&self) {
self.closed.store(true, std::sync::atomic::Ordering::SeqCst);
// Wake every pending command so no caller hangs forever.
let pending = {
let mut map = self.pending.lock().await;
std::mem::take(&mut *map)
};
for (_tag, p) in pending {
let _ = p.waker.send(SessionResponse::Err(ClientError::TransportClosed("session closed".into())));
}
// Stop the reader. Abort is a no-op if it already exited.
if let Some(h) = self.reader_handle.lock().await.take() {
h.abort();
}
}
// ── Typed command helpers ─────────────────────────────────────
pub(crate) async fn ping(&self, timeout: Duration) -> ClientResult<()> {
match self.send_command(commands::TYPE_PING, &[], timeout).await? {
SessionResponse::Empty => Ok(()),
SessionResponse::Err(e) => Err(e),
other => Err(ClientError::Other(format!("unexpected PING response: {other:?}"))),
}
}
pub(crate) async fn get_info(&self, timeout: Duration) -> ClientResult<Info> {
match self.send_command(commands::TYPE_GET_INFO, &[], timeout).await? {
SessionResponse::Info(i) => Ok(i),
SessionResponse::Err(e) => Err(e),
other => Err(ClientError::Other(format!("unexpected GET_INFO response: {other:?}"))),
}
}
pub(crate) async fn set_config(&self, modulation: Modulation, timeout: Duration) -> ClientResult<SetConfigResult> {
let cmd = Command::SetConfig(modulation);
let mut payload = [0u8; MAX_PAYLOAD_FIELD];
let n = cmd.encode_payload(&mut payload).map_err(|e| ClientError::EncodeFailed(format!("{e:?}")))?;
match self.send_command(commands::TYPE_SET_CONFIG, &payload[..n], timeout).await? {
SessionResponse::SetConfig(r) => Ok(r),
SessionResponse::Err(e) => Err(e),
other => Err(ClientError::Other(format!("unexpected SET_CONFIG response: {other:?}"))),
}
}
pub(crate) async fn rx_start(&self, timeout: Duration) -> ClientResult<()> {
match self.send_command(commands::TYPE_RX_START, &[], timeout).await? {
SessionResponse::Empty => Ok(()),
SessionResponse::Err(e) => Err(e),
other => Err(ClientError::Other(format!("unexpected RX_START response: {other:?}"))),
}
}
pub(crate) async fn rx_stop(&self, timeout: Duration) -> ClientResult<()> {
match self.send_command(commands::TYPE_RX_STOP, &[], timeout).await? {
SessionResponse::Empty => Ok(()),
SessionResponse::Err(e) => Err(e),
other => Err(ClientError::Other(format!("unexpected RX_STOP response: {other:?}"))),
}
}
pub(crate) async fn transmit(&self, data: &[u8], skip_cad: bool, timeout: Duration) -> ClientResult<TxDonePayload> {
if data.is_empty() {
return Err(ClientError::Length);
}
let mut payload = [0u8; MAX_PAYLOAD_FIELD];
payload[0] = TxFlags { skip_cad }.as_byte();
payload[1..1 + data.len()].copy_from_slice(data);
match self.send_command(commands::TYPE_TX, &payload[..1 + data.len()], timeout).await? {
SessionResponse::TxDone(td) => match td.result {
TxResult::Transmitted => Ok(td),
TxResult::ChannelBusy => Err(ClientError::ChannelBusy),
TxResult::Cancelled => Err(ClientError::Cancelled),
},
SessionResponse::Err(e) => Err(e),
other => Err(ClientError::Other(format!("unexpected TX response: {other:?}"))),
}
}
}
fn describe_cmd(type_id: u8) -> &'static str {
match type_id {
commands::TYPE_PING => "PING",
commands::TYPE_GET_INFO => "GET_INFO",
commands::TYPE_SET_CONFIG => "SET_CONFIG",
commands::TYPE_TX => "TX",
commands::TYPE_RX_START => "RX_START",
commands::TYPE_RX_STOP => "RX_STOP",
_ => "command",
}
}
async fn reader_loop<R: tokio::io::AsyncRead + Unpin>(
mut reader: R,
pending: PendingMap,
rx_tx: mpsc::UnboundedSender<RxPayload>,
async_err_tx: mpsc::UnboundedSender<ClientError>,
closed: Arc<std::sync::atomic::AtomicBool>,
) {
let mut decoder = FrameDecoder::new();
let mut buf = [0u8; 256];
loop {
if closed.load(std::sync::atomic::Ordering::SeqCst) {
break;
}
let n = match reader.read(&mut buf).await {
Ok(0) => break,
Ok(n) => n,
Err(_) => break,
};
// Collect frames into owned buffers (payload lifetime is tied to
// the decoder's internal buffer, which gets reused on the next
// feed call).
let mut works: Vec<FrameWork> = Vec::new();
decoder.feed(&buf[..n], |res| match res {
FrameResult::Ok { type_id, tag, payload } => {
works.push(FrameWork::Ok { type_id, tag, payload: payload.to_vec() });
}
FrameResult::Err(_) => works.push(FrameWork::Frame),
});
for w in works {
match w {
FrameWork::Ok { type_id, tag, payload } => {
dispatch(&pending, &rx_tx, &async_err_tx, type_id, tag, &payload).await;
}
FrameWork::Frame => {
let _ = async_err_tx.send(ClientError::BadFrame("inbound frame failed CRC or COBS".into()));
}
}
}
}
// Reader is leaving — wake any pending commands so they fail fast.
let pending = {
let mut map = pending.lock().await;
std::mem::take(&mut *map)
};
for (_tag, p) in pending {
let _ = p.waker.send(SessionResponse::Err(ClientError::TransportClosed("reader exited".into())));
}
}
enum FrameWork {
Ok { type_id: u8, tag: u16, payload: Vec<u8> },
Frame,
}
async fn dispatch(
pending: &PendingMap,
rx_tx: &mpsc::UnboundedSender<RxPayload>,
async_err_tx: &mpsc::UnboundedSender<ClientError>,
type_id: u8,
tag: u16,
payload: &[u8],
) {
// Async events (tag == 0): RX and async ERR.
if tag == 0 {
match type_id {
events::TYPE_RX => match RxPayload::decode(payload) {
Ok(rx) => {
let _ = rx_tx.send(rx);
}
Err(_) => {
let _ = async_err_tx.send(ClientError::BadFrame("bad RX payload".into()));
}
},
events::TYPE_ERR => match events::decode_err_payload(payload) {
Ok(code) => {
let _ = async_err_tx.send(ClientError::from_wire(code));
}
Err(_) => {
let _ = async_err_tx.send(ClientError::BadFrame("bad async ERR payload".into()));
}
},
_ => {
// Unknown async frame type — log and drop.
tracing::debug!(type_id, "unknown async frame type");
}
}
return;
}
// Tag-correlated: OK / ERR / TX_DONE.
let Some(Pending { cmd_type, waker }) = pending.lock().await.remove(&tag) else {
tracing::debug!(tag, type_id, "no pending command for tag");
return;
};
// TX has a two-phase completion (OK → TX_DONE). We want the waker
// to fire on TX_DONE (or early ERR), so if we see the intermediate
// OK for a TX, re-insert the pending slot instead of resolving it.
if cmd_type == commands::TYPE_TX && type_id == events::TYPE_OK {
pending.lock().await.insert(tag, Pending { cmd_type, waker });
return;
}
match type_id {
events::TYPE_OK => {
let resp = match OkPayload::parse_for(cmd_type, payload) {
Ok(OkPayload::Empty) => SessionResponse::Empty,
Ok(OkPayload::Info(i)) => SessionResponse::Info(i),
Ok(OkPayload::SetConfig(r)) => SessionResponse::SetConfig(r),
Err(_) => {
SessionResponse::Err(ClientError::BadFrame("OK payload did not parse for command context".into()))
}
};
let _ = waker.send(resp);
}
events::TYPE_ERR => {
let resp = match events::decode_err_payload(payload) {
Ok(code) => SessionResponse::Err(ClientError::from_wire(code)),
Err(_) => SessionResponse::Err(ClientError::BadFrame("bad ERR payload".into())),
};
let _ = waker.send(resp);
}
events::TYPE_TX_DONE => {
let resp = match TxDonePayload::decode(payload) {
Ok(td) => SessionResponse::TxDone(td),
Err(_) => SessionResponse::Err(ClientError::BadFrame("bad TX_DONE payload".into())),
};
let _ = waker.send(resp);
}
_ => {
// Unexpected tagged type — surface as an error to the waiter.
let _ = waker.send(SessionResponse::Err(ClientError::BadFrame(format!(
"unexpected tagged frame type 0x{type_id:02X}"
))));
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::panic)]
mod tests {
use super::*;
use donglora_protocol::{
FrameDecoder, FrameResult, LoRaBandwidth, LoRaCodingRate, LoRaConfig, LoRaHeaderMode, Modulation, Owner,
RxOrigin, SetConfigResultCode, TxResult,
};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
fn lora_cfg() -> Modulation {
Modulation::LoRa(LoRaConfig {
freq_hz: 910_525_000,
sf: 7,
bw: LoRaBandwidth::Khz62,
cr: LoRaCodingRate::Cr4_5,
preamble_len: 16,
sync_word: 0x1424,
tx_power_dbm: 20,
header_mode: LoRaHeaderMode::Explicit,
payload_crc: true,
iq_invert: false,
})
}
/// Read inbound bytes from `device` into a [`FrameDecoder`] until a
/// single frame with `expected_type` (or any `Err` frame) arrives,
/// returning `(tag, payload bytes)`.
async fn read_one_frame(device: &mut tokio::io::DuplexStream) -> (u8, u16, Vec<u8>) {
let mut decoder = FrameDecoder::new();
let mut buf = [0u8; 256];
loop {
let n = device.read(&mut buf).await.unwrap();
assert!(n > 0, "device stream closed");
let mut out: Option<(u8, u16, Vec<u8>)> = None;
decoder.feed(&buf[..n], |res| match res {
FrameResult::Ok { type_id, tag, payload } => {
out = Some((type_id, tag, payload.to_vec()));
}
FrameResult::Err(_) => panic!("inbound frame decode failed"),
});
if let Some(tup) = out {
return tup;
}
}
}
/// Encode a device-side frame and write it back to the session.
async fn write_frame(device: &mut tokio::io::DuplexStream, type_id: u8, tag: u16, payload: &[u8]) {
let mut wire = [0u8; donglora_protocol::MAX_WIRE_FRAME];
let n = donglora_protocol::encode_frame(type_id, tag, payload, &mut wire).unwrap();
device.write_all(&wire[..n]).await.unwrap();
device.flush().await.unwrap();
}
#[tokio::test]
async fn ping_round_trip() {
let (session_side, mut device) = tokio::io::duplex(512);
let session = Session::spawn(session_side);
let ping_task = tokio::spawn(async move {
session.ping(Duration::from_secs(1)).await.unwrap();
session.close().await;
});
let (type_id, tag, _payload) = read_one_frame(&mut device).await;
assert_eq!(type_id, commands::TYPE_PING);
// PING has no payload, so just ack.
write_frame(&mut device, events::TYPE_OK, tag, &[]).await;
ping_task.await.unwrap();
}
#[tokio::test]
async fn err_maps_to_client_error() {
let (session_side, mut device) = tokio::io::duplex(512);
let session = Session::spawn(session_side);
let task = tokio::spawn(async move {
let res = session.ping(Duration::from_secs(1)).await;
session.close().await;
res
});
let (_type_id, tag, _) = read_one_frame(&mut device).await;
let mut payload = [0u8; 2];
donglora_protocol::events::encode_err_payload(donglora_protocol::ErrorCode::ENotConfigured, &mut payload)
.unwrap();
write_frame(&mut device, events::TYPE_ERR, tag, &payload).await;
let result = task.await.unwrap();
assert!(matches!(result, Err(ClientError::NotConfigured)));
}
#[tokio::test]
async fn tx_two_phase_completion() {
let (session_side, mut device) = tokio::io::duplex(512);
let session = Session::spawn(session_side);
let task = tokio::spawn(async move {
let res = session.transmit(b"hello", false, Duration::from_secs(1)).await;
session.close().await;
res
});
let (type_id, tag, payload) = read_one_frame(&mut device).await;
assert_eq!(type_id, commands::TYPE_TX);
// TX flags byte first, then data.
assert_eq!(payload[0] & 1, 0, "skip_cad should be 0");
assert_eq!(&payload[1..], b"hello");
// Intermediate OK → host must NOT resolve yet.
write_frame(&mut device, events::TYPE_OK, tag, &[]).await;
// Give the session a tick to re-insert the pending slot.
tokio::time::sleep(Duration::from_millis(10)).await;
// Now TX_DONE.
let td = donglora_protocol::TxDonePayload { result: TxResult::Transmitted, airtime_us: 12_345 };
let mut td_buf = [0u8; TxDonePayload::WIRE_SIZE];
td.encode(&mut td_buf).unwrap();
write_frame(&mut device, events::TYPE_TX_DONE, tag, &td_buf).await;
let result = task.await.unwrap().unwrap();
assert_eq!(result.airtime_us, 12_345);
assert_eq!(result.result, TxResult::Transmitted);
}
#[tokio::test]
async fn tx_channel_busy_maps_to_error() {
let (session_side, mut device) = tokio::io::duplex(512);
let session = Session::spawn(session_side);
let task = tokio::spawn(async move {
let res = session.transmit(b"x", false, Duration::from_secs(1)).await;
session.close().await;
res
});
let (_t, tag, _p) = read_one_frame(&mut device).await;
write_frame(&mut device, events::TYPE_OK, tag, &[]).await;
tokio::time::sleep(Duration::from_millis(10)).await;
let td = donglora_protocol::TxDonePayload { result: TxResult::ChannelBusy, airtime_us: 0 };
let mut td_buf = [0u8; TxDonePayload::WIRE_SIZE];
td.encode(&mut td_buf).unwrap();
write_frame(&mut device, events::TYPE_TX_DONE, tag, &td_buf).await;
assert!(matches!(task.await.unwrap(), Err(ClientError::ChannelBusy)));
}
#[tokio::test]
async fn async_rx_delivered_to_queue() {
let (session_side, mut device) = tokio::io::duplex(1024);
let session = Session::spawn(session_side);
// Push an RX event (tag=0).
let mut data = heapless::Vec::<u8, { donglora_protocol::MAX_OTA_PAYLOAD }>::new();
data.extend_from_slice(b"rx-payload").unwrap();
let rx = RxPayload {
rssi_tenths_dbm: -720,
snr_tenths_db: 95,
freq_err_hz: -100,
timestamp_us: 1_111_111,
crc_valid: true,
packets_dropped: 0,
origin: RxOrigin::Ota,
data,
};
let mut rx_buf = [0u8; RxPayload::METADATA_SIZE + donglora_protocol::MAX_OTA_PAYLOAD];
let n = rx.encode(&mut rx_buf).unwrap();
write_frame(&mut device, events::TYPE_RX, 0, &rx_buf[..n]).await;
let got = session.recv_rx(Duration::from_secs(1)).await.unwrap();
assert_eq!(&got.data[..], b"rx-payload");
assert_eq!(got.rssi_tenths_dbm, -720);
session.close().await;
}
/// Two out-of-order concurrent sends must correlate correctly by tag.
///
/// Runs three futures via `tokio::join!` on the same task: two pings
/// and a "device" task that drains both command frames and responds
/// in reverse order. If tag correlation is broken, one or both pings
/// resolve with the wrong response (or time out).
#[tokio::test]
async fn concurrent_sends_correlate_by_tag() {
let (session_side, mut device) = tokio::io::duplex(1024);
let session = Session::spawn(session_side);
let ping_a = session.ping(Duration::from_secs(2));
let ping_b = session.ping(Duration::from_secs(2));
let device_drive = async {
// Two PINGs may arrive in the same `device.read` or split
// across reads — drain until we've seen both, regardless.
let mut decoder = FrameDecoder::new();
let mut buf = [0u8; 256];
let mut frames: Vec<(u8, u16, Vec<u8>)> = Vec::new();
while frames.len() < 2 {
let n = device.read(&mut buf).await.unwrap();
decoder.feed(&buf[..n], |res| {
if let FrameResult::Ok { type_id, tag, payload } = res {
frames.push((type_id, tag, payload.to_vec()));
}
});
}
let (t1, tag1, _) = frames[0].clone();
let (t2, tag2, _) = frames[1].clone();
assert_eq!(t1, commands::TYPE_PING);
assert_eq!(t2, commands::TYPE_PING);
assert_ne!(tag1, tag2, "each command gets a fresh tag");
// Ack in reverse order to prove correlation doesn't depend
// on FIFO.
write_frame(&mut device, events::TYPE_OK, tag2, &[]).await;
write_frame(&mut device, events::TYPE_OK, tag1, &[]).await;
(tag1, tag2)
};
let (a, b, (_tag1, _tag2)) = tokio::join!(ping_a, ping_b, device_drive);
a.unwrap();
b.unwrap();
session.close().await;
}
#[tokio::test]
async fn close_wakes_pending_commands() {
let (session_side, _device) = tokio::io::duplex(512);
let session = Arc::new(Session::spawn(session_side));
let s = session.clone();
let task = tokio::spawn(async move { s.ping(Duration::from_secs(5)).await });
// Give the request a moment to hit the writer.
tokio::time::sleep(Duration::from_millis(20)).await;
session.close().await;
let err = task.await.unwrap().unwrap_err();
assert!(matches!(err, ClientError::TransportClosed(_)));
}
#[tokio::test]
async fn timeout_removes_pending_slot() {
let (session_side, _device) = tokio::io::duplex(512);
let session = Session::spawn(session_side);
let err = session.ping(Duration::from_millis(50)).await.unwrap_err();
assert!(matches!(err, ClientError::Timeout { .. }));
session.close().await;
}
#[tokio::test]
async fn set_config_round_trip() {
let (session_side, mut device) = tokio::io::duplex(1024);
let session = Session::spawn(session_side);
let cfg = lora_cfg();
let task = tokio::spawn(async move {
let r = session.set_config(cfg, Duration::from_secs(1)).await;
session.close().await;
r
});
let (type_id, tag, _p) = read_one_frame(&mut device).await;
assert_eq!(type_id, commands::TYPE_SET_CONFIG);
// Build the OK(SetConfigResult) response with APPLIED + MINE + echoed cfg.
let result = SetConfigResult { result: SetConfigResultCode::Applied, owner: Owner::Mine, current: cfg };
let mut rbuf = [0u8; donglora_protocol::MAX_SETCONFIG_OK_PAYLOAD];
let n = result.encode(&mut rbuf).unwrap();
write_frame(&mut device, events::TYPE_OK, tag, &rbuf[..n]).await;
let got = task.await.unwrap().unwrap();
assert_eq!(got.result, SetConfigResultCode::Applied);
assert_eq!(got.owner, Owner::Mine);
}
#[tokio::test]
async fn bad_frame_lands_in_async_err_queue() {
let (session_side, mut device) = tokio::io::duplex(512);
let session = Session::spawn(session_side);
// Write a garbage byte then a zero sentinel — decoder emits Err.
device.write_all(&[0x01, 0x01, 0x00]).await.unwrap();
device.flush().await.unwrap();
// Give the reader time to dispatch.
tokio::time::sleep(Duration::from_millis(50)).await;
let errors = session.drain_async_errors().await;
assert!(!errors.is_empty(), "expected at least one bad-frame error");
session.close().await;
}
}
+142 -163
View File
@@ -1,228 +1,207 @@
//! Transport abstraction for byte-stream connections.
//! Async transport abstractions.
//!
//! The [`Transport`] trait unifies serial ports and mux socket connections,
//! allowing the [`Client`](crate::client::Client) to work over any of them.
//! The [`Transport`] marker trait unifies tokio-based byte streams so the
//! [`Session`](crate::session::Session) can drive any of them. Three
//! concrete transports are provided:
//!
//! - [`SerialTransport`] — `tokio-serial` USB CDC-ACM.
//! - [`UnixSocketTransport`] — mux daemon over `tokio::net::UnixStream`.
//! - [`TcpTransport`] — mux daemon over `tokio::net::TcpStream`.
//!
//! Most callers use [`AnyTransport`] so the concrete variant is decided
//! at `connect()` time without leaking type parameters into user code.
use std::io::{self, Read, Write};
use std::io;
use std::pin::Pin;
use std::task::{Context, Poll};
use std::time::Duration;
/// A byte-stream transport that the client can talk over.
pub trait Transport: Read + Write + Send {
/// Set the read timeout. A read that takes longer returns zero bytes.
fn set_timeout(&mut self, timeout: Duration) -> anyhow::Result<()>;
use tokio::io::{AsyncRead, AsyncWrite, ReadBuf};
use tokio::net::TcpStream;
#[cfg(unix)]
use tokio::net::UnixStream;
use tokio_serial::{SerialPortBuilderExt, SerialStream};
/// Get the current read timeout.
fn timeout(&self) -> Duration;
}
use crate::errors::{ClientError, ClientResult};
/// Marker for any async byte-stream we can carry DongLoRa Protocol frames over.
///
/// This is intentionally a blanket trait so the [`Session`](crate::session::Session)
/// can accept `SerialStream`, `UnixStream`, `TcpStream`, or the
/// type-erased [`AnyTransport`] uniformly.
pub trait Transport: AsyncRead + AsyncWrite + Unpin + Send + 'static {}
impl<T: AsyncRead + AsyncWrite + Unpin + Send + 'static> Transport for T {}
// ── Serial transport ───────────────────────────────────────────────
/// Direct USB serial port connection to a DongLoRa dongle.
/// Direct USB serial port connection (tokio-serial).
pub struct SerialTransport {
port: Box<dyn serialport::SerialPort>,
timeout: Duration,
inner: SerialStream,
}
impl SerialTransport {
/// Open a serial port with the given timeout.
///
/// Baud rate is irrelevant for USB CDC-ACM but we pass a conventional value.
pub fn open(path: &str, timeout: Duration) -> anyhow::Result<Self> {
let port = serialport::new(path, 115_200)
.timeout(timeout)
.open()
.map_err(|e| anyhow::anyhow!("failed to open serial port {path}: {e}"))?;
Ok(Self { port, timeout })
}
/// Clear the serial input buffer.
pub fn reset_input_buffer(&self) -> anyhow::Result<()> {
self.port
.clear(serialport::ClearBuffer::Input)
.map_err(|e| anyhow::anyhow!("failed to clear input buffer: {e}"))
/// Open a serial port. Baud rate is irrelevant for USB CDC-ACM but
/// `tokio-serial` still demands a value; 115200 is conventional.
pub fn open(path: &str) -> ClientResult<Self> {
let inner = tokio_serial::new(path, 115_200)
.open_native_async()
.map_err(|e| ClientError::Other(format!("failed to open serial port {path}: {e}")))?;
Ok(Self { inner })
}
}
impl Read for SerialTransport {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
self.port.read(buf)
impl AsyncRead for SerialTransport {
fn poll_read(mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll<io::Result<()>> {
Pin::new(&mut self.inner).poll_read(cx, buf)
}
}
impl Write for SerialTransport {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.port.write(buf)
impl AsyncWrite for SerialTransport {
fn poll_write(mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &[u8]) -> Poll<io::Result<usize>> {
Pin::new(&mut self.inner).poll_write(cx, buf)
}
fn flush(&mut self) -> io::Result<()> {
self.port.flush()
fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
Pin::new(&mut self.inner).poll_flush(cx)
}
fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
Pin::new(&mut self.inner).poll_shutdown(cx)
}
}
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.timeout = timeout;
Ok(())
}
// ── Unix socket transport ──────────────────────────────────────────
fn timeout(&self) -> Duration {
self.timeout
/// Mux daemon connection over a Unix domain socket.
#[cfg(unix)]
pub struct UnixSocketTransport {
inner: UnixStream,
}
#[cfg(unix)]
impl UnixSocketTransport {
/// Connect to the mux daemon at `path`.
pub async fn connect(path: &str) -> ClientResult<Self> {
let inner = UnixStream::connect(path)
.await
.map_err(|e| ClientError::Other(format!("failed to connect to mux socket {path}: {e}")))?;
Ok(Self { inner })
}
}
// ── Mux transport ──────────────────────────────────────────────────
/// Connection to the DongLoRa mux daemon via Unix socket or TCP.
pub struct MuxTransport {
stream: MuxStream,
timeout: Duration,
#[cfg(unix)]
impl AsyncRead for UnixSocketTransport {
fn poll_read(mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll<io::Result<()>> {
Pin::new(&mut self.inner).poll_read(cx, buf)
}
}
enum MuxStream {
#[cfg(unix)]
Unix(std::os::unix::net::UnixStream),
Tcp(std::net::TcpStream),
}
impl MuxTransport {
/// Connect to the mux daemon via Unix domain socket.
#[cfg(unix)]
pub fn unix(path: &str, timeout: Duration) -> anyhow::Result<Self> {
let stream = std::os::unix::net::UnixStream::connect(path)
.map_err(|e| anyhow::anyhow!("failed to connect to mux socket {path}: {e}"))?;
stream
.set_read_timeout(Some(timeout))
.map_err(|e| anyhow::anyhow!("failed to set socket read timeout: {e}"))?;
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,
})
#[cfg(unix)]
impl AsyncWrite for UnixSocketTransport {
fn poll_write(mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &[u8]) -> Poll<io::Result<usize>> {
Pin::new(&mut self.inner).poll_write(cx, buf)
}
/// Connect to the mux daemon via TCP.
pub fn tcp(host: &str, port: u16, timeout: Duration) -> anyhow::Result<Self> {
use std::net::ToSocketAddrs;
fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
Pin::new(&mut self.inner).poll_flush(cx)
}
fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
Pin::new(&mut self.inner).poll_shutdown(cx)
}
}
// ── TCP transport ──────────────────────────────────────────────────
/// Mux daemon connection over TCP. Uses `TCP_NODELAY` because DongLoRa Protocol frames
/// are small and latency-sensitive.
pub struct TcpTransport {
inner: TcpStream,
}
impl TcpTransport {
/// Connect to `host:port`. A failed connect attempt returns an error
/// immediately; no internal retries (callers decide policy).
pub async fn connect(host: &str, port: u16, timeout: Duration) -> ClientResult<Self> {
let addr = format!("{host}:{port}");
let sock_addr = addr
.to_socket_addrs()
.map_err(|e| anyhow::anyhow!("failed to resolve mux address {addr}: {e}"))?
.next()
.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,
})
let fut = TcpStream::connect(&addr);
let stream = tokio::time::timeout(timeout, fut)
.await
.map_err(|_| ClientError::Timeout { what: "tcp connect" })?
.map_err(|e| ClientError::Other(format!("failed to connect to {addr}: {e}")))?;
stream.set_nodelay(true).map_err(|e| ClientError::Other(format!("failed to set TCP_NODELAY: {e}")))?;
Ok(Self { inner: stream })
}
}
impl Read for MuxTransport {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
match &mut self.stream {
#[cfg(unix)]
MuxStream::Unix(s) => s.read(buf),
MuxStream::Tcp(s) => s.read(buf),
}
impl AsyncRead for TcpTransport {
fn poll_read(mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll<io::Result<()>> {
Pin::new(&mut self.inner).poll_read(cx, buf)
}
}
impl Write for MuxTransport {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
match &mut self.stream {
#[cfg(unix)]
MuxStream::Unix(s) => s.write(buf),
MuxStream::Tcp(s) => s.write(buf),
}
impl AsyncWrite for TcpTransport {
fn poll_write(mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &[u8]) -> Poll<io::Result<usize>> {
Pin::new(&mut self.inner).poll_write(cx, buf)
}
fn flush(&mut self) -> io::Result<()> {
match &mut self.stream {
#[cfg(unix)]
MuxStream::Unix(s) => s.flush(),
MuxStream::Tcp(s) => s.flush(),
}
}
}
impl Transport for MuxTransport {
fn set_timeout(&mut self, timeout: Duration) -> anyhow::Result<()> {
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))),
};
result.map_err(|e| anyhow::anyhow!("failed to set timeout: {e}"))
fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
Pin::new(&mut self.inner).poll_flush(cx)
}
fn timeout(&self) -> Duration {
self.timeout
fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
Pin::new(&mut self.inner).poll_shutdown(cx)
}
}
// ── AnyTransport ───────────────────────────────────────────────────
/// Type-erased transport for the [`connect`](crate::connect::connect) return type.
/// Type-erased transport returned by [`crate::connect`]. One of the three
/// concrete variants depending on which transport the auto-discovery chain
/// succeeded on.
pub enum AnyTransport {
Serial(SerialTransport),
Mux(MuxTransport),
#[cfg(unix)]
Unix(UnixSocketTransport),
Tcp(TcpTransport),
}
impl Read for AnyTransport {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
match self {
Self::Serial(t) => t.read(buf),
Self::Mux(t) => t.read(buf),
impl AsyncRead for AnyTransport {
fn poll_read(self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll<io::Result<()>> {
match self.get_mut() {
Self::Serial(t) => Pin::new(t).poll_read(cx, buf),
#[cfg(unix)]
Self::Unix(t) => Pin::new(t).poll_read(cx, buf),
Self::Tcp(t) => Pin::new(t).poll_read(cx, buf),
}
}
}
impl Write for AnyTransport {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
match self {
Self::Serial(t) => t.write(buf),
Self::Mux(t) => t.write(buf),
impl AsyncWrite for AnyTransport {
fn poll_write(self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &[u8]) -> Poll<io::Result<usize>> {
match self.get_mut() {
Self::Serial(t) => Pin::new(t).poll_write(cx, buf),
#[cfg(unix)]
Self::Unix(t) => Pin::new(t).poll_write(cx, buf),
Self::Tcp(t) => Pin::new(t).poll_write(cx, buf),
}
}
fn flush(&mut self) -> io::Result<()> {
match self {
Self::Serial(t) => t.flush(),
Self::Mux(t) => t.flush(),
fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
match self.get_mut() {
Self::Serial(t) => Pin::new(t).poll_flush(cx),
#[cfg(unix)]
Self::Unix(t) => Pin::new(t).poll_flush(cx),
Self::Tcp(t) => Pin::new(t).poll_flush(cx),
}
}
}
impl Transport for AnyTransport {
fn set_timeout(&mut self, timeout: Duration) -> anyhow::Result<()> {
match self {
Self::Serial(t) => t.set_timeout(timeout),
Self::Mux(t) => t.set_timeout(timeout),
}
}
fn timeout(&self) -> Duration {
match self {
Self::Serial(t) => t.timeout(),
Self::Mux(t) => t.timeout(),
fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
match self.get_mut() {
Self::Serial(t) => Pin::new(t).poll_shutdown(cx),
#[cfg(unix)]
Self::Unix(t) => Pin::new(t).poll_shutdown(cx),
Self::Tcp(t) => Pin::new(t).poll_shutdown(cx),
}
}
}
+98
View File
@@ -0,0 +1,98 @@
//! Smoke-level vector round-trips through the `donglora-client` re-exports.
//!
//! The authoritative Appendix C vectors live in
//! `donglora-protocol/tests/vectors.rs`. Here we check a representative
//! subset just to confirm the client crate's re-exports cover the shapes
//! a real application would use (so a refactor that accidentally hides
//! one of them fails here rather than in downstream code).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use donglora_client::{
Command, LoRaBandwidth, LoRaCodingRate, LoRaConfig, LoRaHeaderMode, Modulation, RxOrigin, RxPayload, TxDonePayload,
TxResult,
};
use donglora_protocol::{FrameDecoder, FrameResult, MAX_WIRE_FRAME, encode_frame};
fn meshcore_us() -> Modulation {
Modulation::LoRa(LoRaConfig {
freq_hz: 910_525_000,
sf: 7,
bw: LoRaBandwidth::Khz62,
cr: LoRaCodingRate::Cr4_5,
preamble_len: 16,
sync_word: 0x1424,
tx_power_dbm: 20,
header_mode: LoRaHeaderMode::Explicit,
payload_crc: true,
iq_invert: false,
})
}
/// `SET_CONFIG(MeshCore US)` should encode/decode byte-for-byte through
/// the public API.
#[test]
fn set_config_roundtrip() {
let cmd = Command::SetConfig(meshcore_us());
let mut payload = [0u8; 256];
let n = cmd.encode_payload(&mut payload).unwrap();
let mut wire = [0u8; MAX_WIRE_FRAME];
let wire_n = encode_frame(cmd.type_id(), 42, &payload[..n], &mut wire).unwrap();
let mut decoder = FrameDecoder::new();
let mut saw: Option<(u8, u16, Vec<u8>)> = None;
decoder.feed(&wire[..wire_n], |res| match res {
FrameResult::Ok { type_id, tag, payload } => {
saw = Some((type_id, tag, payload.to_vec()));
}
FrameResult::Err(e) => panic!("decode error: {e:?}"),
});
let (type_id, tag, payload) = saw.expect("frame");
assert_eq!(type_id, cmd.type_id());
assert_eq!(tag, 42);
let parsed = Command::parse(type_id, &payload).unwrap();
match parsed {
Command::SetConfig(Modulation::LoRa(c)) => {
assert_eq!(c.freq_hz, 910_525_000);
assert_eq!(c.sync_word, 0x1424);
assert_eq!(c.tx_power_dbm, 20);
}
_ => panic!("expected LoRa SET_CONFIG"),
}
}
/// `TX_DONE` round-trips with airtime preserved.
#[test]
fn tx_done_roundtrip() {
let td = TxDonePayload { result: TxResult::Transmitted, airtime_us: 98_765 };
let mut buf = [0u8; TxDonePayload::WIRE_SIZE];
td.encode(&mut buf).unwrap();
let got = TxDonePayload::decode(&buf).unwrap();
assert_eq!(got, td);
}
/// `RX` event round-trips with metadata preserved.
#[test]
fn rx_roundtrip() {
let mut data = heapless::Vec::<u8, { donglora_protocol::MAX_OTA_PAYLOAD }>::new();
data.extend_from_slice(b"hello, lora!").unwrap();
let rx = RxPayload {
rssi_tenths_dbm: -812,
snr_tenths_db: 73,
freq_err_hz: -211,
timestamp_us: 1_000_000_000_000,
crc_valid: true,
packets_dropped: 3,
origin: RxOrigin::Ota,
data,
};
let mut buf = [0u8; RxPayload::METADATA_SIZE + 64];
let n = rx.encode(&mut buf).unwrap();
let got = RxPayload::decode(&buf[..n]).unwrap();
assert_eq!(got.rssi_tenths_dbm, -812);
assert_eq!(got.snr_tenths_db, 73);
assert_eq!(got.freq_err_hz, -211);
assert_eq!(got.packets_dropped, 3);
assert_eq!(&got.data[..], b"hello, lora!");
}