6 Commits
Author SHA1 Message Date
swaits 8f1298a1fa bump MSRV to 1.93, release v0.2.2 2026-04-08 21:46:06 -06:00
swaits f867383b12 fix: exclusive serial port lock prevents USB port theft
The mux now acquires an flock-based exclusive lock on the serial port
before opening it. This prevents bridges or other processes from directly
opening the same device while the mux owns it. The lock is held for the
duration of the dongle session and auto-released on disconnect or crash.
2026-04-07 18:04:52 -06:00
swaits 903729ccbe feat: upgrade to donglora-client 0.2, fix org URLs, publish-ready
- Upgrade donglora-client from 0.1 (monorepo path dep) to 0.2 (crates.io)
- Import wire-level constants from donglora_client::protocol::*
- Fix repository URL to github.com/donglora/mux-rs
- Add homepage, rust-version, exclude to Cargo.toml
- Fix README (remove monorepo paths, add install instructions)
- Allow clippy unwrap in test module
2026-04-07 16:14:50 -06:00
swaits 2b7bdb5628 chore: add consistent mise.toml, justfile, and tooling config 2026-04-07 14:41:26 -06:00
swaits 3871772d9b WIP: functional checkpoint! 2026-04-06 21:15:35 -06:00
swaits 0b9fe272d3 fix(mux/rust): prevent multiple instances via fd-lock (v0.1.1) 2026-04-06 15:20:26 -06:00
12 changed files with 283 additions and 138 deletions

No files matched your search

+39
View File
@@ -1,5 +1,44 @@
# Changelog # Changelog
## 0.2.2 — 2026-04-08
### Fixed
- Bumped MSRV from 1.85 to 1.93 to satisfy `ucobs` dependency requirement.
`cargo install donglora-mux` now works on toolchains >= 1.93.
## 0.2.1 — 2026-04-07
### Fixed
- **Exclusive serial port lock.** The mux now acquires an `flock`-based
exclusive lock on the serial port before opening it. This prevents other
processes (bridges, other mux instances) from directly opening the same
device while the mux owns it. The lock is held for the duration of the
dongle session and auto-released on disconnect or crash.
## 0.2.0 — 2026-04-07
### Changed
- Upgraded to `donglora-client` 0.2 (published crate, no more monorepo path dep).
- Wire-level protocol constants now imported from `donglora_client::protocol::*`.
### Fixed
- Repository URL now points to `github.com/donglora/mux-rs`.
- README links updated (removed stale monorepo paths).
- Added `rust-version`, `homepage`, and `exclude` to Cargo.toml.
## 0.1.1 — 2026-04-06
### Fixed
- Prevent multiple mux instances from running simultaneously via file-based
exclusive lock (`fd-lock`); stale sockets from crashed instances are cleaned
up automatically
- Clearer error message when TCP port is already in use
## 0.1.0 — 2026-04-06 ## 0.1.0 — 2026-04-06
Initial release. Initial release.
Generated
+44 -2
View File
@@ -161,7 +161,9 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]] [[package]]
name = "donglora-client" name = "donglora-client"
version = "0.1.0" version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "343d750fe1e5dad94d4b31e6dbbb05214b39e66f3c94a2ff2a29b0031ffd2826"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"serialport", "serialport",
@@ -171,11 +173,12 @@ dependencies = [
[[package]] [[package]]
name = "donglora-mux" name = "donglora-mux"
version = "0.1.0" version = "0.2.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"clap", "clap",
"donglora-client", "donglora-client",
"fd-lock",
"tokio", "tokio",
"tokio-serial", "tokio-serial",
"tokio-util", "tokio-util",
@@ -193,6 +196,17 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "fd-lock"
version = "4.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78"
dependencies = [
"cfg-if",
"rustix",
"windows-sys 0.59.0",
]
[[package]] [[package]]
name = "futures" name = "futures"
version = "0.3.32" version = "0.3.32"
@@ -335,6 +349,12 @@ dependencies = [
"pkg-config", "pkg-config",
] ]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]] [[package]]
name = "lock_api" name = "lock_api"
version = "0.4.14" version = "0.4.14"
@@ -522,6 +542,19 @@ version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "rustix"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags 2.11.0",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "scopeguard" name = "scopeguard"
version = "1.2.0" version = "1.2.0"
@@ -826,6 +859,15 @@ dependencies = [
"windows-targets", "windows-targets",
] ]
[[package]]
name = "windows-sys"
version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [
"windows-targets",
]
[[package]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.61.2" version = "0.61.2"
+7 -3
View File
@@ -1,16 +1,19 @@
[package] [package]
name = "donglora-mux" name = "donglora-mux"
version = "0.1.0" version = "0.2.2"
edition = "2024" edition = "2024"
rust-version = "1.93"
authors = ["Stephen Waits <steve@waits.net>"] authors = ["Stephen Waits <steve@waits.net>"]
license = "MIT" license = "MIT"
description = "DongLoRa USB Multiplexer — share one dongle with multiple applications" description = "DongLoRa USB Multiplexer — share one dongle with multiple applications"
repository = "https://github.com/swaits/donglora" repository = "https://github.com/donglora/mux-rs"
homepage = "https://github.com/donglora/mux-rs"
keywords = ["lora", "radio", "usb", "multiplexer"] keywords = ["lora", "radio", "usb", "multiplexer"]
categories = ["hardware-support"] categories = ["hardware-support"]
exclude = [".jj/", "deny.toml", "justfile", "mise.toml", "rustfmt.toml"]
[dependencies] [dependencies]
donglora-client = { version = "0.1.0", path = "../../clients/rust" } donglora-client = "0.2"
anyhow = "1" anyhow = "1"
clap = { version = "4", features = ["derive"] } clap = { version = "4", features = ["derive"] }
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
@@ -18,6 +21,7 @@ tokio-serial = "5"
tokio-util = "0.7" tokio-util = "0.7"
tracing = "0.1" tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }
fd-lock = "4"
[lints.clippy] [lints.clippy]
unwrap_used = "deny" unwrap_used = "deny"
+12 -5
View File
@@ -3,6 +3,12 @@
USB multiplexer daemon — lets multiple applications share one DongLoRa USB multiplexer daemon — lets multiple applications share one DongLoRa
dongle simultaneously. dongle simultaneously.
## Install
```sh
cargo install donglora-mux
```
## What It Does ## What It Does
- Owns the USB serial connection exclusively - Owns the USB serial connection exclusively
@@ -11,18 +17,19 @@ dongle simultaneously.
- RxPacket frames broadcast to all connected clients - RxPacket frames broadcast to all connected clients
- StartRx/StopRx reference-counted across clients - StartRx/StopRx reference-counted across clients
- SetConfig locked once set (single client can change freely) - SetConfig locked once set (single client can change freely)
- Single-instance enforcement via file lock (stale sockets auto-cleaned)
- Automatic dongle reconnect on hot-plug - Automatic dongle reconnect on hot-plug
- No panics — enforced by clippy deny lints - No panics — enforced by clippy deny lints
## Running ## Running
```sh ```sh
just run # start the mux daemon donglora-mux # start the mux daemon
just verbose # start with verbose logging donglora-mux --verbose # start with verbose logging
just run -- --tcp 5741 --port /dev/ttyACM0 # with options donglora-mux --tcp 5741 --port /dev/ttyACM0 # with options
``` ```
## Depends On ## Depends On
- [clients/rust](../../clients/rust/) — the `donglora-client` library - [`donglora-client`](https://crates.io/crates/donglora-client) — protocol
(protocol types, COBS framing, device discovery) types, COBS framing, device discovery
+34
View File
@@ -0,0 +1,34 @@
# cargo-deny configuration (v0.19+)
# Run with: cargo deny check
[licenses]
version = 2
# Permissive licenses only. No copyleft.
allow = [
"MIT",
"MIT-0",
"Apache-2.0",
"BSD-1-Clause",
"BSD-2-Clause",
"BSD-3-Clause",
"BSL-1.0",
"ISC",
"Zlib",
"Unlicense",
"CC0-1.0",
"CDLA-Permissive-2.0",
"Unicode-3.0",
"MPL-2.0",
]
[advisories]
[bans]
multiple-versions = "warn"
wildcards = "warn"
[sources]
unknown-registry = "deny"
unknown-git = "warn"
allow-git = []
+37 -6
View File
@@ -2,18 +2,49 @@ set shell := ["bash", "-c"]
default: run default: run
[private]
_ensure_tools:
@mise trust --yes . 2>/dev/null; mise install --quiet
# Run the mux daemon # Run the mux daemon
run *args: run *args: _ensure_tools
cargo run -- {{args}} cargo run -- {{args}}
# Run with verbose logging # Run with verbose logging
verbose *args: verbose *args: _ensure_tools
cargo run -- --verbose {{args}} cargo run -- --verbose {{args}}
# Run clippy and tests # Run all checks (fmt, clippy, test, audit)
check: check: fmt-check clippy test audit
cargo clippy -- -D warnings && cargo test
# Format code
fmt: _ensure_tools
cargo fmt
# Check formatting without changing files
fmt-check: _ensure_tools
cargo fmt --check
# Lint with clippy
clippy: _ensure_tools
cargo clippy --all-targets -- -D warnings
# Run tests
test: _ensure_tools
cargo test
# Security + license audit
audit: _ensure_tools
cargo deny check
# Build release # Build release
build: build: _ensure_tools
cargo build --release cargo build --release
# Check for outdated dependencies
outdated: _ensure_tools
cargo outdated
# Clean build artifacts
clean:
cargo clean
+3
View File
@@ -0,0 +1,3 @@
[tools]
rust = "1.93"
just = "latest"
+3
View File
@@ -0,0 +1,3 @@
edition = "2024"
max_width = 120
use_small_heuristics = "Max"
+79 -65
View File
@@ -9,13 +9,13 @@ use std::path::Path;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use donglora_client::{ use donglora_client::protocol::{
encode_frame, FrameReader, CMD_TAG_SET_CONFIG, CMD_TAG_START_RX, CMD_TAG_STOP_RX, CMD_TAG_SET_CONFIG, CMD_TAG_START_RX, CMD_TAG_STOP_RX, RADIO_CONFIG_SIZE, RESP_TAG_OK, RESP_TAG_RX_PACKET,
RADIO_CONFIG_SIZE, RESP_TAG_OK, RESP_TAG_RX_PACKET,
}; };
use donglora_client::{FrameReader, encode_frame};
use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, UnixListener}; use tokio::net::{TcpListener, UnixListener};
use tokio::sync::{mpsc, oneshot, Mutex}; use tokio::sync::{Mutex, mpsc, oneshot};
/// A queued command: (client_id, raw_cmd_bytes). /// A queued command: (client_id, raw_cmd_bytes).
type QueuedCmd = (u64, Vec<u8>); type QueuedCmd = (u64, Vec<u8>);
@@ -63,35 +63,44 @@ impl MuxDaemon {
tcp_addr: Option<(String, u16)>, tcp_addr: Option<(String, u16)>,
shutdown: CancellationToken, shutdown: CancellationToken,
) -> Self { ) -> Self {
Self { Self { serial_port, socket_path, tcp_addr, shutdown }
serial_port,
socket_path,
tcp_addr,
shutdown,
}
} }
/// Run the mux daemon. Returns when shutdown is signalled. /// Run the mux daemon. Returns when shutdown is signalled.
pub async fn run(&self) -> anyhow::Result<()> { pub async fn run(&self) -> anyhow::Result<()> {
let state: SharedState = Arc::new(Mutex::new(MuxInner { let state: SharedState =
sessions: HashMap::new(), Arc::new(Mutex::new(MuxInner { sessions: HashMap::new(), mux_state: MuxState::new() }));
mux_state: MuxState::new(),
}));
// Command channel: client handlers → dongle writer // Command channel: client handlers → dongle writer
let (cmd_tx, cmd_rx) = mpsc::channel::<(u64, Vec<u8>)>(64); let (cmd_tx, cmd_rx) = mpsc::channel::<(u64, Vec<u8>)>(64);
let cmd_rx = Arc::new(Mutex::new(cmd_rx)); let cmd_rx = Arc::new(Mutex::new(cmd_rx));
// Pending response slot: dongle writer installs, dongle reader resolves // Pending response slot: dongle writer installs, dongle reader resolves
let pending_response: Arc<Mutex<Option<oneshot::Sender<Vec<u8>>>>> = let pending_response: Arc<Mutex<Option<oneshot::Sender<Vec<u8>>>>> = Arc::new(Mutex::new(None));
Arc::new(Mutex::new(None));
// Prepare socket directory // Prepare socket directory
let sock_path = Path::new(&self.socket_path); let sock_path = Path::new(&self.socket_path);
if let Some(parent) = sock_path.parent() { if let Some(parent) = sock_path.parent() {
tokio::fs::create_dir_all(parent).await.ok(); tokio::fs::create_dir_all(parent).await.ok();
} }
// Acquire an exclusive lock to prevent multiple mux instances.
// The lock is held for the process lifetime and auto-released on exit (even SIGKILL).
let lock_path = format!("{}.lock", self.socket_path);
let lock_file = std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(false)
.open(&lock_path)
.map_err(|e| anyhow::anyhow!("failed to open lock file {lock_path}: {e}"))?;
let mut lock = fd_lock::RwLock::new(lock_file);
let _lock_guard = match lock.try_write() {
Ok(guard) => guard,
Err(_) => anyhow::bail!("another donglora-mux is already running (lock held on {lock_path})"),
};
if sock_path.exists() { if sock_path.exists() {
// Lock acquired but socket file exists — stale from a crashed instance.
info!("removing stale socket {}", self.socket_path);
tokio::fs::remove_file(sock_path).await.ok(); tokio::fs::remove_file(sock_path).await.ok();
} }
@@ -103,9 +112,13 @@ impl MuxDaemon {
// Start TCP server (optional) // Start TCP server (optional)
let tcp_listener = if let Some((ref host, port)) = self.tcp_addr { let tcp_listener = if let Some((ref host, port)) = self.tcp_addr {
let addr = format!("{host}:{port}"); let addr = format!("{host}:{port}");
let listener = TcpListener::bind(&addr) let listener = TcpListener::bind(&addr).await.map_err(|e| {
.await if e.kind() == std::io::ErrorKind::AddrInUse {
.map_err(|e| anyhow::anyhow!("failed to bind TCP {addr}: {e}"))?; anyhow::anyhow!("another donglora-mux is already listening on TCP {addr}")
} else {
anyhow::anyhow!("failed to bind TCP {addr}: {e}")
}
})?;
info!("TCP listening on {addr}"); info!("TCP listening on {addr}");
Some(listener) Some(listener)
} else { } else {
@@ -113,24 +126,13 @@ impl MuxDaemon {
}; };
// Spawn client accept tasks // Spawn client accept tasks
tokio::spawn(accept_unix_clients( tokio::spawn(accept_unix_clients(unix_listener, state.clone(), cmd_tx.clone(), self.shutdown.clone()));
unix_listener,
state.clone(),
cmd_tx.clone(),
self.shutdown.clone(),
));
if let Some(listener) = tcp_listener { if let Some(listener) = tcp_listener {
tokio::spawn(accept_tcp_clients( tokio::spawn(accept_tcp_clients(listener, state.clone(), cmd_tx.clone(), self.shutdown.clone()));
listener,
state.clone(),
cmd_tx.clone(),
self.shutdown.clone(),
));
} }
// Connect/reconnect loop // Connect/reconnect loop
self.reconnect_loop(state, cmd_tx, cmd_rx, pending_response) self.reconnect_loop(state, cmd_tx, cmd_rx, pending_response).await;
.await;
// Cleanup // Cleanup
info!("shutting down..."); info!("shutting down...");
@@ -154,12 +156,12 @@ impl MuxDaemon {
} }
info!("opening dongle on {}", self.serial_port); info!("opening dongle on {}", self.serial_port);
let serial = loop { let (serial, _serial_lock_guard) = loop {
if self.shutdown.is_cancelled() { if self.shutdown.is_cancelled() {
return; return;
} }
match open_and_ping(&self.serial_port).await { match open_and_ping(&self.serial_port).await {
Ok(s) => break s, Ok(pair) => break pair,
Err(e) => { Err(e) => {
debug!("could not open dongle: {e}"); debug!("could not open dongle: {e}");
tokio::select! { tokio::select! {
@@ -222,10 +224,42 @@ impl MuxDaemon {
// ── Dongle I/O ───────────────────────────────────────────────────── // ── Dongle I/O ─────────────────────────────────────────────────────
async fn open_and_ping(port: &str) -> anyhow::Result<tokio_serial::SerialStream> { /// Lock file path for exclusive serial port access.
fn serial_lock_path(port: &str) -> String {
format!("/tmp/donglora-serial-{}.lock", port.replace('/', "_"))
}
/// Open the serial port with an exclusive lock and verify the dongle responds to ping.
///
/// Returns the serial stream and the lock. The caller must keep `_serial_lock` alive
/// for the duration of the dongle session. Dropping it releases the exclusive flock,
/// allowing other processes to claim the port.
async fn open_and_ping(
port: &str,
) -> anyhow::Result<(tokio_serial::SerialStream, fd_lock::RwLock<std::fs::File>)> {
// Acquire exclusive flock before opening the serial port. This prevents bridges
// or other processes from directly opening the same device while the mux owns it.
let lock_path = serial_lock_path(port);
let lock_file = std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(false)
.open(&lock_path)
.map_err(|e| anyhow::anyhow!("failed to open serial lock file {lock_path}: {e}"))?;
let mut lock = fd_lock::RwLock::new(lock_file);
// Acquire exclusive flock (non-blocking). Fails if another process holds it.
let guard = lock
.try_write()
.map_err(|_| anyhow::anyhow!("serial port {port} is locked by another process"))?;
// Forget the guard so its Drop doesn't call flock(UNLOCK). The underlying flock
// stays held as long as the file descriptor (owned by `lock`) remains open.
// When `lock` is dropped at the end of the dongle session, the fd closes and
// the OS releases the flock automatically.
std::mem::forget(guard);
let builder = tokio_serial::new(port, 115_200).timeout(Duration::from_secs(2)); let builder = tokio_serial::new(port, 115_200).timeout(Duration::from_secs(2));
let mut serial = tokio_serial::SerialStream::open(&builder) let mut serial =
.map_err(|e| anyhow::anyhow!("failed to open {port}: {e}"))?; tokio_serial::SerialStream::open(&builder).map_err(|e| anyhow::anyhow!("failed to open {port}: {e}"))?;
let ping_frame = encode_frame(&[0]); let ping_frame = encode_frame(&[0]);
AsyncWriteExt::write_all(&mut serial, &ping_frame) AsyncWriteExt::write_all(&mut serial, &ping_frame)
@@ -258,7 +292,7 @@ async fn open_and_ping(port: &str) -> anyhow::Result<tokio_serial::SerialStream>
for frame in reader.feed(&buf[..n]) { for frame in reader.feed(&buf[..n]) {
if frame.first() == Some(&0) { if frame.first() == Some(&0) {
info!("dongle responded to Ping"); info!("dongle responded to Ping");
return Ok(serial); return Ok((serial, lock));
} }
} }
} }
@@ -339,9 +373,7 @@ async fn dispatch_frame(
} }
} }
async fn resolve_pending_with_error( async fn resolve_pending_with_error(pending_response: &Arc<Mutex<Option<oneshot::Sender<Vec<u8>>>>>) {
pending_response: &Arc<Mutex<Option<oneshot::Sender<Vec<u8>>>>>,
) {
let mut slot = pending_response.lock().await; let mut slot = pending_response.lock().await;
if let Some(sender) = slot.take() { if let Some(sender) = slot.take() {
let _ = sender.send(vec![5, 1]); // Error(RadioBusy) let _ = sender.send(vec![5, 1]); // Error(RadioBusy)
@@ -376,12 +408,7 @@ async fn dongle_writer(
let intercepted = { let intercepted = {
let mut inner = state.lock().await; let mut inner = state.lock().await;
let locked = inner.mux_state.locked_config; let locked = inner.mux_state.locked_config;
intercept::maybe_intercept( intercept::maybe_intercept(&raw_cmd, client_id, &mut inner.sessions, &locked)
&raw_cmd,
client_id,
&mut inner.sessions,
&locked,
)
}; };
if let Some(synthetic) = intercepted { if let Some(synthetic) = intercepted {
let inner = state.lock().await; let inner = state.lock().await;
@@ -586,27 +613,16 @@ async fn client_writer<W: AsyncWriteExt + Unpin>(
() = shutdown.cancelled() => return, () = shutdown.cancelled() => return,
}; };
if AsyncWriteExt::write_all(&mut write_half, &frame) if AsyncWriteExt::write_all(&mut write_half, &frame).await.is_err() {
.await
.is_err()
{
return; return;
} }
} }
} }
async fn remove_client( async fn remove_client(client_id: u64, state: &SharedState, label: &str, cmd_tx: &mpsc::Sender<(u64, Vec<u8>)>) {
client_id: u64,
state: &SharedState,
label: &str,
cmd_tx: &mpsc::Sender<(u64, Vec<u8>)>,
) {
let mut inner = state.lock().await; let mut inner = state.lock().await;
let was_interested = inner let was_interested = inner.sessions.get(&client_id).is_some_and(|s| s.rx_interested);
.sessions
.get(&client_id)
.is_some_and(|s| s.rx_interested);
inner.sessions.remove(&client_id); inner.sessions.remove(&client_id);
info!("{label} disconnected ({} remain)", inner.sessions.len()); info!("{label} disconnected ({} remain)", inner.sessions.len());
@@ -616,9 +632,7 @@ async fn remove_client(
info!("last RX-interested client gone — sending StopRx"); info!("last RX-interested client gone — sending StopRx");
// Drop the lock before sending to avoid deadlock // Drop the lock before sending to avoid deadlock
drop(inner); drop(inner);
let _ = cmd_tx let _ = cmd_tx.send((SYNTHETIC_CLIENT_ID, vec![CMD_TAG_STOP_RX])).await;
.send((SYNTHETIC_CLIENT_ID, vec![CMD_TAG_STOP_RX]))
.await;
return; return;
} }
+12 -34
View File
@@ -6,9 +6,9 @@
use std::collections::HashMap; use std::collections::HashMap;
use donglora_client::{ use donglora_client::protocol::{
CMD_TAG_SET_CONFIG, CMD_TAG_START_RX, CMD_TAG_STOP_RX, ERROR_INVALID_CONFIG, RADIO_CONFIG_SIZE, CMD_TAG_SET_CONFIG, CMD_TAG_START_RX, CMD_TAG_STOP_RX, ERROR_INVALID_CONFIG, RADIO_CONFIG_SIZE, RESP_TAG_ERROR,
RESP_TAG_ERROR, RESP_TAG_OK, RESP_TAG_OK,
}; };
use tracing::{debug, warn}; use tracing::{debug, warn};
@@ -22,9 +22,7 @@ pub struct MuxState {
impl MuxState { impl MuxState {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self { locked_config: None }
locked_config: None,
}
} }
} }
@@ -67,27 +65,18 @@ fn intercept_set_config(
// Same config — reply Ok without hitting the dongle // Same config — reply Ok without hitting the dongle
if config_bytes == locked.as_slice() { if config_bytes == locked.as_slice() {
let label = sessions let label = sessions.get(&client_id).map(ClientSession::label).unwrap_or_else(|| format!("id-{client_id}"));
.get(&client_id)
.map(ClientSession::label)
.unwrap_or_else(|| format!("id-{client_id}"));
debug!("{label}: SetConfig matches locked config — Ok"); debug!("{label}: SetConfig matches locked config — Ok");
return Some(vec![RESP_TAG_OK]); return Some(vec![RESP_TAG_OK]);
} }
// Different config — reject // Different config — reject
let label = sessions let label = sessions.get(&client_id).map(ClientSession::label).unwrap_or_else(|| format!("id-{client_id}"));
.get(&client_id)
.map(ClientSession::label)
.unwrap_or_else(|| format!("id-{client_id}"));
warn!("{label}: SetConfig rejected (conflicts with locked config)"); warn!("{label}: SetConfig rejected (conflicts with locked config)");
Some(vec![RESP_TAG_ERROR, ERROR_INVALID_CONFIG]) Some(vec![RESP_TAG_ERROR, ERROR_INVALID_CONFIG])
} }
fn intercept_start_rx( fn intercept_start_rx(client_id: u64, sessions: &mut HashMap<u64, ClientSession>) -> Option<Vec<u8>> {
client_id: u64,
sessions: &mut HashMap<u64, ClientSession>,
) -> Option<Vec<u8>> {
let client = sessions.get(&client_id)?; let client = sessions.get(&client_id)?;
// Already interested — no-op // Already interested — no-op
@@ -107,10 +96,7 @@ fn intercept_start_rx(
None None
} }
fn intercept_stop_rx( fn intercept_stop_rx(client_id: u64, sessions: &mut HashMap<u64, ClientSession>) -> Option<Vec<u8>> {
client_id: u64,
sessions: &mut HashMap<u64, ClientSession>,
) -> Option<Vec<u8>> {
let client = sessions.get(&client_id)?; let client = sessions.get(&client_id)?;
// Wasn't interested — no-op // Wasn't interested — no-op
@@ -140,6 +126,7 @@ pub fn rx_interest_count(sessions: &HashMap<u64, ClientSession>) -> usize {
// ── Tests ────────────────────────────────────────────────────────── // ── Tests ──────────────────────────────────────────────────────────
#[cfg(test)] #[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests { mod tests {
use super::*; use super::*;
@@ -201,10 +188,7 @@ mod tests {
let id = first_id(&sessions); let id = first_id(&sessions);
let mut cmd = vec![CMD_TAG_SET_CONFIG]; let mut cmd = vec![CMD_TAG_SET_CONFIG];
cmd.extend_from_slice(&[99, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13]); cmd.extend_from_slice(&[99, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13]);
assert_eq!( assert_eq!(maybe_intercept(&cmd, id, &mut sessions, &locked), Some(vec![RESP_TAG_ERROR, ERROR_INVALID_CONFIG]));
maybe_intercept(&cmd, id, &mut sessions, &locked),
Some(vec![RESP_TAG_ERROR, ERROR_INVALID_CONFIG])
);
} }
// ── StartRx ──────────────────────────────────────────────────── // ── StartRx ────────────────────────────────────────────────────
@@ -223,10 +207,7 @@ mod tests {
let locked = None; let locked = None;
let id = first_id(&sessions); let id = first_id(&sessions);
sessions.get_mut(&id).unwrap().rx_interested = true; sessions.get_mut(&id).unwrap().rx_interested = true;
assert_eq!( assert_eq!(maybe_intercept(&[CMD_TAG_START_RX], id, &mut sessions, &locked), Some(vec![RESP_TAG_OK]));
maybe_intercept(&[CMD_TAG_START_RX], id, &mut sessions, &locked),
Some(vec![RESP_TAG_OK])
);
} }
#[test] #[test]
@@ -248,10 +229,7 @@ mod tests {
let mut sessions = make_sessions(1); let mut sessions = make_sessions(1);
let locked = None; let locked = None;
let id = first_id(&sessions); let id = first_id(&sessions);
assert_eq!( assert_eq!(maybe_intercept(&[CMD_TAG_STOP_RX], id, &mut sessions, &locked), Some(vec![RESP_TAG_OK]));
maybe_intercept(&[CMD_TAG_STOP_RX], id, &mut sessions, &locked),
Some(vec![RESP_TAG_OK])
);
} }
#[test] #[test]
+12 -18
View File
@@ -50,22 +50,18 @@ async fn main() -> anyhow::Result<()> {
// Resolve serial port // Resolve serial port
let port = match args.port { let port = match args.port {
Some(p) => p, Some(p) => p,
None => { None => tokio::task::spawn_blocking(|| {
tokio::task::spawn_blocking(|| { donglora_client::find_port().unwrap_or_else(|| {
donglora_client::find_port().unwrap_or_else(|| { info!("waiting for DongLoRa device...");
info!("waiting for DongLoRa device..."); donglora_client::wait_for_device()
donglora_client::wait_for_device()
})
}) })
.await })
.map_err(|e| anyhow::anyhow!("device discovery task failed: {e}"))? .await
} .map_err(|e| anyhow::anyhow!("device discovery task failed: {e}"))?,
}; };
// Resolve socket path // Resolve socket path
let socket_path = args let socket_path = args.socket.unwrap_or_else(donglora_client::default_socket_path);
.socket
.unwrap_or_else(donglora_client::default_socket_path);
// Parse TCP address // Parse TCP address
let tcp_addr = parse_tcp_addr(&args.tcp); let tcp_addr = parse_tcp_addr(&args.tcp);
@@ -102,12 +98,10 @@ fn parse_tcp_addr(tcp: &str) -> Option<(String, u16)> {
} }
async fn signal_handler(shutdown: CancellationToken) -> anyhow::Result<()> { async fn signal_handler(shutdown: CancellationToken) -> anyhow::Result<()> {
let mut sigint = let mut sigint = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt()) .map_err(|e| anyhow::anyhow!("failed to register SIGINT handler: {e}"))?;
.map_err(|e| anyhow::anyhow!("failed to register SIGINT handler: {e}"))?; let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
let mut sigterm = .map_err(|e| anyhow::anyhow!("failed to register SIGTERM handler: {e}"))?;
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.map_err(|e| anyhow::anyhow!("failed to register SIGTERM handler: {e}"))?;
tokio::select! { tokio::select! {
_ = sigint.recv() => info!("received SIGINT"), _ = sigint.recv() => info!("received SIGINT"),
+1 -5
View File
@@ -30,11 +30,7 @@ impl ClientSession {
pub fn new() -> (Self, mpsc::Receiver<Vec<u8>>) { pub fn new() -> (Self, mpsc::Receiver<Vec<u8>>) {
let id = NEXT_CLIENT_ID.fetch_add(1, Ordering::Relaxed); let id = NEXT_CLIENT_ID.fetch_add(1, Ordering::Relaxed);
let (tx, rx) = mpsc::channel(SEND_QUEUE_CAP); let (tx, rx) = mpsc::channel(SEND_QUEUE_CAP);
let session = Self { let session = Self { id, rx_interested: false, tx };
id,
rx_interested: false,
tx,
};
(session, rx) (session, rx)
} }