python-aaronia 0.5.0__tar.gz → 0.5.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 (73) hide show
  1. {python_aaronia-0.5.0 → python_aaronia-0.5.1}/CHANGELOG.md +32 -0
  2. {python_aaronia-0.5.0 → python_aaronia-0.5.1}/CONTRIBUTING.md +1 -1
  3. {python_aaronia-0.5.0 → python_aaronia-0.5.1}/Cargo.lock +2 -2
  4. {python_aaronia-0.5.0 → python_aaronia-0.5.1}/Cargo.toml +2 -2
  5. {python_aaronia-0.5.0 → python_aaronia-0.5.1}/DESIGN.md +1 -1
  6. {python_aaronia-0.5.0 → python_aaronia-0.5.1}/PKG-INFO +1 -1
  7. {python_aaronia-0.5.0 → python_aaronia-0.5.1}/README.md +2 -2
  8. {python_aaronia-0.5.0 → python_aaronia-0.5.1}/docs/HTTPSPEC.md +11 -1
  9. {python_aaronia-0.5.0 → python_aaronia-0.5.1}/python-aaronia/Cargo.toml +1 -1
  10. {python_aaronia-0.5.0 → python_aaronia-0.5.1}/python-aaronia/src/lib.rs +15 -7
  11. {python_aaronia-0.5.0 → python_aaronia-0.5.1}/scripts/ci-local.sh +14 -8
  12. {python_aaronia-0.5.0 → python_aaronia-0.5.1}/soapy-aaronia/README.md +5 -4
  13. {python_aaronia-0.5.0 → python_aaronia-0.5.1}/src/http_endpoints.rs +26 -3
  14. {python_aaronia-0.5.0 → python_aaronia-0.5.1}/src/unified_source.rs +34 -10
  15. {python_aaronia-0.5.0 → python_aaronia-0.5.1}/tests/live_smoke.rs +59 -1
  16. {python_aaronia-0.5.0 → python_aaronia-0.5.1}/tests/sdr_source_impl_test.rs +11 -5
  17. {python_aaronia-0.5.0 → python_aaronia-0.5.1}/.cargo/config.toml +0 -0
  18. {python_aaronia-0.5.0 → python_aaronia-0.5.1}/.gitattributes +0 -0
  19. {python_aaronia-0.5.0 → python_aaronia-0.5.1}/.gitignore +0 -0
  20. {python_aaronia-0.5.0 → python_aaronia-0.5.1}/LICENSE +0 -0
  21. {python_aaronia-0.5.0 → python_aaronia-0.5.1}/PLUGINS.md +0 -0
  22. {python_aaronia-0.5.0 → python_aaronia-0.5.1}/benches/decompress_block.rs +0 -0
  23. {python_aaronia-0.5.0 → python_aaronia-0.5.1}/benches/deinterleave_dual_iq.rs +0 -0
  24. {python_aaronia-0.5.0 → python_aaronia-0.5.1}/benches/parse_int16_packet.rs +0 -0
  25. {python_aaronia-0.5.0 → python_aaronia-0.5.1}/benches/rtsa_open_and_read.rs +0 -0
  26. {python_aaronia-0.5.0 → python_aaronia-0.5.1}/deny.toml +0 -0
  27. {python_aaronia-0.5.0 → python_aaronia-0.5.1}/docs/FILESPEC.md +0 -0
  28. {python_aaronia-0.5.0 → python_aaronia-0.5.1}/docs/SDKSPEC.md +0 -0
  29. {python_aaronia-0.5.0 → python_aaronia-0.5.1}/examples/channel_hopping.rs +0 -0
  30. {python_aaronia-0.5.0 → python_aaronia-0.5.1}/examples/device_control.rs +0 -0
  31. {python_aaronia-0.5.0 → python_aaronia-0.5.1}/examples/dump_metadata.rs +0 -0
  32. {python_aaronia-0.5.0 → python_aaronia-0.5.1}/examples/http_iq_quickstart.rs +0 -0
  33. {python_aaronia-0.5.0 → python_aaronia-0.5.1}/examples/native_sdk_basic.rs +0 -0
  34. {python_aaronia-0.5.0 → python_aaronia-0.5.1}/examples/native_sdk_transmit.rs +0 -0
  35. {python_aaronia-0.5.0 → python_aaronia-0.5.1}/examples/noaa_scanner.rs +0 -0
  36. {python_aaronia-0.5.0 → python_aaronia-0.5.1}/examples/python_arrow_example.py +0 -0
  37. {python_aaronia-0.5.0 → python_aaronia-0.5.1}/examples/read_rtsa_file.rs +0 -0
  38. {python_aaronia-0.5.0 → python_aaronia-0.5.1}/examples/soapy_python_example.py +0 -0
  39. {python_aaronia-0.5.0 → python_aaronia-0.5.1}/include/aaronia.h +0 -0
  40. {python_aaronia-0.5.0 → python_aaronia-0.5.1}/pyproject.toml +0 -0
  41. {python_aaronia-0.5.0 → python_aaronia-0.5.1}/python-aaronia/README.md +0 -0
  42. {python_aaronia-0.5.0 → python_aaronia-0.5.1}/python-aaronia/test_basic.py +0 -0
  43. {python_aaronia-0.5.0 → python_aaronia-0.5.1}/soapy-aaronia/AaroniaSoapyDevice.cpp +0 -0
  44. {python_aaronia-0.5.0 → python_aaronia-0.5.1}/soapy-aaronia/AaroniaSoapyDevice.hpp +0 -0
  45. {python_aaronia-0.5.0 → python_aaronia-0.5.1}/soapy-aaronia/CMakeLists.txt +0 -0
  46. {python_aaronia-0.5.0 → python_aaronia-0.5.1}/soapy-aaronia/Registration.cpp +0 -0
  47. {python_aaronia-0.5.0 → python_aaronia-0.5.1}/src/c_api.rs +0 -0
  48. {python_aaronia-0.5.0 → python_aaronia-0.5.1}/src/decompression.rs +0 -0
  49. {python_aaronia-0.5.0 → python_aaronia-0.5.1}/src/detection.rs +0 -0
  50. {python_aaronia-0.5.0 → python_aaronia-0.5.1}/src/error.rs +0 -0
  51. {python_aaronia-0.5.0 → python_aaronia-0.5.1}/src/file_source.rs +0 -0
  52. {python_aaronia-0.5.0 → python_aaronia-0.5.1}/src/http_sink.rs +0 -0
  53. {python_aaronia-0.5.0 → python_aaronia-0.5.1}/src/http_source.rs +0 -0
  54. {python_aaronia-0.5.0 → python_aaronia-0.5.1}/src/http_streaming.rs +0 -0
  55. {python_aaronia-0.5.0 → python_aaronia-0.5.1}/src/lib.rs +0 -0
  56. {python_aaronia-0.5.0 → python_aaronia-0.5.1}/src/native_sdk.rs +0 -0
  57. {python_aaronia-0.5.0 → python_aaronia-0.5.1}/src/sdk_sink.rs +0 -0
  58. {python_aaronia-0.5.0 → python_aaronia-0.5.1}/src/sdk_source.rs +0 -0
  59. {python_aaronia-0.5.0 → python_aaronia-0.5.1}/src/sdr_source_impl.rs +0 -0
  60. {python_aaronia-0.5.0 → python_aaronia-0.5.1}/src/seify_impl.rs +0 -0
  61. {python_aaronia-0.5.0 → python_aaronia-0.5.1}/src/unified_sink.rs +0 -0
  62. {python_aaronia-0.5.0 → python_aaronia-0.5.1}/src/utils.rs +0 -0
  63. {python_aaronia-0.5.0 → python_aaronia-0.5.1}/tests/c_api_test.rs +0 -0
  64. {python_aaronia-0.5.0 → python_aaronia-0.5.1}/tests/http_mock_test.rs +0 -0
  65. {python_aaronia-0.5.0 → python_aaronia-0.5.1}/tests/http_sink_test.rs +0 -0
  66. {python_aaronia-0.5.0 → python_aaronia-0.5.1}/tests/integration_test.rs +0 -0
  67. {python_aaronia-0.5.0 → python_aaronia-0.5.1}/tests/native_sdk_load.rs +0 -0
  68. {python_aaronia-0.5.0 → python_aaronia-0.5.1}/tests/properties.proptest-regressions +0 -0
  69. {python_aaronia-0.5.0 → python_aaronia-0.5.1}/tests/properties.rs +0 -0
  70. {python_aaronia-0.5.0 → python_aaronia-0.5.1}/tests/rtsa_negative_test.rs +0 -0
  71. {python_aaronia-0.5.0 → python_aaronia-0.5.1}/tests/spec_coverage.rs +0 -0
  72. {python_aaronia-0.5.0 → python_aaronia-0.5.1}/tests/test_cw_mag.rs +0 -0
  73. {python_aaronia-0.5.0 → python_aaronia-0.5.1}/tests/test_cw_meta.rs +0 -0
@@ -2,6 +2,38 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [Unreleased]
6
+
7
+ ## [v0.5.1] - 2026-08-11
8
+
9
+ ### Fixed
10
+ - **Mid-stream HTTP retuning was silently broken on real hardware.**
11
+ Live testing against RTSA-Suite PRO (SPECTRAN V6 ECO) showed the
12
+ `/control` capture endpoint applies a frequency change only when
13
+ `frequencyCenter` and `frequencySpan` are both present; a lone
14
+ frequency field returns `{"success":true}` but is ignored, so
15
+ `set_center_frequency` / `set_span_frequency` (and therefore hop-mode
16
+ retuning and the SoapySDR/seify/Python retune surfaces over HTTP)
17
+ reported success while the device kept streaming at the old tuning.
18
+ All HTTP retune paths now send the complete capture tuple (center,
19
+ span, reference level), `configure_capture` warns on lone-frequency
20
+ payloads, and a live smoke test (`live_retune_full_tuple_applies`)
21
+ plus a span-required mock predicate guard the regression.
22
+ `referenceLevel`-only PUTs were unaffected (they apply on their own).
23
+ - Corrected the docs that mis-attributed the silent-ignore behavior to
24
+ the Aaronia "Remote Config" license (READMEs, DESIGN.md, HTTPSPEC.md,
25
+ `probe_remote_config_license` doc comments): retuning goes through the
26
+ license-free `/control` endpoint; the license gates only
27
+ `/remoteconfig` writes.
28
+
29
+ ### CI
30
+ - Clippy now runs with `--workspace` (CI, `scripts/ci-local.sh`, and the
31
+ Linux-VM leg): `python-aaronia` is a workspace member that no job
32
+ linted, and it had accumulated 12 unflagged clippy errors — a genuine
33
+ `type_complexity` (fixed with a type alias) and 11 `useless_conversion`
34
+ reports from pyo3 0.22's `#[pymethods]` wrapper codegen (crate-level
35
+ allow, documented for removal at pyo3 ≥ 0.23).
36
+
5
37
  ## [v0.5.0] - 2026-08-11
6
38
 
7
39
  Breaking 0.x release combining the RTSA file-format verification work
@@ -13,7 +13,7 @@ cd sdr-aaronia-rs
13
13
  # Run the standard validation suite
14
14
  cargo test # unit + integration + properties (default features)
15
15
  cargo test --test spec_coverage -- --nocapture # spec inventory report
16
- cargo clippy --all-features --all-targets -- -D warnings
16
+ cargo clippy --workspace --all-features --all-targets -- -D warnings
17
17
  cargo fmt --all --check
18
18
  ```
19
19
 
@@ -3057,7 +3057,7 @@ dependencies = [
3057
3057
 
3058
3058
  [[package]]
3059
3059
  name = "python-aaronia"
3060
- version = "0.5.0"
3060
+ version = "0.5.1"
3061
3061
  dependencies = [
3062
3062
  "arrow",
3063
3063
  "num-complex",
@@ -3592,7 +3592,7 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
3592
3592
 
3593
3593
  [[package]]
3594
3594
  name = "sdr-aaronia-rs"
3595
- version = "0.5.0"
3595
+ version = "0.5.1"
3596
3596
  dependencies = [
3597
3597
  "anyhow",
3598
3598
  "bitflags 2.13.0",
@@ -1,8 +1,8 @@
1
1
  [package]
2
2
  name = "sdr-aaronia-rs"
3
- description = "Unified Rust interface for Aaronia Spectran V6 SDRs: native RTSA SDK, HTTP streaming, and RTSA capture-file sources"
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.5.0"
5
+ version = "0.5.1"
6
6
  edition = "2024"
7
7
  repository = "https://github.com/isaacbentley/sdr-aaronia-rs"
8
8
  readme = "README.md"
@@ -72,7 +72,7 @@ Available on Windows and Linux only, behind the non-default `native-sdk` feature
72
72
  Under the `sdr-source` feature, `AaroniaSdrSource` (in `sdr_source_impl.rs`) implements the `SdrSource` trait from the external `orecchiette-sdr-source-rs` crate, which the crate re-exports as `sdr_source`. Automatic mid-stream retuning via `SourceConfig.channels_hz` is supported per backend:
73
73
 
74
74
  - **Native SDK**: re-issues `configure_iq_receiver` with the new center frequency, applying the change to the open device handle via the `main/centerfreq` configuration key. No stream restart is required.
75
- - **HTTP**: `AaroniaSource::set_center_frequency` wraps `HttpEndpointsClient::configure_capture(frequency_center=...)` as a `PUT` to the license-free `/control` endpoint. The RTSA-Suite "Remote Config" license gates the separate `/remoteconfig` write path, which the hopping code deliberately avoids — hopping is not gated on the license probe.
75
+ - **HTTP**: `AaroniaSource::set_center_frequency` wraps `HttpEndpointsClient::configure_capture` as a `PUT` to the license-free `/control` endpoint, always sending the complete capture tuple (center, span, reference level) with unchanged values filled from the cached config. The full tuple is required: RTSA servers silently ignore a capture `PUT` carrying only one of the two frequency fields (it returns `{"success":true}` but the device stays put — live-verified). The RTSA-Suite "Remote Config" license gates the separate `/remoteconfig` write path, which the hopping code deliberately avoids — hopping is not gated on the license probe.
76
76
  - **File**: not supported.
77
77
 
78
78
  Hop dwell deadlines come from `sdr_source::DwellController`; per-hop pacing additionally inserts a ~75 ms settle after every retune (`RETUNE_SETTLE` in `sdr_source_impl.rs`) to ride out the RTSA's apply-config latency and flush stale samples from the pipeline.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-aaronia
3
- Version: 0.5.0
3
+ Version: 0.5.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+)
@@ -5,7 +5,7 @@
5
5
  [![CI](https://github.com/isaacbentley/sdr-aaronia-rs/actions/workflows/ci.yml/badge.svg)](https://github.com/isaacbentley/sdr-aaronia-rs/actions/workflows/ci.yml)
6
6
  [![License: GPL-3.0-or-later](https://img.shields.io/github/license/isaacbentley/sdr-aaronia-rs.svg)](https://choosealicense.com/licenses/gpl-3.0/)
7
7
 
8
- A Rust library for interfacing with Aaronia Spectran SDR devices.
8
+ Unified Rust interface for Aaronia Spectran Spectrum Analyzers / SDRs, featuring Python bindings, a SoapySDR plugin, HTTP streaming, and native SDK support.
9
9
 
10
10
  *Disclaimer: This project is not affiliated with Aaronia AG. Aaronia, SPECTRAN, and RTSA-Suite PRO are trademarks of Aaronia AG.*
11
11
 
@@ -33,7 +33,7 @@ Interfacing with SDR hardware typically requires choosing between proprietary na
33
33
  - **Python Data-Science Native:** PyO3 bindings with single-copy reads into NumPy arrays and Apache Arrow buffers (one copy out of the Rust receive buffer per read).
34
34
  - **SDR Ecosystem Plugins:** a C++ `SoapySDR` plugin and a Rust-native `seify` backend.
35
35
 
36
- *Note: writes to the HTTP `/remoteconfig` endpoint require a separate Aaronia "Remote Config" license; capture control via `/control` (including retuning) does not.*
36
+ *Note: writes to the HTTP `/remoteconfig` endpoint require a separate Aaronia "Remote Config" license; capture control via `/control` (including retuning) does not. One server quirk to know: `/control` applies a frequency change only when `frequencyCenter` **and** `frequencySpan` are both present — a lone frequency field returns `{"success":true}` but is silently ignored. The crate always sends the complete tuple.*
37
37
 
38
38
  ## Installation
39
39
 
@@ -283,6 +283,16 @@ packet-metadata object, so `payload`, `minPower`, `maxPower`, and
283
283
  }
284
284
  ```
285
285
 
286
+ > **Both frequency fields are required for a retune to apply.** Live
287
+ > testing against RTSA-Suite PRO (HTTP server block fed by a SPECTRAN
288
+ > V6 ECO) shows the server returns `{"success":true}` for a capture
289
+ > `PUT` carrying only `frequencyCenter` or only `frequencySpan` but
290
+ > silently ignores it — the device keeps streaming at its previous
291
+ > tuning. Sending `frequencyCenter` **and** `frequencySpan` together
292
+ > applies reliably. `referenceLevel` on its own does apply. No license
293
+ > is involved: `/control` capture writes work without the Remote
294
+ > Config license.
295
+
286
296
  #### Start/Stop Antenna Autorotation
287
297
  ```json
288
298
  {
@@ -554,7 +564,7 @@ Data captured using antennas with location or directional information.
554
564
  The client therefore exposes two methods:
555
565
 
556
566
  - `detect_remote_config_license()` — **read-only**. Never touches device state. Classifies 401/403 responses; on read success it returns `Unknown` (write capability unproven).
557
- - `probe_remote_config_write_license()` — **active probe**. Performs a read-modify-restore cycle on `reflevel` (+1 dB, restored best-effort) to positively verify write capability. Use only when you need proof e.g. before frequency hopping, where an unlicensed retune silently no-ops server-side.
567
+ - `probe_remote_config_write_license()` — **active probe**. Performs a read-modify-restore cycle on `reflevel` (+1 dB, restored best-effort) to positively verify write capability. Use only when you genuinely need proof of `/remoteconfig` write access. Frequency hopping does **not** need it retuning goes through the license-free `/control` endpoint (see the capture-control note above; the silent-ignore behavior once attributed to licensing was traced to partial capture payloads).
558
568
 
559
569
  **Practical Detection (Active Probe)**:
560
570
  ```rust
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "python-aaronia"
3
- version = "0.5.0"
3
+ version = "0.5.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"
@@ -13,11 +13,16 @@
13
13
  //! timeout, shutdown) releases the GIL, so other Python threads keep
14
14
  //! running and `KeyboardInterrupt` stays deliverable between calls.
15
15
 
16
- // The two allows: pyo3 0.22's proc-macros expand to code that trips
17
- // edition-2024's unsafe_op_in_unsafe_fn and reference the `gil-refs`
18
- // cfg; both are fixed upstream in pyo3 0.23 — drop these when bumping.
16
+ // These allows are all pyo3 0.22 proc-macro fallout, fixed upstream in
17
+ // pyo3 0.23 drop them when bumping: the expansions trip
18
+ // edition-2024's unsafe_op_in_unsafe_fn, reference the `gil-refs` cfg,
19
+ // and route #[pymethods] `PyResult` returns through an `.into()` in
20
+ // generated wrapper fns that clippy ≥ 1.89 flags as useless_conversion
21
+ // (attributed to the method signature; an impl-level allow doesn't
22
+ // reach the wrappers, hence crate-level).
19
23
  #![allow(unsafe_op_in_unsafe_fn)]
20
24
  #![allow(unexpected_cfgs)]
25
+ #![allow(clippy::useless_conversion)]
21
26
 
22
27
  use arrow::array::{Array, FixedSizeListArray};
23
28
  use arrow::datatypes::{DataType, Field};
@@ -244,6 +249,12 @@ impl PyAaroniaConfig {
244
249
  }
245
250
  }
246
251
 
252
+ /// The `(rx1, rx2)` pair returned by `read_samples_dual_numpy`.
253
+ type DualArrays<'py> = (
254
+ Bound<'py, PyArray1<Complex32>>,
255
+ Bound<'py, PyArray1<Complex32>>,
256
+ );
257
+
247
258
  /// A streaming IQ source. Construct, `start_streaming(config)`, then
248
259
  /// call the `read_samples_*` methods; each blocks (GIL released) until
249
260
  /// `count` samples arrive or the internal 30-second timeout raises
@@ -404,10 +415,7 @@ impl PyAaroniaSource {
404
415
  &mut self,
405
416
  py: Python<'py>,
406
417
  count: usize,
407
- ) -> PyResult<(
408
- Bound<'py, PyArray1<Complex32>>,
409
- Bound<'py, PyArray1<Complex32>>,
410
- )> {
418
+ ) -> PyResult<DualArrays<'py>> {
411
419
  if count > MAX_READ_SAMPLES {
412
420
  return Err(PyValueError::new_err(format!(
413
421
  "count {count} exceeds the per-read limit of {MAX_READ_SAMPLES} samples"
@@ -73,8 +73,9 @@ if ! skipped fmt; then
73
73
  fi
74
74
 
75
75
  if ! skipped clippy; then
76
- step "cargo clippy --all-features --all-targets -- -D warnings"
77
- cargo clippy --all-features --all-targets -- -D warnings
76
+ # --workspace pulls in python-aaronia, which no other step lints.
77
+ step "cargo clippy --workspace --all-features --all-targets -- -D warnings"
78
+ cargo clippy --workspace --all-features --all-targets -- -D warnings
78
79
  fi
79
80
 
80
81
  if ! skipped test; then
@@ -139,7 +140,7 @@ fi
139
140
  # (~/Documents ⇒ EDEADLK), hence the rsync to /private/tmp first.
140
141
  if ! skipped vm; then
141
142
  if container system status >/dev/null 2>&1; then
142
- step "CI ubuntu leg in Linux VM: clippy --all-features --all-targets + cargo test --all-features"
143
+ step "CI ubuntu leg in Linux VM: clippy --workspace --all-features --all-targets + cargo test --all-features"
143
144
  # Dedicated directory: nothing else may write here (a shared
144
145
  # scratch copy once had another tool rsync over it mid-run).
145
146
  vm_dir=/private/tmp/aaronia-ci-vm
@@ -149,17 +150,22 @@ if ! skipped vm; then
149
150
  # stale call signature shipped red to CI past a --lib-only VM
150
151
  # run. This mirrors CI's ubuntu leg command-for-command.
151
152
  # rust:slim lacks what the GitHub ubuntu runner image ships
152
- # preinstalled: clippy, pkg-config, and ALSA headers (futuresdr's
153
- # audio feature -> alsa-sys). Install per run; the persistent
154
- # target/ under $vm_dir keeps rebuilds incremental.
153
+ # preinstalled: clippy, pkg-config, ALSA headers (futuresdr's
154
+ # audio feature -> alsa-sys), and python3 (pyo3's build script
155
+ # needs an interpreter for the --workspace clippy leg). Install
156
+ # per run; the persistent target/ under $vm_dir keeps rebuilds
157
+ # incremental.
155
158
  # --memory 8g: the all-features lib-test link OOM-kills the
156
159
  # container CLI's default allocation (ld dies with SIGKILL).
160
+ # clippy is --workspace (mirrors CI); test stays root-crate —
161
+ # python-aaronia's Python-side behavior is covered by the
162
+ # python step below and CI's maturin+pytest job.
157
163
  container run --rm --memory 8g --cpus 4 \
158
164
  -v "$vm_dir":/w -w /w -e PROPTEST_CASES="$PROPTEST_CASES" rust:slim \
159
165
  sh -c 'rustup component add clippy >/dev/null 2>&1; \
160
166
  apt-get update -qq >/dev/null && \
161
- apt-get install -y -qq pkg-config libasound2-dev >/dev/null; \
162
- cargo clippy --all-features --all-targets -- -D warnings \
167
+ apt-get install -y -qq pkg-config libasound2-dev python3 >/dev/null; \
168
+ cargo clippy --workspace --all-features --all-targets -- -D warnings \
163
169
  && cargo test --all-features'
164
170
  else
165
171
  echo "vm step: 'container system start' not running — skipping" \
@@ -57,7 +57,11 @@ sdr = SoapySDR.Device("driver=aaronia,url=http://atc.local:54664,format=I16")
57
57
  device arg changes what crosses the network.
58
58
  - `readStream` honours `timeoutUs` and returns partial reads within the
59
59
  deadline, per the SoapySDR contract. Retuning while streaming is safe
60
- (fully serialized against the reader).
60
+ (fully serialized against the reader) and needs no Aaronia license:
61
+ the plugin retunes through the RTSA `/control` endpoint, always
62
+ sending center frequency and span together — RTSA servers silently
63
+ ignore capture requests that carry only one of the two (live-verified
64
+ against RTSA-Suite PRO with a SPECTRAN V6 ECO).
61
65
  - **TX:** `CF32`, single channel, available only when the module is
62
66
  built against the native SDK on Windows/Linux — elsewhere
63
67
  `setupStream(TX)` fails with a descriptive error. Bursts are pushed
@@ -78,8 +82,5 @@ sdr = SoapySDR.Device("driver=aaronia,url=http://atc.local:54664,format=I16")
78
82
  - One RX channel through the plugin (`rx_channel=Rx2` selects the second
79
83
  antenna input; true dual-channel reads are available via the crate's
80
84
  Rust/Python/C APIs, not the Soapy streaming interface).
81
- - Without Aaronia's Remote Config license, the RTSA server accepts
82
- retune requests over HTTP but may silently ignore them — the device
83
- keeps streaming at the mission's configured frequency.
84
85
  - Enumeration advertises a default localhost candidate without probing
85
86
  it (`find()` must not block on the network).
@@ -917,7 +917,27 @@ impl HttpEndpointsClient {
917
917
  }
918
918
 
919
919
  /// Configures capture parameters via the `/control` endpoint.
920
+ ///
921
+ /// **Frequency changes need `frequencyCenter` and `frequencySpan`
922
+ /// together.** RTSA servers return `{"success":true}` for a capture
923
+ /// `PUT` that carries only one of the two frequency fields but
924
+ /// silently ignore it; the retune applies only when both are present
925
+ /// (verified live against RTSA-Suite PRO driving a SPECTRAN V6 ECO —
926
+ /// center-only and span-only PUTs each no-opped, center+span
927
+ /// applied). `referenceLevel` does apply on its own. A lone frequency
928
+ /// field logs a warning here and is still sent, since other server
929
+ /// versions may accept it.
920
930
  pub async fn configure_capture(&self, config: CaptureControl) -> Result<()> {
931
+ let has_range = config.frequency_start.is_some() && config.frequency_end.is_some();
932
+ if !has_range && (config.frequency_center.is_some() != config.frequency_span.is_some()) {
933
+ warn!(
934
+ "Partial capture PUT ({:?}): RTSA servers are known to accept a lone \
935
+ frequencyCenter/frequencySpan with success=true but silently ignore it; \
936
+ include both fields for the retune to apply",
937
+ config
938
+ );
939
+ }
940
+
921
941
  let url = format!("{}/control", self.base_url);
922
942
  let request = self.control_request(self.client.put(&url)).json(&config);
923
943
  Self::ensure_success("Capture configuration", request.send().await?)?;
@@ -1060,9 +1080,12 @@ impl HttpEndpointsClient {
1060
1080
  /// `reflevel` parameter by +1 dB and restores it best-effort. If the
1061
1081
  /// restore fails (network drop mid-probe), the device is left with the
1062
1082
  /// adjusted reference level. Only call this when you genuinely need
1063
- /// proof of write capability — e.g. before frequency-hopping, where an
1064
- /// unlicensed `configure_capture` silently no-ops server-side and
1065
- /// mis-tags every downstream packet.
1083
+ /// proof of `/remoteconfig` write capability. Note that retuning does
1084
+ /// **not** — [`Self::configure_capture`] goes through the license-free
1085
+ /// `/control` endpoint. (An earlier revision justified this probe by
1086
+ /// claiming unlicensed `configure_capture` calls silently no-op; live
1087
+ /// testing traced that behavior to partial capture payloads, not
1088
+ /// licensing.)
1066
1089
  ///
1067
1090
  /// # Returns
1068
1091
  /// - `RemoteConfigStatus::Active` - License is active, write operations work
@@ -1012,11 +1012,20 @@ impl AaroniaSource {
1012
1012
 
1013
1013
  /// Retune the source to a new centre frequency without rebuilding it.
1014
1014
  ///
1015
- /// - **HTTP**: wraps `HttpEndpointsClient::configure_capture(frequency_center=freq)`,
1016
- /// a `PUT` to the license-free `/control` endpoint. The RTSA-Suite
1017
- /// "Remote Config" license gates the `/remoteconfig` write path, not
1018
- /// this one; callers that need a `/remoteconfig` write-license check
1019
- /// should call [`Self::probe_remote_config_license`] separately.
1015
+ /// - **HTTP**: wraps `HttpEndpointsClient::configure_capture`, a `PUT`
1016
+ /// to the license-free `/control` endpoint, always sending the
1017
+ /// complete capture tuple (centre, span, reference level) with the
1018
+ /// unchanged values taken from the cached config. The full tuple is
1019
+ /// load-bearing: RTSA servers return `{"success":true}` for a
1020
+ /// capture `PUT` that carries only one of the two frequency fields
1021
+ /// but silently ignore it — the retune applies only when
1022
+ /// `frequencyCenter` and `frequencySpan` are both present (verified
1023
+ /// live against RTSA-Suite PRO driving a SPECTRAN V6 ECO; the
1024
+ /// reference level, by contrast, does apply on its own). The
1025
+ /// RTSA-Suite "Remote Config" license gates
1026
+ /// the `/remoteconfig` write path, not this one; callers that need
1027
+ /// a `/remoteconfig` write-license check should call
1028
+ /// [`Self::probe_remote_config_license`] separately.
1020
1029
  /// - **Native SDK**: re-issues `configure_iq_receiver` with the new
1021
1030
  /// centre frequency, the existing span, and the existing reference
1022
1031
  /// level. The SDK config system applies the change to the open device
@@ -1059,9 +1068,14 @@ impl AaroniaSource {
1059
1068
  .http_client
1060
1069
  .as_ref()
1061
1070
  .ok_or_else(|| Error::Config("HTTP client not initialized".to_string()))?;
1071
+ // Full tuple, not just the changed field: the server
1072
+ // ignores capture PUTs that lack frequencySpan (see the
1073
+ // doc comment above).
1062
1074
  client
1063
1075
  .configure_capture(crate::http_endpoints::CaptureControl {
1064
1076
  frequency_center: Some(freq),
1077
+ frequency_span: Some(self.config.span_frequency),
1078
+ reference_level: Some(self.config.reference_level as f32),
1065
1079
  control_type: crate::http_endpoints::ControlType::Capture,
1066
1080
  ..Default::default()
1067
1081
  })
@@ -1118,9 +1132,13 @@ impl AaroniaSource {
1118
1132
  .http_client
1119
1133
  .as_ref()
1120
1134
  .ok_or_else(|| Error::Config("HTTP client not initialized".to_string()))?;
1135
+ // Full tuple: partial capture PUTs are silently ignored
1136
+ // by the server (see `set_center_frequency`).
1121
1137
  client
1122
1138
  .configure_capture(crate::http_endpoints::CaptureControl {
1139
+ frequency_center: Some(self.config.center_frequency),
1123
1140
  frequency_span: Some(span),
1141
+ reference_level: Some(self.config.reference_level as f32),
1124
1142
  control_type: crate::http_endpoints::ControlType::Capture,
1125
1143
  ..Default::default()
1126
1144
  })
@@ -1171,8 +1189,12 @@ impl AaroniaSource {
1171
1189
  .http_client
1172
1190
  .as_ref()
1173
1191
  .ok_or_else(|| Error::Config("HTTP client not initialized".to_string()))?;
1192
+ // Full tuple: partial capture PUTs are silently ignored
1193
+ // by the server (see `set_center_frequency`).
1174
1194
  client
1175
1195
  .configure_capture(crate::http_endpoints::CaptureControl {
1196
+ frequency_center: Some(self.config.center_frequency),
1197
+ frequency_span: Some(self.config.span_frequency),
1176
1198
  reference_level: Some(ref_level as f32),
1177
1199
  control_type: crate::http_endpoints::ControlType::Capture,
1178
1200
  ..Default::default()
@@ -1189,11 +1211,13 @@ impl AaroniaSource {
1189
1211
 
1190
1212
  /// Probe the RTSA-Suite Remote Config license status, returning
1191
1213
  /// [`crate::http_endpoints::RemoteConfigStatus::Active`] for non-HTTP
1192
- /// sources (they don't need it). Hopping orchestrators should check
1193
- /// this before relying on mid-stream `set_center_frequency` — without
1194
- /// the license, `configure_capture` returns HTTP 200 OK server-side
1195
- /// but silently ignores the frequency change, which would otherwise
1196
- /// cause downstream packets to be mis-tagged with the wrong channel.
1214
+ /// sources (they don't need it). This concerns **`/remoteconfig`
1215
+ /// writes only** mid-stream retuning via [`Self::set_center_frequency`]
1216
+ /// goes through the license-free `/control` endpoint and does not
1217
+ /// need this probe. (An earlier revision claimed unlicensed
1218
+ /// `configure_capture` calls were silently ignored; live testing
1219
+ /// showed the silent ignore was caused by partial capture payloads,
1220
+ /// not licensing — see `set_center_frequency`.)
1197
1221
  ///
1198
1222
  /// **On HTTP sources this is an active probe that temporarily
1199
1223
  /// perturbs device state**: it adjusts the reference level by +1 dB
@@ -13,7 +13,8 @@
13
13
 
14
14
  use futures::stream::StreamExt;
15
15
  use sdr_aaronia_rs::http_endpoints::{
16
- AuthMethod, HttpEndpointsClient, InputProcessingType, StreamParams, TxSampleRequest,
16
+ AuthMethod, CaptureControl, ControlType, HttpEndpointsClient, InputProcessingType,
17
+ StreamParams, TxSampleRequest,
17
18
  };
18
19
  use sdr_aaronia_rs::http_streaming::{DropDetector, PayloadType, StreamFormat};
19
20
  use std::time::{Duration, Instant};
@@ -594,6 +595,63 @@ fn live_stream_seify() {
594
595
  streamer.deactivate_at(None).expect("deactivate");
595
596
  }
596
597
 
598
+ /// The `/control` capture endpoint applies a frequency change only when
599
+ /// `frequencyCenter` and `frequencySpan` travel together — a lone
600
+ /// frequency field returns `{"success":true}` but is silently ignored
601
+ /// (no license involved). This proves the crate's full-tuple retune path
602
+ /// actually moves the device, then restores the original tuning.
603
+ #[tokio::test]
604
+ #[ignore = "requires live RTSA-Suite PRO at AARONIA_LIVE_URL / atc.local:54664"]
605
+ async fn live_retune_full_tuple_applies() {
606
+ let c = client();
607
+
608
+ let before = c.get_sample(None).await.expect("/sample baseline");
609
+ let center0 = (before.start_frequency + before.end_frequency) / 2.0;
610
+ let span0 = before.end_frequency - before.start_frequency;
611
+ println!(
612
+ "baseline: center={:.3} MHz span={:.3} MHz",
613
+ center0 / 1e6,
614
+ span0 / 1e6
615
+ );
616
+
617
+ // Two span-widths down: unambiguous vs. the baseline, and still
618
+ // comfortably inside the V6 tuning range for any plausible mission.
619
+ let target = center0 - 2.0 * span0;
620
+ let retune = |center: f64| CaptureControl {
621
+ frequency_center: Some(center),
622
+ frequency_span: Some(span0),
623
+ control_type: ControlType::Capture,
624
+ ..Default::default()
625
+ };
626
+
627
+ c.configure_capture(retune(target))
628
+ .await
629
+ .expect("retune PUT");
630
+
631
+ let deadline = Instant::now() + Duration::from_secs(10);
632
+ let mut applied = false;
633
+ while Instant::now() < deadline {
634
+ let s = c.get_sample(None).await.expect("/sample poll");
635
+ let center = (s.start_frequency + s.end_frequency) / 2.0;
636
+ if (center - target).abs() < span0 / 2.0 {
637
+ applied = true;
638
+ break;
639
+ }
640
+ tokio::time::sleep(Duration::from_millis(250)).await;
641
+ }
642
+
643
+ // Restore before asserting so a failed assertion doesn't leave the
644
+ // device parked off-mission.
645
+ c.configure_capture(retune(center0))
646
+ .await
647
+ .expect("restore PUT");
648
+
649
+ assert!(
650
+ applied,
651
+ "full-tuple /control retune to {target} Hz never reflected in /sample"
652
+ );
653
+ }
654
+
597
655
  // NOTE: there is intentionally no Rust-side SoapySDR live test here.
598
656
  // A `soapysdr` dev-dependency makes `cargo test` unbuildable on any
599
657
  // machine without system SoapySDR + libclang. Equivalent live coverage:
@@ -56,14 +56,20 @@ async fn wait_for_request(
56
56
  }
57
57
 
58
58
  /// `wait_for_request` predicate: a `/control` PUT whose JSON body retunes
59
- /// `frequencyCenter` to `freq` (within 1 Hz).
59
+ /// `frequencyCenter` to `freq` (within 1 Hz) *and* carries
60
+ /// `frequencySpan`. The span requirement is a regression guard: RTSA
61
+ /// servers silently ignore capture PUTs with a lone frequency field, so
62
+ /// a center-only PUT is not a retune (live-verified; see
63
+ /// `configure_capture`).
60
64
  fn control_put_tuning_to(freq: f64) -> impl Fn(&wiremock::Request) -> bool {
61
65
  move |r: &wiremock::Request| {
62
66
  r.url.path() == "/control"
63
- && r.body_json::<serde_json::Value>()
64
- .ok()
65
- .and_then(|v| v.get("frequencyCenter").and_then(|f| f.as_f64()))
66
- .is_some_and(|f| (f - freq).abs() < 1.0)
67
+ && r.body_json::<serde_json::Value>().ok().is_some_and(|v| {
68
+ v.get("frequencySpan").and_then(|s| s.as_f64()).is_some()
69
+ && v.get("frequencyCenter")
70
+ .and_then(|f| f.as_f64())
71
+ .is_some_and(|f| (f - freq).abs() < 1.0)
72
+ })
67
73
  }
68
74
  }
69
75
 
File without changes
File without changes