python-aaronia 0.11.1__tar.gz → 0.11.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {python_aaronia-0.11.1 → python_aaronia-0.11.2}/CHANGELOG.md +42 -0
- {python_aaronia-0.11.1 → python_aaronia-0.11.2}/Cargo.lock +2 -2
- {python_aaronia-0.11.1 → python_aaronia-0.11.2}/Cargo.toml +1 -1
- {python_aaronia-0.11.1 → python_aaronia-0.11.2}/PKG-INFO +1 -1
- {python_aaronia-0.11.1 → python_aaronia-0.11.2}/README.md +2 -0
- {python_aaronia-0.11.1 → python_aaronia-0.11.2}/python-aaronia/Cargo.toml +1 -1
- {python_aaronia-0.11.1 → python_aaronia-0.11.2}/src/sdr_source_impl.rs +102 -42
- {python_aaronia-0.11.1 → python_aaronia-0.11.2}/src/unified_source.rs +178 -145
- {python_aaronia-0.11.1 → python_aaronia-0.11.2}/.cargo/config.toml +0 -0
- {python_aaronia-0.11.1 → python_aaronia-0.11.2}/.gitattributes +0 -0
- {python_aaronia-0.11.1 → python_aaronia-0.11.2}/.gitignore +0 -0
- {python_aaronia-0.11.1 → python_aaronia-0.11.2}/CONTRIBUTING.md +0 -0
- {python_aaronia-0.11.1 → python_aaronia-0.11.2}/DESIGN.md +0 -0
- {python_aaronia-0.11.1 → python_aaronia-0.11.2}/LICENSE +0 -0
- {python_aaronia-0.11.1 → python_aaronia-0.11.2}/PLUGINS.md +0 -0
- {python_aaronia-0.11.1 → python_aaronia-0.11.2}/benches/decompress_block.rs +0 -0
- {python_aaronia-0.11.1 → python_aaronia-0.11.2}/benches/deinterleave_dual_iq.rs +0 -0
- {python_aaronia-0.11.1 → python_aaronia-0.11.2}/benches/parse_int16_packet.rs +0 -0
- {python_aaronia-0.11.1 → python_aaronia-0.11.2}/benches/rtsa_open_and_read.rs +0 -0
- {python_aaronia-0.11.1 → python_aaronia-0.11.2}/deny.toml +0 -0
- {python_aaronia-0.11.1 → python_aaronia-0.11.2}/docs/APPS.md +0 -0
- {python_aaronia-0.11.1 → python_aaronia-0.11.2}/docs/FILESPEC.md +0 -0
- {python_aaronia-0.11.1 → python_aaronia-0.11.2}/docs/HTTPSPEC.md +0 -0
- {python_aaronia-0.11.1 → python_aaronia-0.11.2}/docs/QUICKSTART.md +0 -0
- {python_aaronia-0.11.1 → python_aaronia-0.11.2}/docs/SDKSPEC.md +0 -0
- {python_aaronia-0.11.1 → python_aaronia-0.11.2}/docs/SYNC.md +0 -0
- {python_aaronia-0.11.1 → python_aaronia-0.11.2}/docs/USAGE.md +0 -0
- {python_aaronia-0.11.1 → python_aaronia-0.11.2}/docs/VERIFICATION.md +0 -0
- {python_aaronia-0.11.1 → python_aaronia-0.11.2}/examples/channel_hopping.rs +0 -0
- {python_aaronia-0.11.1 → python_aaronia-0.11.2}/examples/device_control.rs +0 -0
- {python_aaronia-0.11.1 → python_aaronia-0.11.2}/examples/dump_metadata.rs +0 -0
- {python_aaronia-0.11.1 → python_aaronia-0.11.2}/examples/http_iq_quickstart.rs +0 -0
- {python_aaronia-0.11.1 → python_aaronia-0.11.2}/examples/native_sdk_basic.rs +0 -0
- {python_aaronia-0.11.1 → python_aaronia-0.11.2}/examples/native_sdk_transmit.rs +0 -0
- {python_aaronia-0.11.1 → python_aaronia-0.11.2}/examples/noaa_scanner.rs +0 -0
- {python_aaronia-0.11.1 → python_aaronia-0.11.2}/examples/python_arrow_example.py +0 -0
- {python_aaronia-0.11.1 → python_aaronia-0.11.2}/examples/read_rtsa_file.rs +0 -0
- {python_aaronia-0.11.1 → python_aaronia-0.11.2}/examples/soapy_python_example.py +0 -0
- {python_aaronia-0.11.1 → python_aaronia-0.11.2}/include/spectran.h +0 -0
- {python_aaronia-0.11.1 → python_aaronia-0.11.2}/packaging/homebrew/README.md +0 -0
- {python_aaronia-0.11.1 → python_aaronia-0.11.2}/packaging/homebrew/soapy-aaronia.rb +0 -0
- {python_aaronia-0.11.1 → python_aaronia-0.11.2}/pyproject.toml +0 -0
- {python_aaronia-0.11.1 → python_aaronia-0.11.2}/python-aaronia/README.md +0 -0
- {python_aaronia-0.11.1 → python_aaronia-0.11.2}/python-aaronia/aaronia.pyi +0 -0
- {python_aaronia-0.11.1 → python_aaronia-0.11.2}/python-aaronia/src/lib.rs +0 -0
- {python_aaronia-0.11.1 → python_aaronia-0.11.2}/python-aaronia/test_basic.py +0 -0
- {python_aaronia-0.11.1 → python_aaronia-0.11.2}/scripts/ci-local.sh +0 -0
- {python_aaronia-0.11.1 → python_aaronia-0.11.2}/scripts/fake-rtsa-server.py +0 -0
- {python_aaronia-0.11.1 → python_aaronia-0.11.2}/scripts/native-sdk-validate.ps1 +0 -0
- {python_aaronia-0.11.1 → python_aaronia-0.11.2}/scripts/sdk-container-test.sh +0 -0
- {python_aaronia-0.11.1 → python_aaronia-0.11.2}/scripts/validate-iq-live.py +0 -0
- {python_aaronia-0.11.1 → python_aaronia-0.11.2}/soapy-aaronia/CMakeLists.txt +0 -0
- {python_aaronia-0.11.1 → python_aaronia-0.11.2}/soapy-aaronia/README.md +0 -0
- {python_aaronia-0.11.1 → python_aaronia-0.11.2}/soapy-aaronia/Registration.cpp +0 -0
- {python_aaronia-0.11.1 → python_aaronia-0.11.2}/soapy-aaronia/SpectranSoapyDevice.cpp +0 -0
- {python_aaronia-0.11.1 → python_aaronia-0.11.2}/soapy-aaronia/SpectranSoapyDevice.hpp +0 -0
- {python_aaronia-0.11.1 → python_aaronia-0.11.2}/soapy-aaronia/packaging/install.ps1 +0 -0
- {python_aaronia-0.11.1 → python_aaronia-0.11.2}/soapy-aaronia/packaging/install.sh +0 -0
- {python_aaronia-0.11.1 → python_aaronia-0.11.2}/src/c_api.rs +0 -0
- {python_aaronia-0.11.1 → python_aaronia-0.11.2}/src/decompression.rs +0 -0
- {python_aaronia-0.11.1 → python_aaronia-0.11.2}/src/detection.rs +0 -0
- {python_aaronia-0.11.1 → python_aaronia-0.11.2}/src/error.rs +0 -0
- {python_aaronia-0.11.1 → python_aaronia-0.11.2}/src/file_source.rs +0 -0
- {python_aaronia-0.11.1 → python_aaronia-0.11.2}/src/http_endpoints.rs +0 -0
- {python_aaronia-0.11.1 → python_aaronia-0.11.2}/src/http_sink.rs +0 -0
- {python_aaronia-0.11.1 → python_aaronia-0.11.2}/src/http_source.rs +0 -0
- {python_aaronia-0.11.1 → python_aaronia-0.11.2}/src/http_streaming.rs +0 -0
- {python_aaronia-0.11.1 → python_aaronia-0.11.2}/src/lib.rs +0 -0
- {python_aaronia-0.11.1 → python_aaronia-0.11.2}/src/link_budget.rs +0 -0
- {python_aaronia-0.11.1 → python_aaronia-0.11.2}/src/native_sdk.rs +0 -0
- {python_aaronia-0.11.1 → python_aaronia-0.11.2}/src/sdk_sink.rs +0 -0
- {python_aaronia-0.11.1 → python_aaronia-0.11.2}/src/sdk_source.rs +0 -0
- {python_aaronia-0.11.1 → python_aaronia-0.11.2}/src/seify_impl.rs +0 -0
- {python_aaronia-0.11.1 → python_aaronia-0.11.2}/src/unified_sink.rs +0 -0
- {python_aaronia-0.11.1 → python_aaronia-0.11.2}/src/utils.rs +0 -0
- {python_aaronia-0.11.1 → python_aaronia-0.11.2}/tests/abi_drift.rs +0 -0
- {python_aaronia-0.11.1 → python_aaronia-0.11.2}/tests/c_api_test.rs +0 -0
- {python_aaronia-0.11.1 → python_aaronia-0.11.2}/tests/http_mock_test.rs +0 -0
- {python_aaronia-0.11.1 → python_aaronia-0.11.2}/tests/http_resilience_test.rs +0 -0
- {python_aaronia-0.11.1 → python_aaronia-0.11.2}/tests/http_sink_test.rs +0 -0
- {python_aaronia-0.11.1 → python_aaronia-0.11.2}/tests/integration_test.rs +0 -0
- {python_aaronia-0.11.1 → python_aaronia-0.11.2}/tests/live_smoke.rs +0 -0
- {python_aaronia-0.11.1 → python_aaronia-0.11.2}/tests/native_sdk_live.rs +0 -0
- {python_aaronia-0.11.1 → python_aaronia-0.11.2}/tests/native_sdk_load.rs +0 -0
- {python_aaronia-0.11.1 → python_aaronia-0.11.2}/tests/properties.proptest-regressions +0 -0
- {python_aaronia-0.11.1 → python_aaronia-0.11.2}/tests/properties.rs +0 -0
- {python_aaronia-0.11.1 → python_aaronia-0.11.2}/tests/rtsa_negative_test.rs +0 -0
- {python_aaronia-0.11.1 → python_aaronia-0.11.2}/tests/sdr_source_impl_test.rs +0 -0
- {python_aaronia-0.11.1 → python_aaronia-0.11.2}/tests/spec_coverage.rs +0 -0
- {python_aaronia-0.11.1 → python_aaronia-0.11.2}/tests/test_cw_mag.rs +0 -0
- {python_aaronia-0.11.1 → python_aaronia-0.11.2}/tests/test_cw_meta.rs +0 -0
- {python_aaronia-0.11.1 → python_aaronia-0.11.2}/tests/wire_contract.rs +0 -0
|
@@ -4,6 +4,48 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [v0.11.2] - 2026-09-11
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- **`capture_sample_rate_hz()`** on `SpectranSource`, the rate counterpart of
|
|
11
|
+
`capture_frequency_hz()`. It describes the samples a read just returned, not
|
|
12
|
+
the newest rate the background reader has seen, so a consumer that resamples
|
|
13
|
+
or sizes an FFT from it agrees with the block in its hands.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
- **A read no longer spans a sample-rate change.** The retune boundary rule
|
|
17
|
+
from 0.11.0 covered frequency only, so a rate change could land mid-block —
|
|
18
|
+
with the returned samples labelled at whichever rate the reader had reached
|
|
19
|
+
by then. Reads now stop at a rate change too, holding the rest for the next
|
|
20
|
+
call, and queued samples keep the rate they were captured at.
|
|
21
|
+
- **The HTTP read timeout is a budget for the whole block, not per receive.**
|
|
22
|
+
A read asking for a large block against a slow stream could wait the full
|
|
23
|
+
timeout repeatedly and take far longer than the caller allowed.
|
|
24
|
+
- **File tuning setters no longer rewrite the recording's metadata.**
|
|
25
|
+
`set_center_frequency_hz`, `set_sample_rate_hz` and `set_reference_level_dbm`
|
|
26
|
+
overwrote the config on a file source, so the playback then described itself
|
|
27
|
+
by what was asked for rather than by what was recorded.
|
|
28
|
+
|
|
29
|
+
### Fixed (futuresdr source)
|
|
30
|
+
- **The buffer pool is bounded by bytes, not by a fixed count.** It held 256
|
|
31
|
+
buffers of `block_size` regardless of size — 256 MiB at a 1 Mi-sample block.
|
|
32
|
+
Depth is now derived from `block_size` against a 64 MiB ceiling, and a
|
|
33
|
+
`block_size` past that ceiling is rejected at `start()` instead of consuming
|
|
34
|
+
the memory.
|
|
35
|
+
- **A refused channel override is no longer retried forever.** A retune the
|
|
36
|
+
device rejected slept 100 ms and tried the same frequency again, indefinitely.
|
|
37
|
+
The rejected frequency is now remembered and skipped until the override
|
|
38
|
+
changes. A retune failure on the *commanded* frequency, which has no such
|
|
39
|
+
fallback, propagates rather than spinning.
|
|
40
|
+
- **A parked viewer keeps receiving after its dwell expires.** Reads for an
|
|
41
|
+
override channel were budgeted from the hop deadline, which is already in the
|
|
42
|
+
past once parked — so the read budget was zero and the viewer starved.
|
|
43
|
+
- **Packets carry the metadata of the samples in them.** They reported the
|
|
44
|
+
commanded frequency and the source's current rate; they now carry the
|
|
45
|
+
captured pair, and samples whose frequency cannot be confirmed are dropped
|
|
46
|
+
rather than mislabelled.
|
|
47
|
+
- **Returning a buffer to a full pool cannot block the pump.**
|
|
48
|
+
|
|
7
49
|
## [v0.11.1] - 2026-09-10
|
|
8
50
|
|
|
9
51
|
### Fixed
|
|
@@ -3092,7 +3092,7 @@ dependencies = [
|
|
|
3092
3092
|
|
|
3093
3093
|
[[package]]
|
|
3094
3094
|
name = "python-aaronia"
|
|
3095
|
-
version = "0.11.
|
|
3095
|
+
version = "0.11.2"
|
|
3096
3096
|
dependencies = [
|
|
3097
3097
|
"arrow",
|
|
3098
3098
|
"num-complex",
|
|
@@ -3621,7 +3621,7 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
|
|
3621
3621
|
|
|
3622
3622
|
[[package]]
|
|
3623
3623
|
name = "sdr-aaronia-rs"
|
|
3624
|
-
version = "0.11.
|
|
3624
|
+
version = "0.11.2"
|
|
3625
3625
|
dependencies = [
|
|
3626
3626
|
"anyhow",
|
|
3627
3627
|
"bitflags 2.13.1",
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name = "sdr-aaronia-rs"
|
|
3
3
|
description = "Unified Rust interface for Aaronia Spectran Spectrum Analyzers / SDRs, featuring Python bindings, a SoapySDR plugin, HTTP streaming, and native SDK support."
|
|
4
4
|
license = "GPL-3.0-or-later"
|
|
5
|
-
version = "0.11.
|
|
5
|
+
version = "0.11.2"
|
|
6
6
|
edition = "2024"
|
|
7
7
|
repository = "https://github.com/isaacbentley/sdr-aaronia-rs"
|
|
8
8
|
readme = "README.md"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-aaronia
|
|
3
|
-
Version: 0.11.
|
|
3
|
+
Version: 0.11.2
|
|
4
4
|
Classifier: Programming Language :: Rust
|
|
5
5
|
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
6
6
|
Classifier: License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
|
|
@@ -93,6 +93,8 @@ tokio = { version = "1.43", features = ["rt-multi-thread", "macros"] }
|
|
|
93
93
|
# sdr-aaronia-rs = { version = "0.8", features = ["native-sdk", "futuresdr"] }
|
|
94
94
|
```
|
|
95
95
|
|
|
96
|
+
HTTP reads use one timeout budget for the entire requested block and return partial data at a timeout, frequency change, sample-rate change, or detected gap. `capture_frequency_hz()` and `capture_sample_rate_hz()` describe the returned samples even when newer packets are queued. File tuning setters preserve the recording's metadata.
|
|
97
|
+
|
|
96
98
|
## Quickstart
|
|
97
99
|
|
|
98
100
|
Set the RF parameters and read:
|
|
@@ -129,13 +129,41 @@ const EMPTY_READ_BAILOUT: u32 = 16;
|
|
|
129
129
|
/// should see EOF rather than be silently fed an empty channel.
|
|
130
130
|
const READ_ERROR_BAILOUT: u32 = 5;
|
|
131
131
|
|
|
132
|
+
fn accepted_override(requested: Option<f64>, rejected: Option<u64>) -> Option<f64> {
|
|
133
|
+
requested.filter(|f| f.is_finite() && *f > 0.0 && rejected != Some(f.to_bits()))
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
fn read_budget(parked: bool, deadline: Instant) -> Duration {
|
|
137
|
+
if parked {
|
|
138
|
+
Duration::from_millis(50)
|
|
139
|
+
} else {
|
|
140
|
+
deadline.saturating_duration_since(Instant::now())
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
fn packet_frequency(captured_hz: f64, commanded_hz: f64) -> Option<f64> {
|
|
145
|
+
if captured_hz > 0.0 {
|
|
146
|
+
((captured_hz - commanded_hz).abs() <= STALE_TOLERANCE_HZ).then_some(captured_hz)
|
|
147
|
+
} else {
|
|
148
|
+
Some(commanded_hz)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
132
152
|
impl SdrSource for SpectranSdrSource {
|
|
133
153
|
fn start(
|
|
134
154
|
self: Box<Self>,
|
|
135
155
|
config: SourceConfig,
|
|
136
156
|
advice: Arc<dyn DwellAdvice>,
|
|
137
157
|
) -> Result<SdrHandle, SdrError> {
|
|
138
|
-
let
|
|
158
|
+
let block_size = self.block_size.max(1024);
|
|
159
|
+
if block_size > 8 * 1024 * 1024 {
|
|
160
|
+
return Err(SdrError::BadConfig(
|
|
161
|
+
"block_size exceeds the 64 MiB sample-buffer limit".into(),
|
|
162
|
+
));
|
|
163
|
+
}
|
|
164
|
+
let pool_depth =
|
|
165
|
+
(64 * 1024 * 1024 / (block_size * std::mem::size_of::<Complex32>())).clamp(1, 64);
|
|
166
|
+
let (tx, receiver) = channel::bounded::<IqPacket>(pool_depth);
|
|
139
167
|
let stop_flag = Arc::new(AtomicBool::new(false));
|
|
140
168
|
let stop_thread = stop_flag.clone();
|
|
141
169
|
|
|
@@ -243,12 +271,9 @@ impl SdrSource for SpectranSdrSource {
|
|
|
243
271
|
let hopping =
|
|
244
272
|
!channels_hz.is_empty() && !matches!(backend, SpectranBackend::File(_));
|
|
245
273
|
|
|
246
|
-
let (pool_tx, pool_rx) = channel::bounded::<Vec<Complex32>>(
|
|
247
|
-
for _ in 0..256 {
|
|
248
|
-
let _ = pool_tx.send(Vec::with_capacity(block_size));
|
|
249
|
-
}
|
|
274
|
+
let (pool_tx, pool_rx) = channel::bounded::<Vec<Complex32>>(pool_depth);
|
|
250
275
|
|
|
251
|
-
if hopping {
|
|
276
|
+
let pump_result = if hopping {
|
|
252
277
|
info!(
|
|
253
278
|
"Aaronia hop mode: {} channels, dwell {:?}-{:?} (adaptive={})",
|
|
254
279
|
channels_hz.len(),
|
|
@@ -268,7 +293,6 @@ impl SdrSource for SpectranSdrSource {
|
|
|
268
293
|
&pool_tx,
|
|
269
294
|
)
|
|
270
295
|
.await
|
|
271
|
-
.map_err(|e| Error::Config(e.to_string()))?;
|
|
272
296
|
} else {
|
|
273
297
|
single_channel_pump(
|
|
274
298
|
&mut source,
|
|
@@ -281,11 +305,10 @@ impl SdrSource for SpectranSdrSource {
|
|
|
281
305
|
&pool_tx,
|
|
282
306
|
)
|
|
283
307
|
.await
|
|
284
|
-
|
|
285
|
-
}
|
|
308
|
+
};
|
|
286
309
|
|
|
287
310
|
let _ = source.stop_streaming().await;
|
|
288
|
-
|
|
311
|
+
pump_result
|
|
289
312
|
})?;
|
|
290
313
|
Ok(())
|
|
291
314
|
})() {
|
|
@@ -340,13 +363,23 @@ async fn single_channel_pump(
|
|
|
340
363
|
) -> Result<()> {
|
|
341
364
|
let mut empty_reads = 0u32;
|
|
342
365
|
let mut current_freq = center_frequency_hz;
|
|
366
|
+
let file_playback = source.source_info().source_type == SourceType::File;
|
|
367
|
+
let mut rejected_override = None;
|
|
343
368
|
while !stop_thread.load(Ordering::SeqCst) {
|
|
344
|
-
let
|
|
369
|
+
let requested = if file_playback {
|
|
370
|
+
None
|
|
371
|
+
} else {
|
|
372
|
+
accepted_override(advice.channel_override(), rejected_override)
|
|
373
|
+
};
|
|
374
|
+
let target = requested.unwrap_or(center_frequency_hz);
|
|
345
375
|
if (target - current_freq).abs() > 1.0 {
|
|
346
376
|
if let Err(e) = source.set_center_frequency_hz(target).await {
|
|
347
377
|
warn!("Aaronia retune to {:.3} MHz failed: {e}", target / 1e6);
|
|
348
|
-
|
|
349
|
-
|
|
378
|
+
if requested.is_some() {
|
|
379
|
+
rejected_override = Some(target.to_bits());
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
return Err(e);
|
|
350
383
|
}
|
|
351
384
|
current_freq = target;
|
|
352
385
|
drain_during_settle(source, block_size, RETUNE_SETTLE).await;
|
|
@@ -357,8 +390,12 @@ async fn single_channel_pump(
|
|
|
357
390
|
.unwrap_or_else(|_| Vec::with_capacity(block_size));
|
|
358
391
|
raw_buffer.clear();
|
|
359
392
|
|
|
360
|
-
let n = match source
|
|
393
|
+
let n = match source
|
|
394
|
+
.read_samples_deadline(&mut raw_buffer, block_size, Duration::from_millis(50))
|
|
395
|
+
.await
|
|
396
|
+
{
|
|
361
397
|
Ok(v) => v,
|
|
398
|
+
Err(Error::Io(ref e)) if e.kind() == std::io::ErrorKind::TimedOut => continue,
|
|
362
399
|
Err(e) => {
|
|
363
400
|
warn!("Aaronia read_samples error: {e} — stopping");
|
|
364
401
|
break;
|
|
@@ -374,13 +411,19 @@ async fn single_channel_pump(
|
|
|
374
411
|
continue;
|
|
375
412
|
}
|
|
376
413
|
empty_reads = 0;
|
|
414
|
+
let Some(packet_center) = packet_frequency(source.capture_frequency_hz(), current_freq)
|
|
415
|
+
else {
|
|
416
|
+
raw_buffer.clear();
|
|
417
|
+
let _ = pool_tx.try_send(raw_buffer);
|
|
418
|
+
continue;
|
|
419
|
+
};
|
|
377
420
|
// The rate is read per packet: on HTTP the rate the device actually
|
|
378
421
|
// streams is only known once packets flow, and may not be the one
|
|
379
422
|
// asked for (it snaps to the decimation ladder).
|
|
380
423
|
let pkt = IqPacket {
|
|
381
424
|
samples: crate::sdr_source::PooledIqBuffer::new_pooled(raw_buffer, pool_tx.clone()),
|
|
382
|
-
center_frequency_hz:
|
|
383
|
-
sample_rate_hz: source.
|
|
425
|
+
center_frequency_hz: packet_center,
|
|
426
|
+
sample_rate_hz: source.capture_sample_rate_hz() as f32,
|
|
384
427
|
overrun: source.take_overrun(),
|
|
385
428
|
};
|
|
386
429
|
if tx.send(pkt).is_err() {
|
|
@@ -426,12 +469,13 @@ async fn hop_pump(
|
|
|
426
469
|
// by contrast is per-hop on purpose: a quiet channel isn't a
|
|
427
470
|
// dead source, and hopping is the cure.
|
|
428
471
|
let mut read_errors = 0u32;
|
|
472
|
+
let mut rejected_override = None;
|
|
429
473
|
while !stop_thread.load(Ordering::SeqCst) {
|
|
430
474
|
// A live `/video` viewer wants a specific channel: park there,
|
|
431
475
|
// bypassing the hop list, until the override changes or clears.
|
|
432
476
|
// `channel_idx` only advances on a genuine hop below, so hopping
|
|
433
477
|
// resumes exactly where it left off once the viewer disconnects.
|
|
434
|
-
let override_freq = advice.channel_override();
|
|
478
|
+
let override_freq = accepted_override(advice.channel_override(), rejected_override);
|
|
435
479
|
let channel = override_freq.unwrap_or_else(|| {
|
|
436
480
|
let c = channels_hz[channel_idx];
|
|
437
481
|
channel_idx = (channel_idx + 1) % channels_hz.len();
|
|
@@ -444,6 +488,10 @@ async fn hop_pump(
|
|
|
444
488
|
if channel != current_channel {
|
|
445
489
|
if let Err(e) = source.set_center_frequency_hz(channel).await {
|
|
446
490
|
warn!("Aaronia retune to {:.3} MHz failed: {e}", channel / 1e6);
|
|
491
|
+
if override_freq.is_some() {
|
|
492
|
+
rejected_override = Some(channel.to_bits());
|
|
493
|
+
continue;
|
|
494
|
+
}
|
|
447
495
|
consecutive_retune_failures += 1;
|
|
448
496
|
if consecutive_retune_failures >= READ_ERROR_BAILOUT {
|
|
449
497
|
return Err(Error::Config(format!(
|
|
@@ -473,22 +521,10 @@ async fn hop_pump(
|
|
|
473
521
|
if stop_thread.load(Ordering::SeqCst) {
|
|
474
522
|
break;
|
|
475
523
|
}
|
|
476
|
-
|
|
477
|
-
if override_freq.is_some() {
|
|
478
|
-
// Parked here for live view: hold regardless of the dwell
|
|
479
|
-
// deadline, but break out the moment the viewer's channel
|
|
480
|
-
// changes or they disconnect so the outer loop can retune
|
|
481
|
-
// or resume hopping.
|
|
482
|
-
if !still_overridden {
|
|
483
|
-
break;
|
|
484
|
-
}
|
|
485
|
-
} else if still_overridden {
|
|
486
|
-
// A viewer just requested this exact channel while we were
|
|
487
|
-
// still mid-hop-dwell here — break out now (rather than
|
|
488
|
-
// waiting for the normal dwell deadline) so the next outer
|
|
489
|
-
// iteration parks on it promptly.
|
|
524
|
+
if accepted_override(advice.channel_override(), rejected_override) != override_freq {
|
|
490
525
|
break;
|
|
491
|
-
}
|
|
526
|
+
}
|
|
527
|
+
if override_freq.is_none() && Instant::now() >= deadline {
|
|
492
528
|
break;
|
|
493
529
|
}
|
|
494
530
|
|
|
@@ -506,7 +542,7 @@ async fn hop_pump(
|
|
|
506
542
|
// channel stays open instead of closing. Returning whatever
|
|
507
543
|
// arrived by the deadline keeps the hop cadence intact, and
|
|
508
544
|
// the `n == 0` branch below already handles an empty read.
|
|
509
|
-
let remaining =
|
|
545
|
+
let remaining = read_budget(override_freq.is_some(), deadline);
|
|
510
546
|
let n = match source
|
|
511
547
|
.read_samples_deadline(&mut raw_buffer, block_size, remaining)
|
|
512
548
|
.await
|
|
@@ -553,24 +589,20 @@ async fn hop_pump(
|
|
|
553
589
|
// that carry no per-packet frequency (file, native SDK), so
|
|
554
590
|
// those fall back to the commanded channel.
|
|
555
591
|
let captured_hz = source.capture_frequency_hz();
|
|
556
|
-
|
|
592
|
+
let Some(packet_center) = packet_frequency(captured_hz, channel) else {
|
|
557
593
|
// Pre-retune samples. Discard rather than mislabel them;
|
|
558
594
|
// the settle is short and the next read is clean. Hand
|
|
559
595
|
// the buffer back to the pool by the same route a
|
|
560
596
|
// delivered packet takes — dropping it here would shrink
|
|
561
597
|
// the pool by one on every hop.
|
|
562
598
|
raw_buffer.clear();
|
|
563
|
-
let _ = pool_tx.
|
|
599
|
+
let _ = pool_tx.try_send(raw_buffer);
|
|
564
600
|
continue;
|
|
565
|
-
}
|
|
601
|
+
};
|
|
566
602
|
let pkt = IqPacket {
|
|
567
603
|
samples: crate::sdr_source::PooledIqBuffer::new_pooled(raw_buffer, pool_tx.clone()),
|
|
568
|
-
center_frequency_hz:
|
|
569
|
-
|
|
570
|
-
} else {
|
|
571
|
-
channel
|
|
572
|
-
},
|
|
573
|
-
sample_rate_hz: source.sample_rate_hz() as f32,
|
|
604
|
+
center_frequency_hz: packet_center,
|
|
605
|
+
sample_rate_hz: source.capture_sample_rate_hz() as f32,
|
|
574
606
|
overrun: source.take_overrun(),
|
|
575
607
|
};
|
|
576
608
|
if tx.send(pkt).is_err() {
|
|
@@ -612,3 +644,31 @@ async fn drain_during_settle(
|
|
|
612
644
|
}
|
|
613
645
|
}
|
|
614
646
|
}
|
|
647
|
+
|
|
648
|
+
#[cfg(test)]
|
|
649
|
+
mod review_regressions {
|
|
650
|
+
use super::*;
|
|
651
|
+
#[test]
|
|
652
|
+
fn parked_reads_continue_after_the_original_dwell_expires() {
|
|
653
|
+
let expired = Instant::now() - Duration::from_secs(1);
|
|
654
|
+
assert!(!read_budget(true, expired).is_zero());
|
|
655
|
+
assert!(read_budget(false, expired).is_zero());
|
|
656
|
+
}
|
|
657
|
+
#[test]
|
|
658
|
+
fn single_channel_packets_reject_stale_frequency_metadata() {
|
|
659
|
+
assert_eq!(packet_frequency(433e6, 915e6), None);
|
|
660
|
+
assert_eq!(packet_frequency(915e6 - 500.0, 915e6), Some(915e6 - 500.0));
|
|
661
|
+
assert_eq!(packet_frequency(0.0, 915e6), Some(915e6));
|
|
662
|
+
}
|
|
663
|
+
#[test]
|
|
664
|
+
fn override_can_move_to_another_channel_or_clear() {
|
|
665
|
+
assert_ne!(None, accepted_override(Some(915e6), None));
|
|
666
|
+
assert_ne!(Some(915e6), accepted_override(Some(433e6), None));
|
|
667
|
+
assert_ne!(Some(915e6), accepted_override(None, None));
|
|
668
|
+
assert_eq!(None, accepted_override(Some(f64::NAN), None));
|
|
669
|
+
assert_eq!(
|
|
670
|
+
None,
|
|
671
|
+
accepted_override(Some(915e6), Some(915e6_f64.to_bits()))
|
|
672
|
+
);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
@@ -421,6 +421,7 @@ pub struct SpectranSource {
|
|
|
421
421
|
/// Capture frequency of whatever is sitting in `sample_buffer`, so a
|
|
422
422
|
/// leftover tail is never merged into a read at another frequency.
|
|
423
423
|
sample_buffer_freq_hz: f64,
|
|
424
|
+
sample_buffer_rate_hz: f64,
|
|
424
425
|
/// Whether the stashed samples in `sample_buffer` begin immediately
|
|
425
426
|
/// after a break in the stream.
|
|
426
427
|
///
|
|
@@ -436,6 +437,7 @@ pub struct SpectranSource {
|
|
|
436
437
|
/// what the stream itself reported, not what was requested. Read it
|
|
437
438
|
/// with [`SpectranSource::capture_frequency_hz`].
|
|
438
439
|
last_read_frequency_hz: f64,
|
|
440
|
+
last_read_sample_rate_hz: f64,
|
|
439
441
|
|
|
440
442
|
// Source-specific implementations
|
|
441
443
|
#[cfg(all(
|
|
@@ -462,7 +464,7 @@ pub struct SpectranSource {
|
|
|
462
464
|
/// task's `DropDetector` observed a timestamp gap ending at (or
|
|
463
465
|
/// before) that chunk — see `pending_overrun`.
|
|
464
466
|
#[allow(clippy::type_complexity)]
|
|
465
|
-
http_receiver: Option<tokio::sync::mpsc::Receiver<(Vec<Complex32>, bool, u64, i64, f64)>>,
|
|
467
|
+
http_receiver: Option<tokio::sync::mpsc::Receiver<(Vec<Complex32>, bool, u64, i64, f64, f64)>>,
|
|
466
468
|
/// Background task draining the HTTP `/stream` connection. Held so it
|
|
467
469
|
/// can be aborted on `stop_streaming` / drop — otherwise it lingers
|
|
468
470
|
/// parked on `next().await` (holding the open connection, so the
|
|
@@ -585,8 +587,10 @@ impl SpectranSource {
|
|
|
585
587
|
source_type: SourceType::Http, // Will be updated during detection
|
|
586
588
|
sample_buffer: VecDeque::new(),
|
|
587
589
|
sample_buffer_freq_hz: 0.0,
|
|
590
|
+
sample_buffer_rate_hz: 0.0,
|
|
588
591
|
sample_buffer_after_gap: false,
|
|
589
592
|
last_read_frequency_hz: 0.0,
|
|
593
|
+
last_read_sample_rate_hz: 0.0,
|
|
590
594
|
|
|
591
595
|
#[cfg(all(
|
|
592
596
|
feature = "native-sdk",
|
|
@@ -1006,6 +1010,7 @@ impl SpectranSource {
|
|
|
1006
1010
|
cumulative_drops,
|
|
1007
1011
|
timestamp_ns,
|
|
1008
1012
|
packet_center_hz,
|
|
1013
|
+
packet.sdr_config.sample_rate_hz,
|
|
1009
1014
|
))
|
|
1010
1015
|
.await
|
|
1011
1016
|
.is_err()
|
|
@@ -1329,12 +1334,11 @@ impl SpectranSource {
|
|
|
1329
1334
|
/// runtime should do the same, or call it from a plain thread.
|
|
1330
1335
|
/// Append up to `max_samples` samples, giving up at `timeout`.
|
|
1331
1336
|
///
|
|
1332
|
-
/// A read never spans a
|
|
1333
|
-
///
|
|
1334
|
-
/// the
|
|
1335
|
-
///
|
|
1336
|
-
///
|
|
1337
|
-
/// retune; that was already possible on a timeout.
|
|
1337
|
+
/// A read never spans a frequency or sample-rate change. Samples
|
|
1338
|
+
/// captured before the change are returned and the rest are held for
|
|
1339
|
+
/// the next call. [`Self::capture_frequency_hz`] and
|
|
1340
|
+
/// [`Self::capture_sample_rate_hz`] describe the returned samples.
|
|
1341
|
+
/// Expect a short read at either boundary, as on a timeout.
|
|
1338
1342
|
pub async fn read_samples_deadline(
|
|
1339
1343
|
&mut self,
|
|
1340
1344
|
buffer: &mut Vec<Complex32>,
|
|
@@ -1371,11 +1375,23 @@ impl SpectranSource {
|
|
|
1371
1375
|
} else {
|
|
1372
1376
|
0.0
|
|
1373
1377
|
};
|
|
1378
|
+
let mut batch_rate_hz = if from_buffer > 0 {
|
|
1379
|
+
self.sample_buffer_rate_hz
|
|
1380
|
+
} else {
|
|
1381
|
+
0.0
|
|
1382
|
+
};
|
|
1374
1383
|
let mut collected = from_buffer;
|
|
1375
1384
|
while collected < max_samples {
|
|
1376
1385
|
let recv = tokio::time::timeout_at(deadline, receiver.recv()).await;
|
|
1377
1386
|
match recv {
|
|
1378
|
-
Ok(Some((
|
|
1387
|
+
Ok(Some((
|
|
1388
|
+
mut new_samples,
|
|
1389
|
+
dropped,
|
|
1390
|
+
cum_drops,
|
|
1391
|
+
ts_ns,
|
|
1392
|
+
pkt_freq_hz,
|
|
1393
|
+
pkt_rate_hz,
|
|
1394
|
+
))) => {
|
|
1379
1395
|
if dropped {
|
|
1380
1396
|
if collected > 0 {
|
|
1381
1397
|
// Put the gap on a boundary rather
|
|
@@ -1388,6 +1404,7 @@ impl SpectranSource {
|
|
|
1388
1404
|
// its state between reads.
|
|
1389
1405
|
self.sample_buffer.extend(new_samples);
|
|
1390
1406
|
self.sample_buffer_freq_hz = pkt_freq_hz;
|
|
1407
|
+
self.sample_buffer_rate_hz = pkt_rate_hz;
|
|
1391
1408
|
self.sample_buffer_after_gap = true;
|
|
1392
1409
|
self.cumulative_drops = cum_drops;
|
|
1393
1410
|
self.last_timestamp_ns = ts_ns;
|
|
@@ -1400,24 +1417,29 @@ impl SpectranSource {
|
|
|
1400
1417
|
}
|
|
1401
1418
|
self.cumulative_drops = cum_drops;
|
|
1402
1419
|
self.last_timestamp_ns = ts_ns;
|
|
1403
|
-
// A
|
|
1404
|
-
// packet with its own
|
|
1420
|
+
// A frequency or rate change landed mid-read. Stash
|
|
1421
|
+
// the packet with its own metadata and return
|
|
1405
1422
|
// what we have: mixing the two would produce
|
|
1406
1423
|
// a buffer that is partly stale and wholly
|
|
1407
1424
|
// mislabelled, which is the failure this
|
|
1408
1425
|
// frequency tag exists to prevent.
|
|
1409
1426
|
if collected > 0
|
|
1410
|
-
&& pkt_freq_hz > 0.0
|
|
1411
|
-
|
|
1412
|
-
|
|
1427
|
+
&& ((pkt_freq_hz > 0.0
|
|
1428
|
+
&& batch_freq_hz > 0.0
|
|
1429
|
+
&& (pkt_freq_hz - batch_freq_hz).abs() > 1.0)
|
|
1430
|
+
|| pkt_rate_hz != batch_rate_hz)
|
|
1413
1431
|
{
|
|
1414
1432
|
self.sample_buffer.extend(new_samples);
|
|
1415
1433
|
self.sample_buffer_freq_hz = pkt_freq_hz;
|
|
1434
|
+
self.sample_buffer_rate_hz = pkt_rate_hz;
|
|
1416
1435
|
break;
|
|
1417
1436
|
}
|
|
1418
1437
|
if batch_freq_hz == 0.0 {
|
|
1419
1438
|
batch_freq_hz = pkt_freq_hz;
|
|
1420
1439
|
}
|
|
1440
|
+
if batch_rate_hz == 0.0 {
|
|
1441
|
+
batch_rate_hz = pkt_rate_hz;
|
|
1442
|
+
}
|
|
1421
1443
|
let needed = max_samples - collected;
|
|
1422
1444
|
if new_samples.len() <= needed {
|
|
1423
1445
|
collected += new_samples.len();
|
|
@@ -1427,6 +1449,7 @@ impl SpectranSource {
|
|
|
1427
1449
|
buffer.extend(new_samples.drain(0..needed));
|
|
1428
1450
|
self.sample_buffer.extend(new_samples);
|
|
1429
1451
|
self.sample_buffer_freq_hz = batch_freq_hz;
|
|
1452
|
+
self.sample_buffer_rate_hz = batch_rate_hz;
|
|
1430
1453
|
}
|
|
1431
1454
|
}
|
|
1432
1455
|
Ok(None) => {
|
|
@@ -1452,6 +1475,9 @@ impl SpectranSource {
|
|
|
1452
1475
|
if batch_freq_hz > 0.0 {
|
|
1453
1476
|
self.last_read_frequency_hz = batch_freq_hz;
|
|
1454
1477
|
}
|
|
1478
|
+
if collected > 0 {
|
|
1479
|
+
self.last_read_sample_rate_hz = batch_rate_hz;
|
|
1480
|
+
}
|
|
1455
1481
|
Ok(buffer.len() - start_len)
|
|
1456
1482
|
}
|
|
1457
1483
|
#[cfg(all(
|
|
@@ -1491,7 +1517,7 @@ impl SpectranSource {
|
|
|
1491
1517
|
}
|
|
1492
1518
|
}
|
|
1493
1519
|
}
|
|
1494
|
-
_ => self.read_samples(buffer, max_samples).await,
|
|
1520
|
+
_ => Box::pin(self.read_samples(buffer, max_samples)).await,
|
|
1495
1521
|
}
|
|
1496
1522
|
}
|
|
1497
1523
|
|
|
@@ -1525,128 +1551,8 @@ impl SpectranSource {
|
|
|
1525
1551
|
)))]
|
|
1526
1552
|
SourceType::NativeSdk => Err(Error::Config("Native SDK not available".to_string())),
|
|
1527
1553
|
SourceType::Http => {
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
let read_timeout = self.config.read_timeout;
|
|
1531
|
-
let receiver = self
|
|
1532
|
-
.http_receiver
|
|
1533
|
-
.as_mut()
|
|
1534
|
-
.ok_or_else(|| Error::Config("HTTP receiver not initialized".to_string()))?;
|
|
1535
|
-
|
|
1536
|
-
// 1. Drain from internal buffer first
|
|
1537
|
-
let from_buffer = self.sample_buffer.len().min(max_samples);
|
|
1538
|
-
if from_buffer > 0 {
|
|
1539
|
-
buffer.extend(self.sample_buffer.drain(0..from_buffer));
|
|
1540
|
-
// These samples were stashed because they follow a
|
|
1541
|
-
// gap; this is the read that begins with them, so
|
|
1542
|
-
// this is the read that must be told.
|
|
1543
|
-
if self.sample_buffer_after_gap {
|
|
1544
|
-
self.pending_overrun = true;
|
|
1545
|
-
self.sample_buffer_after_gap = false;
|
|
1546
|
-
}
|
|
1547
|
-
}
|
|
1548
|
-
|
|
1549
|
-
// 2. If we need more, receive from channel
|
|
1550
|
-
let mut batch_freq_hz = if from_buffer > 0 {
|
|
1551
|
-
self.sample_buffer_freq_hz
|
|
1552
|
-
} else {
|
|
1553
|
-
0.0
|
|
1554
|
-
};
|
|
1555
|
-
let mut collected = from_buffer;
|
|
1556
|
-
while collected < max_samples {
|
|
1557
|
-
// If buffer is empty, try to receive more samples from the channel
|
|
1558
|
-
match tokio::time::timeout(read_timeout, receiver.recv()).await {
|
|
1559
|
-
Ok(Some((mut new_samples, dropped, cum_drops, ts_ns, pkt_freq_hz))) => {
|
|
1560
|
-
if dropped {
|
|
1561
|
-
if collected > 0 {
|
|
1562
|
-
// Put the gap on a boundary rather
|
|
1563
|
-
// than inside this block: stash the
|
|
1564
|
-
// post-gap chunk and let the next
|
|
1565
|
-
// read start with it, carrying the
|
|
1566
|
-
// flag. Same shape as the retune
|
|
1567
|
-
// boundary below, for the same
|
|
1568
|
-
// reason — a consumer can only reset
|
|
1569
|
-
// its state between reads.
|
|
1570
|
-
self.sample_buffer.extend(new_samples);
|
|
1571
|
-
self.sample_buffer_freq_hz = pkt_freq_hz;
|
|
1572
|
-
self.sample_buffer_after_gap = true;
|
|
1573
|
-
self.cumulative_drops = cum_drops;
|
|
1574
|
-
self.last_timestamp_ns = ts_ns;
|
|
1575
|
-
break;
|
|
1576
|
-
}
|
|
1577
|
-
// Nothing collected yet, so this chunk
|
|
1578
|
-
// opens the block and the gap really is
|
|
1579
|
-
// immediately before it.
|
|
1580
|
-
self.pending_overrun = true;
|
|
1581
|
-
}
|
|
1582
|
-
self.cumulative_drops = cum_drops;
|
|
1583
|
-
self.last_timestamp_ns = ts_ns;
|
|
1584
|
-
// See `read_samples_deadline`: a read never
|
|
1585
|
-
// spans a retune.
|
|
1586
|
-
if collected > 0
|
|
1587
|
-
&& pkt_freq_hz > 0.0
|
|
1588
|
-
&& batch_freq_hz > 0.0
|
|
1589
|
-
&& (pkt_freq_hz - batch_freq_hz).abs() > 1.0
|
|
1590
|
-
{
|
|
1591
|
-
self.sample_buffer.extend(new_samples);
|
|
1592
|
-
self.sample_buffer_freq_hz = pkt_freq_hz;
|
|
1593
|
-
break;
|
|
1594
|
-
}
|
|
1595
|
-
if batch_freq_hz == 0.0 {
|
|
1596
|
-
batch_freq_hz = pkt_freq_hz;
|
|
1597
|
-
}
|
|
1598
|
-
let needed = max_samples - collected;
|
|
1599
|
-
if new_samples.len() <= needed {
|
|
1600
|
-
collected += new_samples.len();
|
|
1601
|
-
buffer.append(&mut new_samples);
|
|
1602
|
-
} else {
|
|
1603
|
-
collected += needed;
|
|
1604
|
-
buffer.extend(new_samples.drain(0..needed));
|
|
1605
|
-
// `sample_buffer` is a VecDeque, so it can't
|
|
1606
|
-
// accept `Vec::append` — extend from the
|
|
1607
|
-
// remainder instead.
|
|
1608
|
-
self.sample_buffer.extend(new_samples);
|
|
1609
|
-
self.sample_buffer_freq_hz = batch_freq_hz;
|
|
1610
|
-
}
|
|
1611
|
-
}
|
|
1612
|
-
Ok(None) => {
|
|
1613
|
-
// Reader task gone (server closed the stream or
|
|
1614
|
-
// the parser gave up). Returning Ok(0) forever
|
|
1615
|
-
// here made every consumer spin on a dead
|
|
1616
|
-
// stream with no way to distinguish "no data
|
|
1617
|
-
// yet" from "stream is dead" — surface it.
|
|
1618
|
-
if collected == 0 {
|
|
1619
|
-
return Err(Error::StreamClosed(
|
|
1620
|
-
"HTTP sample stream closed (reader task ended)".to_string(),
|
|
1621
|
-
));
|
|
1622
|
-
}
|
|
1623
|
-
break;
|
|
1624
|
-
}
|
|
1625
|
-
Err(_) => {
|
|
1626
|
-
// Samples already moved into `buffer` are not
|
|
1627
|
-
// put back, so a timeout after a partial block
|
|
1628
|
-
// returns that block, as `read_samples_deadline`
|
|
1629
|
-
// does. Erroring here lost everything collected
|
|
1630
|
-
// so far without flagging a gap.
|
|
1631
|
-
if collected > 0 {
|
|
1632
|
-
warn!(
|
|
1633
|
-
"Timeout waiting for HTTP samples; returning {} of {} requested",
|
|
1634
|
-
collected, max_samples
|
|
1635
|
-
);
|
|
1636
|
-
break;
|
|
1637
|
-
}
|
|
1638
|
-
error!("Timeout waiting for HTTP samples.");
|
|
1639
|
-
return Err(Error::Io(std::io::Error::new(
|
|
1640
|
-
std::io::ErrorKind::TimedOut,
|
|
1641
|
-
"Timeout waiting for HTTP samples",
|
|
1642
|
-
)));
|
|
1643
|
-
}
|
|
1644
|
-
}
|
|
1645
|
-
}
|
|
1646
|
-
if batch_freq_hz > 0.0 {
|
|
1647
|
-
self.last_read_frequency_hz = batch_freq_hz;
|
|
1648
|
-
}
|
|
1649
|
-
Ok(buffer.len() - start_len)
|
|
1554
|
+
self.read_samples_deadline(buffer, max_samples, self.config.read_timeout)
|
|
1555
|
+
.await
|
|
1650
1556
|
}
|
|
1651
1557
|
SourceType::File => {
|
|
1652
1558
|
if let Some(ref mut source) = self.file_source {
|
|
@@ -1703,6 +1609,18 @@ impl SpectranSource {
|
|
|
1703
1609
|
self.last_read_frequency_hz
|
|
1704
1610
|
}
|
|
1705
1611
|
|
|
1612
|
+
/// Sample rate of the most recently returned samples. HTTP reads keep
|
|
1613
|
+
/// this metadata with queued samples and stop at a rate change, even
|
|
1614
|
+
/// when the background reader has already observed a newer rate.
|
|
1615
|
+
/// Other backends, or HTTP packets without a rate, use `sample_rate_hz`.
|
|
1616
|
+
pub fn capture_sample_rate_hz(&self) -> f64 {
|
|
1617
|
+
if self.last_read_sample_rate_hz.is_finite() && self.last_read_sample_rate_hz > 0.0 {
|
|
1618
|
+
self.last_read_sample_rate_hz
|
|
1619
|
+
} else {
|
|
1620
|
+
self.sample_rate_hz()
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1706
1624
|
/// Number of timestamp gaps the drop detector has seen since the
|
|
1707
1625
|
/// source was created: one per gap event, however many samples it
|
|
1708
1626
|
/// spanned.
|
|
@@ -1904,6 +1822,9 @@ impl SpectranSource {
|
|
|
1904
1822
|
/// meaningless. Hopping orchestrators shouldn't drive a file backend,
|
|
1905
1823
|
/// but the no-op keeps the abstraction tidy.
|
|
1906
1824
|
pub async fn set_center_frequency_hz(&mut self, hz: f64) -> Result<()> {
|
|
1825
|
+
if self.source_type == SourceType::File {
|
|
1826
|
+
return Ok(());
|
|
1827
|
+
}
|
|
1907
1828
|
match self.source_type {
|
|
1908
1829
|
#[cfg(all(
|
|
1909
1830
|
feature = "native-sdk",
|
|
@@ -1966,6 +1887,9 @@ impl SpectranSource {
|
|
|
1966
1887
|
|
|
1967
1888
|
/// Update the IQ sample rate of the running source.
|
|
1968
1889
|
pub async fn set_sample_rate_hz(&mut self, hz: f64) -> Result<()> {
|
|
1890
|
+
if self.source_type == SourceType::File {
|
|
1891
|
+
return Ok(());
|
|
1892
|
+
}
|
|
1969
1893
|
// Enforce the IQ-mode constraint at runtime exactly as the
|
|
1970
1894
|
// builder does at construction: without this, an out-of-range
|
|
1971
1895
|
// runtime rate was shipped to the server unchecked, the request
|
|
@@ -2030,6 +1954,9 @@ impl SpectranSource {
|
|
|
2030
1954
|
|
|
2031
1955
|
/// Update the reference level (in dBm) of the running source.
|
|
2032
1956
|
pub async fn set_reference_level_dbm(&mut self, dbm: f64) -> Result<()> {
|
|
1957
|
+
if self.source_type == SourceType::File {
|
|
1958
|
+
return Ok(());
|
|
1959
|
+
}
|
|
2033
1960
|
match self.source_type {
|
|
2034
1961
|
#[cfg(all(
|
|
2035
1962
|
feature = "native-sdk",
|
|
@@ -2791,8 +2718,10 @@ mod tests {
|
|
|
2791
2718
|
source_type: SourceType::Http, // Will be updated
|
|
2792
2719
|
sample_buffer: VecDeque::new(),
|
|
2793
2720
|
sample_buffer_freq_hz: 0.0,
|
|
2721
|
+
sample_buffer_rate_hz: 0.0,
|
|
2794
2722
|
sample_buffer_after_gap: false,
|
|
2795
2723
|
last_read_frequency_hz: 0.0,
|
|
2724
|
+
last_read_sample_rate_hz: 0.0,
|
|
2796
2725
|
#[cfg(all(
|
|
2797
2726
|
feature = "native-sdk",
|
|
2798
2727
|
any(target_os = "windows", target_os = "linux")
|
|
@@ -2875,8 +2804,10 @@ mod tests {
|
|
|
2875
2804
|
source_type: SourceType::Http,
|
|
2876
2805
|
sample_buffer: VecDeque::new(),
|
|
2877
2806
|
sample_buffer_freq_hz: 0.0,
|
|
2807
|
+
sample_buffer_rate_hz: 0.0,
|
|
2878
2808
|
sample_buffer_after_gap: false,
|
|
2879
2809
|
last_read_frequency_hz: 0.0,
|
|
2810
|
+
last_read_sample_rate_hz: 0.0,
|
|
2880
2811
|
#[cfg(all(
|
|
2881
2812
|
feature = "native-sdk",
|
|
2882
2813
|
any(target_os = "windows", target_os = "linux")
|
|
@@ -2911,8 +2842,10 @@ mod tests {
|
|
|
2911
2842
|
source_type: SourceType::File,
|
|
2912
2843
|
sample_buffer: VecDeque::new(),
|
|
2913
2844
|
sample_buffer_freq_hz: 0.0,
|
|
2845
|
+
sample_buffer_rate_hz: 0.0,
|
|
2914
2846
|
sample_buffer_after_gap: false,
|
|
2915
2847
|
last_read_frequency_hz: 0.0,
|
|
2848
|
+
last_read_sample_rate_hz: 0.0,
|
|
2916
2849
|
#[cfg(all(
|
|
2917
2850
|
feature = "native-sdk",
|
|
2918
2851
|
any(target_os = "windows", target_os = "linux")
|
|
@@ -2949,8 +2882,10 @@ mod tests {
|
|
|
2949
2882
|
source_type: SourceType::NativeSdk,
|
|
2950
2883
|
sample_buffer: VecDeque::new(),
|
|
2951
2884
|
sample_buffer_freq_hz: 0.0,
|
|
2885
|
+
sample_buffer_rate_hz: 0.0,
|
|
2952
2886
|
sample_buffer_after_gap: false,
|
|
2953
2887
|
last_read_frequency_hz: 0.0,
|
|
2888
|
+
last_read_sample_rate_hz: 0.0,
|
|
2954
2889
|
#[cfg(all(
|
|
2955
2890
|
feature = "native-sdk",
|
|
2956
2891
|
any(target_os = "windows", target_os = "linux")
|
|
@@ -3144,15 +3079,17 @@ mod tests {
|
|
|
3144
3079
|
/// tests. Factored out because three of them build the same thing
|
|
3145
3080
|
/// and a new field otherwise has to be added in every copy.
|
|
3146
3081
|
fn http_test_source(
|
|
3147
|
-
chunk_rx: tokio::sync::mpsc::Receiver<(Vec<Complex32>, bool, u64, i64, f64)>,
|
|
3082
|
+
chunk_rx: tokio::sync::mpsc::Receiver<(Vec<Complex32>, bool, u64, i64, f64, f64)>,
|
|
3148
3083
|
) -> SpectranSource {
|
|
3149
3084
|
SpectranSource {
|
|
3150
3085
|
config: SpectranConfig::default(),
|
|
3151
3086
|
source_type: SourceType::Http,
|
|
3152
3087
|
sample_buffer: VecDeque::new(),
|
|
3153
3088
|
sample_buffer_freq_hz: 0.0,
|
|
3089
|
+
sample_buffer_rate_hz: 0.0,
|
|
3154
3090
|
sample_buffer_after_gap: false,
|
|
3155
3091
|
last_read_frequency_hz: 0.0,
|
|
3092
|
+
last_read_sample_rate_hz: 0.0,
|
|
3156
3093
|
#[cfg(all(
|
|
3157
3094
|
feature = "native-sdk",
|
|
3158
3095
|
any(target_os = "windows", target_os = "linux")
|
|
@@ -3173,6 +3110,88 @@ mod tests {
|
|
|
3173
3110
|
}
|
|
3174
3111
|
}
|
|
3175
3112
|
|
|
3113
|
+
#[tokio::test]
|
|
3114
|
+
async fn queued_samples_keep_their_rate_and_reads_stop_at_rate_changes() {
|
|
3115
|
+
let (tx, rx) = tokio::sync::mpsc::channel(4);
|
|
3116
|
+
let mut source = http_test_source(rx);
|
|
3117
|
+
// The producer has already seen 2 MS/s while 1 MS/s data is queued.
|
|
3118
|
+
source.http_rate_bits = Some(Arc::new(std::sync::atomic::AtomicU64::new(
|
|
3119
|
+
2e6_f64.to_bits(),
|
|
3120
|
+
)));
|
|
3121
|
+
tx.send((vec![Complex32::new(1.0, 0.0); 2], false, 0, 1, 915e6, 1e6))
|
|
3122
|
+
.await
|
|
3123
|
+
.unwrap();
|
|
3124
|
+
tx.send((vec![Complex32::new(2.0, 0.0); 3], false, 0, 2, 915e6, 2e6))
|
|
3125
|
+
.await
|
|
3126
|
+
.unwrap();
|
|
3127
|
+
drop(tx);
|
|
3128
|
+
let mut buffer = Vec::new();
|
|
3129
|
+
assert_eq!(source.read_samples(&mut buffer, 10).await.unwrap(), 2);
|
|
3130
|
+
assert_eq!(source.capture_sample_rate_hz(), 1e6);
|
|
3131
|
+
assert!(buffer.iter().all(|s| s.re == 1.0));
|
|
3132
|
+
buffer.clear();
|
|
3133
|
+
assert_eq!(source.read_samples(&mut buffer, 2).await.unwrap(), 2);
|
|
3134
|
+
assert_eq!(source.capture_sample_rate_hz(), 2e6);
|
|
3135
|
+
buffer.clear();
|
|
3136
|
+
assert_eq!(source.read_samples(&mut buffer, 10).await.unwrap(), 1);
|
|
3137
|
+
assert_eq!(source.capture_sample_rate_hz(), 2e6);
|
|
3138
|
+
assert_eq!(buffer[0].re, 2.0);
|
|
3139
|
+
}
|
|
3140
|
+
|
|
3141
|
+
#[tokio::test]
|
|
3142
|
+
async fn read_timeout_is_a_budget_for_the_whole_block() {
|
|
3143
|
+
let (tx, rx) = tokio::sync::mpsc::channel(4);
|
|
3144
|
+
let mut source = http_test_source(rx);
|
|
3145
|
+
source.config.read_timeout = Duration::from_millis(80);
|
|
3146
|
+
tx.send((vec![Complex32::new(1.0, 0.0)], false, 0, 1, 915e6, 1e6))
|
|
3147
|
+
.await
|
|
3148
|
+
.unwrap();
|
|
3149
|
+
let feeder = tokio::spawn(async move {
|
|
3150
|
+
loop {
|
|
3151
|
+
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
3152
|
+
if tx
|
|
3153
|
+
.send((vec![Complex32::new(2.0, 0.0)], false, 0, 2, 915e6, 1e6))
|
|
3154
|
+
.await
|
|
3155
|
+
.is_err()
|
|
3156
|
+
{
|
|
3157
|
+
break;
|
|
3158
|
+
}
|
|
3159
|
+
}
|
|
3160
|
+
});
|
|
3161
|
+
let mut buffer = Vec::new();
|
|
3162
|
+
let result = tokio::time::timeout(
|
|
3163
|
+
Duration::from_millis(400),
|
|
3164
|
+
source.read_samples(&mut buffer, 100),
|
|
3165
|
+
)
|
|
3166
|
+
.await;
|
|
3167
|
+
feeder.abort();
|
|
3168
|
+
let count = result
|
|
3169
|
+
.expect("each new packet restarted the timeout")
|
|
3170
|
+
.unwrap();
|
|
3171
|
+
assert!((1..100).contains(&count));
|
|
3172
|
+
assert_eq!(buffer.len(), count);
|
|
3173
|
+
}
|
|
3174
|
+
|
|
3175
|
+
#[tokio::test]
|
|
3176
|
+
async fn file_setters_do_not_rewrite_recording_metadata() {
|
|
3177
|
+
let (_tx, rx) = tokio::sync::mpsc::channel(1);
|
|
3178
|
+
let mut source = http_test_source(rx);
|
|
3179
|
+
source.source_type = SourceType::File;
|
|
3180
|
+
let before = source.config.clone();
|
|
3181
|
+
source.set_center_frequency_hz(915e6).await.unwrap();
|
|
3182
|
+
source.set_sample_rate_hz(1e6).await.unwrap();
|
|
3183
|
+
source.set_reference_level_dbm(-10.0).await.unwrap();
|
|
3184
|
+
assert_eq!(
|
|
3185
|
+
source.config.center_frequency_hz,
|
|
3186
|
+
before.center_frequency_hz
|
|
3187
|
+
);
|
|
3188
|
+
assert_eq!(source.config.sample_rate_hz, before.sample_rate_hz);
|
|
3189
|
+
assert_eq!(
|
|
3190
|
+
source.config.reference_level_dbm,
|
|
3191
|
+
before.reference_level_dbm
|
|
3192
|
+
);
|
|
3193
|
+
}
|
|
3194
|
+
|
|
3176
3195
|
/// A gap must land on a read boundary, not inside a block.
|
|
3177
3196
|
///
|
|
3178
3197
|
/// Assembly used to note a dropped chunk and keep concatenating, so
|
|
@@ -3190,15 +3209,15 @@ mod tests {
|
|
|
3190
3209
|
// dropped chunk. All the same frequency, so only the gap can
|
|
3191
3210
|
// split them.
|
|
3192
3211
|
chunk_tx
|
|
3193
|
-
.send((vec![Complex32::new(1.0, 0.0)], false, 0, 1, 915e6))
|
|
3212
|
+
.send((vec![Complex32::new(1.0, 0.0)], false, 0, 1, 915e6, 1e6))
|
|
3194
3213
|
.await
|
|
3195
3214
|
.expect("channel open");
|
|
3196
3215
|
chunk_tx
|
|
3197
|
-
.send((vec![Complex32::new(2.0, 0.0)], false, 0, 2, 915e6))
|
|
3216
|
+
.send((vec![Complex32::new(2.0, 0.0)], false, 0, 2, 915e6, 1e6))
|
|
3198
3217
|
.await
|
|
3199
3218
|
.expect("channel open");
|
|
3200
3219
|
chunk_tx
|
|
3201
|
-
.send((vec![Complex32::new(3.0, 0.0)], true, 1, 3, 915e6))
|
|
3220
|
+
.send((vec![Complex32::new(3.0, 0.0)], true, 1, 3, 915e6, 1e6))
|
|
3202
3221
|
.await
|
|
3203
3222
|
.expect("channel open");
|
|
3204
3223
|
drop(chunk_tx);
|
|
@@ -3236,7 +3255,7 @@ mod tests {
|
|
|
3236
3255
|
let (chunk_tx, chunk_rx) = tokio::sync::mpsc::channel(4);
|
|
3237
3256
|
let mut source = http_test_source(chunk_rx);
|
|
3238
3257
|
chunk_tx
|
|
3239
|
-
.send((vec![Complex32::new(9.0, 0.0)], true, 1, 1, 915e6))
|
|
3258
|
+
.send((vec![Complex32::new(9.0, 0.0)], true, 1, 1, 915e6, 1e6))
|
|
3240
3259
|
.await
|
|
3241
3260
|
.expect("channel open");
|
|
3242
3261
|
drop(chunk_tx);
|
|
@@ -3258,11 +3277,11 @@ mod tests {
|
|
|
3258
3277
|
|
|
3259
3278
|
// Two chunks either side of a retune, queued before any read.
|
|
3260
3279
|
chunk_tx
|
|
3261
|
-
.send((vec![Complex32::new(1.0, 0.0)], false, 0, 1, 915e6))
|
|
3280
|
+
.send((vec![Complex32::new(1.0, 0.0)], false, 0, 1, 915e6, 1e6))
|
|
3262
3281
|
.await
|
|
3263
3282
|
.expect("channel open");
|
|
3264
3283
|
chunk_tx
|
|
3265
|
-
.send((vec![Complex32::new(2.0, 0.0)], false, 0, 2, 2_400e6))
|
|
3284
|
+
.send((vec![Complex32::new(2.0, 0.0)], false, 0, 2, 2_400e6, 1e6))
|
|
3266
3285
|
.await
|
|
3267
3286
|
.expect("channel open");
|
|
3268
3287
|
drop(chunk_tx);
|
|
@@ -3303,11 +3322,25 @@ mod tests {
|
|
|
3303
3322
|
assert!(!source.take_overrun(), "no chunk received yet");
|
|
3304
3323
|
|
|
3305
3324
|
chunk_tx
|
|
3306
|
-
.send((
|
|
3325
|
+
.send((
|
|
3326
|
+
vec![Complex32::new(1.0, 0.0)],
|
|
3327
|
+
false,
|
|
3328
|
+
0,
|
|
3329
|
+
123456789,
|
|
3330
|
+
915e6,
|
|
3331
|
+
1e6,
|
|
3332
|
+
))
|
|
3307
3333
|
.await
|
|
3308
3334
|
.expect("channel open");
|
|
3309
3335
|
chunk_tx
|
|
3310
|
-
.send((
|
|
3336
|
+
.send((
|
|
3337
|
+
vec![Complex32::new(2.0, 0.0)],
|
|
3338
|
+
true,
|
|
3339
|
+
1,
|
|
3340
|
+
123456790,
|
|
3341
|
+
915e6,
|
|
3342
|
+
1e6,
|
|
3343
|
+
))
|
|
3311
3344
|
.await
|
|
3312
3345
|
.expect("channel open");
|
|
3313
3346
|
drop(chunk_tx);
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|