python-aaronia 0.6.1__tar.gz → 0.6.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 (79) hide show
  1. {python_aaronia-0.6.1 → python_aaronia-0.6.2}/CHANGELOG.md +34 -0
  2. {python_aaronia-0.6.1 → python_aaronia-0.6.2}/Cargo.lock +2 -2
  3. {python_aaronia-0.6.1 → python_aaronia-0.6.2}/Cargo.toml +1 -1
  4. {python_aaronia-0.6.1 → python_aaronia-0.6.2}/PKG-INFO +1 -1
  5. {python_aaronia-0.6.1 → python_aaronia-0.6.2}/docs/HTTPSPEC.md +38 -0
  6. {python_aaronia-0.6.1 → python_aaronia-0.6.2}/docs/SDKSPEC.md +58 -0
  7. {python_aaronia-0.6.1 → python_aaronia-0.6.2}/docs/VERIFICATION.md +13 -1
  8. {python_aaronia-0.6.1 → python_aaronia-0.6.2}/python-aaronia/Cargo.toml +1 -1
  9. {python_aaronia-0.6.1 → python_aaronia-0.6.2}/src/lib.rs +2 -2
  10. {python_aaronia-0.6.1 → python_aaronia-0.6.2}/src/native_sdk.rs +236 -10
  11. {python_aaronia-0.6.1 → python_aaronia-0.6.2}/src/unified_sink.rs +5 -0
  12. {python_aaronia-0.6.1 → python_aaronia-0.6.2}/src/utils.rs +64 -9
  13. {python_aaronia-0.6.1 → python_aaronia-0.6.2}/.cargo/config.toml +0 -0
  14. {python_aaronia-0.6.1 → python_aaronia-0.6.2}/.gitattributes +0 -0
  15. {python_aaronia-0.6.1 → python_aaronia-0.6.2}/.gitignore +0 -0
  16. {python_aaronia-0.6.1 → python_aaronia-0.6.2}/CONTRIBUTING.md +0 -0
  17. {python_aaronia-0.6.1 → python_aaronia-0.6.2}/DESIGN.md +0 -0
  18. {python_aaronia-0.6.1 → python_aaronia-0.6.2}/LICENSE +0 -0
  19. {python_aaronia-0.6.1 → python_aaronia-0.6.2}/PLUGINS.md +0 -0
  20. {python_aaronia-0.6.1 → python_aaronia-0.6.2}/README.md +0 -0
  21. {python_aaronia-0.6.1 → python_aaronia-0.6.2}/benches/decompress_block.rs +0 -0
  22. {python_aaronia-0.6.1 → python_aaronia-0.6.2}/benches/deinterleave_dual_iq.rs +0 -0
  23. {python_aaronia-0.6.1 → python_aaronia-0.6.2}/benches/parse_int16_packet.rs +0 -0
  24. {python_aaronia-0.6.1 → python_aaronia-0.6.2}/benches/rtsa_open_and_read.rs +0 -0
  25. {python_aaronia-0.6.1 → python_aaronia-0.6.2}/deny.toml +0 -0
  26. {python_aaronia-0.6.1 → python_aaronia-0.6.2}/docs/APPS.md +0 -0
  27. {python_aaronia-0.6.1 → python_aaronia-0.6.2}/docs/FILESPEC.md +0 -0
  28. {python_aaronia-0.6.1 → python_aaronia-0.6.2}/docs/QUICKSTART.md +0 -0
  29. {python_aaronia-0.6.1 → python_aaronia-0.6.2}/docs/USAGE.md +0 -0
  30. {python_aaronia-0.6.1 → python_aaronia-0.6.2}/examples/channel_hopping.rs +0 -0
  31. {python_aaronia-0.6.1 → python_aaronia-0.6.2}/examples/device_control.rs +0 -0
  32. {python_aaronia-0.6.1 → python_aaronia-0.6.2}/examples/dump_metadata.rs +0 -0
  33. {python_aaronia-0.6.1 → python_aaronia-0.6.2}/examples/http_iq_quickstart.rs +0 -0
  34. {python_aaronia-0.6.1 → python_aaronia-0.6.2}/examples/native_sdk_basic.rs +0 -0
  35. {python_aaronia-0.6.1 → python_aaronia-0.6.2}/examples/native_sdk_transmit.rs +0 -0
  36. {python_aaronia-0.6.1 → python_aaronia-0.6.2}/examples/noaa_scanner.rs +0 -0
  37. {python_aaronia-0.6.1 → python_aaronia-0.6.2}/examples/python_arrow_example.py +0 -0
  38. {python_aaronia-0.6.1 → python_aaronia-0.6.2}/examples/read_rtsa_file.rs +0 -0
  39. {python_aaronia-0.6.1 → python_aaronia-0.6.2}/examples/soapy_python_example.py +0 -0
  40. {python_aaronia-0.6.1 → python_aaronia-0.6.2}/include/aaronia.h +0 -0
  41. {python_aaronia-0.6.1 → python_aaronia-0.6.2}/pyproject.toml +0 -0
  42. {python_aaronia-0.6.1 → python_aaronia-0.6.2}/python-aaronia/README.md +0 -0
  43. {python_aaronia-0.6.1 → python_aaronia-0.6.2}/python-aaronia/aaronia.pyi +0 -0
  44. {python_aaronia-0.6.1 → python_aaronia-0.6.2}/python-aaronia/src/lib.rs +0 -0
  45. {python_aaronia-0.6.1 → python_aaronia-0.6.2}/python-aaronia/test_basic.py +0 -0
  46. {python_aaronia-0.6.1 → python_aaronia-0.6.2}/scripts/ci-local.sh +0 -0
  47. {python_aaronia-0.6.1 → python_aaronia-0.6.2}/soapy-aaronia/AaroniaSoapyDevice.cpp +0 -0
  48. {python_aaronia-0.6.1 → python_aaronia-0.6.2}/soapy-aaronia/AaroniaSoapyDevice.hpp +0 -0
  49. {python_aaronia-0.6.1 → python_aaronia-0.6.2}/soapy-aaronia/CMakeLists.txt +0 -0
  50. {python_aaronia-0.6.1 → python_aaronia-0.6.2}/soapy-aaronia/README.md +0 -0
  51. {python_aaronia-0.6.1 → python_aaronia-0.6.2}/soapy-aaronia/Registration.cpp +0 -0
  52. {python_aaronia-0.6.1 → python_aaronia-0.6.2}/src/c_api.rs +0 -0
  53. {python_aaronia-0.6.1 → python_aaronia-0.6.2}/src/decompression.rs +0 -0
  54. {python_aaronia-0.6.1 → python_aaronia-0.6.2}/src/detection.rs +0 -0
  55. {python_aaronia-0.6.1 → python_aaronia-0.6.2}/src/error.rs +0 -0
  56. {python_aaronia-0.6.1 → python_aaronia-0.6.2}/src/file_source.rs +0 -0
  57. {python_aaronia-0.6.1 → python_aaronia-0.6.2}/src/http_endpoints.rs +0 -0
  58. {python_aaronia-0.6.1 → python_aaronia-0.6.2}/src/http_sink.rs +0 -0
  59. {python_aaronia-0.6.1 → python_aaronia-0.6.2}/src/http_source.rs +0 -0
  60. {python_aaronia-0.6.1 → python_aaronia-0.6.2}/src/http_streaming.rs +0 -0
  61. {python_aaronia-0.6.1 → python_aaronia-0.6.2}/src/sdk_sink.rs +0 -0
  62. {python_aaronia-0.6.1 → python_aaronia-0.6.2}/src/sdk_source.rs +0 -0
  63. {python_aaronia-0.6.1 → python_aaronia-0.6.2}/src/sdr_source_impl.rs +0 -0
  64. {python_aaronia-0.6.1 → python_aaronia-0.6.2}/src/seify_impl.rs +0 -0
  65. {python_aaronia-0.6.1 → python_aaronia-0.6.2}/src/unified_source.rs +0 -0
  66. {python_aaronia-0.6.1 → python_aaronia-0.6.2}/tests/c_api_test.rs +0 -0
  67. {python_aaronia-0.6.1 → python_aaronia-0.6.2}/tests/http_mock_test.rs +0 -0
  68. {python_aaronia-0.6.1 → python_aaronia-0.6.2}/tests/http_resilience_test.rs +0 -0
  69. {python_aaronia-0.6.1 → python_aaronia-0.6.2}/tests/http_sink_test.rs +0 -0
  70. {python_aaronia-0.6.1 → python_aaronia-0.6.2}/tests/integration_test.rs +0 -0
  71. {python_aaronia-0.6.1 → python_aaronia-0.6.2}/tests/live_smoke.rs +0 -0
  72. {python_aaronia-0.6.1 → python_aaronia-0.6.2}/tests/native_sdk_load.rs +0 -0
  73. {python_aaronia-0.6.1 → python_aaronia-0.6.2}/tests/properties.proptest-regressions +0 -0
  74. {python_aaronia-0.6.1 → python_aaronia-0.6.2}/tests/properties.rs +0 -0
  75. {python_aaronia-0.6.1 → python_aaronia-0.6.2}/tests/rtsa_negative_test.rs +0 -0
  76. {python_aaronia-0.6.1 → python_aaronia-0.6.2}/tests/sdr_source_impl_test.rs +0 -0
  77. {python_aaronia-0.6.1 → python_aaronia-0.6.2}/tests/spec_coverage.rs +0 -0
  78. {python_aaronia-0.6.1 → python_aaronia-0.6.2}/tests/test_cw_mag.rs +0 -0
  79. {python_aaronia-0.6.1 → python_aaronia-0.6.2}/tests/test_cw_meta.rs +0 -0
@@ -4,6 +4,8 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [v0.6.2] - 2026-08-12
8
+
7
9
  ## [v0.6.1] - 2026-08-12
8
10
 
9
11
  ### Fixed
@@ -30,8 +32,40 @@ All notable changes to this project will be documented in this file.
30
32
  guesses, so the relationships now live in one tested place. Wanting
31
33
  8 MHz of spectrum needs 10 MHz of sampling, and
32
34
  `iq_sample_rate_for_bandwidth` returns the 15.36 MHz that provides it.
35
+ `iq_sample_rates_for_clock` covers devices whose receiver clock is not
36
+ the default: a V6 ECO has a fixed clock and gives the measured ladder,
37
+ while a full V6 can select a faster one and reach further. Aaronia's
38
+ samples set `device/receiverclock` to "92MHz" or "245MHz"; only the
39
+ default has been checked against hardware.
40
+
41
+ ### Added (native SDK)
42
+ - **Device-family auto-detection.** `detect_device_family` and
43
+ `open_detected_device` try each known family in turn, so an ECO owner
44
+ no longer has to know that the default `spectranv6` will not find
45
+ their device and that `spectranv6eco` is the string they needed.
46
+ - **`read_spectra`**, with the stream index taken from the open mode
47
+ rather than assumed. `spectranv6/raw` carries spectra on stream 2 and
48
+ IQ on stream 0; every other mode uses stream 0. Hardware-unverified.
49
+ - **`receiver_clock_hz`** on the native source, and
50
+ `spectranv6eco/rtsa` added to the known open modes. The clock sets the
51
+ rate ladder's ceiling, so callers that need to know which rates exist
52
+ can now ask instead of assuming.
33
53
 
34
54
  ### Fixed (native SDK)
55
+ - **The V6 ECO's fixed receiver clock was recorded as 61.44 MHz.** It is
56
+ 92.16 MHz: an ECO streams at 61.44 MHz sampling, measured against real
57
+ hardware, and the constraint checked at configuration time is
58
+ `span * 1.5 <= clock`. The old value rejected every span above
59
+ 40.96 MHz, including the device's own maximum.
60
+ - **Dual-channel capture selected the wrong mode and would have
61
+ returned corrupted samples.** `RxChannel::Rx1And2` wrote
62
+ `device/receiverchannel = "Rx1+Rx2"`, which delivers the two inputs as
63
+ two independent streams at indices 0 and 1. This crate reads a single
64
+ stream and deinterleaves it, which is the contract of the other mode,
65
+ `"Rx12"`. On a two-input V6 the result would have been Rx1's samples
66
+ split into two bogus channels, with no error anywhere. It now writes
67
+ `"Rx12"`. Aaronia's `RawIQ2RX` and `RawIQ2RXInterleave` samples
68
+ demonstrate one mode each. Still hardware-unverified.
35
69
  - **Sweep mode set the wrong resolution-bandwidth key.** It sent
36
70
  `main/rbw`, which no Aaronia sample uses; the key is `main/rbwfreq`.
37
71
  Checked against Aaronia's published `SweepSpectrumEco` sample, which
@@ -3027,7 +3027,7 @@ dependencies = [
3027
3027
 
3028
3028
  [[package]]
3029
3029
  name = "python-aaronia"
3030
- version = "0.6.1"
3030
+ version = "0.6.2"
3031
3031
  dependencies = [
3032
3032
  "arrow",
3033
3033
  "num-complex",
@@ -3556,7 +3556,7 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
3556
3556
 
3557
3557
  [[package]]
3558
3558
  name = "sdr-aaronia-rs"
3559
- version = "0.6.1"
3559
+ version = "0.6.2"
3560
3560
  dependencies = [
3561
3561
  "anyhow",
3562
3562
  "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.6.1"
5
+ version = "0.6.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.6.1
3
+ Version: 0.6.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+)
@@ -590,6 +590,44 @@ Data captured using antennas with location or directional information.
590
590
  | `azimuth` | Azimuth of directional antenna |
591
591
  | `declination` | Declination of directional antenna |
592
592
 
593
+ ## What the native SDK samples tell us about this API
594
+
595
+ Aaronia's [C++ SDK samples](https://github.com/Aaronia-Open-source/RTSA-API-Samples)
596
+ drive the same hardware through a different transport, so some of what
597
+ they establish applies here and some does not.
598
+
599
+ ### GPS needs enabling, over HTTP too
600
+
601
+ The `device/gpsmode` and `device/sclksource` keys the `GPSTime` sample
602
+ writes are the same keys this config tree exposes, so the prerequisite
603
+ is identical. A device ships with `gpsmode` set to `Disabled` and takes
604
+ its stream clock from whatever `sclksource` names, which on the system
605
+ tested here was `10MHz` rather than GPS. Timestamps are not
606
+ GPS-disciplined until both change, and nothing reports an error
607
+ meanwhile.
608
+
609
+ Observed on a V6 ECO: `gpsmode` offers `Disabled`, `Location`, `Time`
610
+ and `Location and Time`; `sclksource` offers `Consumer`, `Oscillator`,
611
+ `GPS`, `PPS`, `10MHz` and three `... Provider` variants.
612
+
613
+ ### The rate ladder carries over
614
+
615
+ `decimation0` takes the same labels as the SDK's `main/decimation`,
616
+ `"Full"` through `"1 / 512"`, verified by writing one over HTTP. Both
617
+ transports drive the same divider.
618
+
619
+ ### Two things that do not carry over
620
+
621
+ - **Receiver-channel selection.** There is no `device/receiverchannel`
622
+ here. The tree exposes per-channel settings instead — `centerfreq0`
623
+ and `centerfreq1`, `decimation0` and `decimation1`, `rfchsource0` and
624
+ `rfchsource1` — and which channels reach the stream is a property of
625
+ the RTSA mission graph. The SDK's `Rx12` versus `Rx1+Rx2` distinction
626
+ has no equivalent.
627
+ - **Stream indices.** The SDK separates IQ from spectra by packet stream
628
+ index. HTTP selects data with the `input=` parameter and reports the
629
+ kind in each packet's `payload` field.
630
+
593
631
  ## Authorization & Licensing
594
632
 
595
633
  ### HTTP Streaming vs Remote Configuration
@@ -876,3 +876,61 @@ the Aaronia Software License Agreement governs use of the SDK itself (see
876
876
  [Licensing Considerations](#licensing-considerations)); this community document
877
877
  describes only the public interface. "Aaronia", "RTSA", and "Spectran" are the
878
878
  property of Aaronia AG.
879
+
880
+ ## Facts taken from Aaronia's published samples
881
+
882
+ The following come from
883
+ [Aaronia-Open-source/RTSA-API-Samples](https://github.com/Aaronia-Open-source/RTSA-API-Samples),
884
+ read in full. They are vendor code rather than vendor documentation, but
885
+ they are the closest thing to an authoritative statement of how the API
886
+ is meant to be driven, and this crate's native-SDK paths cannot be
887
+ tested here.
888
+
889
+ ### Both receivers, two different modes
890
+
891
+ `device/receiverchannel` takes four values, and the last two are not
892
+ interchangeable:
893
+
894
+ | Value | Delivery |
895
+ | --- | --- |
896
+ | `Rx1`, `Rx2` | One input, one stream |
897
+ | `Rx12` | Both inputs interleaved into **one** stream: four floats per sample, `[I0, Q0, I1, Q1]`, read from stream 0 |
898
+ | `Rx1+Rx2` | Both inputs as **two independent streams**, fetched and consumed separately at indices 0 and 1 |
899
+
900
+ `RawIQ2RXInterleave` uses the first, `RawIQ2RX` the second. This crate
901
+ reads a single stream and deinterleaves it, so it writes `Rx12`.
902
+
903
+ ### V6 against V6 ECO
904
+
905
+ | | Full V6 | V6 ECO |
906
+ | --- | --- | --- |
907
+ | Family string | `spectranv6` | `spectranv6eco` |
908
+ | `device/receiverchannel` | Set explicitly | Never set: one receiver |
909
+ | `device/receiverclock` | Set, `"92MHz"` or `"245MHz"` | Never set: fixed |
910
+ | Spectrum packets | Stream index 2 | Stream index 0 |
911
+ | Raw-mode open string | `spectranv6/raw` | `spectranv6eco/rtsa` |
912
+
913
+ The clock matters beyond configuration: with `span * 1.5 <=
914
+ receiverclock`, a V6 on the fast clock reaches roughly 163 MHz of span
915
+ where the ECO's fixed clock allows 61.44 MHz.
916
+
917
+ ### Sample rates
918
+
919
+ `main/decimation` accepts either the label (`"1 / 64"`) or the index
920
+ (`6`). Combined with the clock, that gives the rate ladder: the top rate
921
+ is `receiverclock / 1.5` and each step halves it.
922
+
923
+ ### Transmitting
924
+
925
+ The samples flag the first packet `SEGMENT_START | STREAM_START`, the
926
+ last `SEGMENT_END | STREAM_END`, and everything between `0`. The
927
+ transceiver samples additionally send a zero-length packet carrying only
928
+ `STREAM_START`, timestamped at the master stream clock, before real
929
+ data, to improve startup synchronisation.
930
+
931
+ ### Keys this crate does not use
932
+
933
+ `device/outputformat` (`"iq"` or `"spectra"`), `main/demodcenterfreq`
934
+ and `main/demodspanfreq`, `main/centerfreqtx` and `main/centerfreqrx`
935
+ for independent transceiver tuning, `calibration/preamp`, and the
936
+ read-only `boostusbbytessecond` throughput reading.
@@ -21,7 +21,10 @@ SDK are marked unverified.
21
21
  | SoapySDR plugin RX | HTTP | Verified manually (~9.7 Msps via `SoapySDRUtil`); no automated live test, as a `soapysdr` dev-dependency would make `cargo test` unbuildable without system SoapySDR |
22
22
  | Python bindings RX | HTTP | Verified manually (NumPy and Arrow); no automated live test |
23
23
  | TX (`UnifiedSink`, `aaronia_sink_*`, SoapySDR TX) | Native SDK | **Hardware-unverified**. No TX-licensed device available |
24
- | Dual-channel RX (`Rx1And2`, `read_samples_dual`) | Native SDK | **Hardware-unverified**. Requires a full V6 |
24
+ | Dual-channel RX (`Rx1And2`, `read_samples_dual`) | Native SDK | **Hardware-unverified**. Requires a full V6. Selects `Rx12`, the interleaved single-stream mode, matching how this crate reads |
25
+ | Spectra reads (`read_spectra`) | Native SDK | **Hardware-unverified**. Packet layout and stream index follow Aaronia's `RawSpectrum` sample |
26
+ | Device-family detection (`open_detected_device`) | Native SDK | **Hardware-unverified**. Enumerates each known family in turn |
27
+ | Sample-rate ladder and usable bandwidth | Both | **Live-verified at the default clock** on a V6 ECO, rung by rung. Faster receiver clocks are inferred from the documented constraint, not measured |
25
28
  | GPS hardware time | Native SDK | **Hardware-unverified** |
26
29
  | Native SDK capture generally | Native SDK | **Hardware-unverified**; compiled and unit-tested in a Linux VM each release |
27
30
  | HTTP TX push (`/sample`) | HTTP | Endpoint exercised live; RF output not measured |
@@ -40,6 +43,15 @@ cargo test --all-features --test live_smoke -- --ignored --nocapture
40
43
  Contributions that convert an unverified row, particularly from users
41
44
  with a full V6 or a TX licence, are welcome.
42
45
 
46
+ Native-SDK entries carry a second qualifier worth stating plainly: they
47
+ are not merely untested, they run on a platform this project cannot
48
+ build on locally. They are compile-checked for Linux and Windows and
49
+ their logic is drawn from Aaronia's published samples, which caught
50
+ three real defects, but nothing has executed them against a device.
51
+
52
+ If you have a full V6 or a TX licence, closing one of these rows is the
53
+ single most valuable contribution you can make to this crate.
54
+
43
55
  ## Related
44
56
 
45
57
  - [QUICKSTART.md](QUICKSTART.md) — setting up an RTSA-Suite mission.
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "python-aaronia"
3
- version = "0.6.1"
3
+ version = "0.6.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"
@@ -134,6 +134,6 @@ pub use unified_source::{
134
134
  /// Utilities for DB/linear conversions and string parsing
135
135
  pub use utils::{
136
136
  IQ_CLOCK_HZ, USABLE_BANDWIDTH_RATIO, db_to_linear, format_frequency, format_sample_rate,
137
- iq_sample_rate_for_bandwidth, iq_sample_rates, linear_to_db, nearest_iq_sample_rate,
138
- parse_frequency, parse_sample_rate, usable_bandwidth_hz,
137
+ iq_sample_rate_for_bandwidth, iq_sample_rates, iq_sample_rates_for_clock, linear_to_db,
138
+ nearest_iq_sample_rate, parse_frequency, parse_sample_rate, usable_bandwidth_hz,
139
139
  };
@@ -725,6 +725,43 @@ impl NativeSdkClient {
725
725
  }
726
726
  }
727
727
 
728
+ /// The device families this SDK knows about, in the order worth
729
+ /// trying.
730
+ pub const DEVICE_FAMILIES: [&'static str; 2] = ["spectranv6", "spectranv6eco"];
731
+
732
+ /// Find which family actually has a device attached.
733
+ ///
734
+ /// `AARTSAAPI_EnumDevice` matches one family at a time, so a caller
735
+ /// configured for `spectranv6` finds nothing on a machine holding
736
+ /// only a V6 ECO, and the failure reads as "no device" rather than
737
+ /// "wrong family". This tries each family in turn and returns the
738
+ /// first that enumerates a device, so the common case needs no
739
+ /// configuration at all.
740
+ ///
741
+ /// # Safety
742
+ /// `handle` must be an open library handle.
743
+ pub unsafe fn detect_device_family(
744
+ &self,
745
+ handle: &mut AARTSAAPI_Handle,
746
+ ) -> Result<Option<&'static str>> {
747
+ for family in Self::DEVICE_FAMILIES {
748
+ match unsafe { self.enum_device(handle, family, 0) } {
749
+ Ok(Some(_)) => {
750
+ info!("Detected device family {family}");
751
+ return Ok(Some(family));
752
+ }
753
+ // An empty family is the normal answer for the one that
754
+ // is not attached; keep looking.
755
+ Ok(None) => continue,
756
+ Err(e) => {
757
+ debug!("Enumerating {family} failed, trying the next: {e}");
758
+ continue;
759
+ }
760
+ }
761
+ }
762
+ Ok(None)
763
+ }
764
+
728
765
  pub unsafe fn enum_device(
729
766
  &self,
730
767
  handle: &mut AARTSAAPI_Handle,
@@ -1240,6 +1277,27 @@ pub(crate) fn split_packet(packet_samples: usize, remaining: usize) -> (usize, u
1240
1277
  (to_caller, packet_samples - to_caller)
1241
1278
  }
1242
1279
 
1280
+ /// What one spectra packet described, alongside the values appended to
1281
+ /// the caller's buffer.
1282
+ ///
1283
+ /// A packet holds `frames` consecutive spectra of `bins_per_frame`
1284
+ /// values each, laid out one after another, so frame `i` occupies
1285
+ /// `[i * bins_per_frame ..][.. bins_per_frame]` of what was appended.
1286
+ /// Values are dBm.
1287
+ #[derive(Debug, Clone, Copy, PartialEq)]
1288
+ pub struct SpectraRead {
1289
+ /// Number of spectra in the packet.
1290
+ pub frames: usize,
1291
+ /// Bins in each spectrum.
1292
+ pub bins_per_frame: usize,
1293
+ /// Frequency of the first bin, in Hz.
1294
+ pub start_frequency_hz: f64,
1295
+ /// Spacing between bins, in Hz.
1296
+ pub step_frequency_hz: f64,
1297
+ /// Packet start time, on the device's clock.
1298
+ pub start_time: f64,
1299
+ }
1300
+
1243
1301
  // === High-Level Stream Manager ===
1244
1302
 
1245
1303
  /// The open-mode supplied to `AARTSAAPI_OpenDevice`. Tracking which family /
@@ -1251,6 +1309,9 @@ pub enum DeviceOpenMode {
1251
1309
  Raw,
1252
1310
  /// `spectranv6eco/iqreceiver` — IQ receiver on the ECO platform.
1253
1311
  EcoIqReceiver,
1312
+ /// `spectranv6eco/rtsa` — the ECO's raw pipeline, which is what its
1313
+ /// spectrum sample opens. There is no `spectranv6eco/raw`.
1314
+ EcoRtsa,
1254
1315
  /// `spectranv6/sweepsa` — sweep spectrum analyzer on the V6 platform.
1255
1316
  Sweepsa,
1256
1317
  /// `spectranv6eco/sweepsa` — sweep spectrum analyzer on the ECO platform.
@@ -1267,6 +1328,9 @@ impl DeviceOpenMode {
1267
1328
  pub fn from_open_string(s: &str) -> Self {
1268
1329
  match s {
1269
1330
  "spectranv6/raw" => Self::Raw,
1331
+ // The ECO's equivalent of raw mode is called rtsa; it has no
1332
+ // "spectranv6eco/raw".
1333
+ "spectranv6eco/rtsa" => Self::EcoRtsa,
1270
1334
  "spectranv6eco/iqreceiver" => Self::EcoIqReceiver,
1271
1335
  "spectranv6/sweepsa" => Self::Sweepsa,
1272
1336
  "spectranv6eco/sweepsa" => Self::EcoSweepsa,
@@ -1281,6 +1345,22 @@ impl DeviceOpenMode {
1281
1345
  pub fn supports_raw_only_keys(&self) -> bool {
1282
1346
  matches!(self, Self::Raw)
1283
1347
  }
1348
+
1349
+ /// Which stream index carries spectra in this mode.
1350
+ ///
1351
+ /// Not a property of the device family, which is how this reads at
1352
+ /// first glance. In `spectranv6/raw` the same device delivers IQ on
1353
+ /// stream 0 and spectra on stream 2, selected by
1354
+ /// `device/outputformat`; Aaronia's `RawIQ` and `RawSpectrum`
1355
+ /// samples differ in exactly that. Every other mode, including the
1356
+ /// ECO's `rtsa` and both `sweepsa` variants, carries spectra on
1357
+ /// stream 0.
1358
+ pub fn spectra_stream_index(&self) -> i32 {
1359
+ match self {
1360
+ Self::Raw => 2,
1361
+ _ => 0,
1362
+ }
1363
+ }
1284
1364
  }
1285
1365
 
1286
1366
  pub struct NativeSdkSource {
@@ -1305,6 +1385,11 @@ pub struct NativeSdkSource {
1305
1385
  /// time-gaps into both outputs (each call consumes whole packets
1306
1386
  /// the other path never sees), so the second path errors instead.
1307
1387
  read_mode: Option<ReadMode>,
1388
+ /// Receiver clock in Hz, learned when the IQ receiver is configured.
1389
+ /// The sample-rate ladder is derived from it, so callers that need
1390
+ /// to know which rates exist should read it rather than assume the
1391
+ /// default. `None` until the device has been configured.
1392
+ receiver_clock_hz: Option<f64>,
1308
1393
  }
1309
1394
 
1310
1395
  /// Which of the two packet-consuming read paths a streaming session
@@ -1399,6 +1484,7 @@ impl NativeSdkSource {
1399
1484
  sample_buffer: VecDeque::new(),
1400
1485
  dual_sample_buffer: VecDeque::new(),
1401
1486
  read_mode: None,
1487
+ receiver_clock_hz: None,
1402
1488
  })
1403
1489
  }
1404
1490
  }
@@ -1595,6 +1681,40 @@ impl NativeSdkSource {
1595
1681
  }
1596
1682
  }
1597
1683
 
1684
+ /// Open whichever device is present, without the caller naming the
1685
+ /// family.
1686
+ ///
1687
+ /// `mode` is the suffix, for example `"raw"` or `"iqreceiver"`. The
1688
+ /// family is detected first, then joined to it. Note the two
1689
+ /// families do not offer identical modes: the V6 has `raw`, while
1690
+ /// the ECO's equivalent is `rtsa`, so pass a mode the detected
1691
+ /// family supports or use [`Self::open_device`] directly.
1692
+ ///
1693
+ /// # Safety
1694
+ /// Same contract as [`Self::open_device`].
1695
+ pub unsafe fn open_detected_device(
1696
+ &mut self,
1697
+ mode: &str,
1698
+ serial_number: &[WideChar],
1699
+ ) -> Result<()> {
1700
+ unsafe {
1701
+ let family = {
1702
+ let handle = self
1703
+ .handle
1704
+ .as_mut()
1705
+ .ok_or_else(|| Error::Sdk("SDK not initialized".to_string()))?;
1706
+ self.client.detect_device_family(handle)?.ok_or_else(|| {
1707
+ Error::Sdk(format!(
1708
+ "no Aaronia device found in any known family ({})",
1709
+ NativeSdkClient::DEVICE_FAMILIES.join(", ")
1710
+ ))
1711
+ })?
1712
+ };
1713
+ let open_string = format!("{family}/{mode}");
1714
+ self.open_device(&open_string, serial_number)
1715
+ }
1716
+ }
1717
+
1598
1718
  /// Returns the currently configured open mode, if a device is open.
1599
1719
  pub fn open_mode(&self) -> Option<DeviceOpenMode> {
1600
1720
  self.open_mode.clone()
@@ -1887,13 +2007,18 @@ impl NativeSdkSource {
1887
2007
  crate::utils::DEFAULT_RECEIVER_CLOCK_HZ
1888
2008
  }
1889
2009
  } else {
1890
- // Eco devices report no receiverclock key; the family is fixed
1891
- // at 61.44 MHz per the README. Use that here so the IQ Mode
1892
- // constraint is checked against the right clock.
1893
- const ECO_FIXED_CLOCK_HZ: f64 = 61_440_000.0;
1894
- ECO_FIXED_CLOCK_HZ
2010
+ // Eco devices report no receiverclock key: the clock is
2011
+ // fixed. It is 92.16 MHz, not the 61.44 MHz this used to
2012
+ // assume. A V6 ECO streams at 61.44 MHz sampling, measured
2013
+ // over HTTP against real hardware, and the constraint
2014
+ // checked below is `span * 1.5 <= clock`, so the clock
2015
+ // cannot be lower than 92.16 MHz. With the old value this
2016
+ // rejected every span above 40.96 MHz, including the
2017
+ // device's own maximum.
2018
+ crate::utils::DEFAULT_RECEIVER_CLOCK_HZ
1895
2019
  };
1896
2020
 
2021
+ self.receiver_clock_hz = Some(actual_clock_hz);
1897
2022
  crate::utils::validate_iq_mode(span_freq, actual_clock_hz)?;
1898
2023
 
1899
2024
  info!("IQ Receiver configuration completed");
@@ -1979,6 +2104,101 @@ impl NativeSdkSource {
1979
2104
  pub const READ_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(5);
1980
2105
  pub const READ_POLL_DEADLINE: std::time::Duration = std::time::Duration::from_millis(500);
1981
2106
 
2107
+ /// The receiver clock in Hz, once the device has been configured.
2108
+ ///
2109
+ /// Worth reading rather than assuming: the sample-rate ladder runs
2110
+ /// from `clock / 1.5` downward in halves, so a V6 on the 245 MHz
2111
+ /// clock reaches rates a V6 ECO cannot. Pass this to
2112
+ /// [`crate::utils::iq_sample_rates_for_clock`] to get the rates this
2113
+ /// device actually supports.
2114
+ pub fn receiver_clock_hz(&self) -> Option<f64> {
2115
+ self.receiver_clock_hz
2116
+ }
2117
+
2118
+ /// Read one packet of spectra, appending its values to `out`.
2119
+ ///
2120
+ /// Returns `None` when no packet arrived within
2121
+ /// [`Self::READ_POLL_DEADLINE`], matching the IQ read.
2122
+ ///
2123
+ /// The stream index comes from the open mode rather than a constant:
2124
+ /// `spectranv6/raw` carries spectra on stream 2 while its IQ is on
2125
+ /// stream 0, and every other mode uses stream 0. See
2126
+ /// [`DeviceOpenMode::spectra_stream_index`]. The device must also be
2127
+ /// producing spectra in the first place, which in raw mode means
2128
+ /// `device/outputformat` set to `"spectra"`.
2129
+ ///
2130
+ /// > Hardware-unverified. The packet layout, `num` frames of `size`
2131
+ /// > bins, follows Aaronia's `RawSpectrum` sample; no spectra-capable
2132
+ /// > device has been available to run it against.
2133
+ ///
2134
+ /// # Safety
2135
+ /// The device must be open and streaming.
2136
+ pub unsafe fn read_spectra(&mut self, out: &mut Vec<f32>) -> Result<Option<SpectraRead>> {
2137
+ unsafe {
2138
+ if !self.stream_active {
2139
+ return Err(Error::Sdk("Streaming not active".to_string()));
2140
+ }
2141
+ let stream = self
2142
+ .open_mode
2143
+ .as_ref()
2144
+ .map(DeviceOpenMode::spectra_stream_index)
2145
+ .unwrap_or(0);
2146
+ let device = self
2147
+ .device
2148
+ .as_mut()
2149
+ .ok_or_else(|| Error::Sdk("No device opened".to_string()))?;
2150
+
2151
+ let started = std::time::Instant::now();
2152
+ let packet_opt = loop {
2153
+ match self.client.get_packet(device, stream, 0)? {
2154
+ Some(p) => break Some(p),
2155
+ None => {
2156
+ if started.elapsed() >= Self::READ_POLL_DEADLINE {
2157
+ break None;
2158
+ }
2159
+ std::thread::sleep(Self::READ_POLL_INTERVAL);
2160
+ }
2161
+ }
2162
+ };
2163
+
2164
+ let Some(packet) = packet_opt else {
2165
+ return Ok(None);
2166
+ };
2167
+
2168
+ // Consume on every path out from here: `get_packet` always
2169
+ // returns the head of the queue, so leaving one behind makes
2170
+ // every later read return the same packet forever.
2171
+ let result = (|| {
2172
+ if packet.fp32.is_null() || packet.num <= 0 || packet.size == 0 {
2173
+ return Ok(None);
2174
+ }
2175
+ let frames = packet.num as usize;
2176
+ let bins = packet.size as usize;
2177
+ // Bound the product before allocating: a corrupt header
2178
+ // must not size a multi-gigabyte copy.
2179
+ const MAX_VALUES: usize = 1 << 26;
2180
+ let total = frames.checked_mul(bins).filter(|n| *n <= MAX_VALUES);
2181
+ let Some(total) = total else {
2182
+ return Err(Error::Sdk(format!(
2183
+ "spectra packet claims {frames} frames of {bins} bins, which exceeds the {MAX_VALUES}-value limit"
2184
+ )));
2185
+ };
2186
+ out.reserve(total);
2187
+ out.extend_from_slice(std::slice::from_raw_parts(packet.fp32, total));
2188
+ Ok(Some(SpectraRead {
2189
+ frames,
2190
+ bins_per_frame: bins,
2191
+ start_frequency_hz: packet.start_frequency,
2192
+ step_frequency_hz: packet.step_frequency,
2193
+ start_time: packet.start_time,
2194
+ }))
2195
+ })();
2196
+
2197
+ self.client.consume_packets(device, stream, 1)?;
2198
+ result
2199
+ }
2200
+ }
2201
+
1982
2202
  /// Read up to `max_samples` complex IQ pairs from the device.
1983
2203
  ///
1984
2204
  /// **Magnitude scaling differs by open mode.** Comparing the official
@@ -2494,11 +2714,16 @@ impl Drop for NativeSdkSource {
2494
2714
  /// `device/receiverchannel` config item.
2495
2715
  ///
2496
2716
  /// The full SPECTRAN V6 has two RF inputs; the V6 ECO has one. The config
2497
- /// strings (`"Rx1"`, `"Rx2"`, `"Rx1+Rx2"`) come from the official
2498
- /// RTSA-API-Samples; `"Rx1"` is what [`NativeSdkSource::
2499
- /// configure_iq_receiver`] has always written and is the only variant
2500
- /// verified against real hardware (see
2501
- /// [`NativeSdkSource::set_receiver_channel`]).
2717
+ /// strings come from the official RTSA-API-Samples, which use four:
2718
+ /// `"Rx1"`, `"Rx2"`, `"Rx12"` and `"Rx1+Rx2"`. The last two both enable
2719
+ /// both inputs but differ in delivery `"Rx12"` interleaves them into
2720
+ /// one stream, `"Rx1+Rx2"` produces two independent streams — so they
2721
+ /// are not interchangeable. This crate reads one stream and
2722
+ /// deinterleaves it, so [`RxChannel::Rx1And2`] writes `"Rx12"`.
2723
+ ///
2724
+ /// `"Rx1"` is what [`NativeSdkSource::configure_iq_receiver`] has
2725
+ /// always written and is the only variant verified against real
2726
+ /// hardware (see [`NativeSdkSource::set_receiver_channel`]).
2502
2727
  ///
2503
2728
  /// The enum itself lives in [`crate::utils`] so cross-platform
2504
2729
  /// configuration code can name a channel without the `native-sdk`
@@ -3132,6 +3357,7 @@ mod tests {
3132
3357
  sample_buffer: VecDeque::new(),
3133
3358
  dual_sample_buffer: VecDeque::new(),
3134
3359
  read_mode: None,
3360
+ receiver_clock_hz: None,
3135
3361
  };
3136
3362
 
3137
3363
  assert!(!source.is_streaming());
@@ -189,6 +189,11 @@ impl UnifiedSink {
189
189
  )),
190
190
  allow(unused_variables)
191
191
  )]
192
+ /// Aaronia's `IQTransceiverSweep` sample precedes real data with a
193
+ /// zero-length packet carrying only `STREAM_START`, timestamped at
194
+ /// the master stream clock, "to improve startup synch". Passing an
195
+ /// empty slice here does the same thing. Untested against hardware,
196
+ /// like the rest of this path, but it is what the vendor does.
192
197
  pub fn write_samples(
193
198
  &mut self,
194
199
  channel: i32,
@@ -39,9 +39,18 @@ pub enum RxChannel {
39
39
  Rx1,
40
40
  /// Second RF input (full V6 only; hardware-unverified).
41
41
  Rx2,
42
- /// Both inputs, interleaved into one packet (full V6 only;
43
- /// hardware-unverified). Read both streams with
44
- /// `read_samples_dual` on the native SDK source.
42
+ /// Both inputs interleaved into a single stream (full V6 only;
43
+ /// hardware-unverified). Read with `read_samples_dual` on the
44
+ /// native SDK source.
45
+ ///
46
+ /// The device offers two ways to run both inputs and they are not
47
+ /// interchangeable. `"Rx12"`, used here, interleaves the pair into
48
+ /// one stream: four floats per sample, `[I0, Q0, I1, Q1]`, read
49
+ /// from stream 0. `"Rx1+Rx2"` instead delivers two independent
50
+ /// streams that must be fetched and consumed separately, at indices
51
+ /// 0 and 1. Aaronia's `RawIQ2RXInterleave` and `RawIQ2RX` samples
52
+ /// show one each. This crate reads a single stream and
53
+ /// deinterleaves it, so it requires the former.
45
54
  Rx1And2,
46
55
  }
47
56
 
@@ -51,7 +60,10 @@ impl RxChannel {
51
60
  match self {
52
61
  Self::Rx1 => "Rx1",
53
62
  Self::Rx2 => "Rx2",
54
- Self::Rx1And2 => "Rx1+Rx2",
63
+ // Not "Rx1+Rx2": that selects two separate streams, while
64
+ // this crate reads one interleaved stream. See the variant
65
+ // documentation above.
66
+ Self::Rx1And2 => "Rx12",
55
67
  }
56
68
  }
57
69
  }
@@ -80,7 +92,7 @@ pub fn deinterleave_dual_iq<'a>(
80
92
  if stride < 4 {
81
93
  return Err(Error::Sdk(format!(
82
94
  "packet stride {} cannot carry two interleaved IQ channels (need >= 4); \
83
- is device/receiverchannel set to Rx1+Rx2?",
95
+ is device/receiverchannel set to Rx12?",
84
96
  stride
85
97
  )));
86
98
  }
@@ -283,7 +295,13 @@ pub fn receiver_clock_for_label(label: &str) -> f64 {
283
295
  }
284
296
  }
285
297
 
286
- /// Master clock the IQ sample-rate ladder divides down from.
298
+ /// Highest IQ sample rate available with the default receiver clock.
299
+ ///
300
+ /// This is the [`DEFAULT_RECEIVER_CLOCK_HZ`] divided by the 1.5 factor
301
+ /// that [`validate_iq_mode`] enforces, and it matches the maximum
302
+ /// measured on a SPECTRAN V6 ECO. A full V6 can select a faster
303
+ /// receiver clock, so use [`iq_sample_rates_for_clock`] when the clock
304
+ /// is known rather than assuming this ceiling.
287
305
  pub const IQ_CLOCK_HZ: f64 = 61_440_000.0;
288
306
 
289
307
  /// Fraction of the sample rate that survives the anti-alias filter.
@@ -301,9 +319,27 @@ pub const USABLE_BANDWIDTH_RATIO: f64 = 0.8;
301
319
  /// it got what it asked for will compute every derived frequency
302
320
  /// wrongly.
303
321
  pub fn iq_sample_rates() -> [f64; 10] {
322
+ iq_sample_rates_for_clock(DEFAULT_RECEIVER_CLOCK_HZ)
323
+ }
324
+
325
+ /// The IQ sample rates available at a given receiver clock, highest
326
+ /// first.
327
+ ///
328
+ /// The top rate is `receiver_clock_hz / 1.5`, the most that
329
+ /// [`validate_iq_mode`] permits, and each step halves it.
330
+ ///
331
+ /// **Measured only at the default clock.** A V6 ECO has a fixed
332
+ /// receiver clock and produced exactly the ladder this returns for
333
+ /// [`DEFAULT_RECEIVER_CLOCK_HZ`], verified rung by rung. A full V6 can
334
+ /// select other clocks — Aaronia's own samples use `"92MHz"` and
335
+ /// `"245MHz"` — and the rates there follow the same constraint but have
336
+ /// not been confirmed against hardware. Read `device/receiverclock`
337
+ /// from the device and pass it here rather than assuming.
338
+ pub fn iq_sample_rates_for_clock(receiver_clock_hz: f64) -> [f64; 10] {
339
+ let top = receiver_clock_hz / 1.5;
304
340
  let mut rates = [0.0; 10];
305
341
  for (n, rate) in rates.iter_mut().enumerate() {
306
- *rate = IQ_CLOCK_HZ / (1u32 << n) as f64;
342
+ *rate = top / f64::from(1u32 << n);
307
343
  }
308
344
  rates
309
345
  }
@@ -396,6 +432,22 @@ mod ladder_tests {
396
432
  use super::*;
397
433
 
398
434
  /// The ten rates the device reports in its own decimation enum.
435
+ /// A V6 with a faster receiver clock reaches higher rates. Aaronia's
436
+ /// samples select "245MHz", which the constraint puts at 163.84 MHz
437
+ /// of span. Inferred from the constraint, not measured.
438
+ #[test]
439
+ fn faster_clock_raises_the_ceiling() {
440
+ let fast = iq_sample_rates_for_clock(245_760_000.0);
441
+ assert!((fast[0] - 163_840_000.0).abs() < 1.0);
442
+ assert!(
443
+ fast[0] > iq_sample_rates()[0],
444
+ "a faster clock must reach further"
445
+ );
446
+ for pair in fast.windows(2) {
447
+ assert!((pair[0] / pair[1] - 2.0).abs() < 1e-9);
448
+ }
449
+ }
450
+
399
451
  #[test]
400
452
  fn ladder_matches_the_device() {
401
453
  let rates = iq_sample_rates();
@@ -457,7 +509,10 @@ mod tests {
457
509
  // per the official RTSA-API-Samples.
458
510
  assert_eq!(RxChannel::Rx1.as_config_str(), "Rx1");
459
511
  assert_eq!(RxChannel::Rx2.as_config_str(), "Rx2");
460
- assert_eq!(RxChannel::Rx1And2.as_config_str(), "Rx1+Rx2");
512
+ // "Rx12" interleaves both inputs into one stream, which is
513
+ // what this crate reads. "Rx1+Rx2" would deliver two separate
514
+ // streams and silently break the deinterleave.
515
+ assert_eq!(RxChannel::Rx1And2.as_config_str(), "Rx12");
461
516
  }
462
517
 
463
518
  #[test]
@@ -499,7 +554,7 @@ mod tests {
499
554
  Err(e) => e,
500
555
  Ok(_) => panic!("stride 2 must be rejected"),
501
556
  };
502
- assert!(err.to_string().contains("Rx1+Rx2"), "got: {err}");
557
+ assert!(err.to_string().contains("Rx12"), "got: {err}");
503
558
  }
504
559
 
505
560
  #[test]
File without changes
File without changes
File without changes
File without changes