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/sigmf.py ADDED
@@ -0,0 +1,329 @@
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
+ """SigMF recording save and load utilities."""
18
+
19
+ from __future__ import annotations
20
+
21
+ import datetime
22
+ import json
23
+ import math
24
+ import os
25
+ import tempfile
26
+ from typing import TYPE_CHECKING, Any
27
+
28
+ import numpy as np
29
+ from numpy.typing import NDArray
30
+
31
+ from pluto_siege.constants import (
32
+ AUTHOR,
33
+ FREQ_RANGE_EXTENDED,
34
+ FREQ_RANGE_IN_SPEC,
35
+ RX_FULL_SCALE,
36
+ SAMPLE_RATE_RANGE,
37
+ VERSION,
38
+ brief,
39
+ )
40
+
41
+ if TYPE_CHECKING:
42
+ from pluto_siege.gps import GPSFix
43
+
44
+ _MAX_METADATA_BYTES = 8 * 1024 * 1024
45
+
46
+
47
+ def to_sigmf_utc(dt: datetime.datetime) -> str:
48
+ """Format a datetime as an ISO-8601 UTC string with microsecond precision."""
49
+ return (
50
+ dt.astimezone(datetime.timezone.utc)
51
+ .isoformat(timespec="microseconds")
52
+ .replace("+00:00", "Z")
53
+ )
54
+
55
+
56
+ def _meta_path(path: str) -> str:
57
+ return path.removesuffix(".sigmf-data").removesuffix(".sigmf-meta") + ".sigmf-meta"
58
+
59
+
60
+ def _finite_int(value: object) -> int | None:
61
+ """Return an integer when value is a finite whole number, otherwise None."""
62
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
63
+ return None
64
+ try:
65
+ num = float(value)
66
+ except (OverflowError, ValueError):
67
+ return None
68
+ if not math.isfinite(num) or not num.is_integer():
69
+ return None
70
+ return int(num)
71
+
72
+
73
+ def _finite_float(value: object) -> float | None:
74
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
75
+ return None
76
+ try:
77
+ num = float(value)
78
+ except (OverflowError, ValueError):
79
+ return None
80
+ return num if math.isfinite(num) else None
81
+
82
+
83
+ def _validate_coords(
84
+ lon: object, lat: object, alt: object = 0.0
85
+ ) -> tuple[float, float, float] | None:
86
+ """Validate geographic coordinates against RFC 7946 bounds."""
87
+ lon_f = _finite_float(lon)
88
+ lat_f = _finite_float(lat)
89
+ alt_f = _finite_float(alt)
90
+ if lon_f is None or lat_f is None or alt_f is None:
91
+ return None
92
+ if not (-180.0 <= lon_f <= 180.0 and -90.0 <= lat_f <= 90.0):
93
+ return None
94
+ return lon_f, lat_f, alt_f
95
+
96
+
97
+ def _supported_capture(
98
+ meta: object,
99
+ ) -> tuple[tuple[dict[str, Any], dict[str, Any]] | None, str | None]:
100
+ """Validate global and first capture blocks for scalar cf32_le playback."""
101
+ if not isinstance(meta, dict):
102
+ return None, "metadata is not a SigMF object"
103
+ global_meta = meta.get("global")
104
+ if not isinstance(global_meta, dict):
105
+ return None, "metadata has no global block"
106
+ dtype = global_meta.get("core:datatype")
107
+ if dtype is None:
108
+ return None, "metadata has no sample format"
109
+ if dtype != "cf32_le":
110
+ return None, f"sample format is {brief(dtype, 12)!r}, not cf32_le"
111
+
112
+ captures = meta.get("captures")
113
+ if not isinstance(captures, list) or not captures:
114
+ return None, "metadata has no captures block"
115
+ capture = captures[0]
116
+ if (
117
+ not isinstance(capture, dict)
118
+ or _finite_int(capture.get("core:sample_start", 0)) != 0
119
+ ):
120
+ return None, "metadata must start capture at sample 0"
121
+
122
+ if (
123
+ _finite_int(global_meta.get("core:num_channels", 1)) != 1
124
+ or _finite_int(global_meta.get("core:offset", 0)) != 0
125
+ or _finite_int(global_meta.get("core:trailing_bytes", 0)) != 0
126
+ or "core:dataset" in global_meta
127
+ or _finite_int(capture.get("core:header_bytes", 0)) != 0
128
+ ):
129
+ return None, "metadata uses an unsupported data layout"
130
+
131
+ return (global_meta, capture), None
132
+
133
+
134
+ def save_sigmf_pair(
135
+ base_path: str,
136
+ array: NDArray[np.complex64],
137
+ freq: int,
138
+ sr: int,
139
+ timestamp_iso: str,
140
+ hw_model: str,
141
+ gps_fix: GPSFix | None = None,
142
+ ) -> None:
143
+ """Save captured IQ samples and metadata atomically as a SigMF pair."""
144
+ if not isinstance(base_path, str) or not base_path:
145
+ raise ValueError("base_path must be a non-empty string")
146
+ if (
147
+ not isinstance(array, np.ndarray)
148
+ or array.ndim != 1
149
+ or not np.iscomplexobj(array)
150
+ ):
151
+ raise ValueError("SigMF samples must be a one-dimensional complex array")
152
+ if not bool(np.all(np.isfinite(array))):
153
+ raise ValueError("SigMF samples must be finite")
154
+
155
+ use_freq = _finite_int(freq)
156
+ use_sr = _finite_int(sr)
157
+ if (
158
+ use_freq is None
159
+ or not FREQ_RANGE_EXTENDED[0] <= use_freq <= FREQ_RANGE_EXTENDED[1]
160
+ ):
161
+ raise ValueError("frequency is outside the supported tuning range")
162
+ if use_sr is None or not SAMPLE_RATE_RANGE[0] <= use_sr <= SAMPLE_RATE_RANGE[1]:
163
+ raise ValueError("sample rate is outside the supported range")
164
+ if not isinstance(timestamp_iso, str) or not timestamp_iso:
165
+ raise ValueError("timestamp must be a non-empty string")
166
+ if not isinstance(hw_model, str):
167
+ raise TypeError("hardware model must be a string")
168
+
169
+ data_path = base_path + ".sigmf-data"
170
+ meta_path = base_path + ".sigmf-meta"
171
+ directory = os.path.dirname(os.path.abspath(base_path))
172
+ os.makedirs(directory, exist_ok=True)
173
+
174
+ tmp_data_path: str | None = None
175
+ tmp_meta_path: str | None = None
176
+ try:
177
+ norm_data = (array * np.float32(1.0 / RX_FULL_SCALE)).astype("<c8", copy=False)
178
+ data_fd, tmp_data_path = tempfile.mkstemp(
179
+ prefix=".sigmf-data-", suffix=".tmp", dir=directory
180
+ )
181
+ with os.fdopen(data_fd, "wb") as data_file:
182
+ norm_data.tofile(data_file)
183
+ data_file.flush()
184
+ os.fsync(data_file.fileno())
185
+
186
+ capture_entry: dict[str, Any] = {
187
+ "core:sample_start": 0,
188
+ "core:frequency": use_freq,
189
+ "core:datetime": timestamp_iso,
190
+ }
191
+ if gps_fix is not None and getattr(gps_fix, "has_fix", False) is True:
192
+ coords = _validate_coords(
193
+ getattr(gps_fix, "longitude", None),
194
+ getattr(gps_fix, "latitude", None),
195
+ getattr(gps_fix, "altitude", 0.0),
196
+ )
197
+ if coords is not None:
198
+ lon, lat, alt = coords
199
+ capture_entry["core:geolocation"] = {
200
+ "type": "Point",
201
+ "coordinates": [round(lon, 6), round(lat, 6), round(alt, 1)],
202
+ }
203
+
204
+ meta = {
205
+ "global": {
206
+ "core:datatype": "cf32_le",
207
+ "core:sample_rate": use_sr,
208
+ "core:hw": hw_model,
209
+ "core:author": AUTHOR,
210
+ "core:version": "1.0.0",
211
+ "core:recorder": f"PlutoSiege v{VERSION}",
212
+ "core:description": "Host-estimated timestamp. May have USB/IIO latency.",
213
+ },
214
+ "captures": [capture_entry],
215
+ "annotations": [],
216
+ }
217
+ meta_fd, tmp_meta_path = tempfile.mkstemp(
218
+ prefix=".sigmf-meta-", suffix=".tmp", dir=directory
219
+ )
220
+ with os.fdopen(meta_fd, "w", encoding="utf-8") as meta_file:
221
+ json.dump(meta, meta_file, indent=2, allow_nan=False)
222
+ meta_file.flush()
223
+ os.fsync(meta_file.fileno())
224
+
225
+ os.replace(tmp_data_path, data_path)
226
+ tmp_data_path = None
227
+ try:
228
+ os.replace(tmp_meta_path, meta_path)
229
+ tmp_meta_path = None
230
+ except BaseException:
231
+ if os.path.exists(data_path):
232
+ try:
233
+ os.unlink(data_path)
234
+ except OSError:
235
+ pass
236
+ raise
237
+ finally:
238
+ for tmp_file in (tmp_data_path, tmp_meta_path):
239
+ if tmp_file is not None and os.path.exists(tmp_file):
240
+ try:
241
+ os.unlink(tmp_file)
242
+ except OSError:
243
+ pass
244
+
245
+
246
+ def _load_sigmf_json(meta_path: str) -> tuple[object | None, str | None]:
247
+ if not os.path.exists(meta_path):
248
+ return None, "no .sigmf-meta file beside it"
249
+ try:
250
+ if os.path.getsize(meta_path) > _MAX_METADATA_BYTES:
251
+ return None, "metadata unreadable (metadata exceeds 8 MiB)"
252
+ with open(meta_path, "rb") as f:
253
+ raw_bytes = f.read()
254
+ except FileNotFoundError:
255
+ return None, "no .sigmf-meta file beside it"
256
+ except OSError as error:
257
+ return None, f"metadata unreadable ({brief(error.strerror or 'I/O error')})"
258
+
259
+ try:
260
+ return json.loads(raw_bytes.decode("utf-8")), None
261
+ except UnicodeDecodeError:
262
+ return None, "metadata is not valid UTF-8"
263
+ except (json.JSONDecodeError, RecursionError, ValueError):
264
+ return None, "metadata is not valid JSON"
265
+
266
+
267
+ def load_sigmf_meta(
268
+ path: str, bounds: tuple[int, int] | None = None
269
+ ) -> tuple[int | None, int | None, str | None]:
270
+ """Read and validate sample rate and center frequency from a SigMF recording."""
271
+ meta, problem = _load_sigmf_json(_meta_path(path))
272
+ if problem is not None:
273
+ return None, None, problem
274
+
275
+ parsed, err = _supported_capture(meta)
276
+ if parsed is None:
277
+ return None, None, err
278
+ global_meta, capture = parsed
279
+
280
+ sr = _finite_int(global_meta.get("core:sample_rate"))
281
+ freq = _finite_int(capture.get("core:frequency"))
282
+ if sr is None or freq is None:
283
+ return None, None, "metadata lacks an integral finite sample rate or frequency"
284
+
285
+ srlo, srhi = SAMPLE_RATE_RANGE
286
+ if not srlo <= sr <= srhi:
287
+ return (
288
+ None,
289
+ None,
290
+ f"rate {brief(sr, 9)} Hz outside {srlo / 1e3:g}k-{srhi / 1e6:g}M Hz",
291
+ )
292
+
293
+ if bounds is None:
294
+ low, high = FREQ_RANGE_IN_SPEC
295
+ else:
296
+ if not isinstance(bounds, tuple) or len(bounds) != 2:
297
+ return None, None, "invalid frequency bounds"
298
+ b_lo, b_hi = _finite_int(bounds[0]), _finite_int(bounds[1])
299
+ if b_lo is None or b_hi is None or b_lo > b_hi:
300
+ return None, None, "invalid frequency bounds"
301
+ low, high = b_lo, b_hi
302
+
303
+ if not low <= freq <= high:
304
+ freq_str = f"{freq / 1e6:.3f}"
305
+ return (
306
+ None,
307
+ None,
308
+ f"{brief(freq_str, 11)} MHz outside {low / 1e6:g}-{high / 1e6:g} MHz",
309
+ )
310
+ return sr, freq, None
311
+
312
+
313
+ def load_sigmf_geolocation(path: str) -> tuple[float, float, float] | None:
314
+ """Extract (longitude, latitude, altitude) from a SigMF capture if present and valid."""
315
+ meta, problem = _load_sigmf_json(_meta_path(path))
316
+ if problem is not None:
317
+ return None
318
+ parsed, _ = _supported_capture(meta)
319
+ if parsed is None:
320
+ return None
321
+ _, capture = parsed
322
+ geo = capture.get("core:geolocation")
323
+ if not isinstance(geo, dict) or geo.get("type") != "Point":
324
+ return None
325
+ coords = geo.get("coordinates")
326
+ if not isinstance(coords, list) or len(coords) < 2:
327
+ return None
328
+ alt = coords[2] if len(coords) >= 3 else 0.0
329
+ return _validate_coords(coords[0], coords[1], alt)
pluto_siege/sound.py ADDED
@@ -0,0 +1,162 @@
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
+ """Acoustic cue and sound effect synthesis for PlutoSiege."""
18
+
19
+ from __future__ import annotations
20
+
21
+ import io
22
+ import queue
23
+ import shutil
24
+ import subprocess
25
+ import sys
26
+ import threading
27
+ import wave
28
+ from enum import Enum, auto
29
+ from typing import NamedTuple
30
+
31
+ import numpy as np
32
+
33
+
34
+ class SoundCue(Enum):
35
+ """Categorized audio feedback cues for PlutoSiege events."""
36
+
37
+ SUCCESS = auto()
38
+ FAIL = auto()
39
+ START = auto()
40
+ TRIGGER = auto()
41
+ ALERT = auto()
42
+
43
+ # Semantic aliases
44
+ PASS = SUCCESS
45
+ CAPTURE_SUCCESS = SUCCESS
46
+ TX_START = START
47
+ TX_COMPLETE = SUCCESS
48
+
49
+
50
+ class Tone(NamedTuple):
51
+ """A single audible frequency pulse."""
52
+
53
+ freq_hz: int
54
+ duration_ms: int
55
+
56
+
57
+ _CUES: dict[SoundCue, list[Tone]] = {
58
+ SoundCue.SUCCESS: [Tone(880, 80), Tone(1320, 120)],
59
+ SoundCue.FAIL: [Tone(370, 90), Tone(220, 160)],
60
+ SoundCue.START: [Tone(1100, 60)],
61
+ SoundCue.TRIGGER: [Tone(1480, 70)],
62
+ SoundCue.ALERT: [Tone(550, 70), Tone(550, 70)],
63
+ }
64
+
65
+ _sound_queue: queue.Queue[list[Tone]] = queue.Queue(maxsize=10)
66
+ _worker_thread: threading.Thread | None = None
67
+ _worker_lock = threading.Lock()
68
+
69
+
70
+ def _generate_wav(tones: list[Tone]) -> bytes:
71
+ """Synthesize an in-memory 16-bit mono PCM WAV from frequency tone definitions."""
72
+ sample_rate = 22050
73
+ segments: list[np.ndarray] = []
74
+ gap = np.zeros(int(sample_rate * 0.015), dtype="<i2")
75
+ for i, tone in enumerate(tones):
76
+ num_samples = int(sample_rate * (tone.duration_ms / 1000.0))
77
+ if num_samples > 0:
78
+ t = np.arange(num_samples, dtype=np.float64)
79
+ wave_pcm = (18000.0 * np.sin(2.0 * np.pi * tone.freq_hz * t / sample_rate)).astype("<i2")
80
+ segments.append(wave_pcm)
81
+ if i < len(tones) - 1:
82
+ segments.append(gap)
83
+
84
+ pcm_bytes = np.concatenate(segments).tobytes() if segments else b""
85
+ buf = io.BytesIO()
86
+ with wave.open(buf, "wb") as wf:
87
+ wf.setnchannels(1)
88
+ wf.setsampwidth(2)
89
+ wf.setframerate(sample_rate)
90
+ wf.writeframes(pcm_bytes)
91
+ return buf.getvalue()
92
+
93
+
94
+ def _play_tones(tones: list[Tone]) -> None:
95
+ """Synchronously play a sequence of frequency tones on the audio worker thread."""
96
+ try:
97
+ wav_bytes = _generate_wav(tones)
98
+ for player, args in (
99
+ ("aplay", ["-q", "-"]),
100
+ ("paplay", []),
101
+ ("pw-play", ["-"]),
102
+ ):
103
+ executable = shutil.which(player)
104
+ if executable:
105
+ try:
106
+ subprocess.run(
107
+ [executable, *args],
108
+ input=wav_bytes,
109
+ timeout=2.0,
110
+ check=True,
111
+ shell=False,
112
+ stdout=subprocess.DEVNULL,
113
+ stderr=subprocess.DEVNULL,
114
+ )
115
+ return
116
+ except (subprocess.SubprocessError, OSError):
117
+ continue
118
+ except Exception:
119
+ pass
120
+
121
+ try:
122
+ sys.stdout.write("\a")
123
+ sys.stdout.flush()
124
+ except (OSError, ValueError):
125
+ pass
126
+
127
+
128
+ def _worker() -> None:
129
+ """Background consumer thread that plays tones without blocking SDR processing."""
130
+ while True:
131
+ tones = _sound_queue.get()
132
+ try:
133
+ _play_tones(tones)
134
+ except Exception:
135
+ pass
136
+ finally:
137
+ _sound_queue.task_done()
138
+
139
+
140
+ def play_cue(cue: SoundCue, enabled: bool = True) -> None:
141
+ """Queue acoustic cue for non-blocking playback in the background worker thread."""
142
+ if not enabled or not isinstance(cue, SoundCue):
143
+ return
144
+ tones = _CUES.get(cue)
145
+ if not tones:
146
+ return
147
+
148
+ global _worker_thread
149
+ with _worker_lock:
150
+ if _worker_thread is None or not _worker_thread.is_alive():
151
+ _worker_thread = threading.Thread(
152
+ target=_worker, daemon=True, name="PlutoSiegeAudio"
153
+ )
154
+ _worker_thread.start()
155
+
156
+ try:
157
+ _sound_queue.put_nowait(tones)
158
+ except queue.Full:
159
+ pass
160
+
161
+
162
+ __all__ = ["SoundCue", "Tone", "play_cue"]
@@ -0,0 +1,21 @@
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
+ """UI package for PlutoSiege TUI."""
18
+
19
+ from pluto_siege.ui.screens.main_menu import main
20
+
21
+ __all__ = ["main"]
@@ -0,0 +1,32 @@
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
+ """UI screen controllers subpackage."""
18
+
19
+ from pluto_siege.ui.screens.capture import screen_capture
20
+ from pluto_siege.ui.screens.loopback import screen_loopback
21
+ from pluto_siege.ui.screens.main_menu import curses_app, main
22
+ from pluto_siege.ui.screens.settings import screen_settings
23
+ from pluto_siege.ui.screens.transmit import screen_transmit
24
+
25
+ __all__ = [
26
+ "curses_app",
27
+ "main",
28
+ "screen_capture",
29
+ "screen_loopback",
30
+ "screen_settings",
31
+ "screen_transmit",
32
+ ]