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/__init__.py
ADDED
|
@@ -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__"]
|
pluto_siege/__main__.py
ADDED
|
@@ -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()
|
pluto_siege/constants.py
ADDED
|
@@ -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] + "~"
|