use serde::{Deserialize, Serialize};
use std::str::FromStr;
use url::Url;
pub const DEFAULT_SAMPLE_RATE: u32 = 1_050_000;
use desperado::{Gain, IqFormat};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase", untagged)]
pub enum Address {
File {
file: String,
},
#[cfg(feature = "rtlsdr")]
Rtlsdr {
device: Option<usize>,
serial: Option<String>,
},
#[cfg(feature = "airspy")]
Airspy {
device: Option<usize>,
serial: Option<String>,
},
#[cfg(feature = "hackrf")]
Hackrf {
device: Option<usize>,
},
#[cfg(feature = "soapy")]
Soapy {
soapy: String,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Source {
#[serde(flatten)]
pub address: Address,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default)]
pub center_freq: Option<u32>,
#[serde(default)]
pub sample_rate: Option<u32>,
#[serde(default, alias = "channel")]
pub channels: Option<Vec<u32>>,
#[serde(default)]
pub gain: Option<f64>,
#[serde(default)]
pub bias_tee: Option<bool>,
#[serde(default, alias = "rf_amp")]
pub amp_enable: Option<bool>,
#[serde(default, alias = "if_gain")]
pub lna_gain: Option<f64>,
#[serde(default, alias = "bb_gain")]
pub vga_gain: Option<f64>,
#[serde(default, alias = "iq_format")]
pub format: Option<String>,
}
impl Source {
pub fn center_freq_or(&self, default: u32) -> u32 {
self.center_freq.unwrap_or(default)
}
pub fn sample_rate(&self) -> u32 {
self.sample_rate.unwrap_or(DEFAULT_SAMPLE_RATE)
}
pub fn channels_with<F>(&self, auto: F) -> Vec<u32>
where
F: FnOnce(&Self) -> Vec<u32>,
{
self.channels
.clone()
.filter(|v| !v.is_empty())
.unwrap_or_else(|| auto(self))
}
pub fn label(&self) -> String {
if let Some(name) = &self.name {
return name.clone();
}
match &self.address {
Address::File { file } => format!("file:{file}"),
#[cfg(feature = "rtlsdr")]
Address::Rtlsdr { device, serial } => serial
.as_ref()
.map(|s| format!("rtlsdr:{s}"))
.unwrap_or_else(|| format!("rtlsdr:{}", device.unwrap_or(0))),
#[cfg(feature = "airspy")]
Address::Airspy { device, serial } => serial
.as_ref()
.map(|s| format!("airspy:{s}"))
.unwrap_or_else(|| format!("airspy:{}", device.unwrap_or(0))),
#[cfg(feature = "hackrf")]
Address::Hackrf { device } => format!("hackrf:{}", device.unwrap_or(0)),
#[cfg(feature = "soapy")]
Address::Soapy { soapy } => format!("soapy:{soapy}"),
}
}
pub fn iq_format(&self) -> IqFormat {
self.format
.as_deref()
.unwrap_or("cu8")
.parse()
.unwrap_or(IqFormat::Cu8)
}
pub fn gain(&self, default: f64) -> Gain {
self.gain.map(Gain::Manual).unwrap_or(Gain::Manual(default))
}
}
impl FromStr for Source {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if s == "-" {
return Ok(Source {
address: Address::File {
file: "-".to_string(),
},
name: Some("stdin".to_string()),
center_freq: None,
sample_rate: None,
channels: None,
gain: None,
bias_tee: None,
amp_enable: None,
lna_gain: None,
vga_gain: None,
format: None,
});
}
let default = Url::parse("file://").unwrap();
let url = default.join(s).map_err(|e| e.to_string())?;
let address = match url.scheme() {
"file" => {
let file = if let Some(host) = url.host_str() {
format!("{}{}", host, url.path())
} else {
url.path().to_string()
};
Address::File { file }
}
#[cfg(feature = "rtlsdr")]
"rtlsdr" => {
let host = url.host_str().unwrap_or("");
if host.is_empty() {
Address::Rtlsdr {
device: Some(0),
serial: None,
}
} else if let Ok(device) = host.parse::<usize>() {
Address::Rtlsdr {
device: Some(device),
serial: None,
}
} else if let Some(serial) = host.strip_prefix("serial=") {
Address::Rtlsdr {
device: None,
serial: Some(serial.to_string()),
}
} else {
Address::Rtlsdr {
device: Some(0),
serial: None,
}
}
}
#[cfg(feature = "airspy")]
"airspy" => {
let host = url.host_str().unwrap_or("");
if host.is_empty() {
Address::Airspy {
device: Some(0),
serial: None,
}
} else if let Ok(device) = host.parse::<usize>() {
Address::Airspy {
device: Some(device),
serial: None,
}
} else if let Some(serial) = host.strip_prefix("serial=") {
Address::Airspy {
device: None,
serial: Some(serial.to_string()),
}
} else {
Address::Airspy {
device: Some(0),
serial: None,
}
}
}
#[cfg(feature = "hackrf")]
"hackrf" => {
let device = url
.host_str()
.and_then(|s| s.parse::<usize>().ok())
.or(Some(0));
Address::Hackrf { device }
}
#[cfg(feature = "soapy")]
"soapy" => Address::Soapy {
soapy: url.host_str().unwrap_or("").to_string(),
},
other => return Err(format!("unsupported source scheme: {other}")),
};
let mut source = Source {
address,
name: None,
center_freq: None,
sample_rate: None,
channels: None,
gain: None,
bias_tee: None,
amp_enable: None,
lna_gain: None,
vga_gain: None,
format: None,
};
if let Some(query) = url.query() {
for (key, value) in url::form_urlencoded::parse(query.as_bytes()) {
match key.as_ref() {
"name" => source.name = Some(value.into_owned()),
"center_freq" | "freq" => source.center_freq = parse_hz(&value),
"sample_rate" | "rate" => source.sample_rate = parse_hz(&value),
"channel" | "channels" => {
let parsed: Vec<u32> = value.split(',').filter_map(parse_hz).collect();
if !parsed.is_empty() {
source.channels.get_or_insert_with(Vec::new).extend(parsed);
}
}
"gain" => source.gain = value.parse::<f64>().ok(),
"bias_tee" => source.bias_tee = parse_bool(&value),
"amp_enable" | "rf_amp" => source.amp_enable = parse_bool(&value),
"lna_gain" | "if_gain" => source.lna_gain = value.parse::<f64>().ok(),
"vga_gain" | "bb_gain" => source.vga_gain = value.parse::<f64>().ok(),
"format" | "iq_format" => source.format = Some(value.into_owned()),
_ if !key.is_empty() && value.is_empty() => {
source.name = Some(key.into_owned())
}
_ => {}
}
}
}
Ok(source)
}
}
fn parse_bool(value: &str) -> Option<bool> {
match value.to_ascii_lowercase().as_str() {
"true" | "1" | "yes" | "on" => Some(true),
"false" | "0" | "no" | "off" => Some(false),
_ => None,
}
}
fn parse_hz(value: impl AsRef<str>) -> Option<u32> {
let value = value.as_ref().trim();
let (num, mult) = match value.chars().last()? {
'k' | 'K' => (&value[..value.len() - 1], 1_000.0),
'm' | 'M' => (&value[..value.len() - 1], 1_000_000.0),
'g' | 'G' => (&value[..value.len() - 1], 1_000_000_000.0),
_ => (value, 1.0),
};
num.parse::<f64>().ok().map(|v| (v * mult).round() as u32)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_file_url() {
let src: Source = "file:///tmp/acars.cu8?format=cu8&sample_rate=1050000¢er_freq=131700000&channel=131725000".parse().unwrap();
assert_eq!(src.center_freq_or(0), 131_700_000);
assert_eq!(src.sample_rate(), 1_050_000);
assert_eq!(src.channels_with(|_| vec![]), vec![131_725_000]);
}
#[cfg(feature = "rtlsdr")]
#[test]
fn parse_rtlsdr_url() {
let src: Source = "rtlsdr://0?gain=40&bias_tee=true&channel=131.725M"
.parse()
.unwrap();
match src.address {
Address::Rtlsdr { device, .. } => assert_eq!(device, Some(0)),
_ => panic!("expected rtlsdr"),
}
assert_eq!(src.channels_with(|_| vec![]), vec![131_725_000]);
assert_eq!(src.bias_tee, Some(true));
}
#[cfg(feature = "soapy")]
#[test]
fn parse_soapy_url() {
let src: Source = "soapy://driver=rtlsdr?gain=40&channel=131525000,131725000"
.parse()
.unwrap();
match &src.address {
Address::Soapy { soapy } => assert_eq!(soapy, "driver=rtlsdr"),
_ => panic!("expected soapy"),
}
assert_eq!(
src.channels_with(|_| vec![]),
vec![131_525_000, 131_725_000]
);
}
#[cfg(feature = "airspy")]
#[test]
fn parse_airspy_url() {
let src: Source = "airspy://0?sample_rate=6M&channel=131.725M"
.parse()
.unwrap();
match src.address {
Address::Airspy { device, .. } => assert_eq!(device, Some(0)),
_ => panic!("expected airspy"),
}
assert_eq!(src.sample_rate(), 6_000_000);
assert_eq!(src.channels_with(|_| vec![]), vec![131_725_000]);
}
#[cfg(feature = "hackrf")]
#[test]
fn parse_hackrf_url() {
let src: Source =
"hackrf://0?center_freq=131.7M&channel=131.525M&rf_amp=true&if_gain=32&bb_gain=20"
.parse()
.unwrap();
match src.address {
Address::Hackrf { device } => assert_eq!(device, Some(0)),
_ => panic!("expected hackrf"),
}
assert_eq!(src.center_freq_or(0), 131_700_000);
assert_eq!(src.channels_with(|_| vec![]), vec![131_525_000]);
assert_eq!(src.amp_enable, Some(true));
assert_eq!(src.lna_gain, Some(32.0));
assert_eq!(src.vga_gain, Some(20.0));
}
#[test]
fn parse_toml_file_source() {
let text = r#"
file = "~/captures/acars.cu8"
format = "cu8"
center_freq = 131700000
sample_rate = 1050000
channels = [131525000, 131725000]
"#;
let src: Source = toml::from_str(text).unwrap();
assert_eq!(
src.channels_with(|_| vec![]),
vec![131_525_000, 131_725_000]
);
}
#[test]
fn rejects_airframes_source() {
assert!("airframes://live".parse::<Source>().is_err());
}
}