python-aaronia 0.10.0__tar.gz → 0.11.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. {python_aaronia-0.10.0 → python_aaronia-0.11.1}/CHANGELOG.md +133 -0
  2. {python_aaronia-0.10.0 → python_aaronia-0.11.1}/Cargo.lock +2 -2
  3. {python_aaronia-0.10.0 → python_aaronia-0.11.1}/Cargo.toml +1 -1
  4. {python_aaronia-0.10.0 → python_aaronia-0.11.1}/PKG-INFO +1 -1
  5. {python_aaronia-0.10.0 → python_aaronia-0.11.1}/README.md +1 -0
  6. {python_aaronia-0.10.0 → python_aaronia-0.11.1}/docs/SDKSPEC.md +18 -2
  7. python_aaronia-0.11.1/docs/SYNC.md +118 -0
  8. python_aaronia-0.11.1/docs/VERIFICATION.md +73 -0
  9. {python_aaronia-0.10.0 → python_aaronia-0.11.1}/examples/native_sdk_transmit.rs +5 -3
  10. {python_aaronia-0.10.0 → python_aaronia-0.11.1}/include/spectran.h +12 -0
  11. {python_aaronia-0.10.0 → python_aaronia-0.11.1}/python-aaronia/Cargo.toml +1 -1
  12. {python_aaronia-0.10.0 → python_aaronia-0.11.1}/python-aaronia/aaronia.pyi +18 -0
  13. {python_aaronia-0.10.0 → python_aaronia-0.11.1}/python-aaronia/src/lib.rs +25 -0
  14. {python_aaronia-0.10.0 → python_aaronia-0.11.1}/scripts/ci-local.sh +1 -1
  15. {python_aaronia-0.10.0 → python_aaronia-0.11.1}/soapy-aaronia/README.md +39 -3
  16. {python_aaronia-0.10.0 → python_aaronia-0.11.1}/soapy-aaronia/Registration.cpp +11 -2
  17. {python_aaronia-0.10.0 → python_aaronia-0.11.1}/soapy-aaronia/SpectranSoapyDevice.cpp +125 -17
  18. {python_aaronia-0.10.0 → python_aaronia-0.11.1}/soapy-aaronia/SpectranSoapyDevice.hpp +15 -0
  19. {python_aaronia-0.10.0 → python_aaronia-0.11.1}/src/c_api.rs +122 -2
  20. {python_aaronia-0.10.0 → python_aaronia-0.11.1}/src/http_endpoints.rs +203 -2
  21. {python_aaronia-0.10.0 → python_aaronia-0.11.1}/src/lib.rs +4 -0
  22. {python_aaronia-0.10.0 → python_aaronia-0.11.1}/src/native_sdk.rs +251 -14
  23. {python_aaronia-0.10.0 → python_aaronia-0.11.1}/src/sdk_sink.rs +11 -8
  24. {python_aaronia-0.10.0 → python_aaronia-0.11.1}/src/sdr_source_impl.rs +69 -12
  25. {python_aaronia-0.10.0 → python_aaronia-0.11.1}/src/unified_sink.rs +12 -5
  26. {python_aaronia-0.10.0 → python_aaronia-0.11.1}/src/unified_source.rs +568 -19
  27. {python_aaronia-0.10.0 → python_aaronia-0.11.1}/src/utils.rs +114 -18
  28. {python_aaronia-0.10.0 → python_aaronia-0.11.1}/tests/c_api_test.rs +36 -1
  29. {python_aaronia-0.10.0 → python_aaronia-0.11.1}/tests/http_resilience_test.rs +32 -7
  30. {python_aaronia-0.10.0 → python_aaronia-0.11.1}/tests/live_smoke.rs +93 -0
  31. {python_aaronia-0.10.0 → python_aaronia-0.11.1}/tests/native_sdk_live.rs +93 -0
  32. python_aaronia-0.10.0/docs/VERIFICATION.md +0 -80
  33. {python_aaronia-0.10.0 → python_aaronia-0.11.1}/.cargo/config.toml +0 -0
  34. {python_aaronia-0.10.0 → python_aaronia-0.11.1}/.gitattributes +0 -0
  35. {python_aaronia-0.10.0 → python_aaronia-0.11.1}/.gitignore +0 -0
  36. {python_aaronia-0.10.0 → python_aaronia-0.11.1}/CONTRIBUTING.md +0 -0
  37. {python_aaronia-0.10.0 → python_aaronia-0.11.1}/DESIGN.md +0 -0
  38. {python_aaronia-0.10.0 → python_aaronia-0.11.1}/LICENSE +0 -0
  39. {python_aaronia-0.10.0 → python_aaronia-0.11.1}/PLUGINS.md +0 -0
  40. {python_aaronia-0.10.0 → python_aaronia-0.11.1}/benches/decompress_block.rs +0 -0
  41. {python_aaronia-0.10.0 → python_aaronia-0.11.1}/benches/deinterleave_dual_iq.rs +0 -0
  42. {python_aaronia-0.10.0 → python_aaronia-0.11.1}/benches/parse_int16_packet.rs +0 -0
  43. {python_aaronia-0.10.0 → python_aaronia-0.11.1}/benches/rtsa_open_and_read.rs +0 -0
  44. {python_aaronia-0.10.0 → python_aaronia-0.11.1}/deny.toml +0 -0
  45. {python_aaronia-0.10.0 → python_aaronia-0.11.1}/docs/APPS.md +0 -0
  46. {python_aaronia-0.10.0 → python_aaronia-0.11.1}/docs/FILESPEC.md +0 -0
  47. {python_aaronia-0.10.0 → python_aaronia-0.11.1}/docs/HTTPSPEC.md +0 -0
  48. {python_aaronia-0.10.0 → python_aaronia-0.11.1}/docs/QUICKSTART.md +0 -0
  49. {python_aaronia-0.10.0 → python_aaronia-0.11.1}/docs/USAGE.md +0 -0
  50. {python_aaronia-0.10.0 → python_aaronia-0.11.1}/examples/channel_hopping.rs +0 -0
  51. {python_aaronia-0.10.0 → python_aaronia-0.11.1}/examples/device_control.rs +0 -0
  52. {python_aaronia-0.10.0 → python_aaronia-0.11.1}/examples/dump_metadata.rs +0 -0
  53. {python_aaronia-0.10.0 → python_aaronia-0.11.1}/examples/http_iq_quickstart.rs +0 -0
  54. {python_aaronia-0.10.0 → python_aaronia-0.11.1}/examples/native_sdk_basic.rs +0 -0
  55. {python_aaronia-0.10.0 → python_aaronia-0.11.1}/examples/noaa_scanner.rs +0 -0
  56. {python_aaronia-0.10.0 → python_aaronia-0.11.1}/examples/python_arrow_example.py +0 -0
  57. {python_aaronia-0.10.0 → python_aaronia-0.11.1}/examples/read_rtsa_file.rs +0 -0
  58. {python_aaronia-0.10.0 → python_aaronia-0.11.1}/examples/soapy_python_example.py +0 -0
  59. {python_aaronia-0.10.0 → python_aaronia-0.11.1}/packaging/homebrew/README.md +0 -0
  60. {python_aaronia-0.10.0 → python_aaronia-0.11.1}/packaging/homebrew/soapy-aaronia.rb +0 -0
  61. {python_aaronia-0.10.0 → python_aaronia-0.11.1}/pyproject.toml +0 -0
  62. {python_aaronia-0.10.0 → python_aaronia-0.11.1}/python-aaronia/README.md +0 -0
  63. {python_aaronia-0.10.0 → python_aaronia-0.11.1}/python-aaronia/test_basic.py +0 -0
  64. {python_aaronia-0.10.0 → python_aaronia-0.11.1}/scripts/fake-rtsa-server.py +0 -0
  65. {python_aaronia-0.10.0 → python_aaronia-0.11.1}/scripts/native-sdk-validate.ps1 +0 -0
  66. {python_aaronia-0.10.0 → python_aaronia-0.11.1}/scripts/sdk-container-test.sh +0 -0
  67. {python_aaronia-0.10.0 → python_aaronia-0.11.1}/scripts/validate-iq-live.py +0 -0
  68. {python_aaronia-0.10.0 → python_aaronia-0.11.1}/soapy-aaronia/CMakeLists.txt +0 -0
  69. {python_aaronia-0.10.0 → python_aaronia-0.11.1}/soapy-aaronia/packaging/install.ps1 +0 -0
  70. {python_aaronia-0.10.0 → python_aaronia-0.11.1}/soapy-aaronia/packaging/install.sh +0 -0
  71. {python_aaronia-0.10.0 → python_aaronia-0.11.1}/src/decompression.rs +0 -0
  72. {python_aaronia-0.10.0 → python_aaronia-0.11.1}/src/detection.rs +0 -0
  73. {python_aaronia-0.10.0 → python_aaronia-0.11.1}/src/error.rs +0 -0
  74. {python_aaronia-0.10.0 → python_aaronia-0.11.1}/src/file_source.rs +0 -0
  75. {python_aaronia-0.10.0 → python_aaronia-0.11.1}/src/http_sink.rs +0 -0
  76. {python_aaronia-0.10.0 → python_aaronia-0.11.1}/src/http_source.rs +0 -0
  77. {python_aaronia-0.10.0 → python_aaronia-0.11.1}/src/http_streaming.rs +0 -0
  78. {python_aaronia-0.10.0 → python_aaronia-0.11.1}/src/link_budget.rs +0 -0
  79. {python_aaronia-0.10.0 → python_aaronia-0.11.1}/src/sdk_source.rs +0 -0
  80. {python_aaronia-0.10.0 → python_aaronia-0.11.1}/src/seify_impl.rs +0 -0
  81. {python_aaronia-0.10.0 → python_aaronia-0.11.1}/tests/abi_drift.rs +0 -0
  82. {python_aaronia-0.10.0 → python_aaronia-0.11.1}/tests/http_mock_test.rs +0 -0
  83. {python_aaronia-0.10.0 → python_aaronia-0.11.1}/tests/http_sink_test.rs +0 -0
  84. {python_aaronia-0.10.0 → python_aaronia-0.11.1}/tests/integration_test.rs +0 -0
  85. {python_aaronia-0.10.0 → python_aaronia-0.11.1}/tests/native_sdk_load.rs +0 -0
  86. {python_aaronia-0.10.0 → python_aaronia-0.11.1}/tests/properties.proptest-regressions +0 -0
  87. {python_aaronia-0.10.0 → python_aaronia-0.11.1}/tests/properties.rs +0 -0
  88. {python_aaronia-0.10.0 → python_aaronia-0.11.1}/tests/rtsa_negative_test.rs +0 -0
  89. {python_aaronia-0.10.0 → python_aaronia-0.11.1}/tests/sdr_source_impl_test.rs +0 -0
  90. {python_aaronia-0.10.0 → python_aaronia-0.11.1}/tests/spec_coverage.rs +0 -0
  91. {python_aaronia-0.10.0 → python_aaronia-0.11.1}/tests/test_cw_mag.rs +0 -0
  92. {python_aaronia-0.10.0 → python_aaronia-0.11.1}/tests/test_cw_meta.rs +0 -0
  93. {python_aaronia-0.10.0 → python_aaronia-0.11.1}/tests/wire_contract.rs +0 -0
@@ -2,6 +2,139 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [Unreleased]
6
+
7
+ ## [v0.11.1] - 2026-09-10
8
+
9
+ ### Fixed
10
+ - **A stream gap now ends the block it precedes, instead of hiding inside
11
+ one.** Read assembly noted a dropped chunk and carried on concatenating,
12
+ so a returned block held samples from both sides of the hole while
13
+ `take_overrun` said only "somewhere in there". A consumer resetting its
14
+ filters once, before the block, still ran the discontinuity through
15
+ them — and for an FM discriminator that is a phase step, i.e. a
16
+ frequency spike that reads downstream as a sync edge.
17
+
18
+ A gap has a position, and the only position a consumer can act on is a
19
+ read boundary. The post-gap chunk is now stashed and the block ends,
20
+ exactly as it already did for a retune, with the flag carried to the
21
+ read that begins there. A gap on a read's first chunk still reports
22
+ against that read, since nothing precedes it to split from.
23
+
24
+ This also reaches reconnects, which are discontinuities for the same
25
+ reason: the stream stopped and resumed, so the samples either side are
26
+ not adjacent. Reads continue across a reconnect as before, they just
27
+ no longer splice across it.
28
+
29
+
30
+ ## [v0.11.0] - 2026-09-09
31
+
32
+ ### Changed
33
+ - **A read never spans a retune, and packets carry the frequency they
34
+ were captured at.** The centre frequency now travels through the
35
+ sample channel with the samples it belongs to, so
36
+ `read_samples`/`read_samples_deadline` stop at a frequency boundary
37
+ and `SpectranSource::capture_frequency_hz` reports what the stream
38
+ said, not what was requested.
39
+
40
+ Reading the frequency from shared state at consume time cannot work:
41
+ the reader task runs ahead of the consumer, so a buffer parsed before
42
+ a retune would be tagged with the frequency after it. Measured on a
43
+ V6 ECO over RTSA HTTP at 61.44 MSPS, a retune is carried by the signal
44
+ within 24 ms median and 39 ms worst case, and during that window the
45
+ device is still delivering the old centre.
46
+
47
+ Callers already handle short reads — `read_samples_numpy` slices what
48
+ it got, and a SoapySDR `readStream` returning fewer than `numElems` is
49
+ ordinary — so the visible change is that a buffer taken across a
50
+ retune is no longer a mixture of two frequencies. `RETUNE_SETTLE` in
51
+ the `SdrSource` facade drops from 75 ms to 20 ms with it: the
52
+ frequency check rejects stale samples exactly, so the drain is only an
53
+ efficiency measure. A 48-tune sweep of every FPV band falls from 28.2 s
54
+ to 5.9 s.
55
+
56
+ ### Added
57
+ - **The GPS mode can be set, not just the clock source.** `device/gpsmode`
58
+ decides whether GPS supplies location, time, both, or nothing, and the device
59
+ ships on `Disabled` — in which state no fix is ever reported and
60
+ `gps_time_ns` returns `None` forever, reading as broken rather than
61
+ unconfigured. `SpectranSource::set_gps_mode` writes it on both backends,
62
+ with the same confirm-by-read-back rule as the clock source.
63
+ - **`clock_source()`, `clock_sources()`, `gps_mode()`, `gps_modes()`** on
64
+ `SpectranSource`, so reading the current reference no longer means fetching
65
+ the whole capability tree. These read over HTTP; the writes work on both
66
+ backends.
67
+ - **Dual-channel RX reaches SoapySDR.** A full V6's two inputs were readable
68
+ from Rust, C and Python but not through the plugin, which reported one
69
+ channel and refused any other. Open with `rx_channel=Rx1And2` and
70
+ `getNumChannels(RX)` reports 2; `setupStream(RX, …, {0, 1})` puts
71
+ `readStream` on the paired read, filling both buffers with the same count.
72
+ The count follows the request, not the model — dual capture is configured
73
+ before the device opens, so a V6 opened single-channel has one channel to
74
+ offer. Still hardware-unverified: the development device is a single-channel
75
+ ECO.
76
+ - **`read_samples_dual_deadline`** (Rust) and
77
+ **`spectran_source_read_samples_dual_timeout`** (C), the dual counterparts of
78
+ the existing deadline-bounded reads. `readStream` must honour the
79
+ application's `timeoutUs`, which the blocking dual read ignored.
80
+ - **The device's master stream clock is readable from a source.**
81
+ `SpectranSource::master_stream_time_ns`,
82
+ `spectran_source_get_master_stream_time_ns`, Python
83
+ `master_stream_time_ns()`, and SoapySDR `getHardwareTime("master")`. It was
84
+ reachable only through the TX sink before. Unlike `last_timestamp_ns` it
85
+ answers before the first packet, so a caller that must not stamp data to
86
+ 1970 has something to read; it is also the clock two receivers are aligned
87
+ on. Native-SDK backend only.
88
+ - **Python gained `master_stream_time_ns()` and `gps_time_ns()`**, which the
89
+ Rust and C surfaces already had.
90
+ - **[docs/SYNC.md](docs/SYNC.md)** — what the hardware does and does not offer
91
+ for multi-device timing. The vendor API has no set-time, no arm-at-time and
92
+ no trigger in any of its 34 functions, so UHD-style commanded capture is not
93
+ possible; a shared reference plus post-alignment on timestamps is, and that
94
+ is written down with its resolution floor (~240 ns, set by the vendor's own
95
+ `double`). Wired into the doctests, so its snippets cannot rot.
96
+
97
+ ### Breaking changes
98
+ - **`get_master_stream_time()` is now `master_stream_time_ns()`**, on
99
+ `NativeSdkSource`, `SdkSink` and `UnifiedSink`, returning `i64` nanoseconds
100
+ instead of `f64` seconds. One unit per dimension, matching every other clock
101
+ the crate reports. `TxBurst` still carries the vendor's `f64` seconds — it is
102
+ written straight into the packet header — so convert with the new
103
+ `utils::epoch_nanos_to_seconds`.
104
+ - **`utils::gps_seconds_to_nanos` is now `utils::epoch_seconds_to_nanos`.**
105
+ Two of the vendor's clocks arrive as `float64` epoch seconds, not one, and
106
+ the old name claimed otherwise.
107
+
108
+ ### Fixed
109
+ - **A timed-out read no longer discards the staging buffer.**
110
+ `spectran_source_read_samples_timeout` returned early on timeout, past the
111
+ point where it hands the buffer back, so the next read reallocated it — the
112
+ allocation that path exists to avoid.
113
+ - **An unrecognised `rx_channel=` is reported, not silently taken as `Rx1`.**
114
+ A typo used to yield a single-channel stream indistinguishable from a working
115
+ dual request.
116
+ - **Reading the wrong payload for the open mode is refused instead of answered.**
117
+ Neither the SDK nor the packet says what a stream carries, so asking a
118
+ spectrum pipeline for IQ returned dBm bins reinterpreted as voltages, and
119
+ asking an IQ pipeline for spectra returned complex pairs reinterpreted as
120
+ two-bin frames whose bin spacing was the whole span. Both look like data.
121
+ Measured on a V6 ECO's `rtsa`: 200k "samples" spanning -130 to -59 with a
122
+ mean of -77 and not one value near zero — a dBm distribution, not a voltage
123
+ one. `read_samples`, `read_samples_dual` and `read_spectra` now check the
124
+ open mode and say which payload it carries.
125
+
126
+ This also corrects a doc claim: `spectranv6eco/rtsa` was documented as
127
+ yielding IQ at "~0.4 MS/s regardless of the requested rate". It yields no IQ
128
+ at all; that figure was the spectra misread. `spectranv6/raw` genuinely does
129
+ both, IQ on stream 0 and spectra on stream 2, and is unaffected.
130
+ - **Capability lists no longer advertise options the device will refuse.** The
131
+ config tree carries a bitmask of enum entries the device is currently
132
+ rejecting, and it was ignored: a V6 ECO with no GPS antenna offered `GPS` and
133
+ `GPS Provider` as clock sources, and `set_clock_source("GPS")` then failed
134
+ against a list that said it would not. Those entries are filtered out, so what
135
+ `clock_sources()` and `gps_modes()` return is what the device accepts.
136
+ Verified live: the six sources advertised are exactly the six it takes.
137
+
5
138
  ## [v0.10.0] - 2026-09-08
6
139
 
7
140
  ### Breaking changes
@@ -3092,7 +3092,7 @@ dependencies = [
3092
3092
 
3093
3093
  [[package]]
3094
3094
  name = "python-aaronia"
3095
- version = "0.10.0"
3095
+ version = "0.11.1"
3096
3096
  dependencies = [
3097
3097
  "arrow",
3098
3098
  "num-complex",
@@ -3621,7 +3621,7 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
3621
3621
 
3622
3622
  [[package]]
3623
3623
  name = "sdr-aaronia-rs"
3624
- version = "0.10.0"
3624
+ version = "0.11.1"
3625
3625
  dependencies = [
3626
3626
  "anyhow",
3627
3627
  "bitflags 2.13.1",
@@ -2,7 +2,7 @@
2
2
  name = "sdr-aaronia-rs"
3
3
  description = "Unified Rust interface for Aaronia Spectran Spectrum Analyzers / SDRs, featuring Python bindings, a SoapySDR plugin, HTTP streaming, and native SDK support."
4
4
  license = "GPL-3.0-or-later"
5
- version = "0.10.0"
5
+ version = "0.11.1"
6
6
  edition = "2024"
7
7
  repository = "https://github.com/isaacbentley/sdr-aaronia-rs"
8
8
  readme = "README.md"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-aaronia
3
- Version: 0.10.0
3
+ Version: 0.11.1
4
4
  Classifier: Programming Language :: Rust
5
5
  Classifier: Programming Language :: Python :: Implementation :: CPython
6
6
  Classifier: License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
@@ -236,6 +236,7 @@ Start here:
236
236
  - [Quickstart](docs/QUICKSTART.md) — configuring an RTSA-Suite mission, first samples in Rust, Python and SoapySDR, and troubleshooting for common setup failures.
237
237
  - [Usage](docs/USAGE.md) — worked examples for each part of the API, plus the `AARONIA_SDK_PATH` and `AARONIA_USER_AGENT` environment variables.
238
238
  - [Using existing SDR apps](docs/APPS.md) — SDR++, GQRX, GNU Radio, SoapySDR from Python.
239
+ - [Synchronising receivers](docs/SYNC.md) — what the hardware can and cannot do for multi-device timing, and the recipe that works.
239
240
 
240
241
  Reference:
241
242
 
@@ -258,7 +258,16 @@ The Aaronia RTSA SDK (`aaroniartsaapi.h`) exposes a C-style API for maximum comp
258
258
  * **`AARTSAAPI_AvailPackets(AARTSAAPI_Device * dhandle, int32_t channel, int32_t * num)`**: Gets the number of available packets in a specified data `channel`.
259
259
  * **`AARTSAAPI_GetPacket(AARTSAAPI_Device * dhandle, int32_t channel, int32_t index, AARTSAAPI_Packet * packet)`**: Retrieves a specific packet from the output queue.
260
260
  * **`AARTSAAPI_ConsumePackets(AARTSAAPI_Device * dhandle, int32_t channel, int32_t num)`**: Consumes (removes) a number of packets from the data channel. Essential to prevent blocking and data drops.
261
- * **`AARTSAAPI_GetMasterStreamTime(AARTSAAPI_Device * dhandle, double * stime)`**: Gets the current master stream time.
261
+ * **`AARTSAAPI_GetMasterStreamTime(AARTSAAPI_Device * dhandle, double * stime)`**:
262
+ Gets the current master stream time, seconds since the epoch. The
263
+ header declares the out-parameter as a C++ *reference* (`double &`),
264
+ which is a pointer in the ABI — hence `*mut f64` here. Exposed as
265
+ `NativeSdkSource::master_stream_time_ns` and
266
+ `SpectranSource::master_stream_time_ns`, converted to nanoseconds so
267
+ it reads in the same unit as every other clock the crate reports.
268
+ This is the device's own timebase: it paces streams against it, TX
269
+ burst times are expressed in it, and it is what a multi-device
270
+ capture is aligned on — see [SYNC.md](SYNC.md).
262
271
  * **`AARTSAAPI_SendPacket(AARTSAAPI_Device * dhandle, int32_t channel, const AARTSAAPI_Packet * packet)`**: Sends a packet to an inbound channel (for transmission modes).
263
272
 
264
273
  ### File I/O API
@@ -461,7 +470,7 @@ values an `f64`'s step is about **238 ns**, which is the real resolution of
461
470
  any GPS timestamp this device gives — no conversion downstream can improve
462
471
  on it.
463
472
 
464
- `utils::gps_seconds_to_nanos` converts to `i64` nanoseconds by handling the
473
+ `utils::epoch_seconds_to_nanos` converts to `i64` nanoseconds by handling the
465
474
  whole and fractional seconds separately. That is not pedantry: epoch
466
475
  nanoseconds are around `1.7e18`, where an `f64`'s step is 256 ns, so a single
467
476
  `seconds * 1e9` multiply discards tens of nanoseconds the reading still had.
@@ -541,6 +550,13 @@ never sees), so the second path errors instead of corrupting silently.
541
550
  `stop_streaming` clears both carry buffers and the latch, so a
542
551
  restarted session starts clean.
543
552
 
553
+ The SoapySDR plugin exposes the same capture as two RX channels:
554
+ `rx_channel=Rx1And2` at open time makes `getNumChannels(RX)` report 2,
555
+ and `setupStream(RX, …, {0, 1})` switches `readStream` onto the paired
556
+ read. The channel count follows the request rather than the model,
557
+ because dual capture must be configured before the device is opened —
558
+ a full V6 opened single-channel really does have one channel to offer.
559
+
544
560
  > **Hardware-unverified:** like the rest of the `Rx2`/`Rx1+Rx2` paths,
545
561
  > the interleave layout follows the packet contract (`stride` = floats
546
562
  > from sample to sample), not a live dual-channel capture — the
@@ -0,0 +1,118 @@
1
+ # Synchronising two or more receivers
2
+
3
+ The short version: **the SPECTRAN V6 is a shared-reference machine, not a
4
+ commanded-time one.** You cannot tell it to start capturing at a stated
5
+ instant. You can lock several devices to one reference, timestamp
6
+ everything they produce, and align the streams afterwards. That is
7
+ enough for RDF, TDOA and coherent multi-channel work; it is not enough
8
+ for the UHD-style `set_start_time()` pattern, and no amount of API
9
+ wrapping will make it so.
10
+
11
+ ## What the hardware offers
12
+
13
+ | Primitive | Where |
14
+ | --- | --- |
15
+ | Common frequency reference (10 MHz, PPS, GPS) | `device/sclksource` — `set_clock_source()` |
16
+ | GPS discipline and time of day | `device/gpsmode` — `set_gps_mode()`, `gps_time_ns()` |
17
+ | Per-packet hardware timestamps | `AARTSAAPI_Packet::startTime`/`endTime` — `last_timestamp_ns()` |
18
+ | The device's own stream clock | `AARTSAAPI_GetMasterStreamTime` — `master_stream_time_ns()` |
19
+ | Two phase-coherent RX inputs (full V6) | `device/receiverchannel` — `read_samples_dual()` |
20
+
21
+ A V6 ECO with no GPS antenna accepts six clock sources: `Consumer`,
22
+ `Oscillator`, `PPS`, `10MHz`, `Oscillator Provider` and `PPS Provider`.
23
+ The `Provider` variants make that device the reference for others rather
24
+ than a consumer of one. `GPS` and `GPS Provider` appear in the tree but
25
+ are masked out when no antenna is attached, and `clock_sources()`
26
+ filters them for that reason — the list reports what the device will
27
+ accept, not what it can spell.
28
+
29
+ ## What it does not offer
30
+
31
+ The vendor API is 34 functions. There is no set-time, no arm-at-time and
32
+ no trigger among them: the closest thing to time control is
33
+ `AARTSAAPI_GetMasterStreamTime`, which reads. Nothing writes a clock,
34
+ and nothing schedules a capture.
35
+
36
+ Transmission is the exception, and it proves the rule — a `TxBurst`
37
+ carries `startTime`/`endTime` and the device schedules it. The
38
+ capability exists in the FPGA; the API simply does not expose it for
39
+ receive.
40
+
41
+ So a two-receiver capture cannot be started simultaneously by command.
42
+ Both devices are started as promptly as software allows, and the offset
43
+ between them is measured rather than commanded.
44
+
45
+ ## The recipe
46
+
47
+ 1. **Discipline every device from one reference.** One device (or an
48
+ external source) provides; the rest consume:
49
+
50
+ ```rust,no_run
51
+ # async fn discipline() -> sdr_aaronia_rs::Result<()> {
52
+ use sdr_aaronia_rs::{SpectranConfig, SpectranSource};
53
+
54
+ let mut provider =
55
+ SpectranSource::new(SpectranConfig::from_http("http://ref.local:54664")).await?;
56
+ let mut consumer =
57
+ SpectranSource::new(SpectranConfig::from_http("http://sat.local:54664")).await?;
58
+
59
+ provider.set_clock_source("PPS Provider").await?;
60
+ consumer.set_clock_source("PPS").await?;
61
+ # Ok(())
62
+ # }
63
+ ```
64
+
65
+ Confirm by reading back — `set_clock_source` already does, and errors
66
+ if the device did not take it. This is what makes the sample clocks
67
+ agree in *rate*. It does not make them agree in *phase*.
68
+
69
+ 2. **Start the streams.** In whatever order; the offset between them is
70
+ not controlled and does not need to be.
71
+
72
+ 3. **Timestamp everything.** Each packet carries device time.
73
+ `last_timestamp_ns()` reports the most recent one, and every
74
+ SoapySDR buffer comes back flagged `SOAPY_SDR_HAS_TIME`.
75
+
76
+ 4. **Align afterwards.** Compute the offset between the streams from
77
+ their timestamps, then refine it by cross-correlating a common
78
+ signal. The timestamps get you to within the resolution below; the
79
+ correlation gets you the rest of the way, and is what actually
80
+ determines the answer in TDOA work.
81
+
82
+ For two channels on a *single* full V6, none of this applies: `Rx1` and
83
+ `Rx2` share one tuner and one converter, arrive interleaved in one
84
+ packet, and are sample-aligned by construction. Read them with
85
+ `read_samples_dual()` — see [SDKSPEC.md](SDKSPEC.md#receiver-channel-selection-and-dual-channel-capture).
86
+
87
+ ## The resolution floor
88
+
89
+ The device reports both its GPS time and its master stream time as
90
+ `float64` seconds since the epoch. At present-day epoch values an `f64`
91
+ steps about **238 ns**, so those two clocks are quantised at roughly
92
+ that — before any conversion this crate performs, and not something it
93
+ can improve on.
94
+
95
+ For TDOA, 240 ns of timing uncertainty is about **70 m** of ranging
96
+ uncertainty. That is the floor for *timestamp-based* alignment. It is
97
+ not the floor for the technique: cross-correlating a common signal
98
+ resolves far below one sample period, and the timestamps only have to be
99
+ good enough to identify which samples to correlate. Build the geometry
100
+ on the correlation, and use the timestamps to get there.
101
+
102
+ The per-packet stream timestamps are the better of the two clocks for
103
+ this. GPS time is for disciplining and for wall-clock labelling.
104
+
105
+ ## Status
106
+
107
+ Hardware-unverified. The clock-source writes are exercised against a
108
+ single V6 ECO; nothing here has been run across two devices, and the
109
+ crate has never seen a full V6. See
110
+ [VERIFICATION.md](VERIFICATION.md).
111
+
112
+ ## Related
113
+
114
+ - [SDKSPEC.md](SDKSPEC.md) — the native SDK surface, including the
115
+ packet layout and dual-channel capture.
116
+ - [USAGE.md](USAGE.md) — worked examples for each part of the API.
117
+ - [VERIFICATION.md](VERIFICATION.md) — what has been tested against
118
+ hardware.
@@ -0,0 +1,73 @@
1
+ # Hardware verification status
2
+
3
+ The development device is a SPECTRAN V6 ECO — single RX channel, no TX
4
+ licence, no GPS antenna — driven over HTTP through RTSA-Suite PRO from
5
+ macOS and, since 0.8.2, through the native SDK on the Windows machine it
6
+ is attached to.
7
+
8
+ **Live-verified** means an `#[ignore]`d test asserts it against hardware,
9
+ so it cannot regress unnoticed. **Manual** means it was seen working but
10
+ nothing asserts it.
11
+
12
+ | Capability | HTTP | Native SDK |
13
+ | --- | --- | --- |
14
+ | IQ streaming (F32 / F16 / I16 / JSON) | **Live** | **Live** |
15
+ | Spectra streaming | **Live** | **Live** — the ECO's `rtsa` pipeline, 512 frames x 88 bins per packet |
16
+ | Retuning, rate and reference level mid-stream | **Live** | **Live** |
17
+ | Long-run stability, drop and overrun counters | **Live** | **Live** |
18
+ | Sample-rate ladder and usable bandwidth | **Live** | **Live** |
19
+ | Device capability and health reporting | **Live** | Partial — the raw SDK's health tree reads zeros, so sensors are HTTP-only by design |
20
+ | Clock-source and GPS-mode writes | **Live** | Unverified |
21
+ | Auto-reconnect and connect retry | Mock-tested | n/a |
22
+ | End-to-end IQ correctness (`validate-iq-live.py`) | **Live** | — |
23
+ | seify backend | **Live** | **Live** |
24
+ | Python bindings | Manual | Manual |
25
+ | SoapySDR plugin | Manual | Manual |
26
+ | `.rtsa` file playback | **Verified against real captures** | — |
27
+ | TX (`UnifiedSink`, `spectran_sink_*`) | Endpoint only, no RF measured | Unverified |
28
+ | Dual-channel RX (Rust, C, Python, SoapySDR) | — | Unverified — needs a full V6 |
29
+ | GPS time (`gps_time_ns`) | — | Unverified — needs a GPS antenna |
30
+ | Master stream clock (`master_stream_time_ns`) | — | Unverified |
31
+
32
+ ## What is not verified, and why
33
+
34
+ **TX, dual-channel RX, GPS time and multi-device sync** need hardware
35
+ this project does not have: a TX licence, a full V6, a GPS antenna, or a
36
+ second device. If you
37
+ have any of them, exercising one of these paths is the most valuable
38
+ contribution you can make here — the first native-SDK run against a real
39
+ device found five defects that compile-checking never would.
40
+
41
+ **Native-SDK clock and GPS-mode writes** share their shape with the
42
+ HTTP path, which is live-verified, but are not themselves exercised.
43
+
44
+ **Drops.** The live drop test does not assert that drops *occur* — that
45
+ is a property of the link, not the crate, and a fast enough link never
46
+ drops anything. It checks the stream stays coherent and the counters
47
+ agree; the gap logic itself is unit-tested.
48
+
49
+ ## Running them
50
+
51
+ ```bash
52
+ cargo test --all-features --test live_smoke -- --ignored --test-threads=1
53
+ ```
54
+
55
+ The native-SDK set needs the machine the device is attached to, with
56
+ RTSA-Suite PRO closed — it holds the device:
57
+
58
+ ```bash
59
+ cargo test --release --features native-sdk --test native_sdk_live -- --ignored --test-threads=1
60
+ ```
61
+
62
+ `--test-threads=1` matters: one live test writes device state, and a
63
+ concurrent streaming test would see the reference change underneath it.
64
+
65
+ Both suites were re-run in full for 0.10.0 — 21 and 14 tests, all
66
+ passing — along with a SoapySDR probe through the renamed C ABI.
67
+
68
+ ## Related
69
+
70
+ - [QUICKSTART.md](QUICKSTART.md) — setting up an RTSA-Suite mission.
71
+ - [SYNC.md](SYNC.md) — what multi-device timing the hardware supports.
72
+ - [USAGE.md](USAGE.md) — worked examples for each part of the API.
73
+ - [../CHANGELOG.md](../CHANGELOG.md) — what changed in each release.
@@ -67,10 +67,12 @@ async fn main() -> anyhow::Result<()> {
67
67
 
68
68
  // Read the current master stream time from the device
69
69
  // This is required to correctly schedule our packets on the device's FPGA
70
- let mut current_time = sdk_sink.get_master_stream_time()?;
70
+ let now_ns = sdk_sink.master_stream_time_ns()?;
71
71
 
72
- // Pre-buffer by scheduling the first packet 200 ms in the future
73
- current_time += 0.2;
72
+ // Burst times go into the vendor's packet header, which is seconds:
73
+ // convert once, here. Pre-buffer by scheduling the first packet
74
+ // 200 ms in the future.
75
+ let mut current_time = sdr_aaronia_rs::utils::epoch_nanos_to_seconds(now_ns) + 0.2;
74
76
 
75
77
  let duration_per_burst = SAMPLES_PER_BURST as f64 / config.sample_rate_hz;
76
78
 
@@ -187,6 +187,12 @@ intptr_t spectran_source_read_samples_timeout(SpectranSource* source, FfiComplex
187
187
  // the native-SDK backend): fills rx1/rx2 with equal numbers of
188
188
  // time-aligned samples; returns the pair count or -1.
189
189
  intptr_t spectran_source_read_samples_dual(SpectranSource* source, FfiComplex* rx1, FfiComplex* rx2, uintptr_t len);
190
+ // Deadline-bounded dual read, the pair to
191
+ // spectran_source_read_samples_timeout: waits at most timeout_us and
192
+ // returns the pairs collected within the deadline; returns -3 only when
193
+ // the deadline passes with zero pairs. timeout_us == 0 drains without
194
+ // waiting. Both buffers receive the same count, so they stay aligned.
195
+ intptr_t spectran_source_read_samples_dual_timeout(SpectranSource* source, FfiComplex* rx1, FfiComplex* rx2, uintptr_t len, uint64_t timeout_us);
190
196
  bool spectran_source_take_overrun(SpectranSource* source);
191
197
  uint64_t spectran_source_get_cumulative_drops(SpectranSource* source);
192
198
  int64_t spectran_source_get_last_timestamp_ns(SpectranSource* source);
@@ -196,6 +202,12 @@ int64_t spectran_source_get_last_timestamp_ns(SpectranSource* source);
196
202
  // library now, so callers no longer split whole/fractional seconds
197
203
  // themselves. Resolution is ~240 ns, set by the vendor's own double.
198
204
  bool spectran_source_get_gps_time_ns(SpectranSource* source, int64_t* out_gps_time_ns);
205
+ // The device's master stream clock, nanoseconds since the Unix epoch:
206
+ // the timebase it paces streams against, readable before the first
207
+ // packet (unlike get_last_timestamp_ns, which is 0 until one arrives).
208
+ // Native-SDK backend only; false over HTTP and file playback.
209
+ // out_time_ns may be NULL to probe only whether the clock is readable.
210
+ bool spectran_source_get_master_stream_time_ns(SpectranSource* source, int64_t* out_time_ns);
199
211
  SpectranFfiError spectran_source_start_streaming(SpectranSource* source);
200
212
  SpectranFfiError spectran_source_stop_streaming(SpectranSource* source);
201
213
  SpectranFfiError spectran_source_set_center_frequency_hz(SpectranSource* source, double hz);
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "python-aaronia"
3
- version = "0.10.0"
3
+ version = "0.11.1"
4
4
  edition = "2024"
5
5
  description = "Python bindings for sdr-aaronia-rs (Aaronia SPECTRAN V6 SDR source)"
6
6
  license = "GPL-3.0-or-later"
@@ -143,6 +143,12 @@ class SpectranSource:
143
143
  def read_samples_numpy(self, count: int) -> npt.NDArray[np.complex64]:
144
144
  """Read up to ``count`` IQ samples into a NumPy ``complex64`` array.
145
145
 
146
+ A read never spans a retune: if the centre frequency changes
147
+ part-way through, the read returns the samples captured before
148
+ it and the rest arrive on the next call. Expect a short array
149
+ around a :meth:`set_center_frequency_hz`, rather than one whose
150
+ samples come from two different frequencies.
151
+
146
152
  Raises ``SpectranTimeoutError`` if no data arrives within
147
153
  ``config.read_timeout_s``, ``SpectranConnectionError`` if the
148
154
  stream is closed, and ``ValueError`` if ``count`` exceeds the
@@ -181,6 +187,18 @@ class SpectranSource:
181
187
  """Epoch-nanosecond timestamp of the most recent block.
182
188
  HTTP backend only; 0 otherwise."""
183
189
 
190
+ def master_stream_time_ns(self) -> Optional[int]:
191
+ """The device's master stream clock, in epoch nanoseconds.
192
+
193
+ The timebase the device paces streams against, readable before
194
+ the first block arrives. Native-SDK backend only; None otherwise."""
195
+
196
+ def gps_time_ns(self) -> Optional[int]:
197
+ """The latest GPS time in epoch nanoseconds, or None without a fix.
198
+
199
+ Native-SDK backend only, and only once ``device/gpsmode`` is
200
+ enabled — the device ships with GPS off."""
201
+
184
202
  class BlockIterator:
185
203
  """Iterator returned by :meth:`SpectranSource.blocks`."""
186
204
 
@@ -601,6 +601,31 @@ impl PySpectranSource {
601
601
  .ok_or_else(|| SpectranHardwareError::new_err("Not streaming"))?;
602
602
  Ok(source.last_timestamp_ns())
603
603
  }
604
+
605
+ /// The device's master stream clock (epoch ns) — the timebase it
606
+ /// paces streams against. Unlike `last_timestamp_ns` it is readable
607
+ /// before the first block arrives. Native-SDK backend only; `None`
608
+ /// otherwise.
609
+ fn master_stream_time_ns(&mut self) -> PyResult<Option<i64>> {
610
+ let source = self
611
+ .source
612
+ .as_mut()
613
+ .ok_or_else(|| SpectranHardwareError::new_err("Not streaming"))?;
614
+ Ok(source.master_stream_time_ns())
615
+ }
616
+
617
+ /// The latest GPS time (epoch ns), or `None` without a valid fix.
618
+ /// Native-SDK backend only, and only once `device/gpsmode` has been
619
+ /// enabled — the device ships with GPS off, in which state this
620
+ /// stays `None`. Quantised at roughly 240 ns by the device's own
621
+ /// reading.
622
+ fn gps_time_ns(&mut self) -> PyResult<Option<i64>> {
623
+ let source = self
624
+ .source
625
+ .as_mut()
626
+ .ok_or_else(|| SpectranHardwareError::new_err("Not streaming"))?;
627
+ Ok(source.gps_time_ns())
628
+ }
604
629
  }
605
630
 
606
631
  /// Iterator over fixed-size sample blocks, returned by
@@ -95,7 +95,7 @@ if ! skipped fences; then
95
95
  }
96
96
  }
97
97
  END { exit rc }
98
- ' README.md docs/QUICKSTART.md docs/USAGE.md PLUGINS.md
98
+ ' README.md docs/QUICKSTART.md docs/USAGE.md PLUGINS.md docs/SYNC.md
99
99
  fi
100
100
 
101
101
  if ! skipped test; then
@@ -120,6 +120,39 @@ receiver clock and reaches higher, by how much is unsettled — see [the
120
120
  note in HTTPSPEC](../docs/HTTPSPEC.md#unresolved-the-full-v6s-top-rate).
121
121
  There, `getSampleRate` while streaming is the number to trust.
122
122
 
123
+ ## Dual-channel RX
124
+
125
+ A full SPECTRAN V6 has two RF inputs. Ask for both at open time and the
126
+ plugin advertises two RX channels:
127
+
128
+ ```python
129
+ sdr = SoapySDR.Device("driver=aaronia,sdk=1,rx_channel=Rx1And2")
130
+ sdr.getNumChannels(SOAPY_SDR_RX) # 2
131
+ stream = sdr.setupStream(SOAPY_SDR_RX, SOAPY_SDR_CF32, [0, 1])
132
+ ```
133
+
134
+ `readStream` then fills both buffers with the same number of samples,
135
+ index-aligned in time. Channel 0 is Rx1, channel 1 is Rx2.
136
+
137
+ It has to be requested before the device opens, so a V6 opened without
138
+ it reports one channel — the count describes this session, not the
139
+ model. The request needs the native-SDK backend; over HTTP it is
140
+ ignored with a warning.
141
+
142
+ Valid channel sets are `{0}`, `{0, 1}` and `{1, 0}` — the list order
143
+ decides which receiver each buffer gets. `{1}` is not one: the SDK
144
+ interleaves both receivers into a single packet, so Rx2 never arrives
145
+ without Rx1. Open with `rx_channel=Rx2` for a single-channel stream from
146
+ the second input.
147
+
148
+ Both channels share one tuner: centre frequency, sample rate and
149
+ reference level are device-wide, and setting them on either channel sets
150
+ them for both.
151
+
152
+ > **Hardware-unverified.** The development device is a single-channel V6
153
+ > ECO, so this path follows the packet contract rather than a live
154
+ > capture. See [VERIFICATION.md](../docs/VERIFICATION.md).
155
+
123
156
  ## What the probe reports
124
157
 
125
158
  Over the HTTP backend, `SoapySDRUtil --probe` reports the attached
@@ -166,6 +199,11 @@ it does offer. `listClockSources` reports the device's own vocabulary.
166
199
  - `hasHardwareTime("GPS")` reports a value rather than a capability —
167
200
  true only on the native-SDK backend with a valid fix.
168
201
  `getHardwareTime("GPS")` returns epoch nanoseconds.
202
+ - `getHardwareTime("master")` returns the device's own stream clock in
203
+ epoch nanoseconds — the timebase it paces streams against, and the one
204
+ to align two receivers on. Native-SDK backend only. Unlike the default
205
+ it is readable before the first packet, so a probe gets a real answer.
206
+ See [SYNC.md](../docs/SYNC.md).
169
207
  - The single gain element, `REF`, is the Aaronia reference level in dBm.
170
208
  It is not an amplifier gain: raising it reduces sensitivity. Range and
171
209
  step come from the device — −55…+23 dBm in 0.5 dB steps on a V6 ECO.
@@ -174,8 +212,6 @@ it does offer. `listClockSources` reports the device's own vocabulary.
174
212
 
175
213
  ## Known limitations
176
214
 
177
- - One RX channel through the plugin. `rx_channel=Rx2` selects the second
178
- antenna input; simultaneous dual-channel reads are available through
179
- the crate's Rust, Python and C APIs, not the SoapySDR interface.
215
+ - Dual RX is hardware-unverified — see [Dual-channel RX](#dual-channel-rx).
180
216
  - Enumeration advertises a default localhost candidate without probing
181
217
  it, because `find()` must not block on the network.
@@ -167,8 +167,17 @@ static SoapySDR::Device *makeAaronia(const SoapySDR::Kwargs &args) {
167
167
  // itself streams channel 0; Rx2 selects the second antenna input.
168
168
  if (args.count("rx_channel") != 0) {
169
169
  const std::string &ch = args.at("rx_channel");
170
- int32_t sel = ch == "Rx2" ? 1 : (ch == "Rx1And2" ? 2 : 0);
171
- spectran_source_builder_receiver_channel(builder, sel);
170
+ // Warn rather than silently fall back to Rx1: a typo used to
171
+ // hand back a single-channel Rx1 stream that looked exactly
172
+ // like a working dual request.
173
+ if (ch != "Rx1" && ch != "Rx2" && ch != "Rx1And2") {
174
+ SoapySDR::logf(SOAPY_SDR_WARNING,
175
+ "aaronia: ignoring rx_channel=%s; expected Rx1, Rx2 or Rx1And2",
176
+ ch.c_str());
177
+ } else {
178
+ int32_t sel = ch == "Rx2" ? 1 : (ch == "Rx1And2" ? 2 : 0);
179
+ spectran_source_builder_receiver_channel(builder, sel);
180
+ }
172
181
  }
173
182
  // read_timeout=<seconds>: only affects the crate's own blocking
174
183
  // reads. readStream always passes SoapySDR's per-call timeoutUs, so