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
|
@@ -0,0 +1,226 @@
|
|
|
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
|
+
"""Capture screen: UI view layer that delegates signal capture to CaptureEngine."""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import curses
|
|
22
|
+
import os
|
|
23
|
+
import time
|
|
24
|
+
|
|
25
|
+
from pluto_siege.config import CONFIG, AppConfig
|
|
26
|
+
from pluto_siege.constants import (
|
|
27
|
+
IO_TIMEOUT_UNAVAILABLE,
|
|
28
|
+
RECORDS_DIR,
|
|
29
|
+
)
|
|
30
|
+
from pluto_siege.device import SDRDevice, cleanup_sdr
|
|
31
|
+
from pluto_siege.engine import CaptureEngine
|
|
32
|
+
from pluto_siege.gps import get_gps_manager
|
|
33
|
+
from pluto_siege.sound import SoundCue, play_cue
|
|
34
|
+
from pluto_siege.ui.widgets.framework import (
|
|
35
|
+
C_DIM,
|
|
36
|
+
C_ERR,
|
|
37
|
+
C_OK,
|
|
38
|
+
C_WARN,
|
|
39
|
+
KEY_ENTER,
|
|
40
|
+
KEY_ESC,
|
|
41
|
+
METER_REFRESH_SECONDS,
|
|
42
|
+
_tail,
|
|
43
|
+
draw_chrome,
|
|
44
|
+
draw_gps_status,
|
|
45
|
+
draw_signal_meter,
|
|
46
|
+
flush_input,
|
|
47
|
+
screen_is_usable,
|
|
48
|
+
scroll_view,
|
|
49
|
+
wait_for_usable_screen,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
ABORT_HINT = "Esc/Q = abort"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def do_capture(
|
|
56
|
+
win: curses.window,
|
|
57
|
+
sdr: SDRDevice,
|
|
58
|
+
hw_model: str,
|
|
59
|
+
config: AppConfig | None = None,
|
|
60
|
+
) -> int:
|
|
61
|
+
"""Execute a single capture cycle. Returns the exit key pressed in the log view."""
|
|
62
|
+
cfg = CONFIG if config is None else config
|
|
63
|
+
if not screen_is_usable(win) and not wait_for_usable_screen(win):
|
|
64
|
+
return KEY_ESC
|
|
65
|
+
log: list[tuple[str, int]] = []
|
|
66
|
+
|
|
67
|
+
def render_log(hint: str) -> tuple[int, int]:
|
|
68
|
+
start = draw_chrome(win, "Capture Mode", hint)
|
|
69
|
+
if start == -1:
|
|
70
|
+
return -1, 0
|
|
71
|
+
return start, _tail(log, win, start, 4)
|
|
72
|
+
|
|
73
|
+
def finish(title: str, hint: str = "Enter/R = capture again ▎ Esc/Q = back") -> int:
|
|
74
|
+
flush_input(win)
|
|
75
|
+
return scroll_view(
|
|
76
|
+
win,
|
|
77
|
+
title,
|
|
78
|
+
log,
|
|
79
|
+
hint=hint,
|
|
80
|
+
exit_keys=(KEY_ESC, ord("q"), ord("Q"), ord("r"), ord("R")) + KEY_ENTER,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
log.append(("Configuring receiver...", C_DIM))
|
|
85
|
+
render_log("Please wait...")
|
|
86
|
+
win.refresh()
|
|
87
|
+
|
|
88
|
+
engine = CaptureEngine(sdr, config=cfg)
|
|
89
|
+
engine.prepare()
|
|
90
|
+
|
|
91
|
+
if not engine.io_timeout_available:
|
|
92
|
+
log.append((IO_TIMEOUT_UNAVAILABLE, C_WARN))
|
|
93
|
+
|
|
94
|
+
if engine.actual_sr > 4_000_000:
|
|
95
|
+
log.append(("SR > 4 MSPS. USB 2.0 may drop samples.", C_WARN))
|
|
96
|
+
|
|
97
|
+
freq_str = f"{engine.actual_freq / 1e6:.3f} MHz"
|
|
98
|
+
sr_str = f"{engine.actual_sr / 1e6:.2f} MSPS"
|
|
99
|
+
thr_str = f"Threshold: {engine.trigger_threshold:.1f} dBFS"
|
|
100
|
+
log.append((f"{freq_str} {sr_str} {thr_str}", C_DIM))
|
|
101
|
+
if engine.trigger_threshold >= 0.0:
|
|
102
|
+
log.append(("Threshold >= 0 dBFS: nothing can trigger.", C_WARN))
|
|
103
|
+
log.append(("Listening...", C_DIM))
|
|
104
|
+
play_cue(SoundCue.START, enabled=cfg.enable_sound)
|
|
105
|
+
|
|
106
|
+
os.makedirs(RECORDS_DIR, exist_ok=True)
|
|
107
|
+
flush_input(win)
|
|
108
|
+
win.nodelay(True)
|
|
109
|
+
last_draw = [0.0]
|
|
110
|
+
|
|
111
|
+
start, shown = render_log(ABORT_HINT)
|
|
112
|
+
meter_row = start + shown + 1
|
|
113
|
+
|
|
114
|
+
def render_live_widgets(lvl: float = -100.0, saturated: bool = False) -> None:
|
|
115
|
+
if start == -1 or not screen_is_usable(win):
|
|
116
|
+
draw_chrome(win, "Capture Mode", ABORT_HINT)
|
|
117
|
+
win.refresh()
|
|
118
|
+
return
|
|
119
|
+
draw_signal_meter(
|
|
120
|
+
win,
|
|
121
|
+
meter_row,
|
|
122
|
+
lvl,
|
|
123
|
+
engine.trigger_threshold,
|
|
124
|
+
saturated,
|
|
125
|
+
)
|
|
126
|
+
if cfg.enable_gps:
|
|
127
|
+
mgr = get_gps_manager()
|
|
128
|
+
draw_gps_status(
|
|
129
|
+
win,
|
|
130
|
+
meter_row + 1,
|
|
131
|
+
mgr.get_status_text(),
|
|
132
|
+
has_fix=mgr.get_latest_fix().has_fix,
|
|
133
|
+
)
|
|
134
|
+
win.refresh()
|
|
135
|
+
|
|
136
|
+
render_live_widgets()
|
|
137
|
+
|
|
138
|
+
def check_abort() -> bool:
|
|
139
|
+
nonlocal start, shown, meter_row
|
|
140
|
+
k = win.getch()
|
|
141
|
+
if k == curses.KEY_RESIZE:
|
|
142
|
+
start, shown = render_log(ABORT_HINT)
|
|
143
|
+
meter_row = start + shown + 1
|
|
144
|
+
render_live_widgets(engine.current_level_dbfs, engine.current_saturated)
|
|
145
|
+
return k in (KEY_ESC, ord("q"), ord("Q"))
|
|
146
|
+
|
|
147
|
+
def on_trigger_detected() -> None:
|
|
148
|
+
nonlocal start, shown, meter_row
|
|
149
|
+
play_cue(SoundCue.TRIGGER, enabled=cfg.enable_sound)
|
|
150
|
+
log.append(("Key detected! Capturing...", C_DIM))
|
|
151
|
+
start, shown = render_log(ABORT_HINT)
|
|
152
|
+
meter_row = start + shown + 1
|
|
153
|
+
render_live_widgets(engine.current_level_dbfs, engine.current_saturated)
|
|
154
|
+
|
|
155
|
+
def on_meter_update(lvl: float, saturated: bool) -> None:
|
|
156
|
+
now = time.monotonic()
|
|
157
|
+
if now - last_draw[0] > METER_REFRESH_SECONDS:
|
|
158
|
+
render_live_widgets(lvl, saturated)
|
|
159
|
+
last_draw[0] = now
|
|
160
|
+
|
|
161
|
+
engine.listen_and_capture(
|
|
162
|
+
check_abort=check_abort,
|
|
163
|
+
on_trigger_detected=on_trigger_detected,
|
|
164
|
+
on_meter_update=on_meter_update,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
flush_input(win)
|
|
168
|
+
|
|
169
|
+
if engine.is_aborted:
|
|
170
|
+
play_cue(SoundCue.ALERT, enabled=cfg.enable_sound)
|
|
171
|
+
log.append(("Capture aborted.", C_WARN))
|
|
172
|
+
return finish("Capture Aborted")
|
|
173
|
+
|
|
174
|
+
if engine.captured_data is None or engine.start_time is None:
|
|
175
|
+
log.append(("No signal captured.", C_WARN))
|
|
176
|
+
return finish("Capture")
|
|
177
|
+
|
|
178
|
+
if engine.is_partial:
|
|
179
|
+
log.append(("Saving partial recording...", C_WARN))
|
|
180
|
+
|
|
181
|
+
if engine.current_saturated:
|
|
182
|
+
log.append(("SATURATION detected. Lower RX gain.", C_WARN))
|
|
183
|
+
|
|
184
|
+
gps_fix = get_gps_manager().get_latest_fix() if cfg.enable_gps else None
|
|
185
|
+
base = engine.save_recording(RECORDS_DIR, hw_model, gps_fix=gps_fix)
|
|
186
|
+
if gps_fix is not None and gps_fix.has_fix:
|
|
187
|
+
lat_card = "N" if gps_fix.latitude >= 0 else "S"
|
|
188
|
+
lon_card = "E" if gps_fix.longitude >= 0 else "W"
|
|
189
|
+
log.append(
|
|
190
|
+
(
|
|
191
|
+
f"Geotag: {abs(gps_fix.latitude):.5f}°{lat_card}, "
|
|
192
|
+
f"{abs(gps_fix.longitude):.5f}°{lon_card} ({gps_fix.satellites} sats)",
|
|
193
|
+
C_DIM,
|
|
194
|
+
)
|
|
195
|
+
)
|
|
196
|
+
play_cue(SoundCue.CAPTURE_SUCCESS, enabled=cfg.enable_sound)
|
|
197
|
+
log.append((f"Saved: {os.path.basename(base)}.sigmf-data", C_DIM))
|
|
198
|
+
log.append(("Key captured successfully!", C_OK))
|
|
199
|
+
return finish("Capture Complete")
|
|
200
|
+
|
|
201
|
+
except (OSError, RuntimeError, ValueError) as e:
|
|
202
|
+
play_cue(SoundCue.FAIL, enabled=cfg.enable_sound)
|
|
203
|
+
log.append((f"Capture failed: {e}", C_ERR))
|
|
204
|
+
return finish("Capture Failed", hint="Enter/R = retry ▎ Esc/Q = back")
|
|
205
|
+
finally:
|
|
206
|
+
try:
|
|
207
|
+
win.nodelay(False)
|
|
208
|
+
except curses.error:
|
|
209
|
+
pass
|
|
210
|
+
cleanup_sdr(sdr)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def screen_capture(
|
|
214
|
+
win: curses.window,
|
|
215
|
+
sdr: SDRDevice,
|
|
216
|
+
hw_model: str,
|
|
217
|
+
config: AppConfig | None = None,
|
|
218
|
+
) -> None:
|
|
219
|
+
"""Listen for a burst, record with pre-trigger history, and save it as SigMF."""
|
|
220
|
+
while True:
|
|
221
|
+
key = do_capture(win, sdr, hw_model, config=config)
|
|
222
|
+
if key not in KEY_ENTER and key not in (ord("r"), ord("R")):
|
|
223
|
+
break
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
__all__ = ["do_capture", "screen_capture"]
|
|
@@ -0,0 +1,90 @@
|
|
|
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
|
+
"""Loopback test screen: digital loopback tone + SNR measurement."""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import curses
|
|
22
|
+
|
|
23
|
+
from pluto_siege.config import CONFIG, AppConfig
|
|
24
|
+
from pluto_siege.constants import (
|
|
25
|
+
IO_TIMEOUT_UNAVAILABLE,
|
|
26
|
+
LOOPBACK_MIN_SNR_DB,
|
|
27
|
+
)
|
|
28
|
+
from pluto_siege.device import SDRDevice
|
|
29
|
+
from pluto_siege.engine import LoopbackTester
|
|
30
|
+
from pluto_siege.sound import SoundCue, play_cue
|
|
31
|
+
from pluto_siege.ui.widgets.framework import (
|
|
32
|
+
C_DIM,
|
|
33
|
+
C_ERR,
|
|
34
|
+
C_OK,
|
|
35
|
+
C_WARN,
|
|
36
|
+
KEY_ENTER,
|
|
37
|
+
KEY_ESC,
|
|
38
|
+
_tail,
|
|
39
|
+
draw_chrome,
|
|
40
|
+
flush_input,
|
|
41
|
+
screen_is_usable,
|
|
42
|
+
scroll_view,
|
|
43
|
+
wait_for_usable_screen,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def screen_loopback(
|
|
48
|
+
win: curses.window, sdr: SDRDevice, config: AppConfig | None = None
|
|
49
|
+
) -> None:
|
|
50
|
+
"""Send a tone through the chip's digital loopback and measure its SNR."""
|
|
51
|
+
cfg = CONFIG if config is None else config
|
|
52
|
+
while True:
|
|
53
|
+
if not screen_is_usable(win) and not wait_for_usable_screen(win):
|
|
54
|
+
return
|
|
55
|
+
log: list[tuple[str, int]] = [("Running digital loopback test...", C_DIM)]
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
play_cue(SoundCue.START, enabled=cfg.enable_sound)
|
|
59
|
+
start = draw_chrome(win, "Loopback Test", "Please wait...")
|
|
60
|
+
if start != -1:
|
|
61
|
+
_tail(log, win, start, 2)
|
|
62
|
+
win.refresh()
|
|
63
|
+
|
|
64
|
+
snr_db, tone_freq, timeout_ok = LoopbackTester.run_test(
|
|
65
|
+
sdr, cfg.sample_rate, cfg.rx_buffer_size, config=cfg
|
|
66
|
+
)
|
|
67
|
+
if not timeout_ok:
|
|
68
|
+
log.append((IO_TIMEOUT_UNAVAILABLE, C_WARN))
|
|
69
|
+
|
|
70
|
+
log.append((f"Test tone: {tone_freq / 1000:.3f} kHz", C_DIM))
|
|
71
|
+
if snr_db >= LOOPBACK_MIN_SNR_DB:
|
|
72
|
+
play_cue(SoundCue.PASS, enabled=cfg.enable_sound)
|
|
73
|
+
log.append((f"PASSED. SNR: {snr_db:.1f} dB", C_OK))
|
|
74
|
+
else:
|
|
75
|
+
play_cue(SoundCue.FAIL, enabled=cfg.enable_sound)
|
|
76
|
+
log.append((f"FAILED. SNR: {snr_db:.1f} dB", C_ERR))
|
|
77
|
+
except Exception as e:
|
|
78
|
+
play_cue(SoundCue.FAIL, enabled=cfg.enable_sound)
|
|
79
|
+
log.append((f"Loopback failed: {e}", C_ERR))
|
|
80
|
+
|
|
81
|
+
flush_input(win)
|
|
82
|
+
key = scroll_view(
|
|
83
|
+
win,
|
|
84
|
+
"Loopback Test",
|
|
85
|
+
log,
|
|
86
|
+
hint="Enter/R = retest ▎ Esc/Q = back",
|
|
87
|
+
exit_keys=(KEY_ESC, ord("q"), ord("Q"), ord("r"), ord("R")) + KEY_ENTER,
|
|
88
|
+
)
|
|
89
|
+
if key not in (ord("r"), ord("R")) and key not in KEY_ENTER:
|
|
90
|
+
return
|
|
@@ -0,0 +1,363 @@
|
|
|
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
|
+
"""Main menu, startup connection screen, and application entry point."""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import atexit
|
|
22
|
+
import curses
|
|
23
|
+
import signal
|
|
24
|
+
import sys
|
|
25
|
+
import threading
|
|
26
|
+
import time
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from pluto_siege.config import CONFIG, AppConfig, save_settings
|
|
30
|
+
from pluto_siege.device import SDRDevice, open_sdr, release_sdr
|
|
31
|
+
from pluto_siege.gps import init_gps, stop_gps
|
|
32
|
+
from pluto_siege.sound import SoundCue, play_cue
|
|
33
|
+
from pluto_siege.ui.screens.capture import screen_capture
|
|
34
|
+
from pluto_siege.ui.screens.loopback import screen_loopback
|
|
35
|
+
from pluto_siege.ui.screens.settings import screen_settings
|
|
36
|
+
from pluto_siege.ui.screens.transmit import screen_transmit
|
|
37
|
+
from pluto_siege.ui.widgets.framework import (
|
|
38
|
+
C_DIM,
|
|
39
|
+
C_ERR,
|
|
40
|
+
C_OK,
|
|
41
|
+
C_WARN,
|
|
42
|
+
CONTENT_X,
|
|
43
|
+
ESC_DELAY_MS,
|
|
44
|
+
KEY_ENTER,
|
|
45
|
+
KEY_ESC,
|
|
46
|
+
_put,
|
|
47
|
+
cp,
|
|
48
|
+
draw_chrome,
|
|
49
|
+
flush_input,
|
|
50
|
+
init_colors,
|
|
51
|
+
init_mouse,
|
|
52
|
+
menu_select,
|
|
53
|
+
message_box,
|
|
54
|
+
screen_is_usable,
|
|
55
|
+
set_cursor_visible,
|
|
56
|
+
wait_for_usable_screen,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
_ACTIVE_SDR: SDRDevice | None = None
|
|
60
|
+
_SDR_LOCK = threading.Lock()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _set_active_sdr(sdr: SDRDevice | None) -> None:
|
|
64
|
+
global _ACTIVE_SDR
|
|
65
|
+
with _SDR_LOCK:
|
|
66
|
+
_ACTIVE_SDR = sdr
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _cleanup_active_sdr() -> None:
|
|
70
|
+
global _ACTIVE_SDR
|
|
71
|
+
with _SDR_LOCK:
|
|
72
|
+
sdr = _ACTIVE_SDR
|
|
73
|
+
_ACTIVE_SDR = None
|
|
74
|
+
if sdr is not None:
|
|
75
|
+
try:
|
|
76
|
+
release_sdr(sdr)
|
|
77
|
+
except (OSError, RuntimeError, AttributeError):
|
|
78
|
+
pass
|
|
79
|
+
stop_gps()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
atexit.register(_cleanup_active_sdr)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def reconnect_sdr(
|
|
86
|
+
win: curses.window,
|
|
87
|
+
sdr: SDRDevice | None,
|
|
88
|
+
hw_model: str,
|
|
89
|
+
previous_uri: str,
|
|
90
|
+
config: AppConfig | None = None,
|
|
91
|
+
) -> tuple[SDRDevice | None, str]:
|
|
92
|
+
"""Re-open the device after the URI changed in Settings."""
|
|
93
|
+
cfg = CONFIG if config is None else config
|
|
94
|
+
uri = cfg.pluto_uri
|
|
95
|
+
if not screen_is_usable(win) and not wait_for_usable_screen(win):
|
|
96
|
+
cfg.update({"pluto_uri": previous_uri})
|
|
97
|
+
save_settings(cfg)
|
|
98
|
+
return sdr, hw_model
|
|
99
|
+
start_y = draw_chrome(win, "Reconnecting", "Please wait...")
|
|
100
|
+
_put(win, start_y, CONTENT_X, f"Connecting to PlutoSDR at {uri} ...", cp(C_DIM))
|
|
101
|
+
win.refresh()
|
|
102
|
+
_set_active_sdr(None)
|
|
103
|
+
release_sdr(sdr)
|
|
104
|
+
try:
|
|
105
|
+
new_sdr, model = open_sdr(uri, hw_model)
|
|
106
|
+
except Exception as e:
|
|
107
|
+
cfg.update({"pluto_uri": previous_uri})
|
|
108
|
+
save_settings(cfg)
|
|
109
|
+
prev_sdr = None
|
|
110
|
+
try:
|
|
111
|
+
prev_sdr, _ = open_sdr(previous_uri, hw_model)
|
|
112
|
+
_set_active_sdr(prev_sdr)
|
|
113
|
+
except Exception:
|
|
114
|
+
pass
|
|
115
|
+
play_cue(SoundCue.FAIL, enabled=cfg.enable_sound)
|
|
116
|
+
if prev_sdr is not None:
|
|
117
|
+
message_box(
|
|
118
|
+
win,
|
|
119
|
+
"Reconnect Failed",
|
|
120
|
+
[
|
|
121
|
+
(f"Could not open {uri}: {e}", C_ERR),
|
|
122
|
+
("Please check Pluto connection settings.", C_WARN),
|
|
123
|
+
(f"Reverted to {previous_uri}.", C_WARN),
|
|
124
|
+
],
|
|
125
|
+
)
|
|
126
|
+
else:
|
|
127
|
+
message_box(
|
|
128
|
+
win,
|
|
129
|
+
"Reconnect Failed",
|
|
130
|
+
[
|
|
131
|
+
(f"Could not open {uri}: {e}", C_ERR),
|
|
132
|
+
("Please check Pluto connection settings.", C_WARN),
|
|
133
|
+
(f"Could not revert to {previous_uri}.", C_ERR),
|
|
134
|
+
("Device disconnected.", C_WARN),
|
|
135
|
+
],
|
|
136
|
+
)
|
|
137
|
+
return prev_sdr, hw_model
|
|
138
|
+
_set_active_sdr(new_sdr)
|
|
139
|
+
init_gps(cfg)
|
|
140
|
+
play_cue(SoundCue.SUCCESS, enabled=cfg.enable_sound)
|
|
141
|
+
message_box(win, "Reconnected", [(f"Connected: {model} at {uri}", C_OK)])
|
|
142
|
+
return new_sdr, model
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def is_sdr_connected(sdr: SDRDevice | None) -> bool:
|
|
146
|
+
"""Check if the SDR device is still responsive."""
|
|
147
|
+
if sdr is None:
|
|
148
|
+
return False
|
|
149
|
+
try:
|
|
150
|
+
_ = sdr.sample_rate
|
|
151
|
+
return True
|
|
152
|
+
except (OSError, RuntimeError, AttributeError):
|
|
153
|
+
return False
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def connect_screen(
|
|
157
|
+
win: curses.window, config: AppConfig | None = None
|
|
158
|
+
) -> tuple[SDRDevice | None, str]:
|
|
159
|
+
"""Connect at startup, letting the user fix the URI. Returns (None, _) on exit."""
|
|
160
|
+
cfg = CONFIG if config is None else config
|
|
161
|
+
hint = "Enter = settings ▎ R = retry ▎ Esc/Q = quit"
|
|
162
|
+
hw_model = "PlutoSDR"
|
|
163
|
+
attempt = True
|
|
164
|
+
err_msg = ""
|
|
165
|
+
|
|
166
|
+
while True:
|
|
167
|
+
if not screen_is_usable(win) and not wait_for_usable_screen(win):
|
|
168
|
+
return None, hw_model
|
|
169
|
+
uri = cfg.pluto_uri
|
|
170
|
+
if attempt:
|
|
171
|
+
start_y = draw_chrome(win, "Startup", hint)
|
|
172
|
+
_put(
|
|
173
|
+
win,
|
|
174
|
+
start_y,
|
|
175
|
+
CONTENT_X,
|
|
176
|
+
f"Connecting to PlutoSDR at {uri} ...",
|
|
177
|
+
cp(C_DIM),
|
|
178
|
+
)
|
|
179
|
+
win.refresh()
|
|
180
|
+
try:
|
|
181
|
+
sdr, hw_model = open_sdr(uri, hw_model)
|
|
182
|
+
_set_active_sdr(sdr)
|
|
183
|
+
init_gps(cfg)
|
|
184
|
+
_put(win, start_y + 1, CONTENT_X, f"Connected: {hw_model}", cp(C_OK))
|
|
185
|
+
win.refresh()
|
|
186
|
+
time.sleep(0.4)
|
|
187
|
+
return sdr, hw_model
|
|
188
|
+
except Exception as e:
|
|
189
|
+
err_msg = str(e)
|
|
190
|
+
attempt = False
|
|
191
|
+
|
|
192
|
+
start_y = draw_chrome(win, "Startup", hint)
|
|
193
|
+
if start_y == -1:
|
|
194
|
+
win.refresh()
|
|
195
|
+
k = win.getch()
|
|
196
|
+
if k in (KEY_ESC, ord("q"), ord("Q")):
|
|
197
|
+
return None, hw_model
|
|
198
|
+
continue
|
|
199
|
+
err_lines = [line.strip() for line in err_msg.splitlines() if line.strip()]
|
|
200
|
+
curr_y = start_y
|
|
201
|
+
if len(err_lines) <= 1:
|
|
202
|
+
msg = err_lines[0] if err_lines else "Unknown error"
|
|
203
|
+
_put(win, curr_y, CONTENT_X, f"Cannot connect: {msg}", cp(C_ERR))
|
|
204
|
+
curr_y += 1
|
|
205
|
+
else:
|
|
206
|
+
_put(win, curr_y, CONTENT_X, "Cannot connect:", cp(C_ERR))
|
|
207
|
+
curr_y += 1
|
|
208
|
+
for el in err_lines:
|
|
209
|
+
_put(win, curr_y, CONTENT_X + 2, el, cp(C_ERR))
|
|
210
|
+
curr_y += 1
|
|
211
|
+
curr_y += 1
|
|
212
|
+
_put(
|
|
213
|
+
win,
|
|
214
|
+
curr_y,
|
|
215
|
+
CONTENT_X,
|
|
216
|
+
"Check the Pluto URI in Settings, then retry.",
|
|
217
|
+
cp(C_WARN),
|
|
218
|
+
)
|
|
219
|
+
_put(
|
|
220
|
+
win,
|
|
221
|
+
curr_y + 1,
|
|
222
|
+
CONTENT_X,
|
|
223
|
+
"You can still open Settings to change the URI.",
|
|
224
|
+
cp(C_WARN),
|
|
225
|
+
)
|
|
226
|
+
win.refresh()
|
|
227
|
+
|
|
228
|
+
flush_input(win)
|
|
229
|
+
win.nodelay(False)
|
|
230
|
+
k = win.getch()
|
|
231
|
+
if k in (KEY_ESC, ord("q"), ord("Q")):
|
|
232
|
+
return None, hw_model
|
|
233
|
+
elif k == getattr(curses, "KEY_RESIZE", -1):
|
|
234
|
+
continue
|
|
235
|
+
elif k in KEY_ENTER or k in (ord("s"), ord("S")):
|
|
236
|
+
screen_settings(win, config=cfg)
|
|
237
|
+
init_gps(cfg)
|
|
238
|
+
attempt = cfg.pluto_uri != uri
|
|
239
|
+
elif k in (ord("r"), ord("R")):
|
|
240
|
+
attempt = True
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def curses_app(win: curses.window, config: AppConfig | None = None) -> None:
|
|
244
|
+
"""Run the initialized UI lifecycle, always restoring terminal/device state."""
|
|
245
|
+
cfg = CONFIG if config is None else config
|
|
246
|
+
try:
|
|
247
|
+
try:
|
|
248
|
+
win.keypad(True)
|
|
249
|
+
except curses.error:
|
|
250
|
+
pass
|
|
251
|
+
set_cursor_visible(False)
|
|
252
|
+
try:
|
|
253
|
+
curses.set_escdelay(ESC_DELAY_MS)
|
|
254
|
+
except (AttributeError, curses.error):
|
|
255
|
+
pass
|
|
256
|
+
init_colors()
|
|
257
|
+
init_mouse(cfg.enable_mouse)
|
|
258
|
+
init_gps(cfg)
|
|
259
|
+
|
|
260
|
+
sdr, hw_model = connect_screen(win, config=cfg)
|
|
261
|
+
if sdr is None:
|
|
262
|
+
return
|
|
263
|
+
|
|
264
|
+
items = ["Capture", "Transmit", "Loopback Test", "Settings", "Exit"]
|
|
265
|
+
idx = 0
|
|
266
|
+
while True:
|
|
267
|
+
choice = menu_select(
|
|
268
|
+
win,
|
|
269
|
+
"Main Menu",
|
|
270
|
+
items,
|
|
271
|
+
start_idx=idx,
|
|
272
|
+
hint="Up/Down = move ▎ Enter = select ▎ Esc/Q = quit",
|
|
273
|
+
hotkeys=True,
|
|
274
|
+
)
|
|
275
|
+
if choice is None:
|
|
276
|
+
break
|
|
277
|
+
idx = choice
|
|
278
|
+
if items[idx] == "Exit":
|
|
279
|
+
break
|
|
280
|
+
if items[idx] in ("Capture", "Transmit", "Loopback Test"):
|
|
281
|
+
if not is_sdr_connected(sdr):
|
|
282
|
+
_cleanup_active_sdr()
|
|
283
|
+
sdr = None
|
|
284
|
+
play_cue(SoundCue.ALERT, enabled=cfg.enable_sound)
|
|
285
|
+
message_box(
|
|
286
|
+
win,
|
|
287
|
+
"SDR Disconnected",
|
|
288
|
+
[
|
|
289
|
+
(
|
|
290
|
+
"SDR device is not responding (USB disconnected?).",
|
|
291
|
+
C_ERR,
|
|
292
|
+
),
|
|
293
|
+
("Opening connection screen...", C_WARN),
|
|
294
|
+
],
|
|
295
|
+
)
|
|
296
|
+
new_sdr, new_hw = connect_screen(win, config=cfg)
|
|
297
|
+
if new_sdr is None:
|
|
298
|
+
continue
|
|
299
|
+
sdr, hw_model = new_sdr, new_hw
|
|
300
|
+
|
|
301
|
+
if sdr is None:
|
|
302
|
+
continue
|
|
303
|
+
|
|
304
|
+
if items[idx] == "Capture":
|
|
305
|
+
screen_capture(win, sdr, hw_model, config=cfg)
|
|
306
|
+
elif items[idx] == "Transmit":
|
|
307
|
+
screen_transmit(win, sdr, config=cfg)
|
|
308
|
+
elif items[idx] == "Loopback Test":
|
|
309
|
+
screen_loopback(win, sdr, config=cfg)
|
|
310
|
+
|
|
311
|
+
if not is_sdr_connected(sdr):
|
|
312
|
+
_cleanup_active_sdr()
|
|
313
|
+
sdr = None
|
|
314
|
+
elif items[idx] == "Settings":
|
|
315
|
+
uri_before = cfg.pluto_uri
|
|
316
|
+
screen_settings(win, config=cfg)
|
|
317
|
+
init_gps(cfg)
|
|
318
|
+
if cfg.pluto_uri != uri_before:
|
|
319
|
+
new_sdr, new_hw = reconnect_sdr(
|
|
320
|
+
win, sdr, hw_model, uri_before, config=cfg
|
|
321
|
+
)
|
|
322
|
+
if new_sdr is None:
|
|
323
|
+
new_sdr, new_hw = connect_screen(win, config=cfg)
|
|
324
|
+
if new_sdr is None:
|
|
325
|
+
break
|
|
326
|
+
sdr, hw_model = new_sdr, new_hw
|
|
327
|
+
except KeyboardInterrupt:
|
|
328
|
+
pass
|
|
329
|
+
finally:
|
|
330
|
+
set_cursor_visible(True)
|
|
331
|
+
_cleanup_active_sdr()
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def sig_handler(signum: int, frame: Any) -> None:
|
|
335
|
+
"""Defer cleanup to normal control flow; signal handlers must not lock."""
|
|
336
|
+
raise KeyboardInterrupt
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def main() -> None:
|
|
340
|
+
if not (sys.stdin.isatty() and sys.stdout.isatty()):
|
|
341
|
+
print("Requires an interactive terminal.", file=sys.stderr)
|
|
342
|
+
sys.exit(1)
|
|
343
|
+
for sig_name in ("SIGINT", "SIGTERM", "SIGHUP"):
|
|
344
|
+
sig = getattr(signal, sig_name, None)
|
|
345
|
+
if sig is not None:
|
|
346
|
+
try:
|
|
347
|
+
signal.signal(sig, sig_handler)
|
|
348
|
+
except (ValueError, OSError):
|
|
349
|
+
pass
|
|
350
|
+
try:
|
|
351
|
+
curses.wrapper(curses_app)
|
|
352
|
+
except KeyboardInterrupt:
|
|
353
|
+
pass
|
|
354
|
+
except curses.error as e:
|
|
355
|
+
print(f"Terminal error: {e}", file=sys.stderr)
|
|
356
|
+
sys.exit(1)
|
|
357
|
+
finally:
|
|
358
|
+
_cleanup_active_sdr()
|
|
359
|
+
print("Goodbye!")
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
if __name__ == "__main__":
|
|
363
|
+
main()
|