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,504 @@
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
+ """Settings screen controller."""
18
+
19
+ from __future__ import annotations
20
+
21
+ import curses
22
+ from typing import Any
23
+
24
+ from pluto_siege.config import (
25
+ CONFIG,
26
+ DEFAULT_SETTINGS,
27
+ AppConfig,
28
+ freq_bounds,
29
+ save_settings,
30
+ validate_config,
31
+ )
32
+ from pluto_siege.constants import (
33
+ AUTO_MARGIN_RANGE,
34
+ POST_TRIGGER_RANGE,
35
+ PRE_TRIGGER_RANGE,
36
+ RX_BUFFER_RANGE,
37
+ RX_GAIN_RANGE,
38
+ SAMPLE_RATE_RANGE,
39
+ SILENCE_RANGE,
40
+ THRESHOLD_RANGE,
41
+ TX_GAIN_RANGE,
42
+ )
43
+ from pluto_siege.gps import init_gps
44
+ from pluto_siege.sound import SoundCue, play_cue
45
+ from pluto_siege.ui.widgets.framework import (
46
+ BUTTON_CLICK,
47
+ BUTTON_DOUBLE,
48
+ BUTTON_RIGHT,
49
+ C_ERR,
50
+ C_OK,
51
+ C_WARN,
52
+ KEY_ENTER,
53
+ KEY_ESC,
54
+ WHEEL_DOWN,
55
+ WHEEL_UP,
56
+ _content_rows,
57
+ _flash,
58
+ _move_cursor,
59
+ confirm_dialog,
60
+ draw_chrome,
61
+ draw_list_row,
62
+ edit_number,
63
+ edit_text,
64
+ flush_input,
65
+ get_mouse_event,
66
+ init_mouse,
67
+ )
68
+
69
+ SETTINGS_HINT = (
70
+ "Up/Down = move ▎ Left/Right = adjust ▎ Enter = edit ▎ "
71
+ "S = save ▎ R = reset ▎ Esc/Q = back"
72
+ )
73
+
74
+
75
+ def _build_setting_rows(cfg: AppConfig) -> list[dict[str, Any]]:
76
+ freq_range = freq_bounds(cfg)
77
+ return [
78
+ # --- Radio & Connection ---
79
+ {
80
+ "key": "connection_type",
81
+ "label": "Connection type",
82
+ "val": lambda: "ip" if cfg.pluto_uri.startswith("ip:") else "usb",
83
+ "kind": "enum",
84
+ },
85
+ {
86
+ "key": "pluto_ip",
87
+ "label": "Pluto IP address",
88
+ "val": lambda: (
89
+ f"{cfg.pluto_ip}"
90
+ + ("" if cfg.pluto_uri.startswith("ip:") else " (unused: USB mode)")
91
+ ),
92
+ "kind": "text",
93
+ },
94
+ {
95
+ "key": "rx_freq",
96
+ "label": "RX frequency (Hz)",
97
+ "val": lambda: f"{cfg.rx_freq} ({cfg.rx_freq / 1e6:.3f} MHz)",
98
+ "kind": "int",
99
+ "range": freq_range,
100
+ },
101
+ {
102
+ "key": "tx_freq",
103
+ "label": "TX frequency (Hz)",
104
+ "val": lambda: f"{cfg.tx_freq} ({cfg.tx_freq / 1e6:.3f} MHz)",
105
+ "kind": "int",
106
+ "range": freq_range,
107
+ },
108
+ {
109
+ "key": "permit_out_of_spec_frequency",
110
+ "label": "Extended freq (70MHz-6GHz)",
111
+ "val": lambda: "yes" if cfg.permit_out_of_spec_frequency else "no",
112
+ "kind": "bool",
113
+ },
114
+ {
115
+ "key": "sample_rate",
116
+ "label": "Sample rate (Hz)",
117
+ "val": lambda: f"{cfg.sample_rate} ({cfg.sample_rate / 1e6:.2f} MSPS)",
118
+ "kind": "int",
119
+ "range": SAMPLE_RATE_RANGE,
120
+ },
121
+ {
122
+ "key": "rx_gain",
123
+ "label": "RX gain (dB)",
124
+ "val": lambda: f"{cfg.rx_gain:.2f}",
125
+ "kind": "float",
126
+ "range": RX_GAIN_RANGE,
127
+ },
128
+ {
129
+ "key": "tx_gain",
130
+ "label": "TX gain (dB)",
131
+ "val": lambda: f"{cfg.tx_gain:.2f}",
132
+ "kind": "float",
133
+ "range": TX_GAIN_RANGE,
134
+ },
135
+ # --- Capture Engine & Trigger ---
136
+ {
137
+ "key": "auto_threshold",
138
+ "label": "Auto noise threshold",
139
+ "val": lambda: "yes" if cfg.auto_threshold else "no",
140
+ "kind": "bool",
141
+ },
142
+ {
143
+ "key": "auto_trigger_margin",
144
+ "label": "Auto trigger margin (dB)",
145
+ "val": lambda: (
146
+ f"{cfg.auto_trigger_margin:.1f}"
147
+ + ("" if cfg.auto_threshold else " (unused: auto off)")
148
+ ),
149
+ "kind": "float",
150
+ "range": AUTO_MARGIN_RANGE,
151
+ },
152
+ {
153
+ "key": "manual_threshold",
154
+ "label": "Manual threshold (dBFS)",
155
+ "val": lambda: (
156
+ f"{cfg.manual_threshold:.1f}"
157
+ + ("" if not cfg.auto_threshold else " (unused: auto on)")
158
+ ),
159
+ "kind": "float",
160
+ "range": THRESHOLD_RANGE,
161
+ },
162
+ {
163
+ "key": "pre_trigger_buffers",
164
+ "label": "Pre-trigger buffers",
165
+ "val": lambda: str(cfg.pre_trigger_buffers),
166
+ "kind": "int",
167
+ "range": PRE_TRIGGER_RANGE,
168
+ },
169
+ {
170
+ "key": "silence_seconds",
171
+ "label": "Silence seconds",
172
+ "val": lambda: f"{cfg.silence_seconds:.2f}",
173
+ "kind": "float",
174
+ "range": SILENCE_RANGE,
175
+ },
176
+ {
177
+ "key": "max_post_trigger_seconds",
178
+ "label": "Max post-trigger seconds",
179
+ "val": lambda: f"{cfg.max_post_trigger_seconds:.2f}",
180
+ "kind": "float",
181
+ "range": POST_TRIGGER_RANGE,
182
+ },
183
+ {
184
+ "key": "rx_buffer_size",
185
+ "label": "RX buffer size",
186
+ "val": lambda: str(cfg.rx_buffer_size),
187
+ "kind": "int",
188
+ "range": RX_BUFFER_RANGE,
189
+ },
190
+ # --- GPS Geolocation ---
191
+ {
192
+ "key": "enable_gps",
193
+ "label": "Enable GPS (gpsd)",
194
+ "val": lambda: "yes" if cfg.enable_gps else "no",
195
+ "kind": "bool",
196
+ },
197
+ {
198
+ "key": "gpsd_host",
199
+ "label": "gpsd host",
200
+ "val": lambda: (
201
+ cfg.gpsd_host + ("" if cfg.enable_gps else " (unused: GPS off)")
202
+ ),
203
+ "kind": "text",
204
+ },
205
+ {
206
+ "key": "gpsd_port",
207
+ "label": "gpsd port",
208
+ "val": lambda: (
209
+ str(cfg.gpsd_port) + ("" if cfg.enable_gps else " (unused: GPS off)")
210
+ ),
211
+ "kind": "int",
212
+ "range": (1, 65535),
213
+ },
214
+ # --- Interface & System ---
215
+ {
216
+ "key": "enable_sound",
217
+ "label": "Enable sound effects",
218
+ "val": lambda: "yes" if cfg.enable_sound else "no",
219
+ "kind": "bool",
220
+ },
221
+ {
222
+ "key": "enable_mouse",
223
+ "label": "Enable mouse support",
224
+ "val": lambda: "yes" if cfg.enable_mouse else "no",
225
+ "kind": "bool",
226
+ },
227
+ ]
228
+
229
+
230
+ def _guard_inactive_setting(
231
+ key: str, cfg: AppConfig, win: curses.window | None
232
+ ) -> bool:
233
+ """Return True (and flash a warning) if the setting is currently inactive."""
234
+ msg: str | None = None
235
+ if key == "pluto_ip" and not cfg.pluto_uri.startswith("ip:"):
236
+ msg = "USB mode is active. Switch 'Connection type' to 'ip' to set IP address."
237
+ elif not cfg.enable_gps and key in ("gpsd_host", "gpsd_port"):
238
+ msg = "GPS is disabled. Turn on 'Enable GPS (gpsd)' first."
239
+ elif key == "auto_trigger_margin" and not cfg.auto_threshold:
240
+ msg = "Auto margin is inactive (Auto threshold is off)."
241
+ elif key == "manual_threshold" and cfg.auto_threshold:
242
+ msg = "Manual threshold is inactive (Auto threshold is on)."
243
+
244
+ if msg is not None:
245
+ if win is not None:
246
+ _flash(win, msg, C_WARN)
247
+ return True
248
+ return False
249
+
250
+
251
+ def _apply_setting(
252
+ row: dict[str, Any], win: curses.window, cfg: AppConfig, redraw_fn: Any = None
253
+ ) -> None:
254
+ key, kind = row["key"], row["kind"]
255
+ label = row["label"]
256
+
257
+ if _guard_inactive_setting(key, cfg, win):
258
+ return
259
+
260
+ if key == "connection_type":
261
+ if cfg.pluto_uri.startswith("ip:"):
262
+ cfg.pluto_uri = "usb:"
263
+ else:
264
+ ip_val = cfg.pluto_ip.strip() if cfg.pluto_ip else "192.168.2.1"
265
+ cfg.pluto_uri = f"ip:{ip_val}"
266
+ return
267
+
268
+ if key == "pluto_ip":
269
+ prompt = f"{label} (e.g. 192.168.2.1)"
270
+ new = edit_text(win, prompt, cfg.pluto_ip, redraw_fn=redraw_fn)
271
+ if new is not None:
272
+ clean_ip = new.strip()
273
+ if clean_ip.startswith("ip:"):
274
+ clean_ip = clean_ip[3:].strip()
275
+ if clean_ip:
276
+ cfg.pluto_ip = clean_ip
277
+ cfg.pluto_uri = f"ip:{clean_ip}"
278
+ return
279
+
280
+ cur_val = getattr(cfg, key)
281
+ if kind == "text":
282
+ prompt = f"{label} (e.g. 127.0.0.1)" if key == "gpsd_host" else label
283
+ new = edit_text(win, prompt, str(cur_val), redraw_fn=redraw_fn)
284
+ if new is not None and new.strip():
285
+ setattr(cfg, key, new.strip())
286
+ elif kind == "bool":
287
+ setattr(cfg, key, not cur_val)
288
+ else:
289
+ cast = int if kind == "int" else float
290
+ lo, hi = row["range"]
291
+ prompt = label
292
+ if key in ("rx_freq", "tx_freq"):
293
+ prompt = f"{label} (e.g. 433.92M or 433920000)"
294
+ elif key == "sample_rate":
295
+ prompt = f"{label} (e.g. 2M or 2000000)"
296
+ new = edit_number(win, prompt, cur_val, cast, lo, hi, redraw_fn=redraw_fn)
297
+ if new is not None:
298
+ setattr(cfg, key, cast(new))
299
+
300
+
301
+ def _step_setting(
302
+ row: dict[str, Any],
303
+ cfg: AppConfig,
304
+ direction: int,
305
+ win: curses.window | None = None,
306
+ ) -> bool:
307
+ """Increment (+1) or decrement (-1) a setting directly in the list."""
308
+ key = row["key"]
309
+ kind = row["kind"]
310
+
311
+ if _guard_inactive_setting(key, cfg, win):
312
+ return False
313
+
314
+ if key == "connection_type":
315
+ cur_is_ip = cfg.pluto_uri.startswith("ip:")
316
+ new_is_ip = direction >= 0
317
+ if new_is_ip != cur_is_ip:
318
+ if new_is_ip:
319
+ ip_val = cfg.pluto_ip.strip() if cfg.pluto_ip else "192.168.2.1"
320
+ cfg.pluto_uri = f"ip:{ip_val}"
321
+ else:
322
+ cfg.pluto_uri = "usb:"
323
+ return True
324
+ return False
325
+
326
+ cur_val = getattr(cfg, key)
327
+
328
+ if kind == "bool":
329
+ new_val = direction >= 0
330
+ if new_val != cur_val:
331
+ setattr(cfg, key, new_val)
332
+ return True
333
+ return False
334
+
335
+ if kind in ("int", "float"):
336
+ lo, hi = row["range"]
337
+ if key == "rx_buffer_size":
338
+ new_buf = (
339
+ min(hi, cur_val * 2 if cur_val < hi else hi)
340
+ if direction > 0
341
+ else max(lo, cur_val // 2 if cur_val > lo else lo)
342
+ )
343
+ if new_buf != cur_val:
344
+ setattr(cfg, key, new_buf)
345
+ return True
346
+ return False
347
+
348
+ steps: dict[str, float] = {
349
+ "rx_gain": 1.0,
350
+ "tx_gain": 1.0,
351
+ "manual_threshold": 1.0,
352
+ "auto_trigger_margin": 0.5,
353
+ "silence_seconds": 0.05,
354
+ "max_post_trigger_seconds": 0.5,
355
+ "rx_freq": 10_000,
356
+ "tx_freq": 10_000,
357
+ "sample_rate": 250_000,
358
+ "pre_trigger_buffers": 1,
359
+ }
360
+ step = steps.get(key, 1 if kind == "int" else 0.5)
361
+ raw_val = cur_val + direction * step
362
+ new_num: int | float = (
363
+ max(int(lo), min(int(hi), round(raw_val)))
364
+ if kind == "int"
365
+ else max(float(lo), min(float(hi), round(raw_val, 2)))
366
+ )
367
+ if new_num != cur_val:
368
+ setattr(cfg, key, new_num)
369
+ return True
370
+ return False
371
+
372
+ return False
373
+
374
+
375
+ def screen_settings(win: curses.window, config: AppConfig | None = None) -> None:
376
+ cfg = CONFIG if config is None else config
377
+ idx = 0
378
+ top = 0
379
+ win.nodelay(False)
380
+ backup = dict(cfg.to_dict())
381
+ row_hitboxes: dict[int, Any] = {}
382
+
383
+ def render_settings_list(current_rows: list[dict[str, Any]] | None = None) -> int:
384
+ nonlocal top
385
+ rows = _build_setting_rows(cfg) if current_rows is None else current_rows
386
+ start = draw_chrome(win, "Settings", SETTINGS_HINT)
387
+ row_hitboxes.clear()
388
+ if start == -1:
389
+ return -1
390
+ avail, _ = _content_rows(win, start, 4)
391
+ val_col = 38
392
+ if idx < top:
393
+ top = idx
394
+ elif idx >= top + avail:
395
+ top = idx - avail + 1
396
+ top = max(0, min(top, max(0, len(rows) - avail)))
397
+ for i in range(top, min(len(rows), top + avail)):
398
+ row = start + (i - top)
399
+ setting = rows[i]
400
+ value = setting["val"]()
401
+ value_text = f" {value} " if i == idx else value
402
+ label_width = max(0, val_col - 4 - 3)
403
+ sep = " " if not value_text.startswith(" ") and len(setting["label"]) >= label_width else ""
404
+ line = setting["label"].ljust(label_width) + sep + value_text
405
+ row_hitboxes[i] = draw_list_row(win, row, line, i == idx)
406
+ return start
407
+
408
+ def handle_common_key(k: int) -> bool:
409
+ if k in (ord("s"), ord("S")):
410
+ if save_settings(cfg):
411
+ backup.clear()
412
+ backup.update(cfg.to_dict())
413
+ _flash(win, "Settings saved.", C_OK)
414
+ else:
415
+ _flash(win, "Could not write settings file!", C_ERR)
416
+ return True
417
+ elif k in (ord("r"), ord("R")):
418
+ cfg.update(DEFAULT_SETTINGS)
419
+ init_mouse(cfg.enable_mouse)
420
+ init_gps(cfg)
421
+ _flash(win, "Defaults restored.", C_OK)
422
+ return True
423
+ return False
424
+
425
+ def post_change_sync(target_row: dict[str, Any]) -> None:
426
+ flush_input(win)
427
+ before = dict(cfg.to_dict())
428
+ validated = validate_config(before).to_dict()
429
+ cfg.update(validated)
430
+ reset = [key for key, val in validated.items() if before[key] != val]
431
+ if reset:
432
+ _flash(win, "Out of range, reset to default: " + ", ".join(reset), C_WARN)
433
+ init_mouse(cfg.enable_mouse)
434
+ if target_row["key"] in ("enable_gps", "gpsd_host", "gpsd_port"):
435
+ init_gps(cfg)
436
+ if target_row["key"] == "enable_sound" and cfg.enable_sound:
437
+ play_cue(SoundCue.PASS, enabled=True)
438
+
439
+ def handle_exit() -> bool:
440
+ if cfg.to_dict() != backup:
441
+ if confirm_dialog(win, "Unsaved Changes", "Discard unsaved changes?"):
442
+ cfg.update(backup)
443
+ init_mouse(cfg.enable_mouse)
444
+ init_gps(cfg)
445
+ return True
446
+ return False
447
+ return True
448
+
449
+ while True:
450
+ rows = _build_setting_rows(cfg)
451
+ start = render_settings_list(rows)
452
+ if start == -1:
453
+ win.refresh()
454
+ k = win.getch()
455
+ if (k in (KEY_ESC, ord("q"), ord("Q"))) and handle_exit():
456
+ return
457
+ continue
458
+
459
+ avail, _ = _content_rows(win, start, 4)
460
+ win.refresh()
461
+ k = win.getch()
462
+ if handle_common_key(k):
463
+ continue
464
+
465
+ if k in KEY_ENTER or (k == ord(" ") and rows[idx]["kind"] == "bool"):
466
+ _apply_setting(rows[idx], win, cfg, redraw_fn=render_settings_list)
467
+ post_change_sync(rows[idx])
468
+ elif k in (getattr(curses, "KEY_LEFT", 260), ord("h")):
469
+ if _step_setting(rows[idx], cfg, -1, win=win):
470
+ post_change_sync(rows[idx])
471
+ elif k in (getattr(curses, "KEY_RIGHT", 261), ord("l")):
472
+ if _step_setting(rows[idx], cfg, +1, win=win):
473
+ post_change_sync(rows[idx])
474
+ elif k in (KEY_ESC, ord("q"), ord("Q")):
475
+ if handle_exit():
476
+ return
477
+ elif k == getattr(curses, "KEY_MOUSE", -1):
478
+ m = get_mouse_event()
479
+ if m is not None:
480
+ _mid, mx, my, _mz, bstate = m
481
+ if bstate & WHEEL_UP:
482
+ idx = max(0, idx - 1)
483
+ elif bstate & WHEEL_DOWN:
484
+ idx = min(len(rows) - 1, idx + 1)
485
+ elif bstate & BUTTON_RIGHT:
486
+ if handle_exit():
487
+ return
488
+ elif bstate & (BUTTON_CLICK | BUTTON_DOUBLE):
489
+ clicked_idx = top + (my - start)
490
+ hitbox = row_hitboxes.get(clicked_idx)
491
+ if hitbox is not None and hitbox.contains(mx, my):
492
+ if clicked_idx == idx or (bstate & BUTTON_DOUBLE):
493
+ idx = clicked_idx
494
+ _apply_setting(
495
+ rows[clicked_idx],
496
+ win,
497
+ cfg,
498
+ redraw_fn=render_settings_list,
499
+ )
500
+ post_change_sync(rows[clicked_idx])
501
+ else:
502
+ idx = clicked_idx
503
+ else:
504
+ idx = _move_cursor(idx, k, len(rows), avail)