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,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()