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.
Files changed (92) hide show
  1. {python_aaronia-0.11.1 → python_aaronia-0.11.2}/CHANGELOG.md +42 -0
  2. {python_aaronia-0.11.1 → python_aaronia-0.11.2}/Cargo.lock +2 -2
  3. {python_aaronia-0.11.1 → python_aaronia-0.11.2}/Cargo.toml +1 -1
  4. {python_aaronia-0.11.1 → python_aaronia-0.11.2}/PKG-INFO +1 -1
  5. {python_aaronia-0.11.1 → python_aaronia-0.11.2}/README.md +2 -0
  6. {python_aaronia-0.11.1 → python_aaronia-0.11.2}/python-aaronia/Cargo.toml +1 -1
  7. {python_aaronia-0.11.1 → python_aaronia-0.11.2}/src/sdr_source_impl.rs +102 -42
  8. {python_aaronia-0.11.1 → python_aaronia-0.11.2}/src/unified_source.rs +178 -145
  9. {python_aaronia-0.11.1 → python_aaronia-0.11.2}/.cargo/config.toml +0 -0
  10. {python_aaronia-0.11.1 → python_aaronia-0.11.2}/.gitattributes +0 -0
  11. {python_aaronia-0.11.1 → python_aaronia-0.11.2}/.gitignore +0 -0
  12. {python_aaronia-0.11.1 → python_aaronia-0.11.2}/CONTRIBUTING.md +0 -0
  13. {python_aaronia-0.11.1 → python_aaronia-0.11.2}/DESIGN.md +0 -0
  14. {python_aaronia-0.11.1 → python_aaronia-0.11.2}/LICENSE +0 -0
  15. {python_aaronia-0.11.1 → python_aaronia-0.11.2}/PLUGINS.md +0 -0
  16. {python_aaronia-0.11.1 → python_aaronia-0.11.2}/benches/decompress_block.rs +0 -0
  17. {python_aaronia-0.11.1 → python_aaronia-0.11.2}/benches/deinterleave_dual_iq.rs +0 -0
  18. {python_aaronia-0.11.1 → python_aaronia-0.11.2}/benches/parse_int16_packet.rs +0 -0
  19. {python_aaronia-0.11.1 → python_aaronia-0.11.2}/benches/rtsa_open_and_read.rs +0 -0
  20. {python_aaronia-0.11.1 → python_aaronia-0.11.2}/deny.toml +0 -0
  21. {python_aaronia-0.11.1 → python_aaronia-0.11.2}/docs/APPS.md +0 -0
  22. {python_aaronia-0.11.1 → python_aaronia-0.11.2}/docs/FILESPEC.md +0 -0
  23. {python_aaronia-0.11.1 → python_aaronia-0.11.2}/docs/HTTPSPEC.md +0 -0
  24. {python_aaronia-0.11.1 → python_aaronia-0.11.2}/docs/QUICKSTART.md +0 -0
  25. {python_aaronia-0.11.1 → python_aaronia-0.11.2}/docs/SDKSPEC.md +0 -0
  26. {python_aaronia-0.11.1 → python_aaronia-0.11.2}/docs/SYNC.md +0 -0
  27. {python_aaronia-0.11.1 → python_aaronia-0.11.2}/docs/USAGE.md +0 -0
  28. {python_aaronia-0.11.1 → python_aaronia-0.11.2}/docs/VERIFICATION.md +0 -0
  29. {python_aaronia-0.11.1 → python_aaronia-0.11.2}/examples/channel_hopping.rs +0 -0
  30. {python_aaronia-0.11.1 → python_aaronia-0.11.2}/examples/device_control.rs +0 -0
  31. {python_aaronia-0.11.1 → python_aaronia-0.11.2}/examples/dump_metadata.rs +0 -0
  32. {python_aaronia-0.11.1 → python_aaronia-0.11.2}/examples/http_iq_quickstart.rs +0 -0
  33. {python_aaronia-0.11.1 → python_aaronia-0.11.2}/examples/native_sdk_basic.rs +0 -0
  34. {python_aaronia-0.11.1 → python_aaronia-0.11.2}/examples/native_sdk_transmit.rs +0 -0
  35. {python_aaronia-0.11.1 → python_aaronia-0.11.2}/examples/noaa_scanner.rs +0 -0
  36. {python_aaronia-0.11.1 → python_aaronia-0.11.2}/examples/python_arrow_example.py +0 -0
  37. {python_aaronia-0.11.1 → python_aaronia-0.11.2}/examples/read_rtsa_file.rs +0 -0
  38. {python_aaronia-0.11.1 → python_aaronia-0.11.2}/examples/soapy_python_example.py +0 -0
  39. {python_aaronia-0.11.1 → python_aaronia-0.11.2}/include/spectran.h +0 -0
  40. {python_aaronia-0.11.1 → python_aaronia-0.11.2}/packaging/homebrew/README.md +0 -0
  41. {python_aaronia-0.11.1 → python_aaronia-0.11.2}/packaging/homebrew/soapy-aaronia.rb +0 -0
  42. {python_aaronia-0.11.1 → python_aaronia-0.11.2}/pyproject.toml +0 -0
  43. {python_aaronia-0.11.1 → python_aaronia-0.11.2}/python-aaronia/README.md +0 -0
  44. {python_aaronia-0.11.1 → python_aaronia-0.11.2}/python-aaronia/aaronia.pyi +0 -0
  45. {python_aaronia-0.11.1 → python_aaronia-0.11.2}/python-aaronia/src/lib.rs +0 -0
  46. {python_aaronia-0.11.1 → python_aaronia-0.11.2}/python-aaronia/test_basic.py +0 -0
  47. {python_aaronia-0.11.1 → python_aaronia-0.11.2}/scripts/ci-local.sh +0 -0
  48. {python_aaronia-0.11.1 → python_aaronia-0.11.2}/scripts/fake-rtsa-server.py +0 -0
  49. {python_aaronia-0.11.1 → python_aaronia-0.11.2}/scripts/native-sdk-validate.ps1 +0 -0
  50. {python_aaronia-0.11.1 → python_aaronia-0.11.2}/scripts/sdk-container-test.sh +0 -0
  51. {python_aaronia-0.11.1 → python_aaronia-0.11.2}/scripts/validate-iq-live.py +0 -0
  52. {python_aaronia-0.11.1 → python_aaronia-0.11.2}/soapy-aaronia/CMakeLists.txt +0 -0
  53. {python_aaronia-0.11.1 → python_aaronia-0.11.2}/soapy-aaronia/README.md +0 -0
  54. {python_aaronia-0.11.1 → python_aaronia-0.11.2}/soapy-aaronia/Registration.cpp +0 -0
  55. {python_aaronia-0.11.1 → python_aaronia-0.11.2}/soapy-aaronia/SpectranSoapyDevice.cpp +0 -0
  56. {python_aaronia-0.11.1 → python_aaronia-0.11.2}/soapy-aaronia/SpectranSoapyDevice.hpp +0 -0
  57. {python_aaronia-0.11.1 → python_aaronia-0.11.2}/soapy-aaronia/packaging/install.ps1 +0 -0
  58. {python_aaronia-0.11.1 → python_aaronia-0.11.2}/soapy-aaronia/packaging/install.sh +0 -0
  59. {python_aaronia-0.11.1 → python_aaronia-0.11.2}/src/c_api.rs +0 -0
  60. {python_aaronia-0.11.1 → python_aaronia-0.11.2}/src/decompression.rs +0 -0
  61. {python_aaronia-0.11.1 → python_aaronia-0.11.2}/src/detection.rs +0 -0
  62. {python_aaronia-0.11.1 → python_aaronia-0.11.2}/src/error.rs +0 -0
  63. {python_aaronia-0.11.1 → python_aaronia-0.11.2}/src/file_source.rs +0 -0
  64. {python_aaronia-0.11.1 → python_aaronia-0.11.2}/src/http_endpoints.rs +0 -0
  65. {python_aaronia-0.11.1 → python_aaronia-0.11.2}/src/http_sink.rs +0 -0
  66. {python_aaronia-0.11.1 → python_aaronia-0.11.2}/src/http_source.rs +0 -0
  67. {python_aaronia-0.11.1 → python_aaronia-0.11.2}/src/http_streaming.rs +0 -0
  68. {python_aaronia-0.11.1 → python_aaronia-0.11.2}/src/lib.rs +0 -0
  69. {python_aaronia-0.11.1 → python_aaronia-0.11.2}/src/link_budget.rs +0 -0
  70. {python_aaronia-0.11.1 → python_aaronia-0.11.2}/src/native_sdk.rs +0 -0
  71. {python_aaronia-0.11.1 → python_aaronia-0.11.2}/src/sdk_sink.rs +0 -0
  72. {python_aaronia-0.11.1 → python_aaronia-0.11.2}/src/sdk_source.rs +0 -0
  73. {python_aaronia-0.11.1 → python_aaronia-0.11.2}/src/seify_impl.rs +0 -0
  74. {python_aaronia-0.11.1 → python_aaronia-0.11.2}/src/unified_sink.rs +0 -0
  75. {python_aaronia-0.11.1 → python_aaronia-0.11.2}/src/utils.rs +0 -0
  76. {python_aaronia-0.11.1 → python_aaronia-0.11.2}/tests/abi_drift.rs +0 -0
  77. {python_aaronia-0.11.1 → python_aaronia-0.11.2}/tests/c_api_test.rs +0 -0
  78. {python_aaronia-0.11.1 → python_aaronia-0.11.2}/tests/http_mock_test.rs +0 -0
  79. {python_aaronia-0.11.1 → python_aaronia-0.11.2}/tests/http_resilience_test.rs +0 -0
  80. {python_aaronia-0.11.1 → python_aaronia-0.11.2}/tests/http_sink_test.rs +0 -0
  81. {python_aaronia-0.11.1 → python_aaronia-0.11.2}/tests/integration_test.rs +0 -0
  82. {python_aaronia-0.11.1 → python_aaronia-0.11.2}/tests/live_smoke.rs +0 -0
  83. {python_aaronia-0.11.1 → python_aaronia-0.11.2}/tests/native_sdk_live.rs +0 -0
  84. {python_aaronia-0.11.1 → python_aaronia-0.11.2}/tests/native_sdk_load.rs +0 -0
  85. {python_aaronia-0.11.1 → python_aaronia-0.11.2}/tests/properties.proptest-regressions +0 -0
  86. {python_aaronia-0.11.1 → python_aaronia-0.11.2}/tests/properties.rs +0 -0
  87. {python_aaronia-0.11.1 → python_aaronia-0.11.2}/tests/rtsa_negative_test.rs +0 -0
  88. {python_aaronia-0.11.1 → python_aaronia-0.11.2}/tests/sdr_source_impl_test.rs +0 -0
  89. {python_aaronia-0.11.1 → python_aaronia-0.11.2}/tests/spec_coverage.rs +0 -0
  90. {python_aaronia-0.11.1 → python_aaronia-0.11.2}/tests/test_cw_mag.rs +0 -0
  91. {python_aaronia-0.11.1 → python_aaronia-0.11.2}/tests/test_cw_meta.rs +0 -0
  92. {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.1"
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.1"
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.1"
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.1
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:
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "python-aaronia"
3
- version = "0.11.1"
3
+ version = "0.11.2"
4
4
  edition = "2024"
5
5
  description = "Python bindings for sdr-aaronia-rs (Aaronia SPECTRAN V6 SDR source)"
6
6
  license = "GPL-3.0-or-later"
@@ -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 (tx, receiver) = channel::bounded::<IqPacket>(1024);
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>>(256);
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
- .map_err(|e| Error::Config(e.to_string()))?;
285
- }
308
+ };
286
309
 
287
310
  let _ = source.stop_streaming().await;
288
- Ok::<_, Error>(())
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 target = advice.channel_override().unwrap_or(center_frequency_hz);
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
- tokio::time::sleep(Duration::from_millis(100)).await;
349
- continue;
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.read_samples(&mut raw_buffer, block_size).await {
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: current_freq,
383
- sample_rate_hz: source.sample_rate_hz() as f32,
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
- let still_overridden = advice.channel_override() == Some(channel);
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
- } else if Instant::now() >= deadline {
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 = deadline.saturating_duration_since(Instant::now());
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
- if captured_hz > 0.0 && (captured_hz - channel).abs() > STALE_TOLERANCE_HZ {
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.send(raw_buffer);
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: if captured_hz > 0.0 {
569
- captured_hz
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 retune. When the capture frequency changes
1333
- /// part-way through, the samples captured before it are returned and
1334
- /// the rest are held for the next call, so one read is always
1335
- /// describable by a single frequency —
1336
- /// [`Self::capture_frequency_hz`]. Expect a short read around a
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((mut new_samples, dropped, cum_drops, ts_ns, pkt_freq_hz))) => {
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 retune landed mid-read. Stash the whole
1404
- // packet with its own frequency and return
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
- && batch_freq_hz > 0.0
1412
- && (pkt_freq_hz - batch_freq_hz).abs() > 1.0
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
- let start_len = buffer.len();
1529
- // Read before the `&mut self` borrow of `http_receiver`.
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((vec![Complex32::new(1.0, 0.0)], false, 0, 123456789, 915e6))
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((vec![Complex32::new(2.0, 0.0)], true, 1, 123456790, 915e6))
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