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/device.py ADDED
@@ -0,0 +1,311 @@
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
+ """PlutoSDR device access: open, configure, cleanup, and SDRDevice protocol."""
18
+
19
+ from __future__ import annotations
20
+
21
+ import math
22
+ import os
23
+ import sys
24
+ from typing import Any, Protocol, runtime_checkable
25
+
26
+ import numpy as np
27
+ from numpy.typing import NDArray
28
+
29
+ from pluto_siege.config import CONFIG, AppConfig
30
+ from pluto_siege.constants import (
31
+ IO_TIMEOUT_FACTOR,
32
+ IO_TIMEOUT_MIN_MS,
33
+ MAX_RF_BW,
34
+ RX_BUFFER_RANGE,
35
+ RX_GAIN_RANGE,
36
+ SAMPLE_RATE_RANGE,
37
+ TX_GAIN_RANGE,
38
+ )
39
+
40
+ _DRIVER_IMPORT_ERROR: Exception | None = None
41
+ try:
42
+ import adi
43
+ except (ImportError, OSError, AttributeError) as exc:
44
+ adi = None
45
+ _DRIVER_IMPORT_ERROR = exc
46
+
47
+
48
+ @runtime_checkable
49
+ class SDRDevice(Protocol):
50
+ """The AD936x/pyadi-iio interface used by PlutoSiege."""
51
+
52
+ sample_rate: int
53
+ rx_lo: int
54
+ tx_lo: int
55
+ rx_buffer_size: int
56
+ rx_rf_bandwidth: int
57
+ tx_rf_bandwidth: int
58
+ rx_hardwaregain_chan0: float
59
+ tx_hardwaregain_chan0: float
60
+ gain_control_mode_chan0: str
61
+ tx_cyclic_buffer: bool
62
+ loopback: int
63
+ ctx: Any
64
+
65
+ def rx(self) -> NDArray[np.complex64]: ...
66
+ def tx(self, data: NDArray[np.complex64]) -> None: ...
67
+ def rx_destroy_buffer(self) -> None: ...
68
+ def tx_destroy_buffer(self) -> None: ...
69
+
70
+
71
+ class SuppressCStderr:
72
+ """Temporarily redirect stderr (fd 2) to devnull so C-level libiio logs do not corrupt the TUI."""
73
+
74
+ def __init__(self) -> None:
75
+ self._null = -1
76
+ self._stderr = -1
77
+
78
+ def __enter__(self) -> SuppressCStderr:
79
+ try:
80
+ sys.stderr.flush()
81
+ self._null = os.open(os.devnull, os.O_WRONLY)
82
+ self._stderr = os.dup(2)
83
+ os.dup2(self._null, 2)
84
+ except OSError:
85
+ self._cleanup()
86
+ return self
87
+
88
+ def __exit__(self, *args: object) -> None:
89
+ self._cleanup()
90
+
91
+ def _cleanup(self) -> None:
92
+ if self._stderr != -1:
93
+ try:
94
+ os.dup2(self._stderr, 2)
95
+ except OSError:
96
+ pass
97
+ try:
98
+ os.close(self._stderr)
99
+ except OSError:
100
+ pass
101
+ self._stderr = -1
102
+ if self._null != -1:
103
+ try:
104
+ os.close(self._null)
105
+ except OSError:
106
+ pass
107
+ self._null = -1
108
+
109
+
110
+ suppress_c_stderr = SuppressCStderr
111
+
112
+
113
+ def cleanup_sdr(sdr: SDRDevice) -> None:
114
+ """Silence the transmitter, disable loopback, and release both buffers. Never raises."""
115
+ with suppress_c_stderr():
116
+ for attr, value in (
117
+ ("loopback", 0),
118
+ ("tx_cyclic_buffer", False),
119
+ ("tx_hardwaregain_chan0", TX_GAIN_RANGE[0]),
120
+ ):
121
+ try:
122
+ setattr(sdr, attr, value)
123
+ except Exception:
124
+ pass
125
+ for method_name in ("tx_destroy_buffer", "rx_destroy_buffer"):
126
+ try:
127
+ getattr(sdr, method_name)()
128
+ except Exception:
129
+ pass
130
+
131
+
132
+ def set_io_timeout(sdr: SDRDevice, expected_seconds: float) -> bool:
133
+ """Bound a libiio transfer; returns False if the driver does not support timeouts."""
134
+ if not math.isfinite(expected_seconds) or expected_seconds < 0:
135
+ raise ValueError("Expected transfer duration must be finite and non-negative")
136
+ max_ms = 2**32 - 1
137
+ seconds = min(expected_seconds, max_ms / (1000 * IO_TIMEOUT_FACTOR))
138
+ ms = min(max_ms, max(IO_TIMEOUT_MIN_MS, int(seconds * 1000 * IO_TIMEOUT_FACTOR)))
139
+ try:
140
+ sdr.ctx.set_timeout(ms)
141
+ return True
142
+ except (AttributeError, OSError, RuntimeError, OverflowError):
143
+ return False
144
+
145
+
146
+ def safe_rx(sdr: SDRDevice) -> NDArray[np.complex64]:
147
+ """Receive an owned, finite, 1D complex64 buffer or raise OSError on link failure."""
148
+ raw = sdr.rx()
149
+ if raw is None:
150
+ raise OSError("SDR returned empty data (USB disconnected?)")
151
+ try:
152
+ with np.errstate(over="ignore", invalid="ignore"):
153
+ data = np.array(raw, dtype=np.complex64, order="C", copy=True)
154
+ except (TypeError, ValueError, OverflowError) as exc:
155
+ raise OSError("SDR returned invalid sample data") from exc
156
+ if data.size == 0:
157
+ raise OSError("SDR returned empty data (USB disconnected?)")
158
+ if data.ndim != 1:
159
+ raise OSError("SDR must return one channel as a one-dimensional buffer")
160
+ if not bool(np.all(np.isfinite(data))):
161
+ raise OSError("SDR returned non-finite samples")
162
+ return data
163
+
164
+
165
+ def _check_number(
166
+ value: float,
167
+ limits: tuple[float, float],
168
+ name: str,
169
+ *,
170
+ integral: bool = False,
171
+ ) -> None:
172
+ """Validate numeric parameter ranges before programming SDR hardware."""
173
+ try:
174
+ number = float(value)
175
+ except (TypeError, ValueError, OverflowError) as exc:
176
+ raise ValueError(f"{name} must be numeric") from exc
177
+ if (
178
+ isinstance(value, (bool, str))
179
+ or not math.isfinite(number)
180
+ or not limits[0] <= number <= limits[1]
181
+ or (integral and not number.is_integer())
182
+ ):
183
+ raise ValueError(f"{name} must be within {limits[0]:g} .. {limits[1]:g}")
184
+
185
+
186
+ def _apply_rx_settings(
187
+ sdr: SDRDevice, use_sr: int, freq: int, buf_size: int, rx_gain: float
188
+ ) -> None:
189
+ sdr.rx_rf_bandwidth = min(use_sr, MAX_RF_BW)
190
+ sdr.rx_lo = int(freq)
191
+ sdr.rx_buffer_size = int(buf_size)
192
+ sdr.gain_control_mode_chan0 = "manual"
193
+ sdr.rx_hardwaregain_chan0 = float(rx_gain)
194
+
195
+
196
+ def _apply_tx_settings(
197
+ sdr: SDRDevice, use_sr: int, freq: int, tx_gain: float
198
+ ) -> None:
199
+ sdr.tx_rf_bandwidth = min(use_sr, MAX_RF_BW)
200
+ sdr.tx_lo = int(freq)
201
+ sdr.tx_hardwaregain_chan0 = float(tx_gain)
202
+
203
+
204
+ def cfg_rx(
205
+ sdr: SDRDevice,
206
+ sr: int | None = None,
207
+ freq: int | None = None,
208
+ config: AppConfig | None = None,
209
+ ) -> bool:
210
+ """Configure RX and return whether libiio timeout is supported."""
211
+ cfg = CONFIG if config is None else config
212
+ use_sr = cfg.sample_rate if sr is None else sr
213
+ use_freq = cfg.rx_freq if freq is None else freq
214
+ _check_number(use_sr, SAMPLE_RATE_RANGE, "Sample rate", integral=True)
215
+ _check_number(use_freq, cfg.freq_bounds, "RX frequency", integral=True)
216
+ _check_number(cfg.rx_buffer_size, RX_BUFFER_RANGE, "RX buffer size", integral=True)
217
+ _check_number(cfg.rx_gain, RX_GAIN_RANGE, "RX gain")
218
+ with suppress_c_stderr():
219
+ cleanup_sdr(sdr)
220
+ try:
221
+ sdr.sample_rate = int(use_sr)
222
+ _apply_rx_settings(
223
+ sdr, int(use_sr), int(use_freq), cfg.rx_buffer_size, cfg.rx_gain
224
+ )
225
+ return set_io_timeout(sdr, cfg.rx_buffer_size / use_sr)
226
+ except BaseException:
227
+ cleanup_sdr(sdr)
228
+ raise
229
+
230
+
231
+ def cfg_tx(
232
+ sdr: SDRDevice,
233
+ sr: int | None = None,
234
+ freq: int | None = None,
235
+ config: AppConfig | None = None,
236
+ ) -> None:
237
+ """Configure non-cyclic TX."""
238
+ cfg = CONFIG if config is None else config
239
+ use_sr = cfg.sample_rate if sr is None else sr
240
+ use_freq = cfg.tx_freq if freq is None else freq
241
+ _check_number(use_sr, SAMPLE_RATE_RANGE, "Sample rate", integral=True)
242
+ _check_number(use_freq, cfg.freq_bounds, "TX frequency", integral=True)
243
+ _check_number(cfg.tx_gain, TX_GAIN_RANGE, "TX gain")
244
+ with suppress_c_stderr():
245
+ cleanup_sdr(sdr)
246
+ try:
247
+ sdr.sample_rate = int(use_sr)
248
+ _apply_tx_settings(sdr, int(use_sr), int(use_freq), cfg.tx_gain)
249
+ sdr.tx_cyclic_buffer = False
250
+ except BaseException:
251
+ cleanup_sdr(sdr)
252
+ raise
253
+
254
+
255
+ def cfg_loopback(sdr: SDRDevice, config: AppConfig | None = None) -> bool:
256
+ """Configure digital loopback; returns False only when I/O timeout is unavailable."""
257
+ cfg = CONFIG if config is None else config
258
+ _check_number(cfg.sample_rate, SAMPLE_RATE_RANGE, "Sample rate", integral=True)
259
+ _check_number(cfg.rx_freq, cfg.freq_bounds, "RX frequency", integral=True)
260
+ _check_number(cfg.tx_freq, cfg.freq_bounds, "TX frequency", integral=True)
261
+ _check_number(cfg.rx_buffer_size, RX_BUFFER_RANGE, "RX buffer size", integral=True)
262
+ _check_number(cfg.rx_gain, RX_GAIN_RANGE, "RX gain")
263
+ _check_number(cfg.tx_gain, TX_GAIN_RANGE, "TX gain")
264
+ with suppress_c_stderr():
265
+ cleanup_sdr(sdr)
266
+ try:
267
+ sdr.sample_rate = int(cfg.sample_rate)
268
+ _apply_rx_settings(
269
+ sdr, cfg.sample_rate, cfg.rx_freq, cfg.rx_buffer_size, cfg.rx_gain
270
+ )
271
+ _apply_tx_settings(sdr, cfg.sample_rate, cfg.tx_freq, cfg.tx_gain)
272
+ sdr.loopback = 1
273
+ sdr.tx_cyclic_buffer = True
274
+ return set_io_timeout(sdr, cfg.rx_buffer_size / cfg.sample_rate)
275
+ except BaseException:
276
+ cleanup_sdr(sdr)
277
+ raise
278
+
279
+
280
+ def open_sdr(uri: str, fallback_model: str = "PlutoSDR") -> tuple[SDRDevice, str]:
281
+ """Open a PlutoSDR device and read its hardware model."""
282
+ if adi is None:
283
+ detail = f" ({_DRIVER_IMPORT_ERROR})" if _DRIVER_IMPORT_ERROR else ""
284
+ if len(detail) > 40:
285
+ detail = detail[:37] + "...)"
286
+ raise ImportError(
287
+ f"pyadi-iio / libiio driver is not available{detail}.\n"
288
+ "See README.md for driver and installation instructions."
289
+ ) from _DRIVER_IMPORT_ERROR
290
+ if not isinstance(uri, str) or not uri.strip():
291
+ raise ValueError("Pluto connection URI must not be empty")
292
+ clean_uri = uri.strip()
293
+ try:
294
+ with suppress_c_stderr():
295
+ sdr = adi.Pluto(clean_uri)
296
+ except Exception as exc:
297
+ raise OSError(str(exc) or f"Failed to open {clean_uri}") from exc
298
+ model = fallback_model
299
+ try:
300
+ value = sdr.ctx.attrs["hw_model"]
301
+ value = value if isinstance(value, str) else value.value
302
+ if isinstance(value, str) and value.strip():
303
+ model = value.strip()
304
+ except (KeyError, AttributeError, OSError, TypeError):
305
+ pass
306
+ return sdr, model
307
+
308
+
309
+ def release_sdr(sdr: SDRDevice | None) -> None:
310
+ if sdr is not None:
311
+ cleanup_sdr(sdr)
pluto_siege/dsp.py ADDED
@@ -0,0 +1,157 @@
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
+ """Signal-level metrics: sub-window power, dBFS conversions, saturation, and SNR."""
18
+
19
+ from __future__ import annotations
20
+
21
+ import math
22
+
23
+ import numpy as np
24
+ from numpy.typing import NDArray
25
+
26
+ from pluto_siege.constants import (
27
+ DB_EPSILON,
28
+ DETECT_SUB_WINDOW,
29
+ LOOPBACK_TONE_BINS,
30
+ NF_PERCENTILE,
31
+ RX_FULL_SCALE,
32
+ SATURATION_MARGIN,
33
+ )
34
+
35
+ INV_FULL_SCALE_SQ = 1.0 / (RX_FULL_SCALE * RX_FULL_SCALE)
36
+
37
+
38
+ def subwindow_powers(
39
+ data: NDArray[np.complex64], sub_size: int = DETECT_SUB_WINDOW
40
+ ) -> NDArray[np.float64]:
41
+ """Full-scale-normalised mean powers, including a final partial sub-window."""
42
+ sub_size = max(1, int(sub_size))
43
+ if data.ndim != 1:
44
+ data = data.ravel()
45
+ if data.dtype != np.complex64 or not data.flags["C_CONTIGUOUS"]:
46
+ data = np.ascontiguousarray(data, dtype=np.complex64)
47
+ n_samples = data.size
48
+ if n_samples == 0:
49
+ return np.zeros(1, dtype=np.float64)
50
+ usable = n_samples - n_samples % sub_size
51
+ if usable:
52
+ flat = data[:usable].view(np.float32).reshape(-1, sub_size * 2)
53
+ powers = np.einsum("ij,ij->i", flat, flat, dtype=np.float64) * (
54
+ INV_FULL_SCALE_SQ / sub_size
55
+ )
56
+ else:
57
+ powers = np.empty(0, dtype=np.float64)
58
+ if usable < n_samples:
59
+ tail = data[usable:].view(np.float32)
60
+ power = float(np.einsum("i,i->", tail, tail, dtype=np.float64))
61
+ powers = np.append(powers, power * INV_FULL_SCALE_SQ / (n_samples - usable))
62
+ return powers
63
+
64
+
65
+ def power_to_dbfs(power: float) -> float:
66
+ """Linear full-scale-normalised power to dBFS; unusable inputs give the floor."""
67
+ if not math.isfinite(power) or power <= 0.0:
68
+ return -120.0
69
+ return float(10.0 * math.log10(power + DB_EPSILON))
70
+
71
+
72
+ def dbfs_to_power(dbfs: float) -> float:
73
+ """Convert a threshold without log work per buffer, bounded against overflow."""
74
+ if math.isnan(dbfs):
75
+ return 0.0
76
+ return float(10.0 ** (min(dbfs, 300.0) / 10.0))
77
+
78
+
79
+ def max_subwindow_dbfs(
80
+ data: NDArray[np.complex64], sub_size: int = DETECT_SUB_WINDOW
81
+ ) -> float:
82
+ """Peak sub-window level in dBFS, used for triggering."""
83
+ return power_to_dbfs(float(np.max(subwindow_powers(data, sub_size))))
84
+
85
+
86
+ peak_window_dbfs = max_subwindow_dbfs
87
+
88
+
89
+ def noise_floor_dbfs(
90
+ powers: NDArray[np.float64], percentile: float = NF_PERCENTILE
91
+ ) -> float:
92
+ """Noise floor from pooled finite sub-window powers."""
93
+ if powers.size == 0:
94
+ return -120.0
95
+ valid = powers[np.isfinite(powers)]
96
+ if valid.size == 0:
97
+ return -120.0
98
+ return power_to_dbfs(float(np.percentile(valid, percentile)))
99
+
100
+
101
+ def is_saturated(
102
+ data: NDArray[np.complex64], margin: float = SATURATION_MARGIN
103
+ ) -> bool:
104
+ if data.size == 0:
105
+ return False
106
+ if data.ndim != 1:
107
+ data = data.ravel()
108
+ limit = margin * RX_FULL_SCALE
109
+ if data.dtype != np.complex64 or not data.flags["C_CONTIGUOUS"]:
110
+ data = np.ascontiguousarray(data, dtype=np.complex64)
111
+ flat = data.view(np.float32)
112
+ return bool(max(float(np.max(flat)), -float(np.min(flat))) >= limit)
113
+
114
+
115
+ def calculate_snr_db(
116
+ rx: NDArray[np.complex64],
117
+ fs: float,
118
+ tone_freq: float,
119
+ tone_bins: int = LOOPBACK_TONE_BINS,
120
+ ) -> float:
121
+ """Estimate in-band SNR from a Hann-windowed FFT, wrapping at Nyquist.
122
+
123
+ Noise per bin is estimated outside the tone band, then scaled to the band
124
+ width. This preserves the existing local-band metric (not broadband SNR).
125
+ """
126
+ if rx.ndim != 1:
127
+ rx = rx.ravel()
128
+ n_samples = rx.size
129
+ if (
130
+ n_samples < 32
131
+ or not math.isfinite(fs)
132
+ or fs <= 0
133
+ or not math.isfinite(tone_freq)
134
+ or not -fs / 2 <= tone_freq < fs / 2
135
+ or not bool(np.all(np.isfinite(rx)))
136
+ ):
137
+ return -120.0
138
+ tone_bins = max(1, int(tone_bins))
139
+ windowed_fft = np.fft.fft(rx * np.hanning(n_samples))
140
+ spectrum = windowed_fft.real**2 + windowed_fft.imag**2
141
+ if float(np.sum(spectrum)) <= 1e-18:
142
+ return -120.0
143
+ if 2 * tone_bins + 1 >= n_samples:
144
+ return 0.0
145
+
146
+ target_bin = round(tone_freq * n_samples / fs) % n_samples
147
+ signal_bins = (target_bin + np.arange(-tone_bins, tone_bins + 1)) % n_samples
148
+ in_band_mask = np.zeros(n_samples, dtype=bool)
149
+ in_band_mask[signal_bins] = True
150
+ in_band = float(np.sum(spectrum[in_band_mask]))
151
+ if in_band <= 1e-18:
152
+ return -120.0
153
+ # Sum the noise bins directly. Subtracting two nearly equal large totals
154
+ # loses the residual noise on a clean loopback tone.
155
+ noise = float(np.mean(spectrum[~in_band_mask])) * signal_bins.size
156
+ signal_power = max(in_band - noise, 1e-20)
157
+ return float(10.0 * math.log10(signal_power / max(noise, 1e-20)))