python-aaronia 0.11.0__tar.gz → 0.11.1__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.1}/CHANGELOG.md +23 -0
  2. {python_aaronia-0.11.0 → python_aaronia-0.11.1}/Cargo.lock +2 -2
  3. {python_aaronia-0.11.0 → python_aaronia-0.11.1}/Cargo.toml +1 -1
  4. {python_aaronia-0.11.0 → python_aaronia-0.11.1}/PKG-INFO +1 -1
  5. {python_aaronia-0.11.0 → python_aaronia-0.11.1}/python-aaronia/Cargo.toml +1 -1
  6. {python_aaronia-0.11.0 → python_aaronia-0.11.1}/src/unified_source.rs +181 -37
  7. {python_aaronia-0.11.0 → python_aaronia-0.11.1}/tests/http_resilience_test.rs +32 -7
  8. {python_aaronia-0.11.0 → python_aaronia-0.11.1}/.cargo/config.toml +0 -0
  9. {python_aaronia-0.11.0 → python_aaronia-0.11.1}/.gitattributes +0 -0
  10. {python_aaronia-0.11.0 → python_aaronia-0.11.1}/.gitignore +0 -0
  11. {python_aaronia-0.11.0 → python_aaronia-0.11.1}/CONTRIBUTING.md +0 -0
  12. {python_aaronia-0.11.0 → python_aaronia-0.11.1}/DESIGN.md +0 -0
  13. {python_aaronia-0.11.0 → python_aaronia-0.11.1}/LICENSE +0 -0
  14. {python_aaronia-0.11.0 → python_aaronia-0.11.1}/PLUGINS.md +0 -0
  15. {python_aaronia-0.11.0 → python_aaronia-0.11.1}/README.md +0 -0
  16. {python_aaronia-0.11.0 → python_aaronia-0.11.1}/benches/decompress_block.rs +0 -0
  17. {python_aaronia-0.11.0 → python_aaronia-0.11.1}/benches/deinterleave_dual_iq.rs +0 -0
  18. {python_aaronia-0.11.0 → python_aaronia-0.11.1}/benches/parse_int16_packet.rs +0 -0
  19. {python_aaronia-0.11.0 → python_aaronia-0.11.1}/benches/rtsa_open_and_read.rs +0 -0
  20. {python_aaronia-0.11.0 → python_aaronia-0.11.1}/deny.toml +0 -0
  21. {python_aaronia-0.11.0 → python_aaronia-0.11.1}/docs/APPS.md +0 -0
  22. {python_aaronia-0.11.0 → python_aaronia-0.11.1}/docs/FILESPEC.md +0 -0
  23. {python_aaronia-0.11.0 → python_aaronia-0.11.1}/docs/HTTPSPEC.md +0 -0
  24. {python_aaronia-0.11.0 → python_aaronia-0.11.1}/docs/QUICKSTART.md +0 -0
  25. {python_aaronia-0.11.0 → python_aaronia-0.11.1}/docs/SDKSPEC.md +0 -0
  26. {python_aaronia-0.11.0 → python_aaronia-0.11.1}/docs/SYNC.md +0 -0
  27. {python_aaronia-0.11.0 → python_aaronia-0.11.1}/docs/USAGE.md +0 -0
  28. {python_aaronia-0.11.0 → python_aaronia-0.11.1}/docs/VERIFICATION.md +0 -0
  29. {python_aaronia-0.11.0 → python_aaronia-0.11.1}/examples/channel_hopping.rs +0 -0
  30. {python_aaronia-0.11.0 → python_aaronia-0.11.1}/examples/device_control.rs +0 -0
  31. {python_aaronia-0.11.0 → python_aaronia-0.11.1}/examples/dump_metadata.rs +0 -0
  32. {python_aaronia-0.11.0 → python_aaronia-0.11.1}/examples/http_iq_quickstart.rs +0 -0
  33. {python_aaronia-0.11.0 → python_aaronia-0.11.1}/examples/native_sdk_basic.rs +0 -0
  34. {python_aaronia-0.11.0 → python_aaronia-0.11.1}/examples/native_sdk_transmit.rs +0 -0
  35. {python_aaronia-0.11.0 → python_aaronia-0.11.1}/examples/noaa_scanner.rs +0 -0
  36. {python_aaronia-0.11.0 → python_aaronia-0.11.1}/examples/python_arrow_example.py +0 -0
  37. {python_aaronia-0.11.0 → python_aaronia-0.11.1}/examples/read_rtsa_file.rs +0 -0
  38. {python_aaronia-0.11.0 → python_aaronia-0.11.1}/examples/soapy_python_example.py +0 -0
  39. {python_aaronia-0.11.0 → python_aaronia-0.11.1}/include/spectran.h +0 -0
  40. {python_aaronia-0.11.0 → python_aaronia-0.11.1}/packaging/homebrew/README.md +0 -0
  41. {python_aaronia-0.11.0 → python_aaronia-0.11.1}/packaging/homebrew/soapy-aaronia.rb +0 -0
  42. {python_aaronia-0.11.0 → python_aaronia-0.11.1}/pyproject.toml +0 -0
  43. {python_aaronia-0.11.0 → python_aaronia-0.11.1}/python-aaronia/README.md +0 -0
  44. {python_aaronia-0.11.0 → python_aaronia-0.11.1}/python-aaronia/aaronia.pyi +0 -0
  45. {python_aaronia-0.11.0 → python_aaronia-0.11.1}/python-aaronia/src/lib.rs +0 -0
  46. {python_aaronia-0.11.0 → python_aaronia-0.11.1}/python-aaronia/test_basic.py +0 -0
  47. {python_aaronia-0.11.0 → python_aaronia-0.11.1}/scripts/ci-local.sh +0 -0
  48. {python_aaronia-0.11.0 → python_aaronia-0.11.1}/scripts/fake-rtsa-server.py +0 -0
  49. {python_aaronia-0.11.0 → python_aaronia-0.11.1}/scripts/native-sdk-validate.ps1 +0 -0
  50. {python_aaronia-0.11.0 → python_aaronia-0.11.1}/scripts/sdk-container-test.sh +0 -0
  51. {python_aaronia-0.11.0 → python_aaronia-0.11.1}/scripts/validate-iq-live.py +0 -0
  52. {python_aaronia-0.11.0 → python_aaronia-0.11.1}/soapy-aaronia/CMakeLists.txt +0 -0
  53. {python_aaronia-0.11.0 → python_aaronia-0.11.1}/soapy-aaronia/README.md +0 -0
  54. {python_aaronia-0.11.0 → python_aaronia-0.11.1}/soapy-aaronia/Registration.cpp +0 -0
  55. {python_aaronia-0.11.0 → python_aaronia-0.11.1}/soapy-aaronia/SpectranSoapyDevice.cpp +0 -0
  56. {python_aaronia-0.11.0 → python_aaronia-0.11.1}/soapy-aaronia/SpectranSoapyDevice.hpp +0 -0
  57. {python_aaronia-0.11.0 → python_aaronia-0.11.1}/soapy-aaronia/packaging/install.ps1 +0 -0
  58. {python_aaronia-0.11.0 → python_aaronia-0.11.1}/soapy-aaronia/packaging/install.sh +0 -0
  59. {python_aaronia-0.11.0 → python_aaronia-0.11.1}/src/c_api.rs +0 -0
  60. {python_aaronia-0.11.0 → python_aaronia-0.11.1}/src/decompression.rs +0 -0
  61. {python_aaronia-0.11.0 → python_aaronia-0.11.1}/src/detection.rs +0 -0
  62. {python_aaronia-0.11.0 → python_aaronia-0.11.1}/src/error.rs +0 -0
  63. {python_aaronia-0.11.0 → python_aaronia-0.11.1}/src/file_source.rs +0 -0
  64. {python_aaronia-0.11.0 → python_aaronia-0.11.1}/src/http_endpoints.rs +0 -0
  65. {python_aaronia-0.11.0 → python_aaronia-0.11.1}/src/http_sink.rs +0 -0
  66. {python_aaronia-0.11.0 → python_aaronia-0.11.1}/src/http_source.rs +0 -0
  67. {python_aaronia-0.11.0 → python_aaronia-0.11.1}/src/http_streaming.rs +0 -0
  68. {python_aaronia-0.11.0 → python_aaronia-0.11.1}/src/lib.rs +0 -0
  69. {python_aaronia-0.11.0 → python_aaronia-0.11.1}/src/link_budget.rs +0 -0
  70. {python_aaronia-0.11.0 → python_aaronia-0.11.1}/src/native_sdk.rs +0 -0
  71. {python_aaronia-0.11.0 → python_aaronia-0.11.1}/src/sdk_sink.rs +0 -0
  72. {python_aaronia-0.11.0 → python_aaronia-0.11.1}/src/sdk_source.rs +0 -0
  73. {python_aaronia-0.11.0 → python_aaronia-0.11.1}/src/sdr_source_impl.rs +0 -0
  74. {python_aaronia-0.11.0 → python_aaronia-0.11.1}/src/seify_impl.rs +0 -0
  75. {python_aaronia-0.11.0 → python_aaronia-0.11.1}/src/unified_sink.rs +0 -0
  76. {python_aaronia-0.11.0 → python_aaronia-0.11.1}/src/utils.rs +0 -0
  77. {python_aaronia-0.11.0 → python_aaronia-0.11.1}/tests/abi_drift.rs +0 -0
  78. {python_aaronia-0.11.0 → python_aaronia-0.11.1}/tests/c_api_test.rs +0 -0
  79. {python_aaronia-0.11.0 → python_aaronia-0.11.1}/tests/http_mock_test.rs +0 -0
  80. {python_aaronia-0.11.0 → python_aaronia-0.11.1}/tests/http_sink_test.rs +0 -0
  81. {python_aaronia-0.11.0 → python_aaronia-0.11.1}/tests/integration_test.rs +0 -0
  82. {python_aaronia-0.11.0 → python_aaronia-0.11.1}/tests/live_smoke.rs +0 -0
  83. {python_aaronia-0.11.0 → python_aaronia-0.11.1}/tests/native_sdk_live.rs +0 -0
  84. {python_aaronia-0.11.0 → python_aaronia-0.11.1}/tests/native_sdk_load.rs +0 -0
  85. {python_aaronia-0.11.0 → python_aaronia-0.11.1}/tests/properties.proptest-regressions +0 -0
  86. {python_aaronia-0.11.0 → python_aaronia-0.11.1}/tests/properties.rs +0 -0
  87. {python_aaronia-0.11.0 → python_aaronia-0.11.1}/tests/rtsa_negative_test.rs +0 -0
  88. {python_aaronia-0.11.0 → python_aaronia-0.11.1}/tests/sdr_source_impl_test.rs +0 -0
  89. {python_aaronia-0.11.0 → python_aaronia-0.11.1}/tests/spec_coverage.rs +0 -0
  90. {python_aaronia-0.11.0 → python_aaronia-0.11.1}/tests/test_cw_mag.rs +0 -0
  91. {python_aaronia-0.11.0 → python_aaronia-0.11.1}/tests/test_cw_meta.rs +0 -0
  92. {python_aaronia-0.11.0 → python_aaronia-0.11.1}/tests/wire_contract.rs +0 -0
