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/gps.py
ADDED
|
@@ -0,0 +1,419 @@
|
|
|
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 GPS/GNSS receiver module via gpsd TCP client for PlutoSiege.
|
|
18
|
+
|
|
19
|
+
Polls a local or remote gpsd daemon (default port 2947) in the background
|
|
20
|
+
to geotag captured RF bursts in SigMF metadata files.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
import math
|
|
27
|
+
import socket
|
|
28
|
+
import threading
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
from typing import TYPE_CHECKING, Any
|
|
31
|
+
|
|
32
|
+
if TYPE_CHECKING:
|
|
33
|
+
from pluto_siege.config import AppConfig
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class GPSFix:
|
|
38
|
+
"""Snapshot of geographic position and receiver status from gpsd."""
|
|
39
|
+
|
|
40
|
+
longitude: float = 0.0
|
|
41
|
+
latitude: float = 0.0
|
|
42
|
+
altitude: float = 0.0
|
|
43
|
+
has_fix: bool = False
|
|
44
|
+
satellites: int = 0
|
|
45
|
+
timestamp_utc: str = ""
|
|
46
|
+
speed_knots: float = 0.0
|
|
47
|
+
track_deg: float = 0.0
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _finite_number(value: object) -> float | None:
|
|
51
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
52
|
+
return None
|
|
53
|
+
try:
|
|
54
|
+
number = float(value)
|
|
55
|
+
except (OverflowError, ValueError):
|
|
56
|
+
return None
|
|
57
|
+
return number if math.isfinite(number) else None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _integral_number(value: object) -> int | None:
|
|
61
|
+
number = _finite_number(value)
|
|
62
|
+
if number is None or not number.is_integer():
|
|
63
|
+
return None
|
|
64
|
+
return int(number)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _parse_tpv(msg: dict[str, Any], prev: GPSFix) -> GPSFix | None:
|
|
68
|
+
mode = _integral_number(msg.get("mode"))
|
|
69
|
+
if mode is None or not 0 <= mode <= 3:
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
timestamp = prev.timestamp_utc
|
|
73
|
+
if "time" in msg and msg["time"] is not None:
|
|
74
|
+
if not isinstance(msg["time"], str):
|
|
75
|
+
return None
|
|
76
|
+
timestamp = msg["time"]
|
|
77
|
+
|
|
78
|
+
# Mode < 2 (no fix) clears stale coordinates so old locations are never reused.
|
|
79
|
+
if mode < 2:
|
|
80
|
+
return GPSFix(satellites=prev.satellites, timestamp_utc=timestamp)
|
|
81
|
+
|
|
82
|
+
latitude = _finite_number(msg.get("lat"))
|
|
83
|
+
longitude = _finite_number(msg.get("lon"))
|
|
84
|
+
if (
|
|
85
|
+
latitude is None
|
|
86
|
+
or longitude is None
|
|
87
|
+
or not -90.0 <= latitude <= 90.0
|
|
88
|
+
or not -180.0 <= longitude <= 180.0
|
|
89
|
+
):
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
altitude = 0.0 if mode == 2 else prev.altitude
|
|
93
|
+
for key in ("alt", "altHAE", "altMSL"):
|
|
94
|
+
if key in msg and msg[key] is not None:
|
|
95
|
+
altitude_value = _finite_number(msg[key])
|
|
96
|
+
if altitude_value is None:
|
|
97
|
+
return None
|
|
98
|
+
altitude = altitude_value
|
|
99
|
+
break
|
|
100
|
+
|
|
101
|
+
speed_mps = 0.0
|
|
102
|
+
if "speed" in msg and msg["speed"] is not None:
|
|
103
|
+
raw_speed = _finite_number(msg["speed"])
|
|
104
|
+
if raw_speed is None or raw_speed < 0.0:
|
|
105
|
+
return None
|
|
106
|
+
speed_mps = raw_speed
|
|
107
|
+
|
|
108
|
+
track = prev.track_deg
|
|
109
|
+
if "track" in msg and msg["track"] is not None:
|
|
110
|
+
raw_track = _finite_number(msg["track"])
|
|
111
|
+
if raw_track is None or not 0.0 <= raw_track <= 360.0:
|
|
112
|
+
return None
|
|
113
|
+
track = raw_track
|
|
114
|
+
|
|
115
|
+
return GPSFix(
|
|
116
|
+
longitude=longitude,
|
|
117
|
+
latitude=latitude,
|
|
118
|
+
altitude=altitude,
|
|
119
|
+
has_fix=True,
|
|
120
|
+
satellites=prev.satellites,
|
|
121
|
+
timestamp_utc=timestamp,
|
|
122
|
+
speed_knots=speed_mps * 1.94384,
|
|
123
|
+
track_deg=track,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _parse_sky(msg: dict[str, Any], prev: GPSFix) -> GPSFix | None:
|
|
128
|
+
if "uSat" in msg and msg["uSat"] is not None:
|
|
129
|
+
satellites = _integral_number(msg["uSat"])
|
|
130
|
+
if satellites is None or satellites < 0:
|
|
131
|
+
return None
|
|
132
|
+
elif "satellites" in msg and msg["satellites"] is not None:
|
|
133
|
+
raw_satellites = msg["satellites"]
|
|
134
|
+
if not isinstance(raw_satellites, list):
|
|
135
|
+
return None
|
|
136
|
+
satellites = 0
|
|
137
|
+
for satellite in raw_satellites:
|
|
138
|
+
if not isinstance(satellite, dict):
|
|
139
|
+
return None
|
|
140
|
+
satellites += int(satellite.get("used") is True)
|
|
141
|
+
else:
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
return GPSFix(
|
|
145
|
+
longitude=prev.longitude,
|
|
146
|
+
latitude=prev.latitude,
|
|
147
|
+
altitude=prev.altitude,
|
|
148
|
+
has_fix=prev.has_fix,
|
|
149
|
+
satellites=satellites,
|
|
150
|
+
timestamp_utc=prev.timestamp_utc,
|
|
151
|
+
speed_knots=prev.speed_knots,
|
|
152
|
+
track_deg=prev.track_deg,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def parse_gpsd_message(
|
|
157
|
+
msg: dict[str, Any], current_fix: GPSFix | None = None
|
|
158
|
+
) -> GPSFix | None:
|
|
159
|
+
"""Parse one gpsd JSON message (TPV or SKY) into an updated GPSFix."""
|
|
160
|
+
if not isinstance(msg, dict):
|
|
161
|
+
return None
|
|
162
|
+
prev = current_fix or GPSFix()
|
|
163
|
+
cls = msg.get("class")
|
|
164
|
+
if cls == "TPV":
|
|
165
|
+
return _parse_tpv(msg, prev)
|
|
166
|
+
if cls == "SKY":
|
|
167
|
+
return _parse_sky(msg, prev)
|
|
168
|
+
return None
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class GPSManager:
|
|
172
|
+
"""Asynchronous background manager connecting to gpsd daemon via TCP."""
|
|
173
|
+
|
|
174
|
+
_CONNECT_TIMEOUT = 3.0
|
|
175
|
+
_READ_TIMEOUT = 0.5
|
|
176
|
+
_RECONNECT_DELAY = 1.0
|
|
177
|
+
_MAX_LINE_BYTES = 64 * 1024
|
|
178
|
+
|
|
179
|
+
def __init__(self, config: AppConfig | None = None) -> None:
|
|
180
|
+
self.config = config
|
|
181
|
+
self._lock = threading.Lock()
|
|
182
|
+
self._lifecycle_lock = threading.Lock()
|
|
183
|
+
self._stop_event = threading.Event()
|
|
184
|
+
self._thread: threading.Thread | None = None
|
|
185
|
+
self._socket: socket.socket | None = None
|
|
186
|
+
self._latest_fix: GPSFix = GPSFix()
|
|
187
|
+
self._status_text: str = "OFF"
|
|
188
|
+
self._connected: bool = False
|
|
189
|
+
|
|
190
|
+
def start(self) -> None:
|
|
191
|
+
"""Start background gpsd polling thread if not already running."""
|
|
192
|
+
with self._lifecycle_lock:
|
|
193
|
+
if self._thread is not None and self._thread.is_alive():
|
|
194
|
+
return
|
|
195
|
+
self._stop_event.clear()
|
|
196
|
+
self._thread = threading.Thread(
|
|
197
|
+
target=self._run, name="PlutoSiege-GPSD", daemon=True
|
|
198
|
+
)
|
|
199
|
+
self._thread.start()
|
|
200
|
+
|
|
201
|
+
def stop(self) -> None:
|
|
202
|
+
"""Stop background gpsd polling thread and invalidate its last fix."""
|
|
203
|
+
with self._lifecycle_lock:
|
|
204
|
+
self._stop_event.set()
|
|
205
|
+
thread = self._thread
|
|
206
|
+
active_socket = self._socket
|
|
207
|
+
if active_socket is not None:
|
|
208
|
+
try:
|
|
209
|
+
active_socket.close()
|
|
210
|
+
except OSError:
|
|
211
|
+
pass
|
|
212
|
+
|
|
213
|
+
if thread is not None and thread.is_alive():
|
|
214
|
+
thread.join(timeout=self._CONNECT_TIMEOUT)
|
|
215
|
+
with self._lifecycle_lock:
|
|
216
|
+
if self._thread is thread and (thread is None or not thread.is_alive()):
|
|
217
|
+
self._thread = None
|
|
218
|
+
with self._lock:
|
|
219
|
+
self._latest_fix = GPSFix()
|
|
220
|
+
self._status_text = "OFF"
|
|
221
|
+
self._connected = False
|
|
222
|
+
|
|
223
|
+
def get_latest_fix(self) -> GPSFix:
|
|
224
|
+
"""Return a thread-safe snapshot of the latest GPSFix."""
|
|
225
|
+
with self._lock:
|
|
226
|
+
return self._latest_fix
|
|
227
|
+
|
|
228
|
+
def get_status_text(self) -> str:
|
|
229
|
+
"""Return short status description for UI rendering."""
|
|
230
|
+
with self._lock:
|
|
231
|
+
return self._status_text
|
|
232
|
+
|
|
233
|
+
def _endpoint(self) -> tuple[str, int] | None:
|
|
234
|
+
config = self.config
|
|
235
|
+
host = config.gpsd_host if config is not None else "127.0.0.1"
|
|
236
|
+
port = config.gpsd_port if config is not None else 2947
|
|
237
|
+
if not isinstance(host, str) or not host.strip():
|
|
238
|
+
return None
|
|
239
|
+
if (
|
|
240
|
+
isinstance(port, bool)
|
|
241
|
+
or not isinstance(port, int)
|
|
242
|
+
or not 1 <= port <= 65535
|
|
243
|
+
):
|
|
244
|
+
return None
|
|
245
|
+
return host.strip(), port
|
|
246
|
+
|
|
247
|
+
def _set_status(self, status: str, connected: bool = False) -> None:
|
|
248
|
+
with self._lock:
|
|
249
|
+
self._status_text = status
|
|
250
|
+
self._connected = connected
|
|
251
|
+
|
|
252
|
+
def _invalidate_fix(self, status: str | None = None) -> None:
|
|
253
|
+
with self._lock:
|
|
254
|
+
self._latest_fix = GPSFix()
|
|
255
|
+
self._connected = False
|
|
256
|
+
if status is not None:
|
|
257
|
+
self._status_text = status
|
|
258
|
+
|
|
259
|
+
def _update_fix(self, new_fix: GPSFix) -> None:
|
|
260
|
+
with self._lock:
|
|
261
|
+
self._latest_fix = new_fix
|
|
262
|
+
if new_fix.has_fix:
|
|
263
|
+
lat_card = "N" if new_fix.latitude >= 0 else "S"
|
|
264
|
+
lon_card = "E" if new_fix.longitude >= 0 else "W"
|
|
265
|
+
self._status_text = (
|
|
266
|
+
f"{abs(new_fix.latitude):.3f}°{lat_card}, "
|
|
267
|
+
f"{abs(new_fix.longitude):.3f}°{lon_card} ({new_fix.satellites} sats)"
|
|
268
|
+
)
|
|
269
|
+
else:
|
|
270
|
+
self._status_text = (
|
|
271
|
+
f"Searching ({new_fix.satellites} sats)"
|
|
272
|
+
if new_fix.satellites > 0
|
|
273
|
+
else "Searching..."
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
def _read_socket(self, sock: socket.socket, endpoint: tuple[str, int]) -> None:
|
|
277
|
+
"""Read newline-delimited JSON objects from gpsd."""
|
|
278
|
+
buf = b""
|
|
279
|
+
discard_line = False
|
|
280
|
+
while not self._stop_event.is_set() and self._endpoint() == endpoint:
|
|
281
|
+
try:
|
|
282
|
+
chunk = sock.recv(4096)
|
|
283
|
+
except socket.timeout:
|
|
284
|
+
continue
|
|
285
|
+
except OSError:
|
|
286
|
+
return
|
|
287
|
+
if not chunk or not isinstance(chunk, bytes):
|
|
288
|
+
return
|
|
289
|
+
|
|
290
|
+
if discard_line:
|
|
291
|
+
nl = chunk.find(b"\n")
|
|
292
|
+
if nl < 0:
|
|
293
|
+
continue
|
|
294
|
+
chunk = chunk[nl + 1 :]
|
|
295
|
+
discard_line = False
|
|
296
|
+
|
|
297
|
+
buf += chunk
|
|
298
|
+
while b"\n" in buf:
|
|
299
|
+
raw_line, buf = buf.split(b"\n", 1)
|
|
300
|
+
if len(raw_line) > self._MAX_LINE_BYTES:
|
|
301
|
+
continue
|
|
302
|
+
line = raw_line.strip()
|
|
303
|
+
if not line:
|
|
304
|
+
continue
|
|
305
|
+
try:
|
|
306
|
+
message = json.loads(line.decode("utf-8"))
|
|
307
|
+
except (
|
|
308
|
+
ValueError,
|
|
309
|
+
OverflowError,
|
|
310
|
+
UnicodeDecodeError,
|
|
311
|
+
RecursionError,
|
|
312
|
+
TypeError,
|
|
313
|
+
):
|
|
314
|
+
continue
|
|
315
|
+
if not isinstance(message, dict):
|
|
316
|
+
continue
|
|
317
|
+
with self._lock:
|
|
318
|
+
current = self._latest_fix
|
|
319
|
+
new_fix = parse_gpsd_message(message, current_fix=current)
|
|
320
|
+
if new_fix is not None:
|
|
321
|
+
if self._stop_event.is_set() or self._endpoint() != endpoint:
|
|
322
|
+
return
|
|
323
|
+
self._update_fix(new_fix)
|
|
324
|
+
|
|
325
|
+
if len(buf) > self._MAX_LINE_BYTES:
|
|
326
|
+
buf = b""
|
|
327
|
+
discard_line = True
|
|
328
|
+
|
|
329
|
+
def _run(self) -> None:
|
|
330
|
+
try:
|
|
331
|
+
while not self._stop_event.is_set():
|
|
332
|
+
endpoint = self._endpoint()
|
|
333
|
+
if endpoint is None:
|
|
334
|
+
self._invalidate_fix("Invalid gpsd endpoint")
|
|
335
|
+
if self._stop_event.wait(self._RECONNECT_DELAY):
|
|
336
|
+
return
|
|
337
|
+
continue
|
|
338
|
+
host, port = endpoint
|
|
339
|
+
self._set_status(f"Connecting to gpsd ({host}:{port})...")
|
|
340
|
+
sock: socket.socket | None = None
|
|
341
|
+
connected = False
|
|
342
|
+
try:
|
|
343
|
+
sock = socket.create_connection(
|
|
344
|
+
endpoint, timeout=self._CONNECT_TIMEOUT
|
|
345
|
+
)
|
|
346
|
+
with self._lock:
|
|
347
|
+
self._socket = sock
|
|
348
|
+
if self._stop_event.is_set():
|
|
349
|
+
return
|
|
350
|
+
sock.settimeout(self._READ_TIMEOUT)
|
|
351
|
+
sock.sendall(b'?WATCH={"enable":true,"json":true};\r\n')
|
|
352
|
+
connected = True
|
|
353
|
+
self._set_status(
|
|
354
|
+
"Connected to gpsd (waiting for fix)...", connected=True
|
|
355
|
+
)
|
|
356
|
+
self._read_socket(sock, endpoint)
|
|
357
|
+
except OSError:
|
|
358
|
+
pass
|
|
359
|
+
finally:
|
|
360
|
+
with self._lock:
|
|
361
|
+
if self._socket is sock:
|
|
362
|
+
self._socket = None
|
|
363
|
+
if sock is not None:
|
|
364
|
+
try:
|
|
365
|
+
sock.close()
|
|
366
|
+
except OSError:
|
|
367
|
+
pass
|
|
368
|
+
|
|
369
|
+
if self._stop_event.is_set():
|
|
370
|
+
return
|
|
371
|
+
if self._endpoint() != endpoint:
|
|
372
|
+
self._invalidate_fix("gpsd endpoint changed")
|
|
373
|
+
elif connected:
|
|
374
|
+
self._invalidate_fix("Lost gpsd connection")
|
|
375
|
+
else:
|
|
376
|
+
self._invalidate_fix(f"Cannot connect to gpsd ({host}:{port})")
|
|
377
|
+
if self._stop_event.wait(self._RECONNECT_DELAY):
|
|
378
|
+
return
|
|
379
|
+
finally:
|
|
380
|
+
with self._lock:
|
|
381
|
+
self._latest_fix = GPSFix()
|
|
382
|
+
self._connected = False
|
|
383
|
+
self._status_text = (
|
|
384
|
+
"OFF" if self._stop_event.is_set() else "GPS worker stopped"
|
|
385
|
+
)
|
|
386
|
+
self._socket = None
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
_GLOBAL_GPS: GPSManager | None = None
|
|
390
|
+
_GPS_LOCK = threading.Lock()
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def get_gps_manager() -> GPSManager:
|
|
394
|
+
"""Return the global GPSManager singleton."""
|
|
395
|
+
global _GLOBAL_GPS
|
|
396
|
+
with _GPS_LOCK:
|
|
397
|
+
if _GLOBAL_GPS is None:
|
|
398
|
+
_GLOBAL_GPS = GPSManager()
|
|
399
|
+
return _GLOBAL_GPS
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def init_gps(cfg: AppConfig) -> GPSManager:
|
|
403
|
+
"""Initialize and start or stop the global GPS manager based on configuration."""
|
|
404
|
+
mgr = get_gps_manager()
|
|
405
|
+
mgr.config = cfg
|
|
406
|
+
if cfg.enable_gps:
|
|
407
|
+
mgr.start()
|
|
408
|
+
else:
|
|
409
|
+
mgr.stop()
|
|
410
|
+
return mgr
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def stop_gps() -> None:
|
|
414
|
+
"""Stop and release the global GPS manager."""
|
|
415
|
+
global _GLOBAL_GPS
|
|
416
|
+
with _GPS_LOCK:
|
|
417
|
+
if _GLOBAL_GPS is not None:
|
|
418
|
+
_GLOBAL_GPS.stop()
|
|
419
|
+
_GLOBAL_GPS = None
|
pluto_siege/settings.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
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 re-exports from pluto_siege.config."""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from pluto_siege.config import (
|
|
24
|
+
CONFIG,
|
|
25
|
+
DEFAULT_SETTINGS,
|
|
26
|
+
AppConfig,
|
|
27
|
+
freq_bounds,
|
|
28
|
+
load_settings,
|
|
29
|
+
save_settings,
|
|
30
|
+
validate_config,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def validate_settings(s: Any) -> dict[str, Any]:
|
|
35
|
+
"""Validate a raw settings dict or AppConfig and return a clean dictionary."""
|
|
36
|
+
if isinstance(s, AppConfig):
|
|
37
|
+
raw = s.to_dict()
|
|
38
|
+
elif isinstance(s, dict):
|
|
39
|
+
raw = s
|
|
40
|
+
else:
|
|
41
|
+
raw = {}
|
|
42
|
+
return validate_config(raw).to_dict()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
__all__ = [
|
|
46
|
+
"CONFIG",
|
|
47
|
+
"DEFAULT_SETTINGS",
|
|
48
|
+
"AppConfig",
|
|
49
|
+
"freq_bounds",
|
|
50
|
+
"load_settings",
|
|
51
|
+
"save_settings",
|
|
52
|
+
"validate_config",
|
|
53
|
+
"validate_settings",
|
|
54
|
+
]
|