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/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
@@ -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
+ ]