@@ -4,6 +4,29 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [v0.11.1] - 2026-09-10
8
+
9
+ ### Fixed
10
+ - **A stream gap now ends the block it precedes, instead of hiding inside
11
+ one.** Read assembly noted a dropped chunk and carried on concatenating,
12
+ so a returned block held samples from both sides of the hole while
13
+ `take_overrun` said only "somewhere in there". A consumer resetting its
14
+ filters once, before the block, still ran the discontinuity through
15
+ them — and for an FM discriminator that is a phase step, i.e. a
16
+ frequency spike that reads downstream as a sync edge.
17
+
18
+ A gap has a position, and the only position a consumer can act on is a
19
+ read boundary. The post-gap chunk is now stashed and the block ends,
20
+ exactly as it already did for a retune, with the flag carried to the
21
+ read that begins there. A gap on a read's first chunk still reports
22
+ against that read, since nothing precedes it to split from.
23
+
24
+ This also reaches reconnects, which are discontinuities for the same
25
+ reason: the stream stopped and resumed, so the samples either side are
26
+ not adjacent. Reads continue across a reconnect as before, they just
27
+ no longer splice across it.
28
+
29
+
7
30
  ## [v0.11.0] - 2026-09-09
8
31
 
9
32
  ### 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.1"
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.1"
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.1"
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.1
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+)
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "python-aaronia"
3
- version = "0.11.0"
3
+ version = "0.11.1"
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"
@@ -421,6 +421,17 @@ 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
+ /// Whether the stashed samples in `sample_buffer` begin immediately
425
+ /// after a break in the stream.
426
+ ///
427
+ /// A gap has a *position*, not merely a presence. Assembly used to
428
+ /// note a dropped chunk and carry on concatenating, so the block
429
+ /// handed back straddled the hole while the overrun flag said only
430
+ /// "somewhere in there" — and a consumer resetting its filters once,
431
+ /// before the block, still ran the discontinuity through them. The
432
+ /// gap is now put on a read boundary instead, exactly as a retune
433
+ /// is, and this carries the flag to the read that starts there.
434
+ sample_buffer_after_gap: bool,
424
435
  /// Capture frequency of the samples the most recent read returned —
425
436
  /// what the stream itself reported, not what was requested. Read it
426
437
  /// with [`SpectranSource::capture_frequency_hz`].
@@ -574,6 +585,7 @@ impl SpectranSource {
574
585
  source_type: SourceType::Http, // Will be updated during detection
575
586
  sample_buffer: VecDeque::new(),
576
587
  sample_buffer_freq_hz: 0.0,
588
+ sample_buffer_after_gap: false,
577
589
  last_read_frequency_hz: 0.0,
578
590
 
579
591
  #[cfg(all(
@@ -1341,6 +1353,13 @@ impl SpectranSource {
1341
1353
  let from_buffer = self.sample_buffer.len().min(max_samples);
1342
1354
  if from_buffer > 0 {
1343
1355
  buffer.extend(self.sample_buffer.drain(0..from_buffer));
1356
+ // These samples were stashed because they follow a
1357
+ // gap; this is the read that begins with them, so
1358
+ // this is the read that must be told.
1359
+ if self.sample_buffer_after_gap {
1360
+ self.pending_overrun = true;
1361
+ self.sample_buffer_after_gap = false;
1362
+ }
1344
1363
  }
1345
1364
 
1346
1365
  // Capture frequency of this read. Seeded from the
@@ -1358,6 +1377,25 @@ impl SpectranSource {
1358
1377
  match recv {
1359
1378
  Ok(Some((mut new_samples, dropped, cum_drops, ts_ns, pkt_freq_hz))) => {
1360
1379
  if dropped {
1380
+ if collected > 0 {
1381
+ // Put the gap on a boundary rather
1382
+ // than inside this block: stash the
1383
+ // post-gap chunk and let the next
1384
+ // read start with it, carrying the
1385
+ // flag. Same shape as the retune
1386
+ // boundary below, for the same
1387
+ // reason — a consumer can only reset
1388
+ // its state between reads.
1389
+ self.sample_buffer.extend(new_samples);
1390
+ self.sample_buffer_freq_hz = pkt_freq_hz;
1391
+ self.sample_buffer_after_gap = true;
1392
+ self.cumulative_drops = cum_drops;
1393
+ self.last_timestamp_ns = ts_ns;
1394
+ break;
1395
+ }
1396
+ // Nothing collected yet, so this chunk
1397
+ // opens the block and the gap really is
1398
+ // immediately before it.
1361
1399
  self.pending_overrun = true;
1362
1400
  }
1363
1401
  self.cumulative_drops = cum_drops;
@@ -1499,6 +1537,13 @@ impl SpectranSource {
1499
1537
  let from_buffer = self.sample_buffer.len().min(max_samples);
1500
1538
  if from_buffer > 0 {
1501
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
+ }
1502
1547
  }
1503
1548
 
1504
1549
  // 2. If we need more, receive from channel
@@ -1513,6 +1558,25 @@ impl SpectranSource {
1513
1558
  match tokio::time::timeout(read_timeout, receiver.recv()).await {
1514
1559
  Ok(Some((mut new_samples, dropped, cum_drops, ts_ns, pkt_freq_hz))) => {
1515
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.
1516
1580
  self.pending_overrun = true;
1517
1581
  }
1518
1582
  self.cumulative_drops = cum_drops;
@@ -2727,6 +2791,7 @@ mod tests {
2727
2791
  source_type: SourceType::Http, // Will be updated
2728
2792
  sample_buffer: VecDeque::new(),
2729
2793
  sample_buffer_freq_hz: 0.0,
2794
+ sample_buffer_after_gap: false,
2730
2795
  last_read_frequency_hz: 0.0,
2731
2796
  #[cfg(all(
2732
2797
  feature = "native-sdk",
@@ -2810,6 +2875,7 @@ mod tests {
2810
2875
  source_type: SourceType::Http,
2811
2876
  sample_buffer: VecDeque::new(),
2812
2877
  sample_buffer_freq_hz: 0.0,
2878
+ sample_buffer_after_gap: false,
2813
2879
  last_read_frequency_hz: 0.0,
2814
2880
  #[cfg(all(
2815
2881
  feature = "native-sdk",
@@ -2845,6 +2911,7 @@ mod tests {
2845
2911
  source_type: SourceType::File,
2846
2912
  sample_buffer: VecDeque::new(),
2847
2913
  sample_buffer_freq_hz: 0.0,
2914
+ sample_buffer_after_gap: false,
2848
2915
  last_read_frequency_hz: 0.0,
2849
2916
  #[cfg(all(
2850
2917
  feature = "native-sdk",
@@ -2882,6 +2949,7 @@ mod tests {
2882
2949
  source_type: SourceType::NativeSdk,
2883
2950
  sample_buffer: VecDeque::new(),
2884
2951
  sample_buffer_freq_hz: 0.0,
2952
+ sample_buffer_after_gap: false,
2885
2953
  last_read_frequency_hz: 0.0,
2886
2954
  #[cfg(all(
2887
2955
  feature = "native-sdk",
@@ -3072,18 +3140,18 @@ mod tests {
3072
3140
  assert!(debug_str.contains("TEST123"));
3073
3141
  }
3074
3142
 
3075
- /// A read never spans a retune: samples captured before the device
3076
- /// moved must not be handed back tagged with the new frequency.
3077
- /// That mislabelling is what makes a sweep report a signal at a
3078
- /// centre it was never received on.
3079
- #[tokio::test]
3080
- async fn a_read_stops_at_a_capture_frequency_boundary() {
3081
- let (chunk_tx, chunk_rx) = tokio::sync::mpsc::channel(4);
3082
- let mut source = SpectranSource {
3143
+ /// An HTTP-backed source fed by `chunk_rx`, for the read-assembly
3144
+ /// tests. Factored out because three of them build the same thing
3145
+ /// and a new field otherwise has to be added in every copy.
3146
+ fn http_test_source(
3147
+ chunk_rx: tokio::sync::mpsc::Receiver<(Vec<Complex32>, bool, u64, i64, f64)>,
3148
+ ) -> SpectranSource {
3149
+ SpectranSource {
3083
3150
  config: SpectranConfig::default(),
3084
3151
  source_type: SourceType::Http,
3085
3152
  sample_buffer: VecDeque::new(),
3086
3153
  sample_buffer_freq_hz: 0.0,
3154
+ sample_buffer_after_gap: false,
3087
3155
  last_read_frequency_hz: 0.0,
3088
3156
  #[cfg(all(
3089
3157
  feature = "native-sdk",
@@ -3102,7 +3170,91 @@ mod tests {
3102
3170
  last_timestamp_ns: 0,
3103
3171
  read_scratch: Vec::new(),
3104
3172
  read_scratch_rx2: Vec::new(),
3105
- };
3173
+ }
3174
+ }
3175
+
3176
+ /// A gap must land on a read boundary, not inside a block.
3177
+ ///
3178
+ /// Assembly used to note a dropped chunk and keep concatenating, so
3179
+ /// one returned block held samples from both sides of the hole. The
3180
+ /// overrun flag said only "somewhere in this block", and a consumer
3181
+ /// that resets its filters once — before the block — still ran the
3182
+ /// discontinuity through them. A gap has a position, and the only
3183
+ /// position a consumer can act on is a boundary.
3184
+ #[tokio::test]
3185
+ async fn a_stream_gap_ends_the_block_it_precedes() {
3186
+ let (chunk_tx, chunk_rx) = tokio::sync::mpsc::channel(4);
3187
+ let mut source = http_test_source(chunk_rx);
3188
+
3189
+ // Three chunks: two contiguous, then one flagged as following a
3190
+ // dropped chunk. All the same frequency, so only the gap can
3191
+ // split them.
3192
+ chunk_tx
3193
+ .send((vec![Complex32::new(1.0, 0.0)], false, 0, 1, 915e6))
3194
+ .await
3195
+ .expect("channel open");
3196
+ chunk_tx
3197
+ .send((vec![Complex32::new(2.0, 0.0)], false, 0, 2, 915e6))
3198
+ .await
3199
+ .expect("channel open");
3200
+ chunk_tx
3201
+ .send((vec![Complex32::new(3.0, 0.0)], true, 1, 3, 915e6))
3202
+ .await
3203
+ .expect("channel open");
3204
+ drop(chunk_tx);
3205
+
3206
+ // Asking for all three must stop at the gap.
3207
+ let mut buffer = Vec::new();
3208
+ let n = source
3209
+ .read_samples(&mut buffer, 3)
3210
+ .await
3211
+ .expect("first read succeeds");
3212
+ assert_eq!(n, 2, "read should stop before the post-gap chunk");
3213
+ assert!(
3214
+ !source.take_overrun(),
3215
+ "this block is contiguous — the gap is after it, not in it"
3216
+ );
3217
+
3218
+ // The next read starts with the post-gap chunk and owns the flag.
3219
+ buffer.clear();
3220
+ let n = source
3221
+ .read_samples(&mut buffer, 3)
3222
+ .await
3223
+ .expect("second read succeeds");
3224
+ assert_eq!(n, 1);
3225
+ assert_eq!(buffer[0], Complex32::new(3.0, 0.0));
3226
+ assert!(
3227
+ source.take_overrun(),
3228
+ "the read that begins at the gap must report it"
3229
+ );
3230
+ }
3231
+
3232
+ /// A gap on the very first chunk of a read has nothing before it to
3233
+ /// split from, so it is reported against that read directly.
3234
+ #[tokio::test]
3235
+ async fn a_gap_on_the_first_chunk_is_reported_immediately() {
3236
+ let (chunk_tx, chunk_rx) = tokio::sync::mpsc::channel(4);
3237
+ let mut source = http_test_source(chunk_rx);
3238
+ chunk_tx
3239
+ .send((vec![Complex32::new(9.0, 0.0)], true, 1, 1, 915e6))
3240
+ .await
3241
+ .expect("channel open");
3242
+ drop(chunk_tx);
3243
+
3244
+ let mut buffer = Vec::new();
3245
+ let n = source.read_samples(&mut buffer, 4).await.expect("reads");
3246
+ assert_eq!(n, 1);
3247
+ assert!(source.take_overrun(), "nothing preceded it to split from");
3248
+ }
3249
+
3250
+ /// A read never spans a retune: samples captured before the device
3251
+ /// moved must not be handed back tagged with the new frequency.
3252
+ /// That mislabelling is what makes a sweep report a signal at a
3253
+ /// centre it was never received on.
3254
+ #[tokio::test]
3255
+ async fn a_read_stops_at_a_capture_frequency_boundary() {
3256
+ let (chunk_tx, chunk_rx) = tokio::sync::mpsc::channel(4);
3257
+ let mut source = http_test_source(chunk_rx);
3106
3258
 
3107
3259
  // Two chunks either side of a retune, queued before any read.
3108
3260
  chunk_tx
@@ -3139,32 +3291,14 @@ mod tests {
3139
3291
  async fn take_overrun_reflects_a_dropped_http_chunk() {
3140
3292
  // Regression test for overrun wiring: a chunk arriving from the
3141
3293
  // HTTP reader task flagged as dropped must surface via
3142
- // `take_overrun()` on the next call, and clear after being read.
3294
+ // `take_overrun()`, and clear after being read.
3295
+ //
3296
+ // It surfaces on the read that *begins* with that chunk, not on
3297
+ // one that merely contains it: a gap ends the block before it,
3298
+ // so a consumer resetting between reads resets in the right
3299
+ // place. See `a_stream_gap_ends_the_block_it_precedes`.
3143
3300
  let (chunk_tx, chunk_rx) = tokio::sync::mpsc::channel(4);
3144
- let mut source = SpectranSource {
3145
- config: SpectranConfig::default(),
3146
- source_type: SourceType::Http,
3147
- sample_buffer: VecDeque::new(),
3148
- sample_buffer_freq_hz: 0.0,
3149
- last_read_frequency_hz: 0.0,
3150
- #[cfg(all(
3151
- feature = "native-sdk",
3152
- any(target_os = "windows", target_os = "linux")
3153
- ))]
3154
- native_source: None,
3155
- http_client: None,
3156
- http_observed: None,
3157
- http_rate_bits: None,
3158
- http_tuning: None,
3159
- file_source: None,
3160
- http_receiver: Some(chunk_rx),
3161
- http_task: None,
3162
- pending_overrun: false,
3163
- cumulative_drops: 0,
3164
- last_timestamp_ns: 0,
3165
- read_scratch: Vec::new(),
3166
- read_scratch_rx2: Vec::new(),
3167
- };
3301
+ let mut source = http_test_source(chunk_rx);
3168
3302
 
3169
3303
  assert!(!source.take_overrun(), "no chunk received yet");
3170
3304
 
@@ -3182,12 +3316,22 @@ mod tests {
3182
3316
  let n = source
3183
3317
  .read_samples(&mut buffer, 2)
3184
3318
  .await
3185
- .expect("read_samples should drain both queued chunks");
3186
- assert_eq!(n, 2);
3319
+ .expect("first read succeeds");
3320
+ assert_eq!(n, 1, "the gap ends this block");
3321
+ assert!(
3322
+ !source.take_overrun(),
3323
+ "these samples are contiguous; the gap follows them"
3324
+ );
3187
3325
 
3326
+ buffer.clear();
3327
+ let n = source
3328
+ .read_samples(&mut buffer, 2)
3329
+ .await
3330
+ .expect("second read succeeds");
3331
+ assert_eq!(n, 1);
3188
3332
  assert!(
3189
3333
  source.take_overrun(),
3190
- "a dropped chunk was received during read_samples"
3334
+ "a dropped chunk opened this read's block"
3191
3335
  );
3192
3336
  assert!(
3193
3337
  !source.take_overrun(),
@@ -271,16 +271,38 @@ async fn stream_reconnects_and_reapplies_tuning() {
271
271
 
272
272
  let puts_after_start = full_tuple_control_puts(&server).await;
273
273
 
274
+ // A reconnect is a discontinuity: the stream stopped and resumed,
275
+ // so the samples either side are not adjacent. Reads therefore
276
+ // *stop* at it rather than splicing across — the point is that they
277
+ // continue afterwards, not that one read spans the hole.
274
278
  let mut buffer = Vec::new();
275
- source
276
- .read_samples(&mut buffer, 12)
277
- .await
278
- .expect("reads must continue across a reconnect");
279
+ let mut total = 0usize;
280
+ let mut reads = 0;
281
+ let mut saw_overrun = false;
282
+ while total < 12 && reads < 6 {
283
+ let mut chunk = Vec::new();
284
+ let n = source
285
+ .read_samples(&mut chunk, 12 - total)
286
+ .await
287
+ .expect("reads must continue across a reconnect");
288
+ if n == 0 {
289
+ break;
290
+ }
291
+ buffer.extend_from_slice(&chunk);
292
+ total += n;
293
+ reads += 1;
294
+ saw_overrun |= source.take_overrun();
295
+ }
279
296
 
280
297
  assert_eq!(
281
298
  buffer.len(),
282
299
  12,
283
- "expected samples from several connections"
300
+ "expected samples from several connections, in {reads} read(s)"
301
+ );
302
+ assert!(
303
+ reads >= 2,
304
+ "the reconnect should have ended a block, so 12 samples take \
305
+ more than one read"
284
306
  );
285
307
  assert!(
286
308
  opens.load(Ordering::SeqCst) >= 2,
@@ -291,9 +313,12 @@ async fn stream_reconnects_and_reapplies_tuning() {
291
313
  "reconnect must re-apply the tuning, or a restarted server \
292
314
  silently streams its mission's frequency instead"
293
315
  );
316
+ // The flag rides with the read that *began* at the gap, so it was
317
+ // observed during the loop above rather than being left pending
318
+ // here. What matters is that some read carried it.
294
319
  assert!(
295
- source.take_overrun(),
296
- "samples were missed across the gap; the first packet after a \
320
+ saw_overrun,
321
+ "samples were missed across the gap; the read beginning after a \
297
322
  reconnect must be flagged as an overrun"
298
323
  );
299
324
  }
File without changes