Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c43a78f8bb
|
||
|
|
6937ad47ba
|
||
|
|
055fc76b87
|
||
|
|
77addfa698
|
No files matched your search
@@ -1,5 +1,61 @@
|
||||
# Changelog
|
||||
|
||||
## 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
|
||||
|
||||
Generated
+505
-25
@@ -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.2.0"
|
||||
version = "1.0.0"
|
||||
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",
|
||||
]
|
||||
+29
-5
@@ -1,11 +1,11 @@
|
||||
[package]
|
||||
name = "donglora-client"
|
||||
version = "0.2.0"
|
||||
version = "1.0.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.85"
|
||||
authors = ["Stephen Waits <steve@waits.net>"]
|
||||
license = "MIT"
|
||||
description = "DongLoRa host library — connect, configure, send/receive LoRa packets"
|
||||
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"]
|
||||
@@ -13,10 +13,34 @@ 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"
|
||||
|
||||
@@ -1,57 +1,104 @@
|
||||
# DongLoRa Rust Client
|
||||
|
||||
Rust client library for talking to a DongLoRa device — either directly
|
||||
over USB or through a mux daemon.
|
||||
[](https://crates.io/crates/donglora-client)
|
||||
[](https://docs.rs/donglora-client)
|
||||
|
||||
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.
|
||||
|
||||
[dp]: https://crates.io/crates/donglora-protocol
|
||||
|
||||
## Install
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
donglora-client = "0.2"
|
||||
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.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(())
|
||||
}
|
||||
```
|
||||
|
||||
All `connect` functions automatically validate the device by pinging it,
|
||||
so you'll get a clear error immediately if the port isn't a real DongLoRa.
|
||||
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.
|
||||
|
||||
## What's in Here
|
||||
|
||||
- `src/protocol.rs` — wire protocol types (`RadioConfig`, `Command`, `Response`, `ErrorCode`)
|
||||
- `src/codec.rs` — COBS framing, frame accumulator
|
||||
- `src/discovery.rs` — USB VID:PID device discovery
|
||||
- `src/transport.rs` — serial and mux socket transports
|
||||
- `src/client.rs` — high-level `Client<T>` with send/recv
|
||||
- `src/connect.rs` — auto-detection (mux socket, TCP, direct USB)
|
||||
- `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`] and [`try_connect`] try these in order:
|
||||
`connect(None, timeout)` tries these in order:
|
||||
|
||||
1. **TCP mux** — via `DONGLORA_MUX_TCP` env var
|
||||
2. **Unix socket mux** — checks `$XDG_RUNTIME_DIR/donglora/mux.sock` or `/tmp/donglora-mux.sock`
|
||||
3. **Direct USB serial** — auto-detects by VID:PID
|
||||
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).
|
||||
|
||||
`try_connect` returns an error immediately if no device is found.
|
||||
`connect` blocks until a USB device appears.
|
||||
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.
|
||||
|
||||
## Dependencies
|
||||
`try_connect(timeout)` returns an error immediately if no device is
|
||||
found; `connect` blocks until one appears.
|
||||
|
||||
- `ucobs` — COBS framing (same implementation as the firmware)
|
||||
- `serialport` — USB serial communication
|
||||
- `anyhow` — error handling
|
||||
- `tracing` — logging
|
||||
## 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).
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
+20
-15
@@ -1,25 +1,30 @@
|
||||
//! Connect to a DongLoRa device, ping it, and print the MAC address.
|
||||
//! Connect to a DongLoRa device, ping it, and print its info.
|
||||
//!
|
||||
//! Run with: cargo run --example ping
|
||||
//! Run with: `cargo run --example ping`
|
||||
|
||||
use donglora_client::{RadioConfig, try_connect};
|
||||
use std::time::Duration;
|
||||
use donglora_client::connect;
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
let mut client = try_connect(Duration::from_secs(2))?;
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
tracing_subscriber_init();
|
||||
|
||||
let dongle = connect().await?;
|
||||
println!("connected!");
|
||||
|
||||
client.ping()?;
|
||||
dongle.ping().await?;
|
||||
println!("ping ok");
|
||||
|
||||
let mac = client.get_mac()?;
|
||||
println!("MAC: {:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}", mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
|
||||
|
||||
let config = client.get_config()?;
|
||||
println!("current config: {config:?}");
|
||||
|
||||
client.set_config(RadioConfig::default())?;
|
||||
println!("config set to defaults");
|
||||
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();
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
-232
@@ -1,232 +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 std::time::Duration;
|
||||
|
||||
use crate::codec::{encode_frame, read_frame};
|
||||
use crate::protocol::{Command, ErrorCode, MAX_PAYLOAD, 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;
|
||||
|
||||
/// Timeout for the ping-on-connect handshake.
|
||||
const HANDSHAKE_TIMEOUT: Duration = Duration::from_millis(200);
|
||||
|
||||
/// High-level DongLoRa client, generic over transport.
|
||||
///
|
||||
/// Works with any [`Transport`]: direct USB serial, Unix socket mux, or TCP mux.
|
||||
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.
|
||||
// Preserve the original error if both fail.
|
||||
let restore_result = self.transport.set_timeout(old_timeout);
|
||||
result?;
|
||||
restore_result?;
|
||||
Ok(packets)
|
||||
}
|
||||
|
||||
/// Validate the connection by pinging the device.
|
||||
///
|
||||
/// Uses a short timeout to fail fast on non-DongLoRa devices (e.g. USB-UART
|
||||
/// bridges that happen to match known VID:PIDs). Called automatically by the
|
||||
/// [`connect`](crate::connect::connect) family of functions.
|
||||
pub fn validate(&mut self) -> anyhow::Result<()> {
|
||||
let saved = self.transport.timeout();
|
||||
self.transport.set_timeout(HANDSHAKE_TIMEOUT)?;
|
||||
let result = self.ping();
|
||||
self.transport.set_timeout(saved)?;
|
||||
result.map_err(|_| anyhow::anyhow!("device did not respond to ping — not a DongLoRa device"))
|
||||
}
|
||||
|
||||
/// Send a Ping and verify the Pong response.
|
||||
pub fn ping(&mut self) -> anyhow::Result<()> {
|
||||
match self.send(Command::Ping)? {
|
||||
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<()> {
|
||||
if payload.len() > MAX_PAYLOAD {
|
||||
anyhow::bail!("payload too large ({} bytes, max {MAX_PAYLOAD})", payload.len());
|
||||
}
|
||||
let cmd = Command::Transmit { config, payload: payload.to_vec() };
|
||||
match self.send(cmd)? {
|
||||
Response::TxDone => Ok(()),
|
||||
Response::Error(ErrorCode::TxTimeout) => anyhow::bail!("transmit timed out"),
|
||||
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);
|
||||
}
|
||||
}
|
||||
-312
@@ -1,312 +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)]
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
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(), Some(&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());
|
||||
}
|
||||
}
|
||||
+321
-152
@@ -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,195 +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)?;
|
||||
let mut client = Client::new(transport);
|
||||
client.validate()?;
|
||||
Ok(client)
|
||||
// ── 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)?;
|
||||
let mut client = Client::new(transport);
|
||||
client.validate()?;
|
||||
Ok(client)
|
||||
}
|
||||
/// 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)?;
|
||||
let mut client = Client::new(AnyTransport::Serial(transport));
|
||||
client.validate()?;
|
||||
return Ok(client);
|
||||
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}");
|
||||
let mut client = Client::new(AnyTransport::Mux(transport));
|
||||
client.validate()?;
|
||||
return Ok(client);
|
||||
// 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}");
|
||||
let mut client = Client::new(AnyTransport::Mux(transport));
|
||||
if client.validate().is_ok() {
|
||||
return Ok(client);
|
||||
}
|
||||
debug!("mux at {path} did not validate, falling back to USB");
|
||||
}
|
||||
Err(_) => {
|
||||
// Stale socket or connection refused — fall through
|
||||
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)?;
|
||||
let mut client = Client::new(AnyTransport::Serial(transport));
|
||||
client.validate()?;
|
||||
Ok(client)
|
||||
}
|
||||
|
||||
/// 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}");
|
||||
let mut client = Client::new(AnyTransport::Mux(transport));
|
||||
client.validate()?;
|
||||
return Ok(client);
|
||||
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}");
|
||||
let mut client = Client::new(AnyTransport::Mux(transport));
|
||||
client.validate()?;
|
||||
Ok(client)
|
||||
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()))
|
||||
}
|
||||
|
||||
/// Like [`connect`] but returns an error instead of blocking when no USB
|
||||
/// device is present. Suitable for callers with their own retry logic.
|
||||
///
|
||||
/// Runs the same fallback chain as `connect(None, timeout)` — TCP mux, Unix
|
||||
/// socket mux, then direct USB serial — but uses a single non-blocking scan
|
||||
/// instead of polling indefinitely for a USB device.
|
||||
pub fn try_connect(timeout: Duration) -> anyhow::Result<Client<AnyTransport>> {
|
||||
// Try TCP mux via environment variable
|
||||
if let Ok(tcp) = std::env::var("DONGLORA_MUX_TCP")
|
||||
&& let Some(transport) = try_tcp_mux(&tcp, timeout)
|
||||
{
|
||||
debug!("connected to TCP mux at {tcp}");
|
||||
let mut client = Client::new(AnyTransport::Mux(transport));
|
||||
client.validate()?;
|
||||
return Ok(client);
|
||||
}
|
||||
/// 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
|
||||
}
|
||||
|
||||
// Try Unix socket mux
|
||||
/// 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)]
|
||||
if let Some(path) = find_mux_socket() {
|
||||
match MuxTransport::unix(&path, timeout) {
|
||||
Ok(transport) => {
|
||||
debug!("connected to mux socket at {path}");
|
||||
let mut client = Client::new(AnyTransport::Mux(transport));
|
||||
if client.validate().is_ok() {
|
||||
return Ok(client);
|
||||
}
|
||||
debug!("mux socket at {path} did not validate, falling back to USB");
|
||||
{
|
||||
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;
|
||||
}
|
||||
Err(_) => {
|
||||
debug!("mux socket at {path} not reachable, falling back to USB");
|
||||
if !warned {
|
||||
info!("waiting for mux at {path} ...");
|
||||
warned = true;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
}
|
||||
|
||||
// Direct USB serial — single non-blocking scan
|
||||
let port_path =
|
||||
discovery::find_port().ok_or_else(|| anyhow::anyhow!("no DongLoRa device found (no mux, no USB device)"))?;
|
||||
debug!("opening serial port {port_path}");
|
||||
let transport = SerialTransport::open(&port_path, timeout)?;
|
||||
let mut client = Client::new(AnyTransport::Serial(transport));
|
||||
client.validate()?;
|
||||
Ok(client)
|
||||
#[cfg(not(unix))]
|
||||
Err(ClientError::Other("no mux endpoint available".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 };
|
||||
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))
|
||||
}
|
||||
+27
-20
@@ -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.
|
||||
@@ -21,50 +24,54 @@ const BRIDGE_VID_PIDS: &[(u16, u16)] = &[
|
||||
(0x0403, 0x6001), // FT232R (FTDI)
|
||||
];
|
||||
|
||||
/// Find the DongLoRa serial port by USB VID:PID.
|
||||
/// 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`.
|
||||
///
|
||||
/// Checks for native USB CDC-ACM first (VID:PID 1209:5741), then falls
|
||||
/// back to known USB-UART bridge chips found on some board revisions.
|
||||
/// Returns the first matching port path, or `None` if no device is found.
|
||||
/// 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()?;
|
||||
|
||||
// Prefer native USB (our custom VID:PID)
|
||||
if let Some(port) = ports.iter().find(|p| {
|
||||
matches!(
|
||||
&p.port_type,
|
||||
serialport::SerialPortType::UsbPort(info)
|
||||
if info.vid == USB_VID && info.pid == USB_PID
|
||||
SerialPortType::UsbPort(info) if info.vid == USB_VID && info.pid == USB_PID
|
||||
)
|
||||
}) {
|
||||
return Some(port.port_name.clone());
|
||||
}
|
||||
|
||||
// Fall back to known USB-UART bridge chips
|
||||
ports
|
||||
.into_iter()
|
||||
.find(|p| {
|
||||
matches!(
|
||||
&p.port_type,
|
||||
serialport::SerialPortType::UsbPort(info)
|
||||
if BRIDGE_VID_PIDS.contains(&(info.vid, info.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
@@ -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
@@ -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
-28
@@ -1,43 +1,76 @@
|
||||
//! DongLoRa host library — connect, configure, send/receive LoRa packets.
|
||||
//!
|
||||
//! Implements the DongLoRa USB protocol (COBS-framed fixed-size LE).
|
||||
//! See the [`protocol`] module for wire types and constants.
|
||||
//! 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.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::{FrameReader, decode_frame, encode_frame, read_frame};
|
||||
pub use connect::{connect, connect_default, connect_mux_auto, default_socket_path, try_connect};
|
||||
pub use discovery::{USB_PID, USB_VID, find_port, wait_for_device};
|
||||
pub use protocol::{
|
||||
Bandwidth, Command, ErrorCode, MAX_PAYLOAD, PREAMBLE_DEFAULT, RADIO_CONFIG_SIZE, RadioConfig, Response,
|
||||
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,
|
||||
};
|
||||
-659
@@ -1,659 +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.
|
||||
|
||||
/// 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::error::Error for ErrorCode {}
|
||||
|
||||
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)]
|
||||
#[allow(clippy::unwrap_used)]
|
||||
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
@@ -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
@@ -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
-147
@@ -1,212 +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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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!");
|
||||
}
|
||||
Reference in new issue
Block a user