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.
- {python_aaronia-0.11.0 → python_aaronia-0.11.2}/CHANGELOG.md +65 -0
- {python_aaronia-0.11.0 → python_aaronia-0.11.2}/Cargo.lock +2 -2
- {python_aaronia-0.11.0 → python_aaronia-0.11.2}/Cargo.toml +1 -1
- {python_aaronia-0.11.0 → python_aaronia-0.11.2}/PKG-INFO +1 -1
- {python_aaronia-0.11.0 → python_aaronia-0.11.2}/README.md +2 -0
- {python_aaronia-0.11.0 → python_aaronia-0.11.2}/python-aaronia/Cargo.toml +1 -1
- {python_aaronia-0.11.0 → python_aaronia-0.11.2}/src/sdr_source_impl.rs +102 -42
- {python_aaronia-0.11.0 → python_aaronia-0.11.2}/src/unified_source.rs +328 -151
- {python_aaronia-0.11.0 → python_aaronia-0.11.2}/tests/http_resilience_test.rs +32 -7
- {python_aaronia-0.11.0 → python_aaronia-0.11.2}/.cargo/config.toml +0 -0
- {python_aaronia-0.11.0 → python_aaronia-0.11.2}/.gitattributes +0 -0
- {python_aaronia-0.11.0 → python_aaronia-0.11.2}/.gitignore +0 -0
- {python_aaronia-0.11.0 → python_aaronia-0.11.2}/CONTRIBUTING.md +0 -0
- {python_aaronia-0.11.0 → python_aaronia-0.11.2}/DESIGN.md +0 -0
- {python_aaronia-0.11.0 → python_aaronia-0.11.2}/LICENSE +0 -0
- {python_aaronia-0.11.0 → python_aaronia-0.11.2}/PLUGINS.md +0 -0
- {python_aaronia-0.11.0 → python_aaronia-0.11.2}/benches/decompress_block.rs +0 -0
- {python_aaronia-0.11.0 → python_aaronia-0.11.2}/benches/deinterleave_dual_iq.rs +0 -0
- {python_aaronia-0.11.0 → python_aaronia-0.11.2}/benches/parse_int16_packet.rs +0 -0
- {python_aaronia-0.11.0 → python_aaronia-0.11.2}/benches/rtsa_open_and_read.rs +0 -0
- {python_aaronia-0.11.0 → python_aaronia-0.11.2}/deny.toml +0 -0
- {python_aaronia-0.11.0 → python_aaronia-0.11.2}/docs/APPS.md +0 -0
- {python_aaronia-0.11.0 → python_aaronia-0.11.2}/docs/FILESPEC.md +0 -0
- {python_aaronia-0.11.0 → python_aaronia-0.11.2}/docs/HTTPSPEC.md +0 -0
- {python_aaronia-0.11.0 → python_aaronia-0.11.2}/docs/QUICKSTART.md +0 -0
- {python_aaronia-0.11.0 → python_aaronia-0.11.2}/docs/SDKSPEC.md +0 -0
- {python_aaronia-0.11.0 → python_aaronia-0.11.2}/docs/SYNC.md +0 -0
- {python_aaronia-0.11.0 → python_aaronia-0.11.2}/docs/USAGE.md +0 -0
- {python_aaronia-0.11.0 → python_aaronia-0.11.2}/docs/VERIFICATION.md +0 -0
- {python_aaronia-0.11.0 → python_aaronia-0.11.2}/examples/channel_hopping.rs +0 -0
- {python_aaronia-0.11.0 → python_aaronia-0.11.2}/examples/device_control.rs +0 -0
- {python_aaronia-0.11.0 → python_aaronia-0.11.2}/examples/dump_metadata.rs +0 -0
- {python_aaronia-0.11.0 → python_aaronia-0.11.2}/examples/http_iq_quickstart.rs +0 -0
- {python_aaronia-0.11.0 → python_aaronia-0.11.2}/examples/native_sdk_basic.rs +0 -0
- {python_aaronia-0.11.0 → python_aaronia-0.11.2}/examples/native_sdk_transmit.rs +0 -0
- {python_aaronia-0.11.0 → python_aaronia-0.11.2}/examples/noaa_scanner.rs +0 -0
- {python_aaronia-0.11.0 → python_aaronia-0.11.2}/examples/python_arrow_example.py +0 -0
- {python_aaronia-0.11.0 → python_aaronia-0.11.2}/examples/read_rtsa_file.rs +0 -0
- {python_aaronia-0.11.0 → python_aaronia-0.11.2}/examples/soapy_python_example.py +0 -0
- {python_aaronia-0.11.0 → python_aaronia-0.11.2}/include/spectran.h +0 -0
- {python_aaronia-0.11.0 → python_aaronia-0.11.2}/packaging/homebrew/README.md +0 -0
- {python_aaronia-0.11.0 → python_aaronia-0.11.2}/packaging/homebrew/soapy-aaronia.rb +0 -0
- {python_aaronia-0.11.0 → python_aaronia-0.11.2}/pyproject.toml +0 -0
- {python_aaronia-0.11.0 → python_aaronia-0.11.2}/python-aaronia/README.md +0 -0
- {python_aaronia-0.11.0 → python_aaronia-0.11.2}/python-aaronia/aaronia.pyi +0 -0
- {python_aaronia-0.11.0 → python_aaronia-0.11.2}/python-aaronia/src/lib.rs +0 -0
- {python_aaronia-0.11.0 → python_aaronia-0.11.2}/python-aaronia/test_basic.py +0 -0
- {python_aaronia-0.11.0 → python_aaronia-0.11.2}/scripts/ci-local.sh +0 -0
- {python_aaronia-0.11.0 → python_aaronia-0.11.2}/scripts/fake-rtsa-server.py +0 -0
- {python_aaronia-0.11.0 → python_aaronia-0.11.2}/scripts/native-sdk-validate.ps1 +0 -0
- {python_aaronia-0.11.0 → python_aaronia-0.11.2}/scripts/sdk-container-test.sh +0 -0
- {python_aaronia-0.11.0 → python_aaronia-0.11.2}/scripts/validate-iq-live.py +0 -0
- {python_aaronia-0.11.0 → python_aaronia-0.11.2}/soapy-aaronia/CMakeLists.txt +0 -0
- {python_aaronia-0.11.0 → python_aaronia-0.11.2}/soapy-aaronia/README.md +0 -0
- {python_aaronia-0.11.0 → python_aaronia-0.11.2}/soapy-aaronia/Registration.cpp +0 -0
- {python_aaronia-0.11.0 → python_aaronia-0.11.2}/soapy-aaronia/SpectranSoapyDevice.cpp +0 -0
- {python_aaronia-0.11.0 → python_aaronia-0.11.2}/soapy-aaronia/SpectranSoapyDevice.hpp +0 -0
- {python_aaronia-0.11.0 → python_aaronia-0.11.2}/soapy-aaronia/packaging/install.ps1 +0 -0
- {python_aaronia-0.11.0 → python_aaronia-0.11.2}/soapy-aaronia/packaging/install.sh +0 -0
- {python_aaronia-0.11.0 → python_aaronia-0.11.2}/src/c_api.rs +0 -0
- {python_aaronia-0.11.0 → python_aaronia-0.11.2}/src/decompression.rs +0 -0
- {python_aaronia-0.11.0 → python_aaronia-0.11.2}/src/detection.rs +0 -0
- {python_aaronia-0.11.0 → python_aaronia-0.11.2}/src/error.rs +0 -0
- {python_aaronia-0.11.0 → python_aaronia-0.11.2}/src/file_source.rs +0 -0
- {python_aaronia-0.11.0 → python_aaronia-0.11.2}/src/http_endpoints.rs +0 -0
- {python_aaronia-0.11.0 → python_aaronia-0.11.2}/src/http_sink.rs +0 -0
- {python_aaronia-0.11.0 → python_aaronia-0.11.2}/src/http_source.rs +0 -0
- {python_aaronia-0.11.0 → python_aaronia-0.11.2}/src/http_streaming.rs +0 -0
- {python_aaronia-0.11.0 → python_aaronia-0.11.2}/src/lib.rs +0 -0
- {python_aaronia-0.11.0 → python_aaronia-0.11.2}/src/link_budget.rs +0 -0
- {python_aaronia-0.11.0 → python_aaronia-0.11.2}/src/native_sdk.rs +0 -0
- {python_aaronia-0.11.0 → python_aaronia-0.11.2}/src/sdk_sink.rs +0 -0
- {python_aaronia-0.11.0 → python_aaronia-0.11.2}/src/sdk_source.rs +0 -0
- {python_aaronia-0.11.0 → python_aaronia-0.11.2}/src/seify_impl.rs +0 -0
- {python_aaronia-0.11.0 → python_aaronia-0.11.2}/src/unified_sink.rs +0 -0
- {python_aaronia-0.11.0 → python_aaronia-0.11.2}/src/utils.rs +0 -0
- {python_aaronia-0.11.0 → python_aaronia-0.11.2}/tests/abi_drift.rs +0 -0
- {python_aaronia-0.11.0 → python_aaronia-0.11.2}/tests/c_api_test.rs +0 -0
- {python_aaronia-0.11.0 → python_aaronia-0.11.2}/tests/http_mock_test.rs +0 -0
- {python_aaronia-0.11.0 → python_aaronia-0.11.2}/tests/http_sink_test.rs +0 -0
- {python_aaronia-0.11.0 → python_aaronia-0.11.2}/tests/integration_test.rs +0 -0
- {python_aaronia-0.11.0 → python_aaronia-0.11.2}/tests/live_smoke.rs +0 -0
- {python_aaronia-0.11.0 → python_aaronia-0.11.2}/tests/native_sdk_live.rs +0 -0
- {python_aaronia-0.11.0 → python_aaronia-0.11.2}/tests/native_sdk_load.rs +0 -0
- {python_aaronia-0.11.0 → python_aaronia-0.11.2}/tests/properties.proptest-regressions +0 -0
- {python_aaronia-0.11.0 → python_aaronia-0.11.2}/tests/properties.rs +0 -0
- {python_aaronia-0.11.0 → python_aaronia-0.11.2}/tests/rtsa_negative_test.rs +0 -0
- {python_aaronia-0.11.0 → python_aaronia-0.11.2}/tests/sdr_source_impl_test.rs +0 -0
- {python_aaronia-0.11.0 → python_aaronia-0.11.2}/tests/spec_coverage.rs +0 -0
- {python_aaronia-0.11.0 → python_aaronia-0.11.2}/tests/test_cw_mag.rs +0 -0
- {python_aaronia-0.11.0 → python_aaronia-0.11.2}/tests/test_cw_meta.rs +0 -0
- {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.
|
|
3095
|
+
version = "0.11.2"
|
|
3096
3096
|
dependencies = [
|
|
3097
3097
|
"arrow",
|
|
3098
3098
|
"num-complex",
|
|
@@ -3621,7 +3621,7 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
|
|
3621
3621
|
|
|
3622
3622
|
[[package]]
|
|
3623
3623
|
name = "sdr-aaronia-rs"
|
|
3624
|
-
version = "0.11.
|
|
3624
|
+
version = "0.11.2"
|
|
3625
3625
|
dependencies = [
|
|
3626
3626
|
"anyhow",
|
|
3627
3627
|
"bitflags 2.13.1",
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name = "sdr-aaronia-rs"
|
|
3
3
|
description = "Unified Rust interface for Aaronia Spectran Spectrum Analyzers / SDRs, featuring Python bindings, a SoapySDR plugin, HTTP streaming, and native SDK support."
|
|
4
4
|
license = "GPL-3.0-or-later"
|
|
5
|
-
version = "0.11.
|
|
5
|
+
version = "0.11.2"
|
|
6
6
|
edition = "2024"
|
|
7
7
|
repository = "https://github.com/isaacbentley/sdr-aaronia-rs"
|
|
8
8
|
readme = "README.md"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-aaronia
|
|
3
|
-
Version: 0.11.
|
|
3
|
+
Version: 0.11.2
|
|
4
4
|
Classifier: Programming Language :: Rust
|
|
5
5
|
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
6
6
|
Classifier: License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
|
|
@@ -93,6 +93,8 @@ tokio = { version = "1.43", features = ["rt-multi-thread", "macros"] }
|
|
|
93
93
|
# sdr-aaronia-rs = { version = "0.8", features = ["native-sdk", "futuresdr"] }
|
|
94
94
|
```
|
|
95
95
|
|
|
96
|
+
HTTP reads use one timeout budget for the entire requested block and return partial data at a timeout, frequency change, sample-rate change, or detected gap. `capture_frequency_hz()` and `capture_sample_rate_hz()` describe the returned samples even when newer packets are queued. File tuning setters preserve the recording's metadata.
|
|
97
|
+
|
|
96
98
|
## Quickstart
|
|
97
99
|
|
|
98
100
|
Set the RF parameters and read:
|
|
@@ -129,13 +129,41 @@ const EMPTY_READ_BAILOUT: u32 = 16;
|
|
|
129
129
|
/// should see EOF rather than be silently fed an empty channel.
|
|
130
130
|
const READ_ERROR_BAILOUT: u32 = 5;
|
|
131
131
|
|
|
132
|
+
fn accepted_override(requested: Option<f64>, rejected: Option<u64>) -> Option<f64> {
|
|
133
|
+
requested.filter(|f| f.is_finite() && *f > 0.0 && rejected != Some(f.to_bits()))
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
fn read_budget(parked: bool, deadline: Instant) -> Duration {
|
|
137
|
+
if parked {
|
|
138
|
+
Duration::from_millis(50)
|
|
139
|
+
} else {
|
|
140
|
+
deadline.saturating_duration_since(Instant::now())
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
fn packet_frequency(captured_hz: f64, commanded_hz: f64) -> Option<f64> {
|
|
145
|
+
if captured_hz > 0.0 {
|
|
146
|
+
((captured_hz - commanded_hz).abs() <= STALE_TOLERANCE_HZ).then_some(captured_hz)
|
|
147
|
+
} else {
|
|
148
|
+
Some(commanded_hz)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
132
152
|
impl SdrSource for SpectranSdrSource {
|
|
133
153
|
fn start(
|
|
134
154
|
self: Box<Self>,
|
|
135
155
|
config: SourceConfig,
|
|
136
156
|
advice: Arc<dyn DwellAdvice>,
|
|
137
157
|
) -> Result<SdrHandle, SdrError> {
|
|
138
|
-
let
|
|
158
|
+
let block_size = self.block_size.max(1024);
|
|
159
|
+
if block_size > 8 * 1024 * 1024 {
|
|
160
|
+
return Err(SdrError::BadConfig(
|
|
161
|
+
"block_size exceeds the 64 MiB sample-buffer limit".into(),
|
|
162
|
+
));
|
|
163
|
+
}
|
|
164
|
+
let pool_depth =
|
|
165
|
+
(64 * 1024 * 1024 / (block_size * std::mem::size_of::<Complex32>())).clamp(1, 64);
|
|
166
|
+
let (tx, receiver) = channel::bounded::<IqPacket>(pool_depth);
|
|
139
167
|
let stop_flag = Arc::new(AtomicBool::new(false));
|
|
140
168
|
let stop_thread = stop_flag.clone();
|
|
141
169
|
|
|
@@ -243,12 +271,9 @@ impl SdrSource for SpectranSdrSource {
|
|
|
243
271
|
let hopping =
|
|
244
272
|
!channels_hz.is_empty() && !matches!(backend, SpectranBackend::File(_));
|
|
245
273
|
|
|
246
|
-
let (pool_tx, pool_rx) = channel::bounded::<Vec<Complex32>>(
|
|
247
|
-
for _ in 0..256 {
|
|
248
|
-
let _ = pool_tx.send(Vec::with_capacity(block_size));
|
|
249
|
-
}
|
|
274
|
+
let (pool_tx, pool_rx) = channel::bounded::<Vec<Complex32>>(pool_depth);
|
|
250
275
|
|
|
251
|
-
if hopping {
|
|
276
|
+
let pump_result = if hopping {
|
|
252
277
|
info!(
|
|
253
278
|
"Aaronia hop mode: {} channels, dwell {:?}-{:?} (adaptive={})",
|
|
254
279
|
channels_hz.len(),
|
|
@@ -268,7 +293,6 @@ impl SdrSource for SpectranSdrSource {
|
|
|
268
293
|
&pool_tx,
|
|
269
294
|
)
|
|
270
295
|
.await
|
|
271
|
-
.map_err(|e| Error::Config(e.to_string()))?;
|
|
272
296
|
} else {
|
|
273
297
|
single_channel_pump(
|
|
274
298
|
&mut source,
|
|
@@ -281,11 +305,10 @@ impl SdrSource for SpectranSdrSource {
|
|
|
281
305
|
&pool_tx,
|
|
282
306
|
)
|
|
283
307
|
.await
|
|
284
|
-
|
|
285
|
-
}
|
|
308
|
+
};
|
|
286
309
|
|
|
287
310
|
let _ = source.stop_streaming().await;
|
|
288
|
-
|
|
311
|
+
pump_result
|
|
289
312
|
})?;
|
|
290
313
|
Ok(())
|
|
291
314
|
})() {
|
|
@@ -340,13 +363,23 @@ async fn single_channel_pump(
|
|
|
340
363
|
) -> Result<()> {
|
|
341
364
|
let mut empty_reads = 0u32;
|
|
342
365
|
let mut current_freq = center_frequency_hz;
|
|
366
|
+
let file_playback = source.source_info().source_type == SourceType::File;
|
|
367
|
+
let mut rejected_override = None;
|
|
343
368
|
while !stop_thread.load(Ordering::SeqCst) {
|
|
344
|
-
let
|
|
369
|
+
let requested = if file_playback {
|
|
370
|
+
None
|
|
371
|
+
} else {
|
|
372
|
+
accepted_override(advice.channel_override(), rejected_override)
|
|
373
|
+
};
|
|
374
|
+
let target = requested.unwrap_or(center_frequency_hz);
|
|
345
375
|
if (target - current_freq).abs() > 1.0 {
|
|
346
376
|
if let Err(e) = source.set_center_frequency_hz(target).await {
|
|
347
377
|
warn!("Aaronia retune to {:.3} MHz failed: {e}", target / 1e6);
|
|
348
|
-
|
|
349
|
-
|
|
378
|
+
if requested.is_some() {
|
|
379
|
+
rejected_override = Some(target.to_bits());
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
return Err(e);
|
|
350
383
|
}
|
|
351
384
|
current_freq = target;
|
|
352
385
|
drain_during_settle(source, block_size, RETUNE_SETTLE).await;
|
|
@@ -357,8 +390,12 @@ async fn single_channel_pump(
|
|
|
357
390
|
.unwrap_or_else(|_| Vec::with_capacity(block_size));
|
|
358
391
|
raw_buffer.clear();
|
|
359
392
|
|
|
360
|
-
let n = match source
|
|
393
|
+
let n = match source
|
|
394
|
+
.read_samples_deadline(&mut raw_buffer, block_size, Duration::from_millis(50))
|
|
395
|
+
.await
|
|
396
|
+
{
|
|
361
397
|
Ok(v) => v,
|
|
398
|
+
Err(Error::Io(ref e)) if e.kind() == std::io::ErrorKind::TimedOut => continue,
|
|
362
399
|
Err(e) => {
|
|
363
400
|
warn!("Aaronia read_samples error: {e} — stopping");
|
|
364
401
|
break;
|
|
@@ -374,13 +411,19 @@ async fn single_channel_pump(
|
|
|
374
411
|
continue;
|
|
375
412
|
}
|
|
376
413
|
empty_reads = 0;
|
|
414
|
+
let Some(packet_center) = packet_frequency(source.capture_frequency_hz(), current_freq)
|
|
415
|
+
else {
|
|
416
|
+
raw_buffer.clear();
|
|
417
|
+
let _ = pool_tx.try_send(raw_buffer);
|
|
418
|
+
continue;
|
|
419
|
+
};
|
|
377
420
|
// The rate is read per packet: on HTTP the rate the device actually
|
|
378
421
|
// streams is only known once packets flow, and may not be the one
|
|
379
422
|
// asked for (it snaps to the decimation ladder).
|
|
380
423
|
let pkt = IqPacket {
|
|
381
424
|
samples: crate::sdr_source::PooledIqBuffer::new_pooled(raw_buffer, pool_tx.clone()),
|
|
382
|
-
center_frequency_hz:
|
|
383
|
-
sample_rate_hz: source.
|
|
425
|
+
center_frequency_hz: packet_center,
|
|
426
|
+
sample_rate_hz: source.capture_sample_rate_hz() as f32,
|
|
384
427
|
overrun: source.take_overrun(),
|
|
385
428
|
};
|
|
386
429
|
if tx.send(pkt).is_err() {
|
|
@@ -426,12 +469,13 @@ async fn hop_pump(
|
|
|
426
469
|
// by contrast is per-hop on purpose: a quiet channel isn't a
|
|
427
470
|
// dead source, and hopping is the cure.
|
|
428
471
|
let mut read_errors = 0u32;
|
|
472
|
+
let mut rejected_override = None;
|
|
429
473
|
while !stop_thread.load(Ordering::SeqCst) {
|
|
430
474
|
// A live `/video` viewer wants a specific channel: park there,
|
|
431
475
|
// bypassing the hop list, until the override changes or clears.
|
|
432
476
|
// `channel_idx` only advances on a genuine hop below, so hopping
|
|
433
477
|
// resumes exactly where it left off once the viewer disconnects.
|
|
434
|
-
let override_freq = advice.channel_override();
|
|
478
|
+
let override_freq = accepted_override(advice.channel_override(), rejected_override);
|
|
435
479
|
let channel = override_freq.unwrap_or_else(|| {
|
|
436
480
|
let c = channels_hz[channel_idx];
|
|
437
481
|
channel_idx = (channel_idx + 1) % channels_hz.len();
|
|
@@ -444,6 +488,10 @@ async fn hop_pump(
|
|
|
444
488
|
if channel != current_channel {
|
|
445
489
|
if let Err(e) = source.set_center_frequency_hz(channel).await {
|
|
446
490
|
warn!("Aaronia retune to {:.3} MHz failed: {e}", channel / 1e6);
|
|
491
|
+
if override_freq.is_some() {
|
|
492
|
+
rejected_override = Some(channel.to_bits());
|
|
493
|
+
continue;
|
|
494
|
+
}
|
|
447
495
|
consecutive_retune_failures += 1;
|
|
448
496
|
if consecutive_retune_failures >= READ_ERROR_BAILOUT {
|
|
449
497
|
return Err(Error::Config(format!(
|
|
@@ -473,22 +521,10 @@ async fn hop_pump(
|
|
|
473
521
|
if stop_thread.load(Ordering::SeqCst) {
|
|
474
522
|
break;
|
|
475
523
|
}
|
|
476
|
-
|
|
477
|
-
if override_freq.is_some() {
|
|
478
|
-
// Parked here for live view: hold regardless of the dwell
|
|
479
|
-
// deadline, but break out the moment the viewer's channel
|
|
480
|
-
// changes or they disconnect so the outer loop can retune
|
|
481
|
-
// or resume hopping.
|
|
482
|
-
if !still_overridden {
|
|
483
|
-
break;
|
|
484
|
-
}
|
|
485
|
-
} else if still_overridden {
|
|
486
|
-
// A viewer just requested this exact channel while we were
|
|
487
|
-
// still mid-hop-dwell here — break out now (rather than
|
|
488
|
-
// waiting for the normal dwell deadline) so the next outer
|
|
489
|
-
// iteration parks on it promptly.
|
|
524
|
+
if accepted_override(advice.channel_override(), rejected_override) != override_freq {
|
|
490
525
|
break;
|
|
491
|
-
}
|
|
526
|
+
}
|
|
527
|
+
if override_freq.is_none() && Instant::now() >= deadline {
|
|
492
528
|
break;
|
|
493
529
|
}
|
|
494
530
|
|
|
@@ -506,7 +542,7 @@ async fn hop_pump(
|
|
|
506
542
|
// channel stays open instead of closing. Returning whatever
|
|
507
543
|
// arrived by the deadline keeps the hop cadence intact, and
|
|
508
544
|
// the `n == 0` branch below already handles an empty read.
|
|
509
|
-
let remaining =
|
|
545
|
+
let remaining = read_budget(override_freq.is_some(), deadline);
|
|
510
546
|
let n = match source
|
|
511
547
|
.read_samples_deadline(&mut raw_buffer, block_size, remaining)
|
|
512
548
|
.await
|
|
@@ -553,24 +589,20 @@ async fn hop_pump(
|
|
|
553
589
|
// that carry no per-packet frequency (file, native SDK), so
|
|
554
590
|
// those fall back to the commanded channel.
|
|
555
591
|
let captured_hz = source.capture_frequency_hz();
|
|
556
|
-
|
|
592
|
+
let Some(packet_center) = packet_frequency(captured_hz, channel) else {
|
|
557
593
|
// Pre-retune samples. Discard rather than mislabel them;
|
|
558
594
|
// the settle is short and the next read is clean. Hand
|
|
559
595
|
// the buffer back to the pool by the same route a
|
|
560
596
|
// delivered packet takes — dropping it here would shrink
|
|
561
597
|
// the pool by one on every hop.
|
|
562
598
|
raw_buffer.clear();
|
|
563
|
-
let _ = pool_tx.
|
|
599
|
+
let _ = pool_tx.try_send(raw_buffer);
|
|
564
600
|
continue;
|
|
565
|
-
}
|
|
601
|
+
};
|
|
566
602
|
let pkt = IqPacket {
|
|
567
603
|
samples: crate::sdr_source::PooledIqBuffer::new_pooled(raw_buffer, pool_tx.clone()),
|
|
568
|
-
center_frequency_hz:
|
|
569
|
-
|
|
570
|
-
} else {
|
|
571
|
-
channel
|
|
572
|
-
},
|
|
573
|
-
sample_rate_hz: source.sample_rate_hz() as f32,
|
|
604
|
+
center_frequency_hz: packet_center,
|
|
605
|
+
sample_rate_hz: source.capture_sample_rate_hz() as f32,
|
|
574
606
|
overrun: source.take_overrun(),
|
|
575
607
|
};
|
|
576
608
|
if tx.send(pkt).is_err() {
|
|
@@ -612,3 +644,31 @@ async fn drain_during_settle(
|
|
|
612
644
|
}
|
|
613
645
|
}
|
|
614
646
|
}
|
|
647
|
+
|
|
648
|
+
#[cfg(test)]
|
|
649
|
+
mod review_regressions {
|
|
650
|
+
use super::*;
|
|
651
|
+
#[test]
|
|
652
|
+
fn parked_reads_continue_after_the_original_dwell_expires() {
|
|
653
|
+
let expired = Instant::now() - Duration::from_secs(1);
|
|
654
|
+
assert!(!read_budget(true, expired).is_zero());
|
|
655
|
+
assert!(read_budget(false, expired).is_zero());
|
|
656
|
+
}
|
|
657
|
+
#[test]
|
|
658
|
+
fn single_channel_packets_reject_stale_frequency_metadata() {
|
|
659
|
+
assert_eq!(packet_frequency(433e6, 915e6), None);
|
|
660
|
+
assert_eq!(packet_frequency(915e6 - 500.0, 915e6), Some(915e6 - 500.0));
|
|
661
|
+
assert_eq!(packet_frequency(0.0, 915e6), Some(915e6));
|
|
662
|
+
}
|
|
663
|
+
#[test]
|
|
664
|
+
fn override_can_move_to_another_channel_or_clear() {
|
|
665
|
+
assert_ne!(None, accepted_override(Some(915e6), None));
|
|
666
|
+
assert_ne!(Some(915e6), accepted_override(Some(433e6), None));
|
|
667
|
+
assert_ne!(Some(915e6), accepted_override(None, None));
|
|
668
|
+
assert_eq!(None, accepted_override(Some(f64::NAN), None));
|
|
669
|
+
assert_eq!(
|
|
670
|
+
None,
|
|
671
|
+
accepted_override(Some(915e6), Some(915e6_f64.to_bits()))
|
|
672
|
+
);
|
|
673
|
+
}
|
|
674
|
+
}
|