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.
@@ -0,0 +1,43 @@
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 capture and replay tool for short ISM-band bursts.
18
+
19
+ A full-screen terminal application that records brief RF transmissions - car
20
+ remotes, doorbells, weather stations and similar OOK/ASK devices - and replays
21
+ them. Recordings are written as SigMF pairs so they can be opened in other
22
+ tools as well.
23
+
24
+ Usage:
25
+ python3 -m pluto_siege
26
+
27
+ Requires Python 3.9+, an interactive terminal, an ADALM-Pluto (or compatible
28
+ AD936x device) and:
29
+ pip install -r requirements.txt
30
+
31
+ Transmitting is regulated. Replay only signals you are licensed or permitted to
32
+ emit, into a dummy load or at the lowest gain that does the job.
33
+
34
+ Author: SketchTurnerDev
35
+ """
36
+
37
+ from pluto_siege.constants import AUTHOR, LICENSE, VERSION
38
+
39
+ __version__ = VERSION
40
+ __author__ = AUTHOR
41
+ __license__ = LICENSE
42
+
43
+ __all__ = ["__author__", "__license__", "__version__"]
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env python3
2
+ # PlutoSiege - RF capture and replay tool for PlutoSDR.
3
+ # Copyright (C) 2026 SketchTurnerDev
4
+ #
5
+ # This program is free software: you can redistribute it and/or modify
6
+ # it under the terms of the GNU General Public License as published by
7
+ # the Free Software Foundation, either version 3 of the License, or
8
+ # (at your option) any later version.
9
+ #
10
+ # This program is distributed in the hope that it will be useful,
11
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ # GNU General Public License for more details.
14
+ #
15
+ # You should have received a copy of the GNU General Public License
16
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
17
+
18
+ """Entry point for ``python3 -m pluto_siege``."""
19
+
20
+ from pluto_siege.ui.screens.main_menu import main
21
+
22
+ if __name__ == "__main__":
23
+ main()
pluto_siege/config.py ADDED
@@ -0,0 +1,249 @@
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
+ """Configuration management using a validated dataclass."""
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import math
23
+ import os
24
+ import tempfile
25
+ from dataclasses import asdict, dataclass, fields
26
+ from typing import Any
27
+
28
+ from pluto_siege.constants import (
29
+ AUTO_MARGIN_RANGE,
30
+ CONFIG_FILE,
31
+ FREQ_RANGE_EXTENDED,
32
+ FREQ_RANGE_IN_SPEC,
33
+ POST_TRIGGER_RANGE,
34
+ PRE_TRIGGER_RANGE,
35
+ RX_BUFFER_RANGE,
36
+ RX_GAIN_RANGE,
37
+ SAMPLE_RATE_RANGE,
38
+ SILENCE_RANGE,
39
+ THRESHOLD_RANGE,
40
+ TX_GAIN_RANGE,
41
+ )
42
+
43
+
44
+ def _ip_endpoint(uri: object) -> str | None:
45
+ """Extract the host/IP part from an 'ip:...' URI, or None if not an IP URI."""
46
+ if isinstance(uri, str) and uri.startswith("ip:"):
47
+ return uri[3:].strip()
48
+ return None
49
+
50
+
51
+ @dataclass
52
+ class AppConfig:
53
+ """Application configuration with range validation and atomic persistence."""
54
+
55
+ pluto_uri: str = "usb:"
56
+ pluto_ip: str = "192.168.2.1"
57
+ rx_freq: int = 433_920_000
58
+ tx_freq: int = 433_920_000
59
+ sample_rate: int = 2_100_000
60
+ rx_gain: float = 15.0
61
+ tx_gain: float = 0.0
62
+ rx_buffer_size: int = 32768
63
+ auto_threshold: bool = True
64
+ auto_trigger_margin: float = 8.0
65
+ manual_threshold: float = -30.0
66
+ pre_trigger_buffers: int = 2
67
+ silence_seconds: float = 0.8
68
+ max_post_trigger_seconds: float = 4.0
69
+ permit_out_of_spec_frequency: bool = False
70
+ enable_mouse: bool = True
71
+ enable_sound: bool = True
72
+ enable_gps: bool = False
73
+ gpsd_host: str = "127.0.0.1"
74
+ gpsd_port: int = 2947
75
+
76
+ def __post_init__(self) -> None:
77
+ endpoint = _ip_endpoint(self.pluto_uri)
78
+ if endpoint:
79
+ self.pluto_ip = endpoint
80
+
81
+ def update(self, new_values: dict[str, Any]) -> None:
82
+ """Validate and apply known configuration keys in place."""
83
+ if not isinstance(new_values, dict):
84
+ return
85
+ valid_keys = {f.name for f in fields(self)}
86
+ known = {k: v for k, v in new_values.items() if k in valid_keys}
87
+ if not known:
88
+ return
89
+
90
+ candidate = self.to_dict()
91
+ candidate.update(known)
92
+ if (
93
+ "pluto_ip" in known
94
+ and "pluto_uri" not in known
95
+ and self.pluto_uri.startswith("ip:")
96
+ and isinstance(known["pluto_ip"], str)
97
+ and known["pluto_ip"].strip()
98
+ ):
99
+ candidate["pluto_uri"] = f"ip:{known['pluto_ip'].strip()}"
100
+
101
+ validated = validate_config(candidate)
102
+ for f in fields(self):
103
+ setattr(self, f.name, getattr(validated, f.name))
104
+
105
+ def to_dict(self) -> dict[str, Any]:
106
+ values = asdict(self)
107
+ endpoint = _ip_endpoint(values["pluto_uri"])
108
+ if endpoint:
109
+ values["pluto_ip"] = endpoint
110
+ elif endpoint == "" and isinstance(values["pluto_ip"], str):
111
+ ip = values["pluto_ip"].strip()
112
+ if ip:
113
+ values["pluto_uri"] = f"ip:{ip}"
114
+ return values
115
+
116
+ @property
117
+ def freq_bounds(self) -> tuple[int, int]:
118
+ return (
119
+ FREQ_RANGE_EXTENDED
120
+ if self.permit_out_of_spec_frequency
121
+ else FREQ_RANGE_IN_SPEC
122
+ )
123
+
124
+
125
+ def validate_config(raw: dict[str, Any]) -> AppConfig:
126
+ """Validate raw dictionary and return an AppConfig instance.
127
+
128
+ Invalid individual fields fall back to their defaults so a partially edited
129
+ settings file preserves all valid values.
130
+ """
131
+ cfg = AppConfig()
132
+ if not isinstance(raw, dict):
133
+ return cfg
134
+
135
+ uri = raw.get("pluto_uri")
136
+ if isinstance(uri, str) and uri.strip():
137
+ cfg.pluto_uri = uri.strip()
138
+
139
+ raw_ip = raw.get("pluto_ip")
140
+ saved_ip = raw_ip.strip() if isinstance(raw_ip, str) and raw_ip.strip() else None
141
+ endpoint = _ip_endpoint(cfg.pluto_uri)
142
+ if endpoint:
143
+ cfg.pluto_ip = endpoint
144
+ elif endpoint == "" and saved_ip:
145
+ cfg.pluto_ip = saved_ip
146
+ cfg.pluto_uri = f"ip:{saved_ip}"
147
+ elif saved_ip:
148
+ cfg.pluto_ip = saved_ip
149
+
150
+ for bool_key in (
151
+ "permit_out_of_spec_frequency",
152
+ "auto_threshold",
153
+ "enable_mouse",
154
+ "enable_sound",
155
+ "enable_gps",
156
+ ):
157
+ val = raw.get(bool_key)
158
+ if isinstance(val, bool):
159
+ setattr(cfg, bool_key, val)
160
+
161
+ host = raw.get("gpsd_host")
162
+ if isinstance(host, str) and host.strip():
163
+ cfg.gpsd_host = host.strip()
164
+
165
+ def check_num(key: str, limits: tuple[float, float], cast_type: type) -> None:
166
+ val = raw.get(key)
167
+ if isinstance(val, bool) or not isinstance(val, (int, float)):
168
+ return
169
+ try:
170
+ num = float(val)
171
+ except (OverflowError, ValueError):
172
+ return
173
+ if not math.isfinite(num) or not (limits[0] <= num <= limits[1]):
174
+ return
175
+ if cast_type is int:
176
+ if num.is_integer():
177
+ setattr(cfg, key, int(num))
178
+ else:
179
+ setattr(cfg, key, num)
180
+
181
+ freq_range = cfg.freq_bounds
182
+ check_num("rx_freq", freq_range, int)
183
+ check_num("tx_freq", freq_range, int)
184
+ check_num("sample_rate", SAMPLE_RATE_RANGE, int)
185
+ check_num("rx_gain", RX_GAIN_RANGE, float)
186
+ check_num("tx_gain", TX_GAIN_RANGE, float)
187
+ check_num("rx_buffer_size", RX_BUFFER_RANGE, int)
188
+ check_num("pre_trigger_buffers", PRE_TRIGGER_RANGE, int)
189
+ check_num("silence_seconds", SILENCE_RANGE, float)
190
+ check_num("max_post_trigger_seconds", POST_TRIGGER_RANGE, float)
191
+ check_num("manual_threshold", THRESHOLD_RANGE, float)
192
+ check_num("auto_trigger_margin", AUTO_MARGIN_RANGE, float)
193
+ check_num("gpsd_port", (1, 65535), int)
194
+
195
+ return cfg
196
+
197
+
198
+ def load_settings() -> AppConfig:
199
+ """Load and validate settings from CONFIG_FILE, or return defaults."""
200
+ if os.path.exists(CONFIG_FILE):
201
+ try:
202
+ with open(CONFIG_FILE, encoding="utf-8") as f:
203
+ return validate_config(json.load(f))
204
+ except (
205
+ json.JSONDecodeError,
206
+ OSError,
207
+ UnicodeDecodeError,
208
+ ValueError,
209
+ RecursionError,
210
+ ):
211
+ pass
212
+ return AppConfig()
213
+
214
+
215
+ def save_settings(config: AppConfig | None = None) -> bool:
216
+ """Atomically write configuration to CONFIG_FILE."""
217
+ target = CONFIG if config is None else config
218
+ tmp_path: str | None = None
219
+ try:
220
+ payload = json.dumps(target.to_dict(), indent=2, allow_nan=False)
221
+ directory = os.path.dirname(os.path.abspath(CONFIG_FILE))
222
+ os.makedirs(directory, exist_ok=True)
223
+ fd, tmp_path = tempfile.mkstemp(
224
+ prefix=".settings-", suffix=".tmp", dir=directory
225
+ )
226
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
227
+ f.write(payload)
228
+ f.flush()
229
+ os.fsync(f.fileno())
230
+ os.replace(tmp_path, CONFIG_FILE)
231
+ tmp_path = None
232
+ return True
233
+ except (OSError, TypeError, ValueError, AttributeError):
234
+ return False
235
+ finally:
236
+ if tmp_path is not None:
237
+ try:
238
+ os.unlink(tmp_path)
239
+ except OSError:
240
+ pass
241
+
242
+
243
+ def freq_bounds(cfg: AppConfig | None = None) -> tuple[int, int]:
244
+ """Return active frequency limits for the given or global configuration."""
245
+ return (CONFIG if cfg is None else cfg).freq_bounds
246
+
247
+
248
+ DEFAULT_SETTINGS = AppConfig().to_dict()
249
+ CONFIG = load_settings()
@@ -0,0 +1,132 @@
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
+ """Hardware limits, detection tuning, and application-wide constants."""
18
+
19
+ from __future__ import annotations
20
+
21
+ import os
22
+ from typing import Any
23
+
24
+ AUTHOR = "SketchTurnerDev" # recorded in SigMF metadata as core:author
25
+ VERSION = "2.0.1"
26
+ LICENSE = "GPL-3.0-or-later"
27
+ LICENSE_SHORT = "GNU GPLv3"
28
+
29
+ _REPO_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
30
+ if os.path.exists(os.path.join(_REPO_DIR, "pyproject.toml")):
31
+ BASE_DIR = _REPO_DIR
32
+ else:
33
+ BASE_DIR = os.path.join(os.path.expanduser("~"), ".pluto_siege")
34
+
35
+ CONFIG_FILE = os.path.join(BASE_DIR, "settings.json")
36
+ RECORDS_DIR = os.path.join(BASE_DIR, "recordings")
37
+
38
+ # ---------------------------------------------------------------------------
39
+ # Hardware limits and sample accounting
40
+ # ---------------------------------------------------------------------------
41
+
42
+ # AD9363 datasheet range, and the wider range the silicon usually tunes to.
43
+ FREQ_RANGE_IN_SPEC = (325_000_000, 3_800_000_000)
44
+ FREQ_RANGE_EXTENDED = (70_000_000, 6_000_000_000)
45
+
46
+ # Lower bound is pyadi-iio's hard limit: its sample_rate setter raises below
47
+ # 521 kSPS and loads a matching FIR decimation filter below ~2.083 MSPS.
48
+ SAMPLE_RATE_RANGE = (521_000, 61_440_000)
49
+ RX_GAIN_RANGE = (0.0, 74.5)
50
+ TX_GAIN_RANGE = (-89.75, 0.0)
51
+ RX_BUFFER_RANGE = (1024, 32768)
52
+ MAX_RF_BW = 20_000_000
53
+
54
+ # AD936x fractional-N PLL and BBPLL divider rounding tolerances on readback.
55
+ LO_TOLERANCE_HZ = 10_000
56
+ SR_TOLERANCE_HZ = 1_000
57
+
58
+ # Stock Pluto delivers 12-bit samples sign-extended into 16-bit words (2**11).
59
+ RX_FULL_SCALE = 2048.0
60
+
61
+ # TX samples are handed to the DAC as int16; ADI caps amplitude at 2**14 - 1
62
+ # with backoff to leave headroom in the interpolating TX filter chain.
63
+ TX_DAC_MAX = 2**14 - 1
64
+ TX_BACKOFF = 0.8
65
+
66
+ # Samples are held as complex64 (SigMF "cf32_le"): 2 x float32.
67
+ BYTES_PER_SAMPLE = 8
68
+ RAM_SAFETY_FACTOR = 2
69
+ MAX_RAM_BYTES = 512 * 1024 * 1024
70
+ MAX_TX_BURST_SAMPLES = 16 * 1024 * 1024
71
+ TX_DRAIN_MARGIN_SECONDS = 0.30
72
+
73
+ # Bound blocking libiio transfers so a stalled link fails instead of hanging.
74
+ IO_TIMEOUT_MIN_MS = 5_000
75
+ IO_TIMEOUT_FACTOR = 10
76
+ IO_TIMEOUT_UNAVAILABLE = "No I/O timeout: a stalled link cannot be interrupted."
77
+
78
+ # ---------------------------------------------------------------------------
79
+ # Detection tuning
80
+ # ---------------------------------------------------------------------------
81
+
82
+ DETECT_SUB_WINDOW = 512
83
+ NF_PERCENTILE = 25
84
+ NF_PROBE_BUFFERS = 10
85
+ TRIGGER_MARGIN_DB = 8.0
86
+ RELEASE_MARGIN_DB = 3.0
87
+ MANUAL_RELEASE_DROP_DB = 5.0
88
+ MIN_TRIGGER_SUB_WINDOWS = 2
89
+ RX_FLUSH_BUFFERS = 3
90
+ SATURATION_MARGIN = 0.98
91
+ DB_EPSILON = 1e-12
92
+
93
+ # ---------------------------------------------------------------------------
94
+ # Settings ranges
95
+ # ---------------------------------------------------------------------------
96
+
97
+ PRE_TRIGGER_RANGE = (1, 20)
98
+ SILENCE_RANGE = (0.1, 5.0)
99
+ POST_TRIGGER_RANGE = (0.5, 10.0)
100
+ THRESHOLD_RANGE = (-120.0, 0.0)
101
+ AUTO_MARGIN_RANGE = (1.0, 30.0)
102
+
103
+ # ---------------------------------------------------------------------------
104
+ # Loopback test
105
+ # ---------------------------------------------------------------------------
106
+
107
+ LOOPBACK_TONE_HZ = 100_000.0
108
+ LOOPBACK_TONE_BINS = 10
109
+ LOOPBACK_TX_AMPLITUDE = 0.6
110
+ LOOPBACK_MIN_SNR_DB = 10.0
111
+
112
+ # ---------------------------------------------------------------------------
113
+ # Logo
114
+ # ---------------------------------------------------------------------------
115
+
116
+ LOGO_LINES = [
117
+ r" ____ __ __ _____ _ ",
118
+ r" / __ \/ /_ __/ /_____ / ___/(_)__ ____ ____ ",
119
+ r" / /_/ / / / / / __/ __ \\__ \/ / _ \/ __ `/ _ \ ",
120
+ r" / ____/ / /_/ / /_/ /_/ /__/ / / __/ /_/ / __/ ",
121
+ r" /_/ /_/\__,_/\__/\____/____/_/\___/\__, /\___/ ",
122
+ r" /____/ ",
123
+ f" {AUTHOR} v{VERSION} ({LICENSE_SHORT})",
124
+ ]
125
+
126
+
127
+ def brief(text: Any, limit: int = 18) -> str:
128
+ """Shorten a variable fragment so the line it lands in fits error displays."""
129
+ limit = max(1, limit)
130
+ cleaned = "".join(ch if ch.isprintable() else " " for ch in str(text))
131
+ flat = " ".join(cleaned.split())
132
+ return flat if len(flat) <= limit else flat[: limit - 1] + "~"