pluto-siege 2.0.1__py3-none-any.whl
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.
- pluto_siege/__init__.py +43 -0
- pluto_siege/__main__.py +23 -0
- pluto_siege/config.py +249 -0
- pluto_siege/constants.py +132 -0
- pluto_siege/device.py +311 -0
- pluto_siege/dsp.py +157 -0
- pluto_siege/engine.py +460 -0
- pluto_siege/gps.py +419 -0
- pluto_siege/settings.py +54 -0
- pluto_siege/sigmf.py +329 -0
- pluto_siege/sound.py +162 -0
- pluto_siege/ui/__init__.py +21 -0
- pluto_siege/ui/screens/__init__.py +32 -0
- pluto_siege/ui/screens/capture.py +226 -0
- pluto_siege/ui/screens/loopback.py +90 -0
- pluto_siege/ui/screens/main_menu.py +363 -0
- pluto_siege/ui/screens/settings.py +504 -0
- pluto_siege/ui/screens/transmit.py +240 -0
- pluto_siege/ui/widgets/__init__.py +45 -0
- pluto_siege/ui/widgets/framework.py +863 -0
- pluto_siege-2.0.1.dist-info/METADATA +187 -0
- pluto_siege-2.0.1.dist-info/RECORD +26 -0
- pluto_siege-2.0.1.dist-info/WHEEL +5 -0
- pluto_siege-2.0.1.dist-info/entry_points.txt +2 -0
- pluto_siege-2.0.1.dist-info/licenses/LICENSE +674 -0
- pluto_siege-2.0.1.dist-info/top_level.txt +1 -0
pluto_siege/engine.py
ADDED
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
# PlutoSiege - RF capture and replay tool for PlutoSDR.
|
|
2
|
+
# Copyright (C) 2026 SketchTurnerDev
|
|
3
|
+
#
|
|
4
|
+
# This program is free software: you can redistribute it and/or modify
|
|
5
|
+
# it under the terms of the GNU General Public License as published by
|
|
6
|
+
# the Free Software Foundation, either version 3 of the License, or
|
|
7
|
+
# (at your option) any later version.
|
|
8
|
+
#
|
|
9
|
+
# This program is distributed in the hope that it will be useful,
|
|
10
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
11
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
12
|
+
# GNU General Public License for more details.
|
|
13
|
+
#
|
|
14
|
+
# You should have received a copy of the GNU General Public License
|
|
15
|
+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
16
|
+
|
|
17
|
+
"""Engines for RF signal capture, transmission, and loopback testing."""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import datetime
|
|
22
|
+
import math
|
|
23
|
+
import os
|
|
24
|
+
import time
|
|
25
|
+
from collections import deque
|
|
26
|
+
from dataclasses import replace
|
|
27
|
+
from typing import TYPE_CHECKING, Callable
|
|
28
|
+
|
|
29
|
+
import numpy as np
|
|
30
|
+
from numpy.typing import NDArray
|
|
31
|
+
|
|
32
|
+
from pluto_siege.config import CONFIG, AppConfig
|
|
33
|
+
from pluto_siege.constants import (
|
|
34
|
+
AUTO_MARGIN_RANGE,
|
|
35
|
+
BYTES_PER_SAMPLE,
|
|
36
|
+
LO_TOLERANCE_HZ,
|
|
37
|
+
LOOPBACK_TONE_HZ,
|
|
38
|
+
LOOPBACK_TX_AMPLITUDE,
|
|
39
|
+
MANUAL_RELEASE_DROP_DB,
|
|
40
|
+
MAX_RAM_BYTES,
|
|
41
|
+
MAX_TX_BURST_SAMPLES,
|
|
42
|
+
MIN_TRIGGER_SUB_WINDOWS,
|
|
43
|
+
NF_PROBE_BUFFERS,
|
|
44
|
+
POST_TRIGGER_RANGE,
|
|
45
|
+
PRE_TRIGGER_RANGE,
|
|
46
|
+
RAM_SAFETY_FACTOR,
|
|
47
|
+
RELEASE_MARGIN_DB,
|
|
48
|
+
RX_FLUSH_BUFFERS,
|
|
49
|
+
SILENCE_RANGE,
|
|
50
|
+
SR_TOLERANCE_HZ,
|
|
51
|
+
THRESHOLD_RANGE,
|
|
52
|
+
TX_BACKOFF,
|
|
53
|
+
TX_DAC_MAX,
|
|
54
|
+
TX_DRAIN_MARGIN_SECONDS,
|
|
55
|
+
)
|
|
56
|
+
from pluto_siege.device import (
|
|
57
|
+
SDRDevice,
|
|
58
|
+
_check_number,
|
|
59
|
+
cfg_loopback,
|
|
60
|
+
cfg_rx,
|
|
61
|
+
cfg_tx,
|
|
62
|
+
cleanup_sdr,
|
|
63
|
+
safe_rx,
|
|
64
|
+
set_io_timeout,
|
|
65
|
+
suppress_c_stderr,
|
|
66
|
+
)
|
|
67
|
+
from pluto_siege.dsp import (
|
|
68
|
+
calculate_snr_db,
|
|
69
|
+
dbfs_to_power,
|
|
70
|
+
is_saturated,
|
|
71
|
+
noise_floor_dbfs,
|
|
72
|
+
power_to_dbfs,
|
|
73
|
+
subwindow_powers,
|
|
74
|
+
)
|
|
75
|
+
from pluto_siege.sigmf import load_sigmf_meta, save_sigmf_pair, to_sigmf_utc
|
|
76
|
+
|
|
77
|
+
if TYPE_CHECKING:
|
|
78
|
+
from pluto_siege.gps import GPSFix
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class CaptureEngine:
|
|
82
|
+
"""Receiver setup, noise estimation, trigger detection, and sample collection."""
|
|
83
|
+
|
|
84
|
+
def __init__(self, sdr: SDRDevice, config: AppConfig | None = None) -> None:
|
|
85
|
+
self.sdr = sdr
|
|
86
|
+
self.config: AppConfig = CONFIG if config is None else config
|
|
87
|
+
self.actual_sr = 0
|
|
88
|
+
self.actual_freq = 0
|
|
89
|
+
self.buf_size = 0
|
|
90
|
+
self.noise_floor = -120.0
|
|
91
|
+
self.trigger_threshold = 0.0
|
|
92
|
+
self.release_threshold = 0.0
|
|
93
|
+
self.io_timeout_available = True
|
|
94
|
+
self._prepared = False
|
|
95
|
+
self._capture_config: AppConfig | None = None
|
|
96
|
+
self.captured_data: NDArray[np.complex64] | None = None
|
|
97
|
+
self.start_time: datetime.datetime | None = None
|
|
98
|
+
self._reset_result()
|
|
99
|
+
|
|
100
|
+
def _reset_result(self) -> None:
|
|
101
|
+
self.current_level_dbfs = -120.0
|
|
102
|
+
self.current_saturated = False
|
|
103
|
+
self.captured_data = None
|
|
104
|
+
self.start_time = None
|
|
105
|
+
self.is_partial = False
|
|
106
|
+
self.is_aborted = False
|
|
107
|
+
|
|
108
|
+
def _receive_buffer(self) -> NDArray[np.complex64]:
|
|
109
|
+
data = safe_rx(self.sdr)
|
|
110
|
+
if self.buf_size and data.size != self.buf_size:
|
|
111
|
+
raise OSError("SDR buffer size changed during capture; recording stopped")
|
|
112
|
+
return data
|
|
113
|
+
|
|
114
|
+
def prepare(self) -> None:
|
|
115
|
+
"""Configure RX, snapshot settings, validate memory, and estimate thresholds."""
|
|
116
|
+
self._prepared = False
|
|
117
|
+
self._reset_result()
|
|
118
|
+
self._capture_config = replace(self.config)
|
|
119
|
+
cfg = self._capture_config
|
|
120
|
+
_check_number(
|
|
121
|
+
cfg.pre_trigger_buffers,
|
|
122
|
+
PRE_TRIGGER_RANGE,
|
|
123
|
+
"Pre-trigger buffers",
|
|
124
|
+
integral=True,
|
|
125
|
+
)
|
|
126
|
+
_check_number(
|
|
127
|
+
cfg.max_post_trigger_seconds, POST_TRIGGER_RANGE, "Post-trigger duration"
|
|
128
|
+
)
|
|
129
|
+
_check_number(cfg.silence_seconds, SILENCE_RANGE, "Silence duration")
|
|
130
|
+
_check_number(cfg.manual_threshold, THRESHOLD_RANGE, "Manual threshold")
|
|
131
|
+
_check_number(cfg.auto_trigger_margin, AUTO_MARGIN_RANGE, "Auto trigger margin")
|
|
132
|
+
try:
|
|
133
|
+
with suppress_c_stderr():
|
|
134
|
+
self.io_timeout_available = cfg_rx(self.sdr, config=cfg)
|
|
135
|
+
self.actual_sr = int(self.sdr.sample_rate)
|
|
136
|
+
self.actual_freq = int(self.sdr.rx_lo)
|
|
137
|
+
lo_min, lo_max = cfg.freq_bounds
|
|
138
|
+
if (
|
|
139
|
+
self.actual_sr <= 0
|
|
140
|
+
or not (lo_min - LO_TOLERANCE_HZ <= self.actual_freq <= lo_max + LO_TOLERANCE_HZ)
|
|
141
|
+
):
|
|
142
|
+
raise OSError("Device reported invalid RX sample rate or frequency")
|
|
143
|
+
for _ in range(RX_FLUSH_BUFFERS):
|
|
144
|
+
safe_rx(self.sdr)
|
|
145
|
+
probe = safe_rx(self.sdr)
|
|
146
|
+
self.buf_size = probe.size
|
|
147
|
+
|
|
148
|
+
max_post_buffers = max(
|
|
149
|
+
1,
|
|
150
|
+
math.ceil(
|
|
151
|
+
cfg.max_post_trigger_seconds * self.actual_sr / self.buf_size
|
|
152
|
+
),
|
|
153
|
+
)
|
|
154
|
+
total_buffers = cfg.pre_trigger_buffers + 1 + max_post_buffers
|
|
155
|
+
est_ram = (
|
|
156
|
+
total_buffers * self.buf_size * BYTES_PER_SAMPLE * RAM_SAFETY_FACTOR
|
|
157
|
+
)
|
|
158
|
+
if est_ram > MAX_RAM_BYTES:
|
|
159
|
+
raise ValueError(
|
|
160
|
+
"Estimated RAM exceeds limit. Reduce SR or max_post_trigger_seconds."
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
if cfg.auto_threshold:
|
|
164
|
+
nf_pool = [subwindow_powers(probe)]
|
|
165
|
+
for _ in range(NF_PROBE_BUFFERS - 1):
|
|
166
|
+
nf_pool.append(subwindow_powers(self._receive_buffer()))
|
|
167
|
+
self.noise_floor = noise_floor_dbfs(np.concatenate(nf_pool))
|
|
168
|
+
self.trigger_threshold = self.noise_floor + cfg.auto_trigger_margin
|
|
169
|
+
self.release_threshold = min(
|
|
170
|
+
self.noise_floor + RELEASE_MARGIN_DB,
|
|
171
|
+
self.trigger_threshold - 1.0,
|
|
172
|
+
)
|
|
173
|
+
else:
|
|
174
|
+
self.noise_floor = -120.0
|
|
175
|
+
self.trigger_threshold = cfg.manual_threshold
|
|
176
|
+
self.release_threshold = (
|
|
177
|
+
self.trigger_threshold - MANUAL_RELEASE_DROP_DB
|
|
178
|
+
)
|
|
179
|
+
self._prepared = True
|
|
180
|
+
except BaseException:
|
|
181
|
+
cleanup_sdr(self.sdr)
|
|
182
|
+
raise
|
|
183
|
+
|
|
184
|
+
def _collect_post_trigger_chunks(
|
|
185
|
+
self,
|
|
186
|
+
initial_data: NDArray[np.complex64],
|
|
187
|
+
check_abort: Callable[[], bool],
|
|
188
|
+
max_post_buffers: int,
|
|
189
|
+
silence_buf_limit: int,
|
|
190
|
+
) -> list[NDArray[np.complex64]]:
|
|
191
|
+
"""Collect post-trigger buffers until silence, duration limit, or cancellation."""
|
|
192
|
+
chunks = [initial_data]
|
|
193
|
+
silence_count = 0
|
|
194
|
+
post_buf_count = 0
|
|
195
|
+
release_power = dbfs_to_power(self.release_threshold)
|
|
196
|
+
while silence_count < silence_buf_limit and post_buf_count < max_post_buffers:
|
|
197
|
+
if check_abort():
|
|
198
|
+
self.is_aborted = True
|
|
199
|
+
break
|
|
200
|
+
data = self._receive_buffer()
|
|
201
|
+
chunks.append(data)
|
|
202
|
+
post_buf_count += 1
|
|
203
|
+
silence_count = (
|
|
204
|
+
silence_count + 1
|
|
205
|
+
if float(np.max(subwindow_powers(data))) < release_power
|
|
206
|
+
else 0
|
|
207
|
+
)
|
|
208
|
+
self.is_partial = (
|
|
209
|
+
post_buf_count >= max_post_buffers and silence_count < silence_buf_limit
|
|
210
|
+
)
|
|
211
|
+
if silence_count > 1 and not self.is_partial and not self.is_aborted:
|
|
212
|
+
chunks = chunks[: -(silence_count - 1)]
|
|
213
|
+
return chunks
|
|
214
|
+
|
|
215
|
+
def _finalize_captured_buffer(
|
|
216
|
+
self,
|
|
217
|
+
pre_trigger_data: list[NDArray[np.complex64]],
|
|
218
|
+
chunks: list[NDArray[np.complex64]],
|
|
219
|
+
prefix_samples: int,
|
|
220
|
+
trigger_time: datetime.datetime,
|
|
221
|
+
) -> None:
|
|
222
|
+
"""Remove pre-trigger DC offset and assemble a timestamped buffer."""
|
|
223
|
+
if not chunks:
|
|
224
|
+
self.captured_data = np.empty(0, dtype=np.complex64)
|
|
225
|
+
self.start_time = None
|
|
226
|
+
return
|
|
227
|
+
if any(is_saturated(chunk) for chunk in chunks):
|
|
228
|
+
self.current_saturated = True
|
|
229
|
+
valid_pre = [chunk for chunk in pre_trigger_data if chunk.size > 0]
|
|
230
|
+
dc_offset = (
|
|
231
|
+
np.complex64(np.concatenate(valid_pre).mean(dtype=np.complex128))
|
|
232
|
+
if valid_pre
|
|
233
|
+
else np.complex64(0)
|
|
234
|
+
)
|
|
235
|
+
captured = np.concatenate(chunks).astype(np.complex64, copy=False)
|
|
236
|
+
captured -= dc_offset
|
|
237
|
+
self.captured_data = captured
|
|
238
|
+
self.start_time = trigger_time - datetime.timedelta(
|
|
239
|
+
seconds=prefix_samples / self.actual_sr if self.actual_sr > 0 else 0.0
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
def listen_and_capture(
|
|
243
|
+
self,
|
|
244
|
+
check_abort: Callable[[], bool],
|
|
245
|
+
on_trigger_detected: Callable[[], None] | None = None,
|
|
246
|
+
on_meter_update: Callable[[float, bool], None] | None = None,
|
|
247
|
+
) -> None:
|
|
248
|
+
"""Listen using the prepared settings and capture a burst upon trigger."""
|
|
249
|
+
if not self._prepared:
|
|
250
|
+
raise RuntimeError(
|
|
251
|
+
"CaptureEngine.prepare() must be called before listen_and_capture()"
|
|
252
|
+
)
|
|
253
|
+
self._reset_result()
|
|
254
|
+
cfg = self._capture_config if self._capture_config is not None else self.config
|
|
255
|
+
with suppress_c_stderr():
|
|
256
|
+
ring: deque[NDArray[np.complex64]] = deque(maxlen=cfg.pre_trigger_buffers)
|
|
257
|
+
for _ in range(cfg.pre_trigger_buffers):
|
|
258
|
+
if check_abort():
|
|
259
|
+
self.is_aborted = True
|
|
260
|
+
return
|
|
261
|
+
ring.append(self._receive_buffer())
|
|
262
|
+
trigger_power = dbfs_to_power(self.trigger_threshold)
|
|
263
|
+
max_post_buffers = max(
|
|
264
|
+
1,
|
|
265
|
+
math.ceil(
|
|
266
|
+
cfg.max_post_trigger_seconds * self.actual_sr / self.buf_size
|
|
267
|
+
),
|
|
268
|
+
)
|
|
269
|
+
silence_buf_limit = max(
|
|
270
|
+
1, math.ceil(cfg.silence_seconds * self.actual_sr / self.buf_size)
|
|
271
|
+
)
|
|
272
|
+
while True:
|
|
273
|
+
if check_abort():
|
|
274
|
+
self.is_aborted = True
|
|
275
|
+
return
|
|
276
|
+
data = self._receive_buffer()
|
|
277
|
+
powers = subwindow_powers(data)
|
|
278
|
+
self.current_level_dbfs = power_to_dbfs(float(np.max(powers)))
|
|
279
|
+
self.current_saturated = is_saturated(data)
|
|
280
|
+
hot_windows = int(np.count_nonzero(powers > trigger_power))
|
|
281
|
+
if on_meter_update:
|
|
282
|
+
on_meter_update(self.current_level_dbfs, self.current_saturated)
|
|
283
|
+
if hot_windows >= MIN_TRIGGER_SUB_WINDOWS:
|
|
284
|
+
trigger_time = datetime.datetime.now(datetime.timezone.utc)
|
|
285
|
+
if on_trigger_detected:
|
|
286
|
+
on_trigger_detected()
|
|
287
|
+
pre_trigger_data = list(ring)
|
|
288
|
+
prefix_samples = (
|
|
289
|
+
sum(chunk.size for chunk in pre_trigger_data) + data.size
|
|
290
|
+
)
|
|
291
|
+
post_chunks = self._collect_post_trigger_chunks(
|
|
292
|
+
data, check_abort, max_post_buffers, silence_buf_limit
|
|
293
|
+
)
|
|
294
|
+
self._finalize_captured_buffer(
|
|
295
|
+
pre_trigger_data,
|
|
296
|
+
pre_trigger_data + post_chunks,
|
|
297
|
+
prefix_samples,
|
|
298
|
+
trigger_time,
|
|
299
|
+
)
|
|
300
|
+
break
|
|
301
|
+
ring.append(data)
|
|
302
|
+
|
|
303
|
+
def save_recording(
|
|
304
|
+
self, records_dir: str, hw_model: str, gps_fix: GPSFix | None = None
|
|
305
|
+
) -> str:
|
|
306
|
+
"""Save captured buffer as a SigMF recording pair and return base path."""
|
|
307
|
+
if (
|
|
308
|
+
self.captured_data is None
|
|
309
|
+
or self.captured_data.size == 0
|
|
310
|
+
or self.start_time is None
|
|
311
|
+
):
|
|
312
|
+
raise ValueError("No captured data to save")
|
|
313
|
+
os.makedirs(records_dir, exist_ok=True)
|
|
314
|
+
timestamp = self.start_time.strftime("%Y%m%d_%H%M%S_%f")
|
|
315
|
+
base = os.path.join(
|
|
316
|
+
records_dir, f"rec_{timestamp}_{self.actual_freq}_{self.actual_sr}"
|
|
317
|
+
)
|
|
318
|
+
save_sigmf_pair(
|
|
319
|
+
base,
|
|
320
|
+
self.captured_data,
|
|
321
|
+
self.actual_freq,
|
|
322
|
+
self.actual_sr,
|
|
323
|
+
to_sigmf_utc(self.start_time),
|
|
324
|
+
hw_model,
|
|
325
|
+
gps_fix=gps_fix,
|
|
326
|
+
)
|
|
327
|
+
return base
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
class TransmitEngine:
|
|
331
|
+
"""Load a bounded recording and transmit one non-cyclic, drained burst."""
|
|
332
|
+
|
|
333
|
+
@staticmethod
|
|
334
|
+
def load_payload(path: str) -> NDArray[np.complex64]:
|
|
335
|
+
clean_path = os.path.normpath(
|
|
336
|
+
path.removesuffix(".sigmf-meta").removesuffix(".sigmf-data") + ".sigmf-data"
|
|
337
|
+
)
|
|
338
|
+
if not os.path.isfile(clean_path):
|
|
339
|
+
raise ValueError("Target is not a regular file.")
|
|
340
|
+
file_size = os.path.getsize(clean_path)
|
|
341
|
+
if file_size == 0:
|
|
342
|
+
raise ValueError("Recording is empty")
|
|
343
|
+
if file_size % BYTES_PER_SAMPLE != 0:
|
|
344
|
+
raise ValueError("File size not aligned to complex64")
|
|
345
|
+
n_samples = file_size // BYTES_PER_SAMPLE
|
|
346
|
+
if n_samples > MAX_TX_BURST_SAMPLES:
|
|
347
|
+
raise ValueError(
|
|
348
|
+
f"Recording too large: {n_samples} samples (limit {MAX_TX_BURST_SAMPLES})"
|
|
349
|
+
)
|
|
350
|
+
data = np.fromfile(clean_path, dtype="<c8", count=n_samples).astype(
|
|
351
|
+
np.complex64, copy=False
|
|
352
|
+
)
|
|
353
|
+
if data.size != n_samples:
|
|
354
|
+
raise ValueError("Incomplete read of recording")
|
|
355
|
+
peak = float(np.max(np.abs(data.view(np.float32))))
|
|
356
|
+
if not math.isfinite(peak):
|
|
357
|
+
raise ValueError("NaN/Inf in recording")
|
|
358
|
+
if peak < 1e-6:
|
|
359
|
+
raise ValueError("Recording is too quiet or empty")
|
|
360
|
+
data *= np.float32(TX_BACKOFF * TX_DAC_MAX / peak)
|
|
361
|
+
return data
|
|
362
|
+
|
|
363
|
+
@classmethod
|
|
364
|
+
def prepare_transmission(
|
|
365
|
+
cls, path: str, bounds: tuple[int, int] | None = None
|
|
366
|
+
) -> tuple[NDArray[np.complex64], int, int]:
|
|
367
|
+
use_sr, use_freq, problem = load_sigmf_meta(path, bounds=bounds)
|
|
368
|
+
if problem is not None or use_sr is None or use_freq is None:
|
|
369
|
+
raise ValueError(f"untrusted recording - {problem}")
|
|
370
|
+
return cls.load_payload(path), use_sr, use_freq
|
|
371
|
+
|
|
372
|
+
@staticmethod
|
|
373
|
+
def transmit(
|
|
374
|
+
sdr: SDRDevice,
|
|
375
|
+
data: NDArray[np.complex64],
|
|
376
|
+
sample_rate: int,
|
|
377
|
+
freq: int,
|
|
378
|
+
config: AppConfig | None = None,
|
|
379
|
+
) -> bool:
|
|
380
|
+
try:
|
|
381
|
+
if (
|
|
382
|
+
not isinstance(data, np.ndarray)
|
|
383
|
+
or data.ndim != 1
|
|
384
|
+
or not np.iscomplexobj(data)
|
|
385
|
+
):
|
|
386
|
+
raise ValueError("TX payload must be a one-dimensional complex array")
|
|
387
|
+
if not 0 < data.size <= MAX_TX_BURST_SAMPLES:
|
|
388
|
+
raise ValueError("TX payload is empty or exceeds the sample limit")
|
|
389
|
+
if not bool(np.all(np.isfinite(data))):
|
|
390
|
+
raise ValueError("TX payload contains non-finite samples")
|
|
391
|
+
peak = max(
|
|
392
|
+
float(np.max(np.abs(data.real))), float(np.max(np.abs(data.imag)))
|
|
393
|
+
)
|
|
394
|
+
if peak > TX_DAC_MAX:
|
|
395
|
+
raise ValueError("TX payload exceeds the DAC amplitude limit")
|
|
396
|
+
payload = np.ascontiguousarray(data, dtype=np.complex64)
|
|
397
|
+
cfg_tx(sdr, sample_rate, freq, config=config)
|
|
398
|
+
actual_sr = int(sdr.sample_rate)
|
|
399
|
+
actual_freq = int(sdr.tx_lo)
|
|
400
|
+
if (
|
|
401
|
+
actual_sr <= 0
|
|
402
|
+
or abs(actual_sr - sample_rate) > SR_TOLERANCE_HZ
|
|
403
|
+
or abs(actual_freq - freq) > LO_TOLERANCE_HZ
|
|
404
|
+
):
|
|
405
|
+
raise OSError(
|
|
406
|
+
"SDR did not apply the recording's sample rate or frequency"
|
|
407
|
+
)
|
|
408
|
+
duration = payload.size / actual_sr
|
|
409
|
+
timeout_ok = set_io_timeout(sdr, duration)
|
|
410
|
+
with suppress_c_stderr():
|
|
411
|
+
sdr.tx(payload)
|
|
412
|
+
deadline = time.monotonic() + duration + TX_DRAIN_MARGIN_SECONDS
|
|
413
|
+
while True:
|
|
414
|
+
remaining = deadline - time.monotonic()
|
|
415
|
+
if remaining <= 0:
|
|
416
|
+
break
|
|
417
|
+
time.sleep(min(remaining, 0.05))
|
|
418
|
+
return timeout_ok
|
|
419
|
+
finally:
|
|
420
|
+
cleanup_sdr(sdr)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
class LoopbackTester:
|
|
424
|
+
"""Digital loopback tone generator and SNR verifier."""
|
|
425
|
+
|
|
426
|
+
@staticmethod
|
|
427
|
+
def run_test(
|
|
428
|
+
sdr: SDRDevice,
|
|
429
|
+
sample_rate: int,
|
|
430
|
+
rx_buffer_size: int,
|
|
431
|
+
config: AppConfig | None = None,
|
|
432
|
+
) -> tuple[float, float, bool]:
|
|
433
|
+
cfg = replace(
|
|
434
|
+
CONFIG if config is None else config,
|
|
435
|
+
sample_rate=sample_rate,
|
|
436
|
+
rx_buffer_size=rx_buffer_size,
|
|
437
|
+
)
|
|
438
|
+
try:
|
|
439
|
+
timeout_ok = cfg_loopback(sdr, config=cfg)
|
|
440
|
+
fs = int(sdr.sample_rate)
|
|
441
|
+
if fs <= 0 or rx_buffer_size <= 0:
|
|
442
|
+
raise OSError("Invalid sample rate or buffer size reported by SDR")
|
|
443
|
+
n_samples = int(rx_buffer_size)
|
|
444
|
+
tone_bin = max(1, round(LOOPBACK_TONE_HZ * n_samples / fs))
|
|
445
|
+
tone_freq = tone_bin * fs / n_samples
|
|
446
|
+
sample_indices = np.arange(n_samples, dtype=np.float64)
|
|
447
|
+
tx = (
|
|
448
|
+
LOOPBACK_TX_AMPLITUDE
|
|
449
|
+
* np.exp(2j * np.pi * tone_bin * sample_indices / n_samples)
|
|
450
|
+
* TX_DAC_MAX
|
|
451
|
+
).astype(np.complex64)
|
|
452
|
+
with suppress_c_stderr():
|
|
453
|
+
sdr.tx(tx)
|
|
454
|
+
time.sleep(0.05)
|
|
455
|
+
for _ in range(2):
|
|
456
|
+
safe_rx(sdr)
|
|
457
|
+
rx = safe_rx(sdr)
|
|
458
|
+
return calculate_snr_db(rx, fs, tone_freq), tone_freq, timeout_ok
|
|
459
|
+
finally:
|
|
460
|
+
cleanup_sdr(sdr)
|