use super::batch::run_batch;
use super::format::SampleFormat;
use super::sdrx::{layout_label, open_sdrx, read_sdrx};
use super::sigmf_stream::{
open_sigmf_collection, open_sigmf_files, sigmf_paths, CaptureBoundary, SigmfStream,
};
use super::stream::{open_raw, DEFAULT_CHUNK_BYTES};
use crate::iq::{IqError, IqSource, SampleSpec};
use serde::{Deserialize, Serialize};
use std::io::Read;
use std::path::{Path, PathBuf};
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RawSidecar {
pub format: String,
pub sample_rate_hz: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub center_hz: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub if_hz: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub header_bytes: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub channels: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub channel: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub datetime: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
impl RawSidecar {
pub fn resolve(&self) -> Result<(SampleFormat, SampleSpec), IqError> {
let f = SampleFormat::parse(&self.format)?;
if !(self.sample_rate_hz.is_finite() && self.sample_rate_hz > 0.0) {
return Err(IqError::Format(format!(
"sidecar sample_rate_hz {} is not positive",
self.sample_rate_hz
)));
}
Ok((
f,
SampleSpec {
fs_hz: self.sample_rate_hz,
center_hz: self.center_hz.unwrap_or(0.0),
if_hz: self.if_hz.unwrap_or(0.0),
},
))
}
}
pub fn sidecar_candidates(path: &Path) -> Vec<PathBuf> {
let s = path.as_os_str().to_string_lossy();
let mut v = vec![
PathBuf::from(format!("{s}.json")),
PathBuf::from(format!("{s}.toml")),
];
v.push(path.with_extension("json"));
v.push(path.with_extension("toml"));
v
}
pub fn find_sidecar(path: &Path) -> Result<Option<(PathBuf, RawSidecar)>, IqError> {
for c in sidecar_candidates(path) {
if !c.is_file() {
continue;
}
let text = std::fs::read_to_string(&c)
.map_err(|e| IqError::Io(format!("{}: {e}", c.display())))?;
let parsed: Result<RawSidecar, String> = if c.extension().is_some_and(|e| e == "toml") {
toml::from_str(&text).map_err(|e| e.to_string())
} else {
serde_json::from_str(&text).map_err(|e| e.to_string())
};
return parsed
.map(|s| Some((c.clone(), s)))
.map_err(|e| IqError::Format(format!("{}: {e}", c.display())));
}
Ok(None)
}
pub fn write_sidecar(path: &Path, sidecar: &RawSidecar) -> Result<PathBuf, IqError> {
let p = PathBuf::from(format!("{}.json", path.display()));
let json = serde_json::to_string_pretty(sidecar).map_err(|e| IqError::Format(e.to_string()))?;
std::fs::write(&p, json).map_err(|e| IqError::Io(format!("{}: {e}", p.display())))?;
Ok(p)
}
pub fn sha256_reader<R: Read>(mut r: R) -> Result<String, IqError> {
use sha2::{Digest, Sha256};
let mut h = Sha256::new();
let mut buf = vec![0u8; 1 << 16];
loop {
match r.read(&mut buf) {
Ok(0) => break,
Ok(n) => h.update(&buf[..n]),
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {}
Err(e) => return Err(IqError::Io(e.to_string())),
}
}
Ok(hex::encode(h.finalize()))
}
pub fn sha256_file(path: &Path) -> Result<String, IqError> {
let f =
std::fs::File::open(path).map_err(|e| IqError::Io(format!("{}: {e}", path.display())))?;
sha256_reader(f)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RecordingKind {
Sigmf,
SigmfCollection,
Raw,
Sdrx,
}
pub fn recording_kind(path: &Path) -> Option<RecordingKind> {
let name = path.file_name()?.to_string_lossy().to_ascii_lowercase();
if name.ends_with(".sigmf-meta") || name.ends_with(".sigmf-data") {
Some(RecordingKind::Sigmf)
} else if name.ends_with(".sigmf-collection") {
Some(RecordingKind::SigmfCollection)
} else if name.ends_with(".sdrx") {
Some(RecordingKind::Sdrx)
} else if [".bin", ".dat", ".raw"].iter().any(|e| name.ends_with(e)) {
Some(RecordingKind::Raw)
} else {
None
}
}
pub struct OpenedRecording {
pub source: Box<dyn IqSource + Send>,
pub format: SampleFormat,
pub format_label: String,
pub channels: (usize, usize),
pub n_samples: u64,
pub boundaries: Vec<CaptureBoundary>,
pub n_annotations: usize,
pub data_files: Vec<(PathBuf, u64)>,
}
fn from_stream(
s: SigmfStream,
data_files: Vec<(PathBuf, u64)>,
channel: Option<usize>,
) -> Result<OpenedRecording, IqError> {
let s = match channel {
Some(c) => s.select_channel(c)?,
None => s,
};
Ok(OpenedRecording {
format: s.format(),
format_label: s.format().name(),
channels: s.channels(),
n_samples: s.total_samples(),
boundaries: s.boundaries().to_vec(),
n_annotations: s.annotations().len(),
source: Box::new(s),
data_files,
})
}
fn file_len(p: &Path) -> Result<u64, IqError> {
std::fs::metadata(p)
.map(|m| m.len())
.map_err(|e| IqError::Io(format!("{}: {e}", p.display())))
}
pub fn open_recording(path: &Path, raw: Option<RawSidecar>) -> Result<OpenedRecording, IqError> {
open_recording_with(path, raw, None)
}
pub fn open_recording_with(
path: &Path,
raw: Option<RawSidecar>,
channel: Option<usize>,
) -> Result<OpenedRecording, IqError> {
match recording_kind(path) {
Some(RecordingKind::Sigmf) => {
let (_, dp) = sigmf_paths(path);
let len = file_len(&dp)?;
let s = open_sigmf_files(&[path], DEFAULT_CHUNK_BYTES)?;
from_stream(s, vec![(dp, len)], channel)
}
Some(RecordingKind::Sdrx) => {
if channel.is_some_and(|c| c != 0) {
return Err(IqError::Format(format!(
"{}: .sdrx recordings hold one stream; channel {} does not exist",
path.display(),
channel.unwrap_or(0)
)));
}
let (src, data, len) = open_sdrx(path)?;
let label = layout_label(src.layout());
let spec = src.spec();
Ok(OpenedRecording {
format: SampleFormat::CF32_LE,
format_label: label,
channels: (1, 0),
n_samples: src.remaining(),
boundaries: vec![CaptureBoundary {
sample: 0,
file_index: 0,
file_sample: 0,
frequency_hz: Some(spec.center_hz),
datetime: None,
}],
n_annotations: 0,
source: Box::new(src),
data_files: vec![(data, len)],
})
}
Some(RecordingKind::SigmfCollection) => {
let s = open_sigmf_collection(path, DEFAULT_CHUNK_BYTES)?;
let json = std::fs::read_to_string(path)
.map_err(|e| IqError::Io(format!("{}: {e}", path.display())))?;
let dir = path.parent().unwrap_or(Path::new("."));
let mut files = Vec::new();
for st in super::sigmf_stream::parse_collection(&json)?.streams {
let (_, dp) = sigmf_paths(&dir.join(&st.name));
let len = file_len(&dp)?;
files.push((dp, len));
}
from_stream(s, files, channel)
}
_ => {
let sc = match raw {
Some(s) => s,
None => find_sidecar(path)?.map(|(_, s)| s).ok_or_else(|| {
IqError::Format(format!(
"{}: raw file with no sidecar; state its format, or write {}",
path.display(),
sidecar_candidates(path)[0].display()
))
})?,
};
let (format, spec) = sc.resolve()?;
let header = sc.header_bytes.unwrap_or(0);
let len = file_len(path)?;
let channels = sc.channels.unwrap_or(1);
let select = channel.or(sc.channel).unwrap_or(0);
let r = open_raw(path, format, spec, header)?.with_channels(channels, select)?;
let n = format.samples_in_bytes(len.saturating_sub(header)) / channels as u64;
Ok(OpenedRecording {
source: Box::new(r),
format,
format_label: format.name(),
channels: (channels, select),
n_samples: n,
boundaries: vec![CaptureBoundary {
sample: 0,
file_index: 0,
file_sample: 0,
frequency_hz: sc.center_hz,
datetime: sc.datetime.clone(),
}],
n_annotations: 0,
data_files: vec![(path.to_path_buf(), len)],
})
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct InventoryEntry {
pub path: String,
pub kind: RecordingKind,
pub format: Option<String>,
pub sample_rate_hz: Option<f64>,
pub center_hz: Option<f64>,
pub n_samples: Option<u64>,
#[serde(default = "one")]
pub channels: usize,
pub duration_s: Option<f64>,
pub size_bytes: u64,
pub sha256: Vec<String>,
pub data_files: Vec<String>,
pub captures: Vec<CaptureBoundary>,
pub n_annotations: usize,
pub error: Option<String>,
}
fn one() -> usize {
1
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct InventoryOptions {
pub recursive: bool,
pub hash: bool,
pub workers: usize,
}
impl Default for InventoryOptions {
fn default() -> Self {
InventoryOptions {
recursive: false,
hash: true,
workers: 0,
}
}
}
pub fn inventory_entry(path: &Path, hash: bool) -> InventoryEntry {
let kind = recording_kind(path).unwrap_or(RecordingKind::Raw);
let shown = match kind {
RecordingKind::Sigmf => sigmf_paths(path).0,
_ => path.to_path_buf(),
};
let mut e = InventoryEntry {
path: shown.display().to_string(),
kind,
format: None,
sample_rate_hz: None,
center_hz: None,
n_samples: None,
channels: 1,
duration_s: None,
size_bytes: 0,
sha256: Vec::new(),
data_files: Vec::new(),
captures: Vec::new(),
n_annotations: 0,
error: None,
};
let opened = match open_recording(path, None) {
Ok(o) => o,
Err(err) => {
if kind == RecordingKind::Raw {
e.size_bytes = file_len(path).unwrap_or(0);
e.data_files = vec![path.display().to_string()];
if hash {
e.sha256 = sha256_file(path).into_iter().collect();
}
}
e.error = Some(err.to_string());
return e;
}
};
let spec = opened.source.spec();
e.format = Some(opened.format_label.clone());
e.sample_rate_hz = Some(spec.fs_hz);
e.center_hz = opened.boundaries.first().and_then(|b| b.frequency_hz);
e.n_samples = Some(opened.n_samples);
e.channels = opened.channels.0;
e.duration_s = Some(opened.n_samples as f64 / spec.fs_hz);
e.size_bytes = opened.data_files.iter().map(|(_, n)| n).sum();
e.data_files = opened
.data_files
.iter()
.map(|(p, _)| p.display().to_string())
.collect();
e.captures = opened.boundaries;
e.n_annotations = opened.n_annotations;
if hash {
for (p, _) in &opened.data_files {
match sha256_file(p) {
Ok(h) => e.sha256.push(h),
Err(err) => {
e.error = Some(err.to_string());
break;
}
}
}
}
e
}
pub fn list_recordings(dir: &Path, recursive: bool) -> Result<Vec<PathBuf>, IqError> {
let mut out = Vec::new();
let mut stack = vec![dir.to_path_buf()];
while let Some(d) = stack.pop() {
let rd = std::fs::read_dir(&d).map_err(|e| IqError::Io(format!("{}: {e}", d.display())))?;
for ent in rd {
let p = ent.map_err(|e| IqError::Io(e.to_string()))?.path();
if p.is_dir() {
if recursive {
stack.push(p);
}
continue;
}
let name = p.to_string_lossy().to_ascii_lowercase();
if recording_kind(&p).is_some() && !name.ends_with(".sigmf-data") {
out.push(p);
}
}
}
let sdrx_data: std::collections::HashSet<PathBuf> = out
.iter()
.filter(|p| recording_kind(p) == Some(RecordingKind::Sdrx))
.filter_map(|p| read_sdrx(p).ok().map(|(_, d)| d))
.collect();
out.retain(|p| !sdrx_data.contains(p));
out.sort();
Ok(out)
}
pub fn scan_dir(dir: &Path, opts: InventoryOptions) -> Result<Vec<InventoryEntry>, IqError> {
let paths = list_recordings(dir, opts.recursive)?;
Ok(
run_batch(&paths, opts.workers, |p| Ok(inventory_entry(p, opts.hash)))
.into_iter()
.map(|r| {
r.outcome.unwrap_or_else(|err| {
let mut e = inventory_entry(Path::new(&r.input), false);
e.error = Some(err);
e
})
})
.collect(),
)
}