[refactor](trx-rs): resolve all P1/P2 improvement areas

P1 (High Priority):
- Fix LIFO command batching in rig_task.rs (batch.pop→batch.remove(0))
- Add ±25% jitter to ExponentialBackoff to prevent thundering herd
- Add 10,000-entry capacity bounds to decoder history queues
- Add rig task crash detection with Error state broadcast
- Decompose FrontendRuntimeContext 50-field god-struct into 9 sub-structs
  (AudioContext, DecodeHistoryContext, HttpAuthConfig, HttpUiConfig,
   RigRoutingContext, OwnerInfo, VChanContext, SpectrumContext, PerRigAudioContext)
- Migrate std::sync::RwLock to tokio::sync::RwLock in background_decode.rs
- Extract find_input_device/find_output_device helpers from audio pipeline

P2 (Medium Priority):
- Introduce SoapySdrConfig builder struct (replaces 20+ positional params)
- Add define_command_mappings! macro for ClientCommand↔RigCommand mapping
- Replace silent lock poison recovery with lock_or_recover() warning logger
- Make timeouts configurable via RigTaskConfig/ListenerConfig and TOML
- Extract shared config types to trx-app/src/shared_config.rs

Documentation updated in CLAUDE.md, Architecture.md, Improvement-Areas.md.

https://claude.ai/code/session_01P9G7QCWfiYbPVJ7cgiXznf
Signed-off-by: Claude <noreply@anthropic.com>
This commit is contained in:
Claude
2026-03-28 23:26:55 +00:00
committed by Stan Grams
parent 0a60684e28
commit 16426548de
22 changed files with 1245 additions and 916 deletions
+1 -23
View File
@@ -17,7 +17,7 @@ use std::path::{Path, PathBuf};
use std::time::Duration;
use serde::{Deserialize, Serialize};
use trx_app::{ConfigError, ConfigFile};
use trx_app::{validate_log_level, validate_tokens, ConfigError, ConfigFile};
/// Top-level client configuration structure.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
@@ -677,28 +677,6 @@ impl ClientConfig {
}
}
fn validate_log_level(level: Option<&str>) -> Result<(), String> {
if let Some(level) = level {
match level {
"trace" | "debug" | "info" | "warn" | "error" => {}
_ => {
return Err(format!(
"[general].log_level '{}' is invalid (expected one of: trace, debug, info, warn, error)",
level
))
}
}
}
Ok(())
}
fn validate_tokens(path: &str, tokens: &[String]) -> Result<(), String> {
if tokens.iter().any(|t| t.trim().is_empty()) {
return Err(format!("{path} must not contain empty tokens"));
}
Ok(())
}
fn validate_http_auth(auth: &HttpAuthConfig) -> Result<(), String> {
if !auth.enabled {
return Ok(());
+53 -50
View File
@@ -150,7 +150,7 @@ async fn async_init() -> DynResult<AppState> {
info!("Loaded configuration from {}", path.display());
}
frontend_runtime.auth_tokens = cfg
frontend_runtime.http_auth.tokens = cfg
.frontends
.http_json
.auth
@@ -161,28 +161,28 @@ async fn async_init() -> DynResult<AppState> {
.collect();
// Set HTTP frontend authentication config
frontend_runtime.http_auth_enabled = cfg.frontends.http.auth.enabled;
frontend_runtime.http_auth_rx_passphrase = cfg.frontends.http.auth.rx_passphrase.clone();
frontend_runtime.http_auth_control_passphrase =
frontend_runtime.http_auth.enabled = cfg.frontends.http.auth.enabled;
frontend_runtime.http_auth.rx_passphrase = cfg.frontends.http.auth.rx_passphrase.clone();
frontend_runtime.http_auth.control_passphrase =
cfg.frontends.http.auth.control_passphrase.clone();
frontend_runtime.http_auth_tx_access_control_enabled =
frontend_runtime.http_auth.tx_access_control_enabled =
cfg.frontends.http.auth.tx_access_control_enabled;
frontend_runtime.http_auth_session_ttl_secs = cfg.frontends.http.auth.session_ttl_min * 60;
frontend_runtime.http_auth_cookie_secure = cfg.frontends.http.auth.cookie_secure;
frontend_runtime.http_auth_cookie_same_site = match cfg.frontends.http.auth.cookie_same_site {
frontend_runtime.http_auth.session_ttl_secs = cfg.frontends.http.auth.session_ttl_min * 60;
frontend_runtime.http_auth.cookie_secure = cfg.frontends.http.auth.cookie_secure;
frontend_runtime.http_auth.cookie_same_site = match cfg.frontends.http.auth.cookie_same_site {
config::CookieSameSite::Strict => "Strict".to_string(),
config::CookieSameSite::Lax => "Lax".to_string(),
config::CookieSameSite::None => "None".to_string(),
};
frontend_runtime.http_show_sdr_gain_control = cfg.frontends.http.show_sdr_gain_control;
frontend_runtime.http_initial_map_zoom = cfg.frontends.http.initial_map_zoom;
frontend_runtime.http_spectrum_coverage_margin_hz =
frontend_runtime.http_ui.show_sdr_gain_control = cfg.frontends.http.show_sdr_gain_control;
frontend_runtime.http_ui.initial_map_zoom = cfg.frontends.http.initial_map_zoom;
frontend_runtime.http_ui.spectrum_coverage_margin_hz =
cfg.frontends.http.spectrum_coverage_margin_hz;
frontend_runtime.http_spectrum_usable_span_ratio =
frontend_runtime.http_ui.spectrum_usable_span_ratio =
cfg.frontends.http.spectrum_usable_span_ratio;
frontend_runtime.http_decode_history_retention_min =
frontend_runtime.http_ui.decode_history_retention_min =
cfg.frontends.http.decode_history_retention_min;
frontend_runtime.http_decode_history_retention_min_by_rig = cfg
frontend_runtime.http_ui.decode_history_retention_min_by_rig = cfg
.frontends
.http
.decode_history_retention_min_by_rig
@@ -219,7 +219,7 @@ async fn async_init() -> DynResult<AppState> {
.clone()
.or_else(|| cfg.frontends.http.default_rig_name.clone())
.or_else(|| resolved_remotes.first().map(|e| e.name.clone()));
if let Ok(mut guard) = frontend_runtime.remote_active_rig_id.lock() {
if let Ok(mut guard) = frontend_runtime.routing.active_rig_id.lock() {
*guard = default_rig.clone();
}
@@ -264,10 +264,10 @@ async fn async_init() -> DynResult<AppState> {
.callsign
.clone()
.or_else(|| cfg.general.callsign.clone());
frontend_runtime.owner_callsign = callsign.clone();
frontend_runtime.owner_website_url = cfg.general.website_url.clone();
frontend_runtime.owner_website_name = cfg.general.website_name.clone();
frontend_runtime.ais_vessel_url_base = cfg.general.ais_vessel_url_base.clone();
frontend_runtime.owner.callsign = callsign.clone();
frontend_runtime.owner.website_url = cfg.general.website_url.clone();
frontend_runtime.owner.website_name = cfg.general.website_name.clone();
frontend_runtime.owner.ais_vessel_url_base = cfg.general.ais_vessel_url_base.clone();
let remote_names: Vec<&str> = resolved_remotes.iter().map(|e| e.name.as_str()).collect();
info!(
@@ -373,17 +373,17 @@ async fn async_init() -> DynResult<AppState> {
let remote_cfg = RemoteClientConfig {
addr: addr.clone(),
token: token.clone(),
selected_rig_id: frontend_runtime.remote_active_rig_id.clone(),
known_rigs: frontend_runtime.remote_rigs.clone(),
rig_states: frontend_runtime.rig_states.clone(),
selected_rig_id: frontend_runtime.routing.active_rig_id.clone(),
known_rigs: frontend_runtime.routing.remote_rigs.clone(),
rig_states: frontend_runtime.routing.rig_states.clone(),
poll_interval: Duration::from_millis(poll_interval),
spectrum: frontend_runtime.spectrum.clone(),
rig_spectrums: frontend_runtime.rig_spectrums.clone(),
server_connected: frontend_runtime.server_connected.clone(),
rig_server_connected: frontend_runtime.rig_server_connected.clone(),
spectrum: frontend_runtime.spectrum.sender.clone(),
rig_spectrums: frontend_runtime.spectrum.per_rig.clone(),
server_connected: frontend_runtime.routing.server_connected.clone(),
rig_server_connected: frontend_runtime.routing.rig_server_connected.clone(),
rig_id_to_short_name,
short_name_to_rig_id: Arc::new(RwLock::new(HashMap::new())),
sat_passes: frontend_runtime.sat_passes.clone(),
sat_passes: frontend_runtime.routing.sat_passes.clone(),
};
let state_tx = state_tx.clone();
let remote_shutdown_rx = shutdown_rx.clone();
@@ -405,7 +405,7 @@ async fn async_init() -> DynResult<AppState> {
// channel and dispatches to the per-server channel based on rig_id_override
// (short name).
let route_map = Arc::new(route_map);
let default_rig_for_router = frontend_runtime.remote_active_rig_id.clone();
let default_rig_for_router = frontend_runtime.routing.active_rig_id.clone();
{
let route_map = route_map.clone();
let mut frontend_rx = rx;
@@ -446,24 +446,24 @@ async fn async_init() -> DynResult<AppState> {
let (stream_info_tx, stream_info_rx) = watch::channel::<Option<AudioStreamInfo>>(None);
let (decode_tx, _) = broadcast::channel::<DecodedMessage>(256);
frontend_runtime.audio_rx = Some(rx_audio_tx.clone());
frontend_runtime.audio_tx = Some(tx_audio_tx);
frontend_runtime.audio_info = Some(stream_info_rx);
frontend_runtime.decode_rx = Some(decode_tx.clone());
frontend_runtime.audio.rx = Some(rx_audio_tx.clone());
frontend_runtime.audio.tx = Some(tx_audio_tx);
frontend_runtime.audio.info = Some(stream_info_rx);
frontend_runtime.audio.decode_rx = Some(decode_tx.clone());
// Virtual-channel audio: shared broadcaster map + command channel.
let (vchan_cmd_tx, vchan_cmd_rx) = mpsc::channel::<trx_frontend::VChanAudioCmd>(256);
*frontend_runtime.vchan_audio_cmd.lock().unwrap() = Some(vchan_cmd_tx);
*frontend_runtime.vchan.audio_cmd.lock().unwrap() = Some(vchan_cmd_tx);
let (vchan_destroyed_tx, _) = broadcast::channel::<uuid::Uuid>(64);
frontend_runtime.vchan_destroyed = Some(vchan_destroyed_tx.clone());
let ais_history = frontend_runtime.ais_history.clone();
let vdes_history = frontend_runtime.vdes_history.clone();
let aprs_history = frontend_runtime.aprs_history.clone();
let hf_aprs_history = frontend_runtime.hf_aprs_history.clone();
let cw_history = frontend_runtime.cw_history.clone();
let ft8_history = frontend_runtime.ft8_history.clone();
let wspr_history = frontend_runtime.wspr_history.clone();
frontend_runtime.vchan.destroyed = Some(vchan_destroyed_tx.clone());
let ais_history = frontend_runtime.decode_history.ais.clone();
let vdes_history = frontend_runtime.decode_history.vdes.clone();
let aprs_history = frontend_runtime.decode_history.aprs.clone();
let hf_aprs_history = frontend_runtime.decode_history.hf_aprs.clone();
let cw_history = frontend_runtime.decode_history.cw.clone();
let ft8_history = frontend_runtime.decode_history.ft8.clone();
let wspr_history = frontend_runtime.decode_history.wspr.clone();
let replay_history_sink: Arc<dyn Fn(DecodedMessage) + Send + Sync> = Arc::new(move |msg| {
let now = std::time::Instant::now();
match msg {
@@ -527,10 +527,10 @@ async fn async_init() -> DynResult<AppState> {
info!("Audio enabled: decode channel set");
let audio_shutdown_rx = shutdown_rx.clone();
let vchan_audio_map = frontend_runtime.vchan_audio.clone();
let rig_audio_rx_map = frontend_runtime.rig_audio_rx.clone();
let rig_audio_info_map = frontend_runtime.rig_audio_info.clone();
let rig_vchan_cmd_map = frontend_runtime.rig_vchan_audio_cmd.clone();
let vchan_audio_map = frontend_runtime.vchan.audio.clone();
let rig_audio_rx_map = frontend_runtime.rig_audio.rx.clone();
let rig_audio_info_map = frontend_runtime.rig_audio.info.clone();
let rig_vchan_cmd_map = frontend_runtime.vchan.rig_audio_cmd.clone();
let default_audio_connect = if let Some(addr) = global_audio_addr {
AudioConnectConfig::fixed(addr)
} else {
@@ -539,8 +539,8 @@ async fn async_init() -> DynResult<AppState> {
pending_audio_client = Some(tokio::spawn(audio_client::run_multi_rig_audio_manager(
default_audio_connect,
audio_connect,
frontend_runtime.remote_active_rig_id.clone(),
frontend_runtime.remote_rigs.clone(),
frontend_runtime.routing.active_rig_id.clone(),
frontend_runtime.routing.remote_rigs.clone(),
rx_audio_tx,
tx_audio_rx,
stream_info_tx,
@@ -642,17 +642,20 @@ async fn async_init() -> DynResult<AppState> {
task_handles.push(audio_bridge::spawn_audio_bridge(
bridge_cfg,
frontend_runtime_ctx
.audio_rx
.audio
.rx
.as_ref()
.expect("audio rx must be set")
.clone(),
frontend_runtime_ctx
.audio_tx
.audio
.tx
.as_ref()
.expect("audio tx must be set")
.clone(),
frontend_runtime_ctx
.audio_info
.audio
.info
.as_ref()
.expect("audio info must be set")
.clone(),
+243 -158
View File
@@ -189,130 +189,253 @@ impl Default for FrontendRegistrationContext {
}
}
/// Runtime context for frontend operation, containing audio channels and decode state.
pub struct FrontendRuntimeContext {
// ---------------------------------------------------------------------------
// Sub-structs for FrontendRuntimeContext decomposition
// ---------------------------------------------------------------------------
/// Audio streaming channels (server ↔ browser).
pub struct AudioContext {
/// Audio RX broadcast channel (server → browser)
pub audio_rx: Option<broadcast::Sender<Bytes>>,
pub rx: Option<broadcast::Sender<Bytes>>,
/// Audio TX channel (browser → server)
pub audio_tx: Option<mpsc::Sender<Bytes>>,
pub tx: Option<mpsc::Sender<Bytes>>,
/// Audio stream info watch channel
pub audio_info: Option<watch::Receiver<Option<AudioStreamInfo>>>,
pub info: Option<watch::Receiver<Option<AudioStreamInfo>>>,
/// Decode message broadcast channel
pub decode_rx: Option<broadcast::Sender<DecodedMessage>>,
/// Decode history entry: (record_time, rig_id, message).
/// AIS decode history
pub ais_history: DecodeHistory<AisMessage>,
/// VDES decode history
pub vdes_history: DecodeHistory<VdesMessage>,
/// APRS decode history
pub aprs_history: DecodeHistory<AprsPacket>,
/// HF APRS decode history
pub hf_aprs_history: DecodeHistory<AprsPacket>,
/// CW decode history
pub cw_history: DecodeHistory<CwEvent>,
/// FT8 decode history
pub ft8_history: DecodeHistory<Ft8Message>,
/// FT4 decode history
pub ft4_history: DecodeHistory<Ft8Message>,
/// FT2 decode history
pub ft2_history: DecodeHistory<Ft8Message>,
/// WSPR decode history
pub wspr_history: DecodeHistory<WsprMessage>,
/// Authentication tokens for HTTP-JSON frontend
pub auth_tokens: HashSet<String>,
/// Active HTTP SSE clients (incremented on /events connect, decremented on disconnect).
pub sse_clients: Arc<AtomicUsize>,
/// Active rigctl TCP clients.
pub rigctl_clients: Arc<AtomicUsize>,
/// Active audio WebSocket streams.
pub audio_clients: Arc<AtomicUsize>,
/// rigctl listen endpoint, if enabled.
pub rigctl_listen_addr: Arc<Mutex<Option<SocketAddr>>>,
/// Guard to avoid spawning duplicate decode collectors.
pub decode_collector_started: AtomicBool,
/// HTTP frontend authentication configuration (enabled, passphrases, TTL, etc.)
pub http_auth_enabled: bool,
/// HTTP frontend auth rx passphrase
pub http_auth_rx_passphrase: Option<String>,
/// HTTP frontend auth control passphrase
pub http_auth_control_passphrase: Option<String>,
/// HTTP frontend auth tx access control enabled
pub http_auth_tx_access_control_enabled: bool,
/// HTTP frontend auth session TTL in seconds
pub http_auth_session_ttl_secs: u64,
/// HTTP frontend auth cookie secure flag
pub http_auth_cookie_secure: bool,
/// HTTP frontend auth cookie same-site policy
pub http_auth_cookie_same_site: String,
/// Whether the HTTP UI should expose the RF Gain control.
pub http_show_sdr_gain_control: bool,
/// Initial APRS map zoom level when receiver coordinates are available.
pub http_initial_map_zoom: u8,
/// Spectrum center-retune guard margin on each side of the tuned passband.
pub http_spectrum_coverage_margin_hz: u32,
/// Fraction of the sampled spectrum span treated as usable by the web UI.
pub http_spectrum_usable_span_ratio: f32,
/// Default decode history retention in minutes.
pub http_decode_history_retention_min: u64,
/// Per-rig decode history retention overrides in minutes.
pub http_decode_history_retention_min_by_rig: HashMap<String, u64>,
/// Currently selected remote rig id (used by remote client routing).
pub remote_active_rig_id: Arc<Mutex<Option<String>>>,
pub clients: Arc<AtomicUsize>,
}
impl Default for AudioContext {
fn default() -> Self {
Self {
rx: None,
tx: None,
info: None,
decode_rx: None,
clients: Arc::new(AtomicUsize::new(0)),
}
}
}
/// Decode history entries for all decoder types.
pub struct DecodeHistoryContext {
pub ais: DecodeHistory<AisMessage>,
pub vdes: DecodeHistory<VdesMessage>,
pub aprs: DecodeHistory<AprsPacket>,
pub hf_aprs: DecodeHistory<AprsPacket>,
pub cw: DecodeHistory<CwEvent>,
pub ft8: DecodeHistory<Ft8Message>,
pub ft4: DecodeHistory<Ft8Message>,
pub ft2: DecodeHistory<Ft8Message>,
pub wspr: DecodeHistory<WsprMessage>,
}
impl Default for DecodeHistoryContext {
fn default() -> Self {
Self {
ais: Arc::new(Mutex::new(VecDeque::new())),
vdes: Arc::new(Mutex::new(VecDeque::new())),
aprs: Arc::new(Mutex::new(VecDeque::new())),
hf_aprs: Arc::new(Mutex::new(VecDeque::new())),
cw: Arc::new(Mutex::new(VecDeque::new())),
ft8: Arc::new(Mutex::new(VecDeque::new())),
ft4: Arc::new(Mutex::new(VecDeque::new())),
ft2: Arc::new(Mutex::new(VecDeque::new())),
wspr: Arc::new(Mutex::new(VecDeque::new())),
}
}
}
/// HTTP authentication configuration.
pub struct HttpAuthConfig {
pub enabled: bool,
pub rx_passphrase: Option<String>,
pub control_passphrase: Option<String>,
pub tx_access_control_enabled: bool,
pub session_ttl_secs: u64,
pub cookie_secure: bool,
pub cookie_same_site: String,
/// Authentication tokens for HTTP-JSON frontend.
pub tokens: HashSet<String>,
}
impl Default for HttpAuthConfig {
fn default() -> Self {
Self {
enabled: false,
rx_passphrase: None,
control_passphrase: None,
tx_access_control_enabled: true,
session_ttl_secs: 480 * 60,
cookie_secure: false,
cookie_same_site: "Lax".to_string(),
tokens: HashSet::new(),
}
}
}
/// HTTP UI display configuration.
pub struct HttpUiConfig {
pub show_sdr_gain_control: bool,
pub initial_map_zoom: u8,
pub spectrum_coverage_margin_hz: u32,
pub spectrum_usable_span_ratio: f32,
pub decode_history_retention_min: u64,
pub decode_history_retention_min_by_rig: HashMap<String, u64>,
}
impl Default for HttpUiConfig {
fn default() -> Self {
Self {
show_sdr_gain_control: true,
initial_map_zoom: 10,
spectrum_coverage_margin_hz: 50_000,
spectrum_usable_span_ratio: 0.92,
decode_history_retention_min: 24 * 60,
decode_history_retention_min_by_rig: HashMap::new(),
}
}
}
/// Remote rig routing and state management.
pub struct RigRoutingContext {
/// Currently selected remote rig id.
pub active_rig_id: Arc<Mutex<Option<String>>>,
/// Cached remote rig list from GetRigs polling.
pub remote_rigs: Arc<Mutex<Vec<RemoteRigEntry>>>,
/// Cached satellite pass predictions from the server (GetSatPasses).
pub sat_passes: Arc<RwLock<Option<trx_core::geo::PassPredictionResult>>>,
/// Per-rig state watch channels, keyed by rig_id.
/// Populated by the remote client poll loop so each SSE session can
/// subscribe to a specific rig's state independently.
pub rig_states: Arc<RwLock<HashMap<String, watch::Sender<RigState>>>>,
/// Owner callsign from trx-client config/CLI for frontend display.
pub owner_callsign: Option<String>,
/// Optional website URL for the web UI header title link.
pub owner_website_url: Option<String>,
/// Optional website name for the web UI header title label.
pub owner_website_name: Option<String>,
/// Optional base URL used to link AIS vessel names as `<base><mmsi>`.
pub ais_vessel_url_base: Option<String>,
/// Spectrum sender; SSE clients subscribe via `spectrum.subscribe()`.
pub spectrum: Arc<watch::Sender<SharedSpectrum>>,
/// Per-rig spectrum watch channels, keyed by rig_id.
/// Populated by the remote client spectrum polling task so each SSE
/// session can subscribe to a specific rig's spectrum independently.
pub rig_spectrums: Arc<RwLock<HashMap<String, watch::Sender<SharedSpectrum>>>>,
/// Per-rig RX audio broadcast senders, keyed by rig_id.
/// Each rig's audio client task publishes Opus frames here.
pub rig_audio_rx: Arc<RwLock<HashMap<String, broadcast::Sender<Bytes>>>>,
/// Per-rig audio stream info watch channels, keyed by rig_id.
pub rig_audio_info: Arc<RwLock<HashMap<String, watch::Sender<Option<AudioStreamInfo>>>>>,
/// Per-rig virtual-channel command senders, keyed by rig_id.
pub rig_vchan_audio_cmd: Arc<RwLock<HashMap<String, mpsc::Sender<VChanAudioCmd>>>>,
/// Per-virtual-channel Opus audio senders.
/// Key: server-side virtual channel UUID.
/// Value: `broadcast::Sender<Bytes>` that receives per-channel Opus packets
/// forwarded by the audio-client task from `AUDIO_MSG_RX_FRAME_CH` frames.
pub vchan_audio: Arc<RwLock<HashMap<Uuid, broadcast::Sender<Bytes>>>>,
/// Channel to send `VChanAudioCmd` to the audio-client task, which in turn
/// forwards `VCHAN_SUB` / `VCHAN_UNSUB` frames over the audio TCP connection.
/// `None` when no audio connection is active.
pub vchan_audio_cmd: Arc<Mutex<Option<mpsc::Sender<VChanAudioCmd>>>>,
/// Broadcast sender that fires whenever the server destroys a virtual
/// channel (e.g. out-of-bandwidth after center-frequency retune).
/// The HTTP frontend subscribes to clean up `ClientChannelManager`.
pub vchan_destroyed: Option<broadcast::Sender<Uuid>>,
/// Whether the remote client currently has an active TCP connection to
/// trx-server. Set to `true` on successful connect, `false` on drop.
/// Whether the remote client currently has an active TCP connection.
pub server_connected: Arc<AtomicBool>,
/// Per-rig server connection state, keyed by short name (or rig_id in legacy mode).
/// `true` while the rig's trx-server connection is active.
/// Allows the UI to freeze only the rig that lost its connection.
/// Per-rig server connection state.
pub rig_server_connected: Arc<RwLock<HashMap<String, bool>>>,
}
impl Default for RigRoutingContext {
fn default() -> Self {
Self {
active_rig_id: Arc::new(Mutex::new(None)),
remote_rigs: Arc::new(Mutex::new(Vec::new())),
sat_passes: Arc::new(RwLock::new(None)),
rig_states: Arc::new(RwLock::new(HashMap::new())),
server_connected: Arc::new(AtomicBool::new(false)),
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
}
}
}
/// Owner/station metadata for frontend display.
#[derive(Default)]
pub struct OwnerInfo {
pub callsign: Option<String>,
pub website_url: Option<String>,
pub website_name: Option<String>,
pub ais_vessel_url_base: Option<String>,
}
/// Virtual channel audio management.
pub struct VChanContext {
/// Per-virtual-channel Opus audio senders.
pub audio: Arc<RwLock<HashMap<Uuid, broadcast::Sender<Bytes>>>>,
/// Channel to send `VChanAudioCmd` to the audio-client task.
pub audio_cmd: Arc<Mutex<Option<mpsc::Sender<VChanAudioCmd>>>>,
/// Broadcast sender that fires when the server destroys a virtual channel.
pub destroyed: Option<broadcast::Sender<Uuid>>,
/// Per-rig virtual-channel command senders.
pub rig_audio_cmd: Arc<RwLock<HashMap<String, mpsc::Sender<VChanAudioCmd>>>>,
}
impl Default for VChanContext {
fn default() -> Self {
Self {
audio: Arc::new(RwLock::new(HashMap::new())),
audio_cmd: Arc::new(Mutex::new(None)),
destroyed: None,
rig_audio_cmd: Arc::new(RwLock::new(HashMap::new())),
}
}
}
/// Spectrum data management.
pub struct SpectrumContext {
/// Spectrum sender; SSE clients subscribe via `sender.subscribe()`.
pub sender: Arc<watch::Sender<SharedSpectrum>>,
/// Per-rig spectrum watch channels, keyed by rig_id.
pub per_rig: Arc<RwLock<HashMap<String, watch::Sender<SharedSpectrum>>>>,
}
impl Default for SpectrumContext {
fn default() -> Self {
Self {
sender: {
let (tx, _rx) = watch::channel(SharedSpectrum::default());
Arc::new(tx)
},
per_rig: Arc::new(RwLock::new(HashMap::new())),
}
}
}
/// Per-rig audio channels for multi-rig setups.
pub struct PerRigAudioContext {
/// Per-rig RX audio broadcast senders.
pub rx: Arc<RwLock<HashMap<String, broadcast::Sender<Bytes>>>>,
/// Per-rig audio stream info watch channels.
pub info: Arc<RwLock<HashMap<String, watch::Sender<Option<AudioStreamInfo>>>>>,
}
impl Default for PerRigAudioContext {
fn default() -> Self {
Self {
rx: Arc::new(RwLock::new(HashMap::new())),
info: Arc::new(RwLock::new(HashMap::new())),
}
}
}
/// Runtime context for frontend operation.
///
/// Decomposed into coherent sub-structs to improve readability and allow
/// frontends to access only the context groups they need.
pub struct FrontendRuntimeContext {
/// Audio streaming channels.
pub audio: AudioContext,
/// Decode history for all decoder types.
pub decode_history: DecodeHistoryContext,
/// HTTP authentication configuration.
pub http_auth: HttpAuthConfig,
/// HTTP UI display configuration.
pub http_ui: HttpUiConfig,
/// Remote rig routing and state.
pub routing: RigRoutingContext,
/// Owner/station metadata.
pub owner: OwnerInfo,
/// Virtual channel management.
pub vchan: VChanContext,
/// Spectrum data.
pub spectrum: SpectrumContext,
/// Per-rig audio channels.
pub rig_audio: PerRigAudioContext,
/// Active HTTP SSE clients.
pub sse_clients: Arc<AtomicUsize>,
/// Active rigctl TCP clients.
pub rigctl_clients: Arc<AtomicUsize>,
/// rigctl listen endpoint, if enabled.
pub rigctl_listen_addr: Arc<Mutex<Option<SocketAddr>>>,
/// Guard to avoid spawning duplicate decode collectors.
pub decode_collector_started: AtomicBool,
}
impl FrontendRuntimeContext {
/// Get a watch receiver for a specific rig's state.
pub fn rig_state_rx(&self, rig_id: &str) -> Option<watch::Receiver<RigState>> {
self.rig_states
self.routing
.rig_states
.read()
.ok()
.and_then(|map| map.get(rig_id).map(|tx| tx.subscribe()))
@@ -321,13 +444,13 @@ impl FrontendRuntimeContext {
/// Get a watch receiver for a specific rig's spectrum.
/// Lazily inserts a new channel if the rig_id is not yet present.
pub fn rig_spectrum_rx(&self, rig_id: &str) -> watch::Receiver<SharedSpectrum> {
if let Ok(map) = self.rig_spectrums.read() {
if let Ok(map) = self.spectrum.per_rig.read() {
if let Some(tx) = map.get(rig_id) {
return tx.subscribe();
}
}
// Insert on miss.
if let Ok(mut map) = self.rig_spectrums.write() {
if let Ok(mut map) = self.spectrum.per_rig.write() {
map.entry(rig_id.to_string())
.or_insert_with(|| watch::channel(SharedSpectrum::default()).0)
.subscribe()
@@ -339,7 +462,8 @@ impl FrontendRuntimeContext {
/// Subscribe to a specific rig's RX audio broadcast.
pub fn rig_audio_subscribe(&self, rig_id: &str) -> Option<broadcast::Receiver<Bytes>> {
self.rig_audio_rx
self.rig_audio
.rx
.read()
.ok()
.and_then(|map| map.get(rig_id).map(|tx| tx.subscribe()))
@@ -350,7 +474,8 @@ impl FrontendRuntimeContext {
&self,
rig_id: &str,
) -> Option<watch::Receiver<Option<AudioStreamInfo>>> {
self.rig_audio_info
self.rig_audio
.info
.read()
.ok()
.and_then(|map| map.get(rig_id).map(|tx| tx.subscribe()))
@@ -359,59 +484,19 @@ impl FrontendRuntimeContext {
/// Create a new empty runtime context.
pub fn new() -> Self {
Self {
audio_rx: None,
audio_tx: None,
audio_info: None,
decode_rx: None,
ais_history: Arc::new(Mutex::new(VecDeque::new())),
vdes_history: Arc::new(Mutex::new(VecDeque::new())),
aprs_history: Arc::new(Mutex::new(VecDeque::new())),
hf_aprs_history: Arc::new(Mutex::new(VecDeque::new())),
cw_history: Arc::new(Mutex::new(VecDeque::new())),
ft8_history: Arc::new(Mutex::new(VecDeque::new())),
ft4_history: Arc::new(Mutex::new(VecDeque::new())),
ft2_history: Arc::new(Mutex::new(VecDeque::new())),
wspr_history: Arc::new(Mutex::new(VecDeque::new())),
auth_tokens: HashSet::new(),
audio: AudioContext::default(),
decode_history: DecodeHistoryContext::default(),
http_auth: HttpAuthConfig::default(),
http_ui: HttpUiConfig::default(),
routing: RigRoutingContext::default(),
owner: OwnerInfo::default(),
vchan: VChanContext::default(),
spectrum: SpectrumContext::default(),
rig_audio: PerRigAudioContext::default(),
sse_clients: Arc::new(AtomicUsize::new(0)),
rigctl_clients: Arc::new(AtomicUsize::new(0)),
audio_clients: Arc::new(AtomicUsize::new(0)),
rigctl_listen_addr: Arc::new(Mutex::new(None)),
decode_collector_started: AtomicBool::new(false),
http_auth_enabled: false,
http_auth_rx_passphrase: None,
http_auth_control_passphrase: None,
http_auth_tx_access_control_enabled: true,
http_auth_session_ttl_secs: 480 * 60,
http_auth_cookie_secure: false,
http_auth_cookie_same_site: "Lax".to_string(),
http_show_sdr_gain_control: true,
http_initial_map_zoom: 10,
http_spectrum_coverage_margin_hz: 50_000,
http_spectrum_usable_span_ratio: 0.92,
http_decode_history_retention_min: 24 * 60,
http_decode_history_retention_min_by_rig: HashMap::new(),
remote_active_rig_id: Arc::new(Mutex::new(None)),
remote_rigs: Arc::new(Mutex::new(Vec::new())),
sat_passes: Arc::new(RwLock::new(None)),
rig_states: Arc::new(RwLock::new(HashMap::new())),
owner_callsign: None,
owner_website_url: None,
owner_website_name: None,
ais_vessel_url_base: None,
spectrum: {
let (tx, _rx) = watch::channel(SharedSpectrum::default());
Arc::new(tx)
},
rig_spectrums: Arc::new(RwLock::new(HashMap::new())),
rig_audio_rx: Arc::new(RwLock::new(HashMap::new())),
rig_audio_info: Arc::new(RwLock::new(HashMap::new())),
rig_vchan_audio_cmd: Arc::new(RwLock::new(HashMap::new())),
vchan_audio: Arc::new(RwLock::new(HashMap::new())),
vchan_audio_cmd: Arc::new(Mutex::new(None)),
vchan_destroyed: None,
server_connected: Arc::new(AtomicBool::new(false)),
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
}
}
}
@@ -129,7 +129,7 @@ async fn handle_client(
}
if let Some(rig_id) = envelope.rig_id.as_ref() {
if let Ok(mut active) = context.remote_active_rig_id.lock() {
if let Ok(mut active) = context.routing.active_rig_id.lock() {
*active = Some(rig_id.clone());
}
}
@@ -148,7 +148,8 @@ async fn handle_client(
}
let active_rig_id = context
.remote_active_rig_id
.routing
.active_rig_id
.lock()
.ok()
.and_then(|v| v.clone());
@@ -245,6 +246,7 @@ async fn handle_client(
fn snapshot_remote_rigs(context: &FrontendRuntimeContext) -> Vec<RigEntry> {
context
.routing
.remote_rigs
.lock()
.ok()
@@ -333,7 +335,7 @@ async fn send_response(
}
fn authorize(token: &Option<String>, context: &FrontendRuntimeContext) -> Result<(), String> {
let validator = SimpleTokenValidator::new(context.auth_tokens.clone());
let validator = SimpleTokenValidator::new(context.http_auth.tokens.clone());
validator.validate(token)
}
@@ -436,7 +438,7 @@ mod tests {
let addr = loopback_addr();
let (rig_tx, _rig_rx) = mpsc::channel::<RigRequest>(8);
let mut runtime = FrontendRuntimeContext::new();
runtime.auth_tokens = HashSet::from(["secret".to_string()]);
runtime.http_auth.tokens = HashSet::from(["secret".to_string()]);
let ctx = Arc::new(runtime);
let handle = tokio::spawn(serve(addr, rig_tx, ctx));
@@ -211,16 +211,17 @@ fn frontend_meta_from_context(
let server_connected = rig_id
.and_then(|rid| {
context
.routing
.rig_server_connected
.read()
.ok()
.and_then(|m| m.get(rid).copied())
})
.unwrap_or_else(|| context.server_connected.load(Ordering::Relaxed));
.unwrap_or_else(|| context.routing.server_connected.load(Ordering::Relaxed));
FrontendMeta {
http_clients,
rigctl_clients: context.rigctl_clients.load(Ordering::Relaxed),
audio_clients: context.audio_clients.load(Ordering::Relaxed),
audio_clients: context.audio.clients.load(Ordering::Relaxed),
rigctl_addr: rigctl_addr_from_context(context),
active_remote: active_rig_id_from_context(context),
remotes: rig_ids_from_context(context),
@@ -248,7 +249,8 @@ fn rigctl_addr_from_context(context: &FrontendRuntimeContext) -> Option<String>
fn active_rig_id_from_context(context: &FrontendRuntimeContext) -> Option<String> {
context
.remote_active_rig_id
.routing
.active_rig_id
.lock()
.ok()
.and_then(|v| v.clone())
@@ -256,6 +258,7 @@ fn active_rig_id_from_context(context: &FrontendRuntimeContext) -> Option<String
fn rig_ids_from_context(context: &FrontendRuntimeContext) -> Vec<String> {
context
.routing
.remote_rigs
.lock()
.ok()
@@ -264,41 +267,42 @@ fn rig_ids_from_context(context: &FrontendRuntimeContext) -> Vec<String> {
}
fn owner_callsign_from_context(context: &FrontendRuntimeContext) -> Option<String> {
context.owner_callsign.clone()
context.owner.callsign.clone()
}
fn owner_website_url_from_context(context: &FrontendRuntimeContext) -> Option<String> {
context.owner_website_url.clone()
context.owner.website_url.clone()
}
fn owner_website_name_from_context(context: &FrontendRuntimeContext) -> Option<String> {
context.owner_website_name.clone()
context.owner.website_name.clone()
}
fn ais_vessel_url_base_from_context(context: &FrontendRuntimeContext) -> Option<String> {
context.ais_vessel_url_base.clone()
context.owner.ais_vessel_url_base.clone()
}
fn show_sdr_gain_control_from_context(context: &FrontendRuntimeContext) -> bool {
context.http_show_sdr_gain_control
context.http_ui.show_sdr_gain_control
}
fn initial_map_zoom_from_context(context: &FrontendRuntimeContext) -> u8 {
context.http_initial_map_zoom
context.http_ui.initial_map_zoom
}
fn spectrum_coverage_margin_hz_from_context(context: &FrontendRuntimeContext) -> u32 {
context.http_spectrum_coverage_margin_hz
context.http_ui.spectrum_coverage_margin_hz
}
fn spectrum_usable_span_ratio_from_context(context: &FrontendRuntimeContext) -> f32 {
context.http_spectrum_usable_span_ratio
context.http_ui.spectrum_usable_span_ratio
}
fn decode_history_retention_min_from_context(context: &FrontendRuntimeContext) -> u64 {
let default_minutes = context.http_decode_history_retention_min.max(1);
let default_minutes = context.http_ui.decode_history_retention_min.max(1);
let Some(active_rig_id) = context
.remote_active_rig_id
.routing
.active_rig_id
.lock()
.ok()
.and_then(|v| v.clone())
@@ -306,7 +310,8 @@ fn decode_history_retention_min_from_context(context: &FrontendRuntimeContext) -
return default_minutes;
};
context
.http_decode_history_retention_min_by_rig
.http_ui
.decode_history_retention_min_by_rig
.get(&active_rig_id)
.copied()
.filter(|minutes| *minutes > 0)
@@ -343,7 +348,8 @@ pub async fn events(
// rig it has selected without mutating global state.
let active_rig_id = query.remote.clone().filter(|s| !s.is_empty()).or_else(|| {
context
.remote_active_rig_id
.routing
.active_rig_id
.lock()
.ok()
.and_then(|g| g.clone())
@@ -419,7 +425,8 @@ pub async fn events(
state.snapshot().and_then(|v| {
let rig_id_opt = session_rig_mgr.get_rig(session_id).or_else(|| {
context
.remote_active_rig_id
.routing
.active_rig_id
.lock()
.ok()
.and_then(|g| g.clone())
@@ -687,7 +694,7 @@ pub async fn decode_history(
context: web::Data<Arc<FrontendRuntimeContext>>,
query: web::Query<RemoteQuery>,
) -> impl Responder {
if context.decode_rx.is_none() {
if context.audio.decode_rx.is_none() {
return HttpResponse::NotFound().body("decode not enabled");
}
let rig_filter = query.remote.as_deref().filter(|s| !s.is_empty());
@@ -807,7 +814,7 @@ pub async fn spectrum(
let rx = if let Some(ref remote) = query.remote {
context.rig_spectrum_rx(remote)
} else {
context.spectrum.subscribe()
context.spectrum.sender.subscribe()
};
let mut last_rds_json: Option<String> = None;
let mut last_vchan_rds_json: Option<String> = None;
@@ -1351,7 +1358,7 @@ struct SatPassesResponse {
/// are not yet available.
#[get("/sat_passes")]
pub async fn sat_passes(context: web::Data<Arc<FrontendRuntimeContext>>) -> impl Responder {
let cached = context.sat_passes.read().ok().and_then(|g| g.clone());
let cached = context.routing.sat_passes.read().ok().and_then(|g| g.clone());
match cached {
Some(result) => {
let error = match result.tle_source {
@@ -1901,6 +1908,7 @@ struct RigListResponse {
fn build_rig_list_payload(context: &FrontendRuntimeContext) -> RigListResponse {
let active_remote = active_rig_id_from_context(context);
let rigs = context
.routing
.remote_rigs
.lock()
.ok()
@@ -1952,6 +1960,7 @@ pub async fn select_rig(
}
let known = context
.routing
.remote_rigs
.lock()
.ok()
@@ -36,15 +36,17 @@ fn current_timestamp_ms() -> i64 {
}
fn decode_history_retention(context: &FrontendRuntimeContext) -> Duration {
let default_minutes = context.http_decode_history_retention_min.max(1);
let default_minutes = context.http_ui.decode_history_retention_min.max(1);
let minutes = context
.remote_active_rig_id
.routing
.active_rig_id
.lock()
.ok()
.and_then(|v| v.clone())
.and_then(|rig_id| {
context
.http_decode_history_retention_min_by_rig
.http_ui
.decode_history_retention_min_by_rig
.get(&rig_id)
.copied()
})
@@ -111,7 +113,8 @@ fn prune_vdes_history(
fn active_rig_id(context: &FrontendRuntimeContext) -> Option<String> {
context
.remote_active_rig_id
.routing
.active_rig_id
.lock()
.ok()
.and_then(|g| g.clone())
@@ -123,7 +126,7 @@ fn record_ais(context: &FrontendRuntimeContext, mut msg: AisMessage) {
}
let rig_id = msg.rig_id.clone().or_else(|| active_rig_id(context));
let mut history = context
.ais_history
.decode_history.ais
.lock()
.expect("ais history mutex poisoned");
history.push_back((Instant::now(), rig_id, msg));
@@ -136,7 +139,7 @@ fn record_vdes(context: &FrontendRuntimeContext, mut msg: VdesMessage) {
}
let rig_id = msg.rig_id.clone().or_else(|| active_rig_id(context));
let mut history = context
.vdes_history
.decode_history.vdes
.lock()
.expect("vdes history mutex poisoned");
history.push_back((Instant::now(), rig_id, msg));
@@ -214,7 +217,7 @@ fn record_aprs(context: &FrontendRuntimeContext, mut pkt: AprsPacket) {
}
let rig_id = pkt.rig_id.clone().or_else(|| active_rig_id(context));
let mut history = context
.aprs_history
.decode_history.aprs
.lock()
.expect("aprs history mutex poisoned");
history.push_back((Instant::now(), rig_id, pkt));
@@ -227,7 +230,7 @@ fn record_hf_aprs(context: &FrontendRuntimeContext, mut pkt: AprsPacket) {
}
let rig_id = pkt.rig_id.clone().or_else(|| active_rig_id(context));
let mut history = context
.hf_aprs_history
.decode_history.hf_aprs
.lock()
.expect("hf_aprs history mutex poisoned");
history.push_back((Instant::now(), rig_id, pkt));
@@ -237,7 +240,7 @@ fn record_hf_aprs(context: &FrontendRuntimeContext, mut pkt: AprsPacket) {
fn record_cw(context: &FrontendRuntimeContext, event: CwEvent) {
let rig_id = event.rig_id.clone().or_else(|| active_rig_id(context));
let mut history = context
.cw_history
.decode_history.cw
.lock()
.expect("cw history mutex poisoned");
history.push_back((Instant::now(), rig_id, event));
@@ -247,7 +250,7 @@ fn record_cw(context: &FrontendRuntimeContext, event: CwEvent) {
fn record_ft8(context: &FrontendRuntimeContext, msg: Ft8Message) {
let rig_id = msg.rig_id.clone().or_else(|| active_rig_id(context));
let mut history = context
.ft8_history
.decode_history.ft8
.lock()
.expect("ft8 history mutex poisoned");
history.push_back((Instant::now(), rig_id, msg));
@@ -257,7 +260,7 @@ fn record_ft8(context: &FrontendRuntimeContext, msg: Ft8Message) {
fn record_ft4(context: &FrontendRuntimeContext, msg: Ft8Message) {
let rig_id = msg.rig_id.clone().or_else(|| active_rig_id(context));
let mut history = context
.ft4_history
.decode_history.ft4
.lock()
.expect("ft4 history mutex poisoned");
history.push_back((Instant::now(), rig_id, msg));
@@ -267,7 +270,7 @@ fn record_ft4(context: &FrontendRuntimeContext, msg: Ft8Message) {
fn record_ft2(context: &FrontendRuntimeContext, msg: Ft8Message) {
let rig_id = msg.rig_id.clone().or_else(|| active_rig_id(context));
let mut history = context
.ft2_history
.decode_history.ft2
.lock()
.expect("ft2 history mutex poisoned");
history.push_back((Instant::now(), rig_id, msg));
@@ -277,7 +280,7 @@ fn record_ft2(context: &FrontendRuntimeContext, msg: Ft8Message) {
fn record_wspr(context: &FrontendRuntimeContext, msg: WsprMessage) {
let rig_id = msg.rig_id.clone().or_else(|| active_rig_id(context));
let mut history = context
.wspr_history
.decode_history.wspr
.lock()
.expect("wspr history mutex poisoned");
history.push_back((Instant::now(), rig_id, msg));
@@ -298,7 +301,7 @@ pub fn snapshot_aprs_history(
rig_filter: Option<&str>,
) -> Vec<AprsPacket> {
let mut history = context
.aprs_history
.decode_history.aprs
.lock()
.expect("aprs history mutex poisoned");
prune_aprs_history(context, &mut history);
@@ -314,7 +317,7 @@ pub fn snapshot_hf_aprs_history(
rig_filter: Option<&str>,
) -> Vec<AprsPacket> {
let mut history = context
.hf_aprs_history
.decode_history.hf_aprs
.lock()
.expect("hf_aprs history mutex poisoned");
prune_hf_aprs_history(context, &mut history);
@@ -337,7 +340,7 @@ pub fn snapshot_ais_history(
rig_filter: Option<&str>,
) -> Vec<AisMessage> {
let mut history = context
.ais_history
.decode_history.ais
.lock()
.expect("ais history mutex poisoned");
prune_ais_history(context, &mut history);
@@ -359,7 +362,7 @@ pub fn snapshot_vdes_history(
rig_filter: Option<&str>,
) -> Vec<VdesMessage> {
let mut history = context
.vdes_history
.decode_history.vdes
.lock()
.expect("vdes history mutex poisoned");
prune_vdes_history(context, &mut history);
@@ -375,7 +378,7 @@ pub fn snapshot_cw_history(
rig_filter: Option<&str>,
) -> Vec<CwEvent> {
let mut history = context
.cw_history
.decode_history.cw
.lock()
.expect("cw history mutex poisoned");
prune_cw_history(context, &mut history);
@@ -391,7 +394,7 @@ pub fn snapshot_ft8_history(
rig_filter: Option<&str>,
) -> Vec<Ft8Message> {
let mut history = context
.ft8_history
.decode_history.ft8
.lock()
.expect("ft8 history mutex poisoned");
prune_ft8_history(context, &mut history);
@@ -407,7 +410,7 @@ pub fn snapshot_ft4_history(
rig_filter: Option<&str>,
) -> Vec<Ft8Message> {
let mut history = context
.ft4_history
.decode_history.ft4
.lock()
.expect("ft4 history mutex poisoned");
prune_ft4_history(context, &mut history);
@@ -423,7 +426,7 @@ pub fn snapshot_ft2_history(
rig_filter: Option<&str>,
) -> Vec<Ft8Message> {
let mut history = context
.ft2_history
.decode_history.ft2
.lock()
.expect("ft2 history mutex poisoned");
prune_ft2_history(context, &mut history);
@@ -439,7 +442,7 @@ pub fn snapshot_wspr_history(
rig_filter: Option<&str>,
) -> Vec<WsprMessage> {
let mut history = context
.wspr_history
.decode_history.wspr
.lock()
.expect("wspr history mutex poisoned");
prune_wspr_history(context, &mut history);
@@ -452,7 +455,7 @@ pub fn snapshot_wspr_history(
pub fn clear_aprs_history(context: &FrontendRuntimeContext) {
let mut history = context
.aprs_history
.decode_history.aprs
.lock()
.expect("aprs history mutex poisoned");
history.clear();
@@ -460,7 +463,7 @@ pub fn clear_aprs_history(context: &FrontendRuntimeContext) {
pub fn clear_hf_aprs_history(context: &FrontendRuntimeContext) {
let mut history = context
.hf_aprs_history
.decode_history.hf_aprs
.lock()
.expect("hf_aprs history mutex poisoned");
history.clear();
@@ -468,7 +471,7 @@ pub fn clear_hf_aprs_history(context: &FrontendRuntimeContext) {
pub fn clear_ais_history(context: &FrontendRuntimeContext) {
let mut history = context
.ais_history
.decode_history.ais
.lock()
.expect("ais history mutex poisoned");
history.clear();
@@ -476,7 +479,7 @@ pub fn clear_ais_history(context: &FrontendRuntimeContext) {
pub fn clear_vdes_history(context: &FrontendRuntimeContext) {
let mut history = context
.vdes_history
.decode_history.vdes
.lock()
.expect("vdes history mutex poisoned");
history.clear();
@@ -484,7 +487,7 @@ pub fn clear_vdes_history(context: &FrontendRuntimeContext) {
pub fn clear_cw_history(context: &FrontendRuntimeContext) {
let mut history = context
.cw_history
.decode_history.cw
.lock()
.expect("cw history mutex poisoned");
history.clear();
@@ -492,7 +495,7 @@ pub fn clear_cw_history(context: &FrontendRuntimeContext) {
pub fn clear_ft8_history(context: &FrontendRuntimeContext) {
let mut history = context
.ft8_history
.decode_history.ft8
.lock()
.expect("ft8 history mutex poisoned");
history.clear();
@@ -500,7 +503,7 @@ pub fn clear_ft8_history(context: &FrontendRuntimeContext) {
pub fn clear_ft4_history(context: &FrontendRuntimeContext) {
let mut history = context
.ft4_history
.decode_history.ft4
.lock()
.expect("ft4 history mutex poisoned");
history.clear();
@@ -508,7 +511,7 @@ pub fn clear_ft4_history(context: &FrontendRuntimeContext) {
pub fn clear_ft2_history(context: &FrontendRuntimeContext) {
let mut history = context
.ft2_history
.decode_history.ft2
.lock()
.expect("ft2 history mutex poisoned");
history.clear();
@@ -516,7 +519,7 @@ pub fn clear_ft2_history(context: &FrontendRuntimeContext) {
pub fn clear_wspr_history(context: &FrontendRuntimeContext) {
let mut history = context
.wspr_history
.decode_history.wspr
.lock()
.expect("wspr history mutex poisoned");
history.clear();
@@ -525,7 +528,7 @@ pub fn clear_wspr_history(context: &FrontendRuntimeContext) {
pub fn subscribe_decode(
context: &FrontendRuntimeContext,
) -> Option<broadcast::Receiver<DecodedMessage>> {
context.decode_rx.as_ref().map(|tx| tx.subscribe())
context.audio.decode_rx.as_ref().map(|tx| tx.subscribe())
}
pub fn start_decode_history_collector(context: Arc<FrontendRuntimeContext>) {
@@ -536,7 +539,7 @@ pub fn start_decode_history_collector(context: Arc<FrontendRuntimeContext>) {
return;
}
let Some(tx) = context.decode_rx.as_ref().cloned() else {
let Some(tx) = context.audio.decode_rx.as_ref().cloned() else {
return;
};
@@ -576,7 +579,7 @@ pub async fn audio_ws(
query: web::Query<AudioQuery>,
context: web::Data<Arc<FrontendRuntimeContext>>,
) -> Result<HttpResponse, Error> {
let Some(tx_sender) = context.audio_tx.as_ref().cloned() else {
let Some(tx_sender) = context.audio.tx.as_ref().cloned() else {
return Ok(HttpResponse::NotFound().body("audio not enabled"));
};
@@ -596,14 +599,14 @@ pub async fn audio_ws(
let info_rx = if let Some(ref remote) = query.remote {
context.rig_audio_info_rx(remote)
} else {
context.audio_info.as_ref().cloned()
context.audio.info.as_ref().cloned()
};
let Some(info_rx) = info_rx else {
return Ok(HttpResponse::NotFound().body("audio not enabled"));
};
let deadline = Instant::now() + Duration::from_secs(2);
let rx_sub = loop {
match context.vchan_audio.read() {
match context.vchan.audio.read() {
Ok(map) => {
if let Some(tx) = map.get(&ch_id) {
break tx.subscribe();
@@ -639,10 +642,10 @@ pub async fn audio_ws(
};
(rx_sub, info_rx)
} else {
let Some(info_rx) = context.audio_info.as_ref().cloned() else {
let Some(info_rx) = context.audio.info.as_ref().cloned() else {
return Ok(HttpResponse::NotFound().body("audio not enabled"));
};
let Some(rx) = context.audio_rx.as_ref() else {
let Some(rx) = context.audio.rx.as_ref() else {
return Ok(HttpResponse::NotFound().body("audio not enabled"));
};
(rx.subscribe(), info_rx)
@@ -651,7 +654,7 @@ pub async fn audio_ws(
let (response, mut session, mut msg_stream) = actix_ws::handle(&req, body)?;
let audio_clients = context.audio_clients.clone();
let audio_clients = context.audio.clients.clone();
audio_clients.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
actix_web::rt::spawn(async move {
@@ -5,7 +5,8 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::atomic::Ordering;
use std::sync::{Arc, RwLock};
use std::sync::Arc;
use tokio::sync::RwLock;
use std::time::Duration;
use actix_web::{delete, get, put, web, HttpResponse, Responder};
@@ -115,18 +116,18 @@ impl BackgroundDecodeStore {
.unwrap_or_else(|| PathBuf::from("background_decode.db"))
}
pub fn get(&self, rig_id: &str) -> Option<BackgroundDecodeConfig> {
let db = self.db.read().unwrap_or_else(|e| e.into_inner());
pub async fn get(&self, rig_id: &str) -> Option<BackgroundDecodeConfig> {
let db = self.db.read().await;
db.get::<BackgroundDecodeConfig>(&format!("bgd:{rig_id}"))
}
pub fn upsert(&self, config: &BackgroundDecodeConfig) -> bool {
let mut db = self.db.write().unwrap_or_else(|e| e.into_inner());
pub async fn upsert(&self, config: &BackgroundDecodeConfig) -> bool {
let mut db = self.db.write().await;
db.set(&format!("bgd:{}", config.rig_id), config).is_ok()
}
pub fn remove(&self, rig_id: &str) -> bool {
let mut db = self.db.write().unwrap_or_else(|e| e.into_inner());
pub async fn remove(&self, rig_id: &str) -> bool {
let mut db = self.db.write().await;
db.rem(&format!("bgd:{rig_id}")).unwrap_or(false)
}
}
@@ -171,9 +172,10 @@ impl BackgroundDecodeManager {
});
}
pub fn get_config(&self, rig_id: &str) -> BackgroundDecodeConfig {
pub async fn get_config(&self, rig_id: &str) -> BackgroundDecodeConfig {
self.store
.get(rig_id)
.await
.unwrap_or_else(|| BackgroundDecodeConfig {
rig_id: rig_id.to_string(),
enabled: false,
@@ -181,9 +183,9 @@ impl BackgroundDecodeManager {
})
}
pub fn put_config(&self, mut config: BackgroundDecodeConfig) -> Option<BackgroundDecodeConfig> {
pub async fn put_config(&self, mut config: BackgroundDecodeConfig) -> Option<BackgroundDecodeConfig> {
config.bookmark_ids = dedup_ids(&config.bookmark_ids);
if self.store.upsert(&config) {
if self.store.upsert(&config).await {
self.trigger();
Some(config)
} else {
@@ -191,19 +193,20 @@ impl BackgroundDecodeManager {
}
}
pub fn reset_config(&self, rig_id: &str) -> bool {
let removed = self.store.remove(rig_id);
pub async fn reset_config(&self, rig_id: &str) -> bool {
let removed = self.store.remove(rig_id).await;
self.trigger();
removed
}
pub fn status(&self, rig_id: &str) -> BackgroundDecodeStatus {
if let Ok(status) = self.status.read() {
pub async fn status(&self, rig_id: &str) -> BackgroundDecodeStatus {
{
let status = self.status.read().await;
if let Some(entry) = status.get(rig_id) {
return entry.clone();
}
}
let cfg = self.get_config(rig_id);
let cfg = self.get_config(rig_id).await;
let bookmarks: HashMap<String, Bookmark> = self
.bookmarks
.list_for_rig(rig_id)
@@ -243,7 +246,8 @@ impl BackgroundDecodeManager {
fn active_rig_id(&self) -> Option<String> {
self.context
.remote_active_rig_id
.routing
.active_rig_id
.lock()
.ok()
.and_then(|guard| guard.clone())
@@ -252,7 +256,7 @@ impl BackgroundDecodeManager {
fn send_audio_cmd(&self, cmd: VChanAudioCmd) {
// Route through per-rig sender when available.
if let Some(rig_id) = self.active_rig_id() {
if let Ok(map) = self.context.rig_vchan_audio_cmd.read() {
if let Ok(map) = self.context.vchan.rig_audio_cmd.read() {
if let Some(tx) = map.get(&rig_id) {
let _ = tx.try_send(cmd);
return;
@@ -260,7 +264,7 @@ impl BackgroundDecodeManager {
}
}
// Fall back to global sender.
if let Ok(guard) = self.context.vchan_audio_cmd.lock() {
if let Ok(guard) = self.context.vchan.audio_cmd.lock() {
if let Some(tx) = guard.as_ref() {
let _ = tx.try_send(cmd);
}
@@ -316,15 +320,14 @@ impl BackgroundDecodeManager {
.any(|channel| channel_matches_bookmark(&channel, bookmark))
}
fn reconcile(&self, runtime: &mut BackgroundRuntimeState, spectrum: &SharedSpectrum) {
async fn reconcile(&self, runtime: &mut BackgroundRuntimeState, spectrum: &SharedSpectrum) {
let active_rig_id = self.active_rig_id();
if runtime.current_rig_id != active_rig_id {
if let Some(prev_rig_id) = runtime.current_rig_id.clone() {
if let Ok(mut guard) = self.status.write() {
if let Some(prev_status) = guard.get_mut(&prev_rig_id) {
prev_status.active_rig = false;
}
let mut guard = self.status.write().await;
if let Some(prev_status) = guard.get_mut(&prev_rig_id) {
prev_status.active_rig = false;
}
}
self.clear_runtime_channels(runtime);
@@ -335,7 +338,7 @@ impl BackgroundDecodeManager {
};
runtime.current_rig_id = Some(rig_id.clone());
let config = self.get_config(&rig_id);
let config = self.get_config(&rig_id).await;
let selected = dedup_ids(&config.bookmark_ids);
let users_connected = self.context.sse_clients.load(Ordering::Relaxed) > 0;
let scheduler_has_control = self.scheduler_control.scheduler_allowed() && users_connected;
@@ -467,19 +470,18 @@ impl BackgroundDecodeManager {
runtime.active_channels.insert(bookmark_id, desired);
}
if let Ok(mut guard) = self.status.write() {
guard.insert(
rig_id.clone(),
BackgroundDecodeStatus {
rig_id,
enabled: config.enabled,
active_rig: true,
center_hz,
sample_rate,
entries: statuses,
},
);
}
let mut guard = self.status.write().await;
guard.insert(
rig_id.clone(),
BackgroundDecodeStatus {
rig_id,
enabled: config.enabled,
active_rig: true,
center_hz,
sample_rate,
entries: statuses,
},
);
}
fn scheduler_bookmark_ids(&self, rig_id: &str) -> Vec<String> {
@@ -513,7 +515,7 @@ impl BackgroundDecodeManager {
loop {
let users_connected = self.context.sse_clients.load(Ordering::Relaxed) > 0;
if users_connected && spectrum_rx.is_none() {
spectrum_rx = Some(self.context.spectrum.subscribe());
spectrum_rx = Some(self.context.spectrum.sender.subscribe());
} else if !users_connected {
spectrum_rx = None;
}
@@ -522,7 +524,7 @@ impl BackgroundDecodeManager {
.as_ref()
.map(|rx| rx.borrow().clone())
.unwrap_or_default();
self.reconcile(&mut runtime, &spectrum);
self.reconcile(&mut runtime, &spectrum).await;
tokio::select! {
changed = async {
match spectrum_rx.as_mut() {
@@ -599,7 +601,7 @@ pub async fn get_background_decode(
path: web::Path<String>,
manager: web::Data<Arc<BackgroundDecodeManager>>,
) -> impl Responder {
HttpResponse::Ok().json(manager.get_config(&path.into_inner()))
HttpResponse::Ok().json(manager.get_config(&path.into_inner()).await)
}
#[put("/background-decode/{rig_id}")]
@@ -611,7 +613,7 @@ pub async fn put_background_decode(
let rig_id = path.into_inner();
let mut config = body.into_inner();
config.rig_id = rig_id;
match manager.put_config(config) {
match manager.put_config(config).await {
Some(saved) => HttpResponse::Ok().json(saved),
None => HttpResponse::InternalServerError().body("failed to save background decode config"),
}
@@ -623,7 +625,7 @@ pub async fn delete_background_decode(
manager: web::Data<Arc<BackgroundDecodeManager>>,
) -> impl Responder {
let rig_id = path.into_inner();
manager.reset_config(&rig_id);
manager.reset_config(&rig_id).await;
HttpResponse::Ok().json(BackgroundDecodeConfig {
rig_id,
enabled: false,
@@ -636,5 +638,5 @@ pub async fn get_background_decode_status(
path: web::Path<String>,
manager: web::Data<Arc<BackgroundDecodeManager>>,
) -> impl Responder {
HttpResponse::Ok().json(manager.status(&path.into_inner()))
HttpResponse::Ok().json(manager.status(&path.into_inner()).await)
}
@@ -73,6 +73,7 @@ async fn serve(
// Collect rig IDs for per-rig store initialisation / migration.
let rig_ids: Vec<String> = context
.routing
.remote_rigs
.lock()
.unwrap_or_else(|e| e.into_inner())
@@ -98,7 +99,7 @@ async fn serve(
let background_decode_store = Arc::new(BackgroundDecodeStore::open(&background_decode_path));
let vchan_mgr = Arc::new(ClientChannelManager::new(
4,
context.rig_vchan_audio_cmd.clone(),
context.vchan.rig_audio_cmd.clone(),
));
let session_rig_mgr = Arc::new(api::SessionRigManager::default());
let background_decode_mgr = BackgroundDecodeManager::new(
@@ -113,7 +114,7 @@ async fn serve(
// Wire the audio-command sender so allocate/delete/freq/mode operations on
// virtual channels are forwarded to the audio-client task.
if let Ok(guard) = context.vchan_audio_cmd.lock() {
if let Ok(guard) = context.vchan.audio_cmd.lock() {
if let Some(tx) = guard.as_ref() {
vchan_mgr.set_audio_cmd(tx.clone());
}
@@ -121,7 +122,7 @@ async fn serve(
// Spawn a task that removes channels destroyed server-side (OOB) from the
// client-side registry so the SSE channel list stays in sync.
if let Some(ref destroyed_tx) = context.vchan_destroyed {
if let Some(ref destroyed_tx) = context.vchan.destroyed {
let mut destroyed_rx = destroyed_tx.subscribe();
let mgr_for_destroyed = vchan_mgr.clone();
tokio::spawn(async move {
@@ -193,18 +194,18 @@ fn build_server(
let background_decode_mgr = web::Data::new(background_decode_mgr);
// Extract auth config values before moving context
let same_site = match context.http_auth_cookie_same_site.as_str() {
let same_site = match context.http_auth.cookie_same_site.as_str() {
"Strict" => SameSite::Strict,
"None" => SameSite::None,
_ => SameSite::Lax, // default
};
let auth_config = AuthConfig::new(
context.http_auth_enabled,
context.http_auth_rx_passphrase.clone(),
context.http_auth_control_passphrase.clone(),
context.http_auth_tx_access_control_enabled,
Duration::from_secs(context.http_auth_session_ttl_secs),
context.http_auth_cookie_secure,
context.http_auth.enabled,
context.http_auth.rx_passphrase.clone(),
context.http_auth.control_passphrase.clone(),
context.http_auth.tx_access_control_enabled,
Duration::from_secs(context.http_auth.session_ttl_secs),
context.http_auth.cookie_secure,
same_site,
);