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/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)