python-aaronia 0.11.0__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.0 → python_aaronia-0.11.2}/CHANGELOG.md +65 -0
  2. {python_aaronia-0.11.0 → python_aaronia-0.11.2}/Cargo.lock +2 -2
  3. {python_aaronia-0.11.0 → python_aaronia-0.11.2}/Cargo.toml +1 -1
  4. {python_aaronia-0.11.0 → python_aaronia-0.11.2}/PKG-INFO +1 -1
  5. {python_aaronia-0.11.0 → python_aaronia-0.11.2}/README.md +2 -0
  6. {python_aaronia-0.11.0 → python_aaronia-0.11.2}/python-aaronia/Cargo.toml +1 -1
  7. {python_aaronia-0.11.0 → python_aaronia-0.11.2}/src/sdr_source_impl.rs +102 -42
  8. {python_aaronia-0.11.0 → python_aaronia-0.11.2}/src/unified_source.rs +328 -151
  9. {python_aaronia-0.11.0 → python_aaronia-0.11.2}/tests/http_resilience_test.rs +32 -7
  10. {python_aaronia-0.11.0 → python_aaronia-0.11.2}/.cargo/config.toml +0 -0
  11. {python_aaronia-0.11.0 → python_aaronia-0.11.2}/.gitattributes +0 -0
  12. {python_aaronia-0.11.0 → python_aaronia-0.11.2}/.gitignore +0 -0
  13. {python_aaronia-0.11.0 → python_aaronia-0.11.2}/CONTRIBUTING.md +0 -0
  14. {python_aaronia-0.11.0 → python_aaronia-0.11.2}/DESIGN.md +0 -0
  15. {python_aaronia-0.11.0 → python_aaronia-0.11.2}/LICENSE +0 -0
  16. {python_aaronia-0.11.0 → python_aaronia-0.11.2}/PLUGINS.md +0 -0
  17. {python_aaronia-0.11.0 → python_aaronia-0.11.2}/benches/decompress_block.rs +0 -0
  18. {python_aaronia-0.11.0 → python_aaronia-0.11.2}/benches/deinterleave_dual_iq.rs +0 -0
  19. {python_aaronia-0.11.0 → python_aaronia-0.11.2}/benches/parse_int16_packet.rs +0 -0
  20. {python_aaronia-0.11.0 → python_aaronia-0.11.2}/benches/rtsa_open_and_read.rs +0 -0
  21. {python_aaronia-0.11.0 → python_aaronia-0.11.2}/deny.toml +0 -0
  22. {python_aaronia-0.11.0 → python_aaronia-0.11.2}/docs/APPS.md +0 -0
  23. {python_aaronia-0.11.0 → python_aaronia-0.11.2}/docs/FILESPEC.md +0 -0
  24. {python_aaronia-0.11.0 → python_aaronia-0.11.2}/docs/HTTPSPEC.md +0 -0
  25. {python_aaronia-0.11.0 → python_aaronia-0.11.2}/docs/QUICKSTART.md +0 -0
  26. {python_aaronia-0.11.0 → python_aaronia-0.11.2}/docs/SDKSPEC.md +0 -0
  27. {python_aaronia-0.11.0 → python_aaronia-0.11.2}/docs/SYNC.md +0 -0
  28. {python_aaronia-0.11.0 → python_aaronia-0.11.2}/docs/USAGE.md +0 -0
  29. {python_aaronia-0.11.0 → python_aaronia-0.11.2}/docs/VERIFICATION.md +0 -0
  30. {python_aaronia-0.11.0 → python_aaronia-0.11.2}/examples/channel_hopping.rs +0 -0
  31. {python_aaronia-0.11.0 → python_aaronia-0.11.2}/examples/device_control.rs +0 -0
  32. {python_aaronia-0.11.0 → python_aaronia-0.11.2}/examples/dump_metadata.rs +0 -0
  33. {python_aaronia-0.11.0 → python_aaronia-0.11.2}/examples/http_iq_quickstart.rs +0 -0
  34. {python_aaronia-0.11.0 → python_aaronia-0.11.2}/examples/native_sdk_basic.rs +0 -0
  35. {python_aaronia-0.11.0 → python_aaronia-0.11.2}/examples/native_sdk_transmit.rs +0 -0
  36. {python_aaronia-0.11.0 → python_aaronia-0.11.2}/examples/noaa_scanner.rs +0 -0
  37. {python_aaronia-0.11.0 → python_aaronia-0.11.2}/examples/python_arrow_example.py +0 -0
  38. {python_aaronia-0.11.0 → python_aaronia-0.11.2}/examples/read_rtsa_file.rs +0 -0
  39. {python_aaronia-0.11.0 → python_aaronia-0.11.2}/examples/soapy_python_example.py +0 -0
  40. {python_aaronia-0.11.0 → python_aaronia-0.11.2}/include/spectran.h +0 -0
  41. {python_aaronia-0.11.0 → python_aaronia-0.11.2}/packaging/homebrew/README.md +0 -0
  42. {python_aaronia-0.11.0 → python_aaronia-0.11.2}/packaging/homebrew/soapy-aaronia.rb +0 -0
  43. {python_aaronia-0.11.0 → python_aaronia-0.11.2}/pyproject.toml +0 -0
  44. {python_aaronia-0.11.0 → python_aaronia-0.11.2}/python-aaronia/README.md +0 -0
  45. {python_aaronia-0.11.0 → python_aaronia-0.11.2}/python-aaronia/aaronia.pyi +0 -0
  46. {python_aaronia-0.11.0 → python_aaronia-0.11.2}/python-aaronia/src/lib.rs +0 -0
  47. {python_aaronia-0.11.0 → python_aaronia-0.11.2}/python-aaronia/test_basic.py +0 -0
  48. {python_aaronia-0.11.0 → python_aaronia-0.11.2}/scripts/ci-local.sh +0 -0
  49. {python_aaronia-0.11.0 → python_aaronia-0.11.2}/scripts/fake-rtsa-server.py +0 -0
  50. {python_aaronia-0.11.0 → python_aaronia-0.11.2}/scripts/native-sdk-validate.ps1 +0 -0
  51. {python_aaronia-0.11.0 → python_aaronia-0.11.2}/scripts/sdk-container-test.sh +0 -0
  52. {python_aaronia-0.11.0 → python_aaronia-0.11.2}/scripts/validate-iq-live.py +0 -0
  53. {python_aaronia-0.11.0 → python_aaronia-0.11.2}/soapy-aaronia/CMakeLists.txt +0 -0
  54. {python_aaronia-0.11.0 → python_aaronia-0.11.2}/soapy-aaronia/README.md +0 -0
  55. {python_aaronia-0.11.0 → python_aaronia-0.11.2}/soapy-aaronia/Registration.cpp +0 -0
  56. {python_aaronia-0.11.0 → python_aaronia-0.11.2}/soapy-aaronia/SpectranSoapyDevice.cpp +0 -0
  57. {python_aaronia-0.11.0 → python_aaronia-0.11.2}/soapy-aaronia/SpectranSoapyDevice.hpp +0 -0
  58. {python_aaronia-0.11.0 → python_aaronia-0.11.2}/soapy-aaronia/packaging/install.ps1 +0 -0
  59. {python_aaronia-0.11.0 → python_aaronia-0.11.2}/soapy-aaronia/packaging/install.sh +0 -0
  60. {python_aaronia-0.11.0 → python_aaronia-0.11.2}/src/c_api.rs +0 -0
  61. {python_aaronia-0.11.0 → python_aaronia-0.11.2}/src/decompression.rs +0 -0
  62. {python_aaronia-0.11.0 → python_aaronia-0.11.2}/src/detection.rs +0 -0
  63. {python_aaronia-0.11.0 → python_aaronia-0.11.2}/src/error.rs +0 -0
  64. {python_aaronia-0.11.0 → python_aaronia-0.11.2}/src/file_source.rs +0 -0
  65. {python_aaronia-0.11.0 → python_aaronia-0.11.2}/src/http_endpoints.rs +0 -0
  66. {python_aaronia-0.11.0 → python_aaronia-0.11.2}/src/http_sink.rs +0 -0
  67. {python_aaronia-0.11.0 → python_aaronia-0.11.2}/src/http_source.rs +0 -0
  68. {python_aaronia-0.11.0 → python_aaronia-0.11.2}/src/http_streaming.rs +0 -0
  69. {python_aaronia-0.11.0 → python_aaronia-0.11.2}/src/lib.rs +0 -0
  70. {python_aaronia-0.11.0 → python_aaronia-0.11.2}/src/link_budget.rs +0 -0
  71. {python_aaronia-0.11.0 → python_aaronia-0.11.2}/src/native_sdk.rs +0 -0
  72. {python_aaronia-0.11.0 → python_aaronia-0.11.2}/src/sdk_sink.rs +0 -0
  73. {python_aaronia-0.11.0 → python_aaronia-0.11.2}/src/sdk_source.rs +0 -0
  74. {python_aaronia-0.11.0 → python_aaronia-0.11.2}/src/seify_impl.rs +0 -0
  75. {python_aaronia-0.11.0 → python_aaronia-0.11.2}/src/unified_sink.rs +0 -0
  76. {python_aaronia-0.11.0 → python_aaronia-0.11.2}/src/utils.rs +0 -0
  77. {python_aaronia-0.11.0 → python_aaronia-0.11.2}/tests/abi_drift.rs +0 -0
  78. {python_aaronia-0.11.0 → python_aaronia-0.11.2}/tests/c_api_test.rs +0 -0
  79. {python_aaronia-0.11.0 → python_aaronia-0.11.2}/tests/http_mock_test.rs +0 -0
  80. {python_aaronia-0.11.0 → python_aaronia-0.11.2}/tests/http_sink_test.rs +0 -0
  81. {python_aaronia-0.11.0 → python_aaronia-0.11.2}/tests/integration_test.rs +0 -0
  82. {python_aaronia-0.11.0 → python_aaronia-0.11.2}/tests/live_smoke.rs +0 -0
  83. {python_aaronia-0.11.0 → python_aaronia-0.11.2}/tests/native_sdk_live.rs +0 -0
  84. {python_aaronia-0.11.0 → python_aaronia-0.11.2}/tests/native_sdk_load.rs +0 -0
  85. {python_aaronia-0.11.0 → python_aaronia-0.11.2}/tests/properties.proptest-regressions +0 -0
  86. {python_aaronia-0.11.0 → python_aaronia-0.11.2}/tests/properties.rs +0 -0
  87. {python_aaronia-0.11.0 → python_aaronia-0.11.2}/tests/rtsa_negative_test.rs +0 -0
  88. {python_aaronia-0.11.0 → python_aaronia-0.11.2}/tests/sdr_source_impl_test.rs +0 -0
  89. {python_aaronia-0.11.0 → python_aaronia-0.11.2}/tests/spec_coverage.rs +0 -0
  90. {python_aaronia-0.11.0 → python_aaronia-0.11.2}/tests/test_cw_mag.rs +0 -0
  91. {python_aaronia-0.11.0 → python_aaronia-0.11.2}/tests/test_cw_meta.rs +0 -0
  92. {python_aaronia-0.11.0 → python_aaronia-0.11.2}/tests/wire_contract.rs +0 -0
@@ -4,6 +4,71 @@ 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
+
49
+ ## [v0.11.1] - 2026-09-10
50
+
51
+ ### Fixed
52
+ - **A stream gap now ends the block it precedes, instead of hiding inside
53
+ one.** Read assembly noted a dropped chunk and carried on concatenating,
54
+ so a returned block held samples from both sides of the hole while
55
+ `take_overrun` said only "somewhere in there". A consumer resetting its
56
+ filters once, before the block, still ran the discontinuity through
57
+ them — and for an FM discriminator that is a phase step, i.e. a
58
+ frequency spike that reads downstream as a sync edge.
59
+
60
+ A gap has a position, and the only position a consumer can act on is a
61
+ read boundary. The post-gap chunk is now stashed and the block ends,
62
+ exactly as it already did for a retune, with the flag carried to the
63
+ read that begins there. A gap on a read's first chunk still reports
64
+ against that read, since nothing precedes it to split from.
65
+
66
+ This also reaches reconnects, which are discontinuities for the same
67
+ reason: the stream stopped and resumed, so the samples either side are
68
+ not adjacent. Reads continue across a reconnect as before, they just
69
+ no longer splice across it.
70
+
71
+
7
72
  ## [v0.11.0] - 2026-09-09
8
73
 
9
74
  ### Changed
@@ -3092,7 +3092,7 @@ dependencies = [
3092
3092
 
3093
3093
  [[package]]
3094
3094
  name = "python-aaronia"
3095
- version = "0.11.0"
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.0"
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.0"
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.0
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.0"
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
+ }