diff --git a/CHANGELOG.md b/CHANGELOG.md index 144b7c0..72920d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,13 +1,49 @@ # Changelog -## Unreleased +## 1.0.0-alpha.1 — 2026-04-21 -### Fixed +### Breaking -- Once a mux connection succeeds, all future `connect()` calls in the same - process only try the mux. `connect()` blocks waiting for the socket to - reappear; `try_connect()` returns an error so callers can retry with backoff. - Prevents clients from stealing the serial port during mux restarts. +- **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` 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 = Result`. + 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`, `Response`, `RadioConfig`, `Bandwidth`, and `TX_POWER_MAX` + types. `Modulation` / `LoRaConfig` / `LoRaBandwidth` (re-exported from + `donglora-protocol`) replace them. ## 0.2.1 — 2026-04-07 diff --git a/Cargo.lock b/Cargo.lock index ca586f9..897cfc4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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,147 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "donglora-client" -version = "0.2.1" +version = "1.0.0-alpha.1" dependencies = [ "anyhow", - "serialport", + "donglora-protocol", + "heapless", + "rand", + "thiserror", + "tokio", + "tokio-serial", + "tokio-stream", "tracing", + "tracing-subscriber", +] + +[[package]] +name = "donglora-protocol" +version = "0.1.0" +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 +222,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 +235,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 +249,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 +300,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 +334,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 +360,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 +429,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 +504,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 +593,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 +646,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 +704,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 +776,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", +] diff --git a/Cargo.toml b/Cargo.toml index 30f201e..5b55708 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,11 +1,11 @@ [package] name = "donglora-client" -version = "0.2.1" +version = "1.0.0-alpha.1" edition = "2024" rust-version = "1.85" authors = ["Stephen Waits "] 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 = { path = "../protocol", 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 = { path = "../protocol", 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" diff --git a/examples/common/mod.rs b/examples/common/mod.rs new file mode 100644 index 0000000..eee5a54 --- /dev/null +++ b/examples/common/mod.rs @@ -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, + }) +} diff --git a/examples/ping.rs b/examples/ping.rs index b3bc687..a6744ec 100644 --- a/examples/ping.rs +++ b/examples/ping.rs @@ -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(); +} diff --git a/examples/rx.rs b/examples/rx.rs new file mode 100644 index 0000000..a246a53 --- /dev/null +++ b/examples/rx.rs @@ -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(()) +} diff --git a/examples/tx.rs b/examples/tx.rs new file mode 100644 index 0000000..6a676e8 --- /dev/null +++ b/examples/tx.rs @@ -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; + } +} diff --git a/src/client.rs b/src/client.rs deleted file mode 100644 index c23f225..0000000 --- a/src/client.rs +++ /dev/null @@ -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 { - transport: T, - rx_buffer: VecDeque, -} - -impl Client { - /// 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 { - 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 = 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> { - 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> { - let mut packets: Vec = 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) -> 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 { - 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); - } -} diff --git a/src/codec.rs b/src/codec.rs deleted file mode 100644 index c1331ac..0000000 --- a/src/codec.rs +++ /dev/null @@ -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 { - 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> { - 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, -} - -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> { - 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>> { - 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::::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 = (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()); - } -} diff --git a/src/connect.rs b/src/connect.rs index 99cdf0e..557d929 100644 --- a/src/connect.rs +++ b/src/connect.rs @@ -1,30 +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 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}; -/// Set once this process connects via mux. All future `connect()` calls -/// will only try mux, never fall through to direct serial. +/// 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 read timeout for connections. +/// 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, + timeout: Option, + config: Option, + 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) -> 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; @@ -35,204 +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 { +/// 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 { 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> { - 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 { + 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> { - 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 { + 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> { - // 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; } - // Previously connected via mux — stay on mux, wait if necessary + // Sticky: once we've used a mux, stay on mux (wait if socket + // disappeared — a mux restart). if USED_MUX.load(Ordering::Relaxed) { - let path = default_socket_path(); - while !std::path::Path::new(&path).exists() { - info!("Waiting for mux at {path} ..."); - std::thread::sleep(Duration::from_millis(500)); + return connect_mux_sticky(&opts, timeout).await; + } + + // 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() { + 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 { + try_connect_with(ConnectOptions::new()).await +} + +/// Non-blocking variant of [`connect_with`]. +pub async fn try_connect_with(opts: ConnectOptions) -> ClientResult { + 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())); } - let transport = MuxTransport::unix(&path, timeout)?; - let mut client = Client::new(AnyTransport::Mux(transport)); - client.validate()?; - return Ok(client); } - // 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}"); + if let Some((transport, endpoint)) = try_tcp_env(timeout).await { USED_MUX.store(true, Ordering::Relaxed); - let mut client = Client::new(AnyTransport::Mux(transport)); - client.validate()?; - return Ok(client); + return finalize(AnyTransport::Tcp(transport), TransportKind::MuxTcp(endpoint), &opts, timeout).await; } - // Try Unix socket mux — if a mux socket exists, commit to it #[cfg(unix)] if let Some(path) = find_mux_socket() { - debug!("mux socket found at {path} — connecting via mux only"); + let transport = UnixSocketTransport::connect(&path).await?; USED_MUX.store(true, Ordering::Relaxed); - let transport = MuxTransport::unix(&path, timeout)?; - let mut client = Client::new(AnyTransport::Mux(transport)); - client.validate()?; - return Ok(client); + return finalize(AnyTransport::Unix(transport), TransportKind::MuxUnix(path), &opts, timeout).await; } - // No mux found on first attempt — direct USB serial - 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) + 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 } -/// Convenience: connect with default timeout. -pub fn connect_default() -> anyhow::Result> { - connect(None, DEFAULT_TIMEOUT) +/// 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 { + connect_mux_auto_with(ConnectOptions::new()).await } -/// 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> { - // 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); +/// Mux-only connect, with caller-supplied options. +pub async fn connect_mux_auto_with(opts: ConnectOptions) -> ClientResult { + 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; } - - // Try Unix socket mux. #[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> { - // Previously connected via mux — only try mux - if USED_MUX.load(Ordering::Relaxed) { - let path = find_mux_socket().ok_or_else(|| anyhow::anyhow!("mux not available (waiting for restart)"))?; - let transport = MuxTransport::unix(&path, timeout)?; - 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 { + 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 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}"); - USED_MUX.store(true, Ordering::Relaxed); - let mut client = Client::new(AnyTransport::Mux(transport)); - client.validate()?; - return Ok(client); - } +/// Connect to an explicit TCP mux endpoint. +pub async fn mux_tcp_connect(host: &str, port: u16) -> ClientResult { + 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 +} - // Try Unix socket mux — commit to it if socket exists +// ── internals ───────────────────────────────────────────────────── + +async fn connect_mux_sticky(opts: &ConnectOptions, timeout: Duration) -> ClientResult { + 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() { - debug!("mux socket found at {path} — connecting via mux only"); - USED_MUX.store(true, Ordering::Relaxed); - let transport = MuxTransport::unix(&path, timeout)?; - let mut client = Client::new(AnyTransport::Mux(transport)); - client.validate()?; - return Ok(client); + { + let path = default_socket_path(); + let mut warned = false; + loop { + if Path::new(&path).exists() { + let transport = UnixSocketTransport::connect(&path).await?; + return finalize(AnyTransport::Unix(transport), TransportKind::MuxUnix(path), opts, timeout).await; + } + if !warned { + info!("waiting for mux at {path} ..."); + warned = true; + } + tokio::time::sleep(Duration::from_millis(500)).await; + } } - - // No mux found on first attempt — 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 { - 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( + transport: T, + kind: TransportKind, + opts: &ConnectOptions, + timeout: Duration, +) -> ClientResult { + 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)) } diff --git a/src/discovery.rs b/src/discovery.rs index 0ce8d8d..4fba11c 100644 --- a/src/discovery.rs +++ b/src/discovery.rs @@ -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 { - 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; } } diff --git a/src/dongle.rs b/src/dongle.rs new file mode 100644 index 0000000..1072e0e --- /dev/null +++ b/src/dongle.rs @@ -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, + /// Keepalive task handle. `std::sync::Mutex` so `Drop` can abort + /// without an async context. + keepalive_handle: std::sync::Mutex>>, +} + +pub(crate) struct Inner { + pub(crate) session: Session, + info: Info, + transport_kind: TransportKind, + applied_config: Mutex>, + rx_started: Mutex, + last_write_at: Mutex, + closed: Mutex, +} + +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, + 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 { + *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 { + 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 { + 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 { + self.check_open().await?; + let mut attempts: Vec = 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 { + 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 { + 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 { + 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 { + 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) { + 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)> = 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; + } +} diff --git a/src/errors.rs b/src/errors.rs new file mode 100644 index 0000000..bc9c8c5 --- /dev/null +++ b/src/errors.rs @@ -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 = Result; diff --git a/src/lib.rs b/src/lib.rs index cb81656..8e6d90b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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, +}; diff --git a/src/protocol.rs b/src/protocol.rs deleted file mode 100644 index 9487096..0000000 --- a/src/protocol.rs +++ /dev/null @@ -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 { - 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 { - 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, payload: Vec }, - 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 { - 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 { - 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 { - 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 }, - 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 { - 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 { - 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]); - } -} diff --git a/src/retry.rs b/src/retry.rs new file mode 100644 index 0000000..0f70efd --- /dev/null +++ b/src/retry.rs @@ -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, + /// 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, +} + +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 + } +} diff --git a/src/session.rs b/src/session.rs new file mode 100644 index 0000000..e801240 --- /dev/null +++ b/src/session.rs @@ -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, +} + +type PendingMap = Arc>>; + +/// Session state. +pub(crate) struct Session { + /// Monotonic tag counter; skips 0. + next_tag: Arc>, + pending: PendingMap, + rx_rx: Arc>>, + async_err_rx: Arc>>, + /// Half-duplex writer side. Owned exclusively by write calls via + /// the mutex. + writer: Arc>>, + reader_handle: Mutex>>, + closed: Arc, +} + +/// 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 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(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 { + 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 { + 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 { + let mut rx = self.rx_rx.lock().await; + rx.recv().await + } + + pub(crate) async fn drain_async_errors(&self) -> Vec { + 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 { + 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 { + 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 { + 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( + mut reader: R, + pending: PendingMap, + rx_tx: mpsc::UnboundedSender, + async_err_tx: mpsc::UnboundedSender, + closed: Arc, +) { + 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 = 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 }, + Frame, +} + +async fn dispatch( + pending: &PendingMap, + rx_tx: &mpsc::UnboundedSender, + async_err_tx: &mpsc::UnboundedSender, + 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) { + 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)> = 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::::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)> = 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; + } +} diff --git a/src/transport.rs b/src/transport.rs index 429dae0..3a788f5 100644 --- a/src/transport.rs +++ b/src/transport.rs @@ -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 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, - 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 { - 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 { + 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 { - self.port.read(buf) +impl AsyncRead for SerialTransport { + fn poll_read(mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll> { + Pin::new(&mut self.inner).poll_read(cx, buf) } } -impl Write for SerialTransport { - fn write(&mut self, buf: &[u8]) -> io::Result { - self.port.write(buf) +impl AsyncWrite for SerialTransport { + fn poll_write(mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &[u8]) -> Poll> { + 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> { + Pin::new(&mut self.inner).poll_flush(cx) + } + + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + 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 { + 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> { + 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 { - 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> { + 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 { - use std::net::ToSocketAddrs; + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.inner).poll_flush(cx) + } + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + 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 { 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 { - 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> { + Pin::new(&mut self.inner).poll_read(cx, buf) } } -impl Write for MuxTransport { - fn write(&mut self, buf: &[u8]) -> io::Result { - 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> { + 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> { + 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> { + 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 { - 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> { + 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 { - 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> { + 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> { + 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> { + 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), } } } diff --git a/tests/vectors.rs b/tests/vectors.rs new file mode 100644 index 0000000..d1c960a --- /dev/null +++ b/tests/vectors.rs @@ -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)> = 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::::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!"); +}