Compare commits

...

4 Commits

Author SHA1 Message Date
James Smith 456404a266 feat: ADS-B historical playback map (closes #208)
- New GET /adsb/history/playback endpoint returns time-bucketed snapshots
  (one position per aircraft per N-second bucket) via a SQL window function
- Playback tab added to adsb_history.html: Leaflet map with animated
  aircraft markers, play/pause, 1x–20x speed, scrubber, and time display
- Configurable window (15 min – 24 h) and bucket step (10 s – 5 min)
- Requires the history Docker profile or INTERCEPT_ADSB_HISTORY_ENABLED=true
- Bump version to 2.32.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 13:04:23 +01:00
James Smith 1faa390ea7 feat: BladeRF and HydraSDR RFOne support (closes #121, closes #203)
- BladeRF 2.0 micro / x40 / x115 detected via SoapyBladeRF (47 MHz–6 GHz,
  TX capable, gain 0–66 dB)
- HydraSDR RFOne detected via SoapyHydraSDR (24 MHz–1800 MHz, RX only,
  linear gain 0–21)
- Both follow the established SoapySDR command-builder pattern and support
  FM demod, ADS-B, ISM, AIS, and I/Q capture
- Bump version to 2.31.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 12:08:03 +01:00
James Smith dfab714104 feat: APRS export, USRP support, and MQTT data export (closes #222)
- GET /aprs/export?format=json|csv downloads all tracked APRS stations
- USRP (Ettus N200/B200/B210) detected via SoapyUHD and supported across
  all signal modes (FM demod, ADS-B, ISM, AIS, I/Q capture)
- Optional MQTT publisher broadcasts decoded events from every module;
  enabled by setting INTERCEPT_MQTT_BROKER, disabled by default
- paho-mqtt added to requirements.txt (optional dep)
- .gitignore: add *.kismet, aircraft_db.json/meta, SoapySDR/
- Bump version to 2.30.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 11:47:21 +01:00
James Smith 6d0b0437b3 fix: hide feed column in sensor dashboard mode, not just #output
The feed column (.pdir-feed-col) was left as a flex sibling with flex:1
while only its child #output was hidden, causing the sensor dashboard to
occupy ~50% width. Hide/restore the column itself in applyViewState().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 10:55:39 +01:00
18 changed files with 1208 additions and 13 deletions
+11
View File
@@ -60,6 +60,17 @@ data/radiosonde/
# SDR capture files (large IQ recordings) # SDR capture files (large IQ recordings)
data/subghz/captures/ data/subghz/captures/
# Kismet capture files
*.kismet
*.kismet.json
# ADS-B aircraft database (downloaded at runtime)
aircraft_db.json
aircraft_db_meta.json
# SoapySDR build directory (compiled during setup, contains its own .git)
SoapySDR/
# Env files # Env files
.env .env
.env.* .env.*
+24
View File
@@ -2,6 +2,30 @@
All notable changes to iNTERCEPT will be documented in this file. All notable changes to iNTERCEPT will be documented in this file.
## [2.32.0] - 2026-07-07
### Added
- **ADS-B historical playback** — new Playback tab on the ADS-B history page. Loads time-bucketed snapshots from the Postgres history database and animates aircraft positions on a Leaflet map. Controls: configurable time window (15 min 24 h), bucket step (10 s 5 min), play/pause, speed multiplier (1×–20×), and a time scrubber. Requires the `history` Docker profile or `INTERCEPT_ADSB_HISTORY_ENABLED=true`. Backed by new `GET /adsb/history/playback` endpoint.
---
## [2.31.0] - 2026-07-07
### Added
- **BladeRF support** — bladeRF 2.0 micro (47 MHz 6 GHz) and bladeRF x40/x115 (300 MHz 3.8 GHz) detected and usable via SoapyBladeRF. TX capable. Supports FM demod, ADS-B, ISM, AIS, and I/Q capture.
- **HydraSDR RFOne support** — Detected and usable via SoapyHydraSDR (24 MHz 1800 MHz, RX only, 10 MHz instantaneous bandwidth). Supports FM demod, ADS-B, ISM, AIS, and I/Q capture.
---
## [2.30.0] - 2026-07-07
### Added
- **APRS station export** — `GET /aprs/export?format=json|csv` downloads all currently tracked APRS stations, consistent with the existing WiFi/Bluetooth/AIS export pattern.
- **USRP support** — Ettus USRP devices (N200, B200, B210) are now detected and usable via the SoapyUHD bridge. FM demod, ADS-B, ISM, AIS, and I/Q capture all supported through the existing SoapySDR toolchain.
- **MQTT data export** — Optional MQTT publisher that broadcasts decoded events from every module to a configurable broker. Disabled by default; set `INTERCEPT_MQTT_BROKER` to enable. Topics follow the pattern `<prefix>/<module>/<event_type>` (e.g. `intercept/aprs/packet`). Configure with `INTERCEPT_MQTT_PORT`, `INTERCEPT_MQTT_USER`, `INTERCEPT_MQTT_PASSWORD`, `INTERCEPT_MQTT_TOPIC_PREFIX`, `INTERCEPT_MQTT_RETAIN`.
---
## [2.29.0] - 2026-07-05 ## [2.29.0] - 2026-07-05
### Added ### Added
+33 -1
View File
@@ -7,10 +7,34 @@ import os
import sys import sys
# Application version # Application version
VERSION = "2.29.0" VERSION = "2.32.0"
# Changelog - latest release notes (shown on welcome screen) # Changelog - latest release notes (shown on welcome screen)
CHANGELOG = [ CHANGELOG = [
{
"version": "2.32.0",
"date": "July 2026",
"highlights": [
"Feat: ADS-B historical playback — replay past aircraft positions on an interactive map with play/pause, speed control, and time scrubber (requires history profile)",
],
},
{
"version": "2.31.0",
"date": "July 2026",
"highlights": [
"Feat: BladeRF support — bladeRF 2.0 micro and x40/x115 detected and usable via SoapyBladeRF (47 MHz 6 GHz, TX capable)",
"Feat: HydraSDR RFOne support — detected and usable via SoapyHydraSDR (24 MHz 1800 MHz, RX only)",
],
},
{
"version": "2.30.0",
"date": "July 2026",
"highlights": [
"Feat: APRS station export — download all tracked stations as JSON or CSV via /aprs/export",
"Feat: USRP support — Ettus USRP N200/B200/B210 detected and usable via SoapyUHD bridge",
"Feat: MQTT data export — publish decoded events from all modules to a configurable broker (set INTERCEPT_MQTT_BROKER to enable)",
],
},
{ {
"version": "2.29.0", "version": "2.29.0",
"date": "July 2026", "date": "July 2026",
@@ -534,6 +558,14 @@ ALERT_WEBHOOK_URL = _get_env("ALERT_WEBHOOK_URL", "")
ALERT_WEBHOOK_SECRET = _get_env("ALERT_WEBHOOK_SECRET", "") ALERT_WEBHOOK_SECRET = _get_env("ALERT_WEBHOOK_SECRET", "")
ALERT_WEBHOOK_TIMEOUT = _get_env_int("ALERT_WEBHOOK_TIMEOUT", 5) ALERT_WEBHOOK_TIMEOUT = _get_env_int("ALERT_WEBHOOK_TIMEOUT", 5)
# MQTT data export (optional — disabled when MQTT_BROKER is empty)
MQTT_BROKER = _get_env("MQTT_BROKER", "")
MQTT_PORT = _get_env_int("MQTT_PORT", 1883)
MQTT_USER = _get_env("MQTT_USER", "")
MQTT_PASSWORD = _get_env("MQTT_PASSWORD", "")
MQTT_TOPIC_PREFIX = _get_env("MQTT_TOPIC_PREFIX", "intercept")
MQTT_RETAIN = _get_env_bool("MQTT_RETAIN", False)
# Admin credentials # Admin credentials
ADMIN_USERNAME = _get_env("ADMIN_USERNAME", "admin") ADMIN_USERNAME = _get_env("ADMIN_USERNAME", "admin")
ADMIN_PASSWORD = _get_env("ADMIN_PASSWORD", "admin") ADMIN_PASSWORD = _get_env("ADMIN_PASSWORD", "admin")
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "intercept" name = "intercept"
version = "2.27.0" version = "2.32.0"
description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth" description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth"
readme = "README.md" readme = "README.md"
requires-python = ">=3.9" requires-python = ">=3.9"
+3
View File
@@ -53,6 +53,9 @@ websocket-client>=1.6.0
# System health monitoring (optional - graceful fallback if unavailable) # System health monitoring (optional - graceful fallback if unavailable)
psutil>=5.9.0 psutil>=5.9.0
# MQTT data export (optional - only needed if INTERCEPT_MQTT_BROKER is set)
paho-mqtt>=1.6.0
# Production WSGI server (optional - falls back to Flask dev server) # Production WSGI server (optional - falls back to Flask dev server)
gunicorn>=21.2.0 gunicorn>=21.2.0
gevent>=23.9.0 gevent>=23.9.0
+81
View File
@@ -1664,6 +1664,87 @@ def adsb_history_export():
return response return response
@adsb_bp.route("/history/playback")
def adsb_history_playback():
"""Return time-bucketed snapshots for map playback.
Query params:
since_minutes: window size in minutes (default 60, max 1440)
bucket_seconds: bucket width in seconds (default 30, min 10, max 300)
Returns one position per aircraft per bucket (latest within bucket),
structured as a list of {t, aircraft:[]} frames ready for animation.
"""
if not ADSB_HISTORY_ENABLED or not PSYCOPG2_AVAILABLE:
return api_error("ADS-B history is disabled", 503)
_ensure_history_schema()
since_minutes = _parse_int_param(request.args.get("since_minutes"), 60, 1, 1440)
bucket_seconds = _parse_int_param(request.args.get("bucket_seconds"), 30, 10, 300)
window = f"{since_minutes} minutes"
sql = """
WITH bucketed AS (
SELECT
(EXTRACT(EPOCH FROM captured_at)::bigint / %(bkt)s) * %(bkt)s AS bucket_epoch,
icao,
callsign,
lat,
lon,
altitude,
heading,
speed,
ROW_NUMBER() OVER (
PARTITION BY (EXTRACT(EPOCH FROM captured_at)::bigint / %(bkt)s), icao
ORDER BY captured_at DESC
) AS rn
FROM adsb_snapshots
WHERE captured_at >= NOW() - INTERVAL %(window)s
AND lat IS NOT NULL
AND lon IS NOT NULL
)
SELECT bucket_epoch, icao, callsign, lat, lon, altitude, heading, speed
FROM bucketed
WHERE rn = 1
ORDER BY bucket_epoch ASC, icao ASC
"""
try:
with _get_history_connection() as conn, conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(sql, {"bkt": bucket_seconds, "window": window})
rows = cur.fetchall()
# Group rows into frames keyed by bucket timestamp
frames: dict[int, list] = {}
for row in rows:
epoch = int(row["bucket_epoch"])
frames.setdefault(epoch, []).append(
{
"icao": row["icao"],
"callsign": row["callsign"] or "",
"lat": float(row["lat"]),
"lon": float(row["lon"]),
"alt": row["altitude"],
"hdg": row["heading"],
"spd": row["speed"],
}
)
buckets = [{"t": epoch, "aircraft": aircraft} for epoch, aircraft in sorted(frames.items())]
return jsonify(
{
"buckets": buckets,
"bucket_seconds": bucket_seconds,
"since_minutes": since_minutes,
"count": len(buckets),
}
)
except Exception as exc:
logger.warning("ADS-B history playback query failed: %s", exc)
return api_error("History database unavailable", 503)
@adsb_bp.route("/history/prune", methods=["POST"]) @adsb_bp.route("/history/prune", methods=["POST"])
def adsb_history_prune(): def adsb_history_prune():
"""Delete ADS-B history for a selected time range or entire dataset.""" """Delete ADS-B history for a selected time range or entire dataset."""
+42
View File
@@ -1653,6 +1653,48 @@ def get_stations() -> Response:
return jsonify({"stations": list(aprs_stations.values()), "count": len(aprs_stations)}) return jsonify({"stations": list(aprs_stations.values()), "count": len(aprs_stations)})
@aprs_bp.route("/export")
def export_data() -> Response:
"""Export tracked APRS stations as JSON or CSV.
Query params:
format: 'json' or 'csv' (default: json)
"""
export_format = request.args.get("format", "json").lower()
stations = list(aprs_stations.values())
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
if export_format == "csv":
import io
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(["Callsign", "Latitude", "Longitude", "Symbol", "Packet Type", "Last Seen"])
for s in stations:
writer.writerow(
[
s.get("callsign", ""),
s.get("lat", ""),
s.get("lon", ""),
s.get("symbol", ""),
s.get("packet_type", ""),
s.get("last_seen", ""),
]
)
response = Response(output.getvalue(), mimetype="text/csv")
response.headers["Content-Disposition"] = f"attachment; filename=aprs_stations_{timestamp}.csv"
return response
data = {
"stations": stations,
"count": len(stations),
"exported_at": datetime.now().isoformat(),
}
response = Response(json.dumps(data, indent=2), mimetype="application/json")
response.headers["Content-Disposition"] = f"attachment; filename=aprs_stations_{timestamp}.json"
return response
@aprs_bp.route("/data") @aprs_bp.route("/data")
def aprs_data() -> Response: def aprs_data() -> Response:
"""Get APRS data snapshot for remote controller polling compatibility.""" """Get APRS data snapshot for remote controller polling compatibility."""
+135
View File
@@ -746,3 +746,138 @@ html[data-ui-tier="lean"] {
--border-glow: transparent; --border-glow: transparent;
--grid-line: transparent; --grid-line: transparent;
} }
/* ── View toggle ───────────────────────────────────────────────────────── */
.view-toggle {
display: flex;
gap: 2px;
border: 1px solid var(--border-color);
border-radius: 4px;
overflow: hidden;
}
.view-btn {
padding: 6px 14px;
background: transparent;
border: none;
color: var(--text-secondary);
font-size: 11px;
font-family: var(--font-mono);
letter-spacing: 0.05em;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.view-btn.active,
.view-btn:hover {
background: var(--accent-cyan);
color: #000;
}
/* ── Playback section ──────────────────────────────────────────────────── */
.playback-section {
display: flex;
flex-direction: column;
gap: 0;
height: calc(100vh - 320px);
min-height: 420px;
border: 1px solid var(--border-color);
border-radius: 6px;
overflow: hidden;
margin: 0 var(--page-gutter, 16px);
}
.playback-map {
flex: 1;
min-height: 300px;
background: var(--bg-card);
}
.playback-controls {
padding: 10px 14px;
background: var(--bg-card);
border-top: 1px solid var(--border-color);
display: flex;
flex-direction: column;
gap: 8px;
}
.playback-ctrl-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
}
.playback-scrubber-row {
display: flex;
align-items: center;
gap: 10px;
}
.playback-scrubber-row input[type="range"] {
flex: 1;
accent-color: var(--accent-cyan);
cursor: pointer;
}
.playback-scrubber-row input[type="range"]:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.playback-time {
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-secondary);
white-space: nowrap;
min-width: 140px;
}
.playback-time:last-child { text-align: right; }
.playback-current-time {
font-family: var(--font-mono);
font-size: 13px;
color: var(--accent-cyan);
text-align: center;
letter-spacing: 0.05em;
}
.playback-counters {
display: flex;
flex-direction: column;
align-items: center;
min-width: 52px;
}
.playback-label {
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text-secondary);
}
.playback-value {
font-family: var(--font-mono);
font-size: 13px;
color: var(--text-primary);
}
/* Aircraft markers on playback map */
.pb-aircraft-icon {
background: none;
border: none;
}
.pb-tooltip {
font-family: var(--font-mono);
font-size: 11px;
color: #fff;
white-space: nowrap;
}
.status-pill.loading { background: var(--accent-yellow, #f0a500); color: #000; }
.status-pill.active { background: var(--accent-cyan); color: #000; }
.status-pill.warn { background: var(--accent-orange, #e06c00); color: #fff; }
.status-pill.error { background: var(--accent-red, #c0392b); color: #fff; }
+8 -5
View File
@@ -162,17 +162,20 @@ const SensorDashboard = (function () {
// ---- Show / hide / reset ---- // ---- Show / hide / reset ----
function applyViewState(mode) { function applyViewState(mode) {
const view = document.getElementById('sensorDashboardView'); const view = document.getElementById('sensorDashboardView');
const output = document.getElementById('output'); const output = document.getElementById('output');
const feedCol = document.querySelector('.pdir-feed-col');
if (mode === 'sensor') { if (mode === 'sensor') {
const saved = localStorage.getItem(STORAGE_KEY) || 'dashboard'; const saved = localStorage.getItem(STORAGE_KEY) || 'dashboard';
const isDash = saved === 'dashboard'; const isDash = saved === 'dashboard';
if (view) view.style.display = isDash ? 'block' : 'none'; if (view) view.style.display = isDash ? 'block' : 'none';
if (output) output.style.display = isDash ? 'none' : ''; if (output) output.style.display = isDash ? 'none' : '';
if (feedCol) feedCol.style.display = isDash ? 'none' : '';
_updateToggle(isDash); _updateToggle(isDash);
} else { } else {
if (view) view.style.display = 'none'; if (view) view.style.display = 'none';
if (feedCol) feedCol.style.display = '';
} }
} }
+272
View File
@@ -16,6 +16,9 @@
<link rel="stylesheet" href="{{ url_for('static', filename='css/settings.css') }}?v={{ version }}&r=maptheme17"> <link rel="stylesheet" href="{{ url_for('static', filename='css/settings.css') }}?v={{ version }}&r=maptheme17">
<link rel="stylesheet" href="{{ url_for('static', filename='css/help-modal.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/help-modal.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/adsb_history.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/adsb_history.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/leaflet/leaflet.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/core/map-utils.css') }}">
<script defer src="{{ url_for('static', filename='vendor/leaflet/leaflet.js') }}"></script>
</head> </head>
<body> <body>
<div class="radar-bg"></div> <div class="radar-bg"></div>
@@ -145,6 +148,10 @@
</div> </div>
</div> </div>
<button class="primary-btn" id="refreshBtn">Refresh</button> <button class="primary-btn" id="refreshBtn">Refresh</button>
<div class="view-toggle">
<button class="view-btn active" id="viewDataBtn" type="button">Data</button>
<button class="view-btn" id="viewPlaybackBtn" type="button">&#9654; Playback</button>
</div>
<div class="status-pill" id="historyStatus"> <div class="status-pill" id="historyStatus">
{% if history_enabled %} {% if history_enabled %}
HISTORY ONLINE HISTORY ONLINE
@@ -218,6 +225,62 @@
</div> </div>
</div> </div>
</section> </section>
<!-- ── PLAYBACK SECTION ─────────────────────────────── -->
<section class="playback-section" id="playbackSection" style="display:none;">
<div class="playback-map" id="playbackMap"></div>
<div class="playback-controls">
<div class="playback-ctrl-row">
<div class="control-group">
<label for="playbackWindowSelect">Window</label>
<select id="playbackWindowSelect">
<option value="15">15 min</option>
<option value="60" selected>1 hour</option>
<option value="360">6 hours</option>
<option value="1440">24 hours</option>
</select>
</div>
<div class="control-group">
<label for="playbackBucketSelect">Step</label>
<select id="playbackBucketSelect">
<option value="10">10 s</option>
<option value="30" selected>30 s</option>
<option value="60">1 min</option>
<option value="300">5 min</option>
</select>
</div>
<button class="primary-btn" id="playbackLoadBtn" type="button">Load</button>
<button class="primary-btn" id="playbackPlayBtn" type="button" disabled>&#9654; Play</button>
<div class="control-group">
<label for="playbackSpeedSelect">Speed</label>
<select id="playbackSpeedSelect">
<option value="1">1×</option>
<option value="2">2×</option>
<option value="5" selected>5×</option>
<option value="10">10×</option>
<option value="20">20×</option>
</select>
</div>
<div class="playback-counters">
<span class="playback-label">Aircraft</span>
<span class="playback-value" id="playbackAircraftCount">--</span>
</div>
<div class="playback-counters">
<span class="playback-label">Frames</span>
<span class="playback-value" id="playbackFrameCount">--</span>
</div>
<div class="status-pill" id="playbackStatus">READY</div>
</div>
<div class="playback-scrubber-row">
<span class="playback-time" id="playbackTimeStart">--</span>
<input type="range" id="playbackScrubber" min="0" max="0" value="0" step="1" disabled>
<span class="playback-time" id="playbackTimeEnd">--</span>
</div>
<div class="playback-current-time" id="playbackCurrentTime">--</div>
</div>
</section>
<!-- ── END PLAYBACK ──────────────────────────────────── -->
</main> </main>
<div class="modal-backdrop" id="aircraftModalBackdrop" aria-hidden="true"> <div class="modal-backdrop" id="aircraftModalBackdrop" aria-hidden="true">
@@ -1101,5 +1164,214 @@
<script src="{{ url_for('static', filename='js/core/settings-manager.js') }}?v={{ version }}&r=maptheme17"></script> <script src="{{ url_for('static', filename='js/core/settings-manager.js') }}?v={{ version }}&r=maptheme17"></script>
<script src="{{ url_for('static', filename='js/core/global-nav.js') }}"></script> <script src="{{ url_for('static', filename='js/core/global-nav.js') }}"></script>
<script src="{{ url_for('static', filename='js/map-utils.js') }}"></script>
<script>
// ── ADS-B HISTORY PLAYBACK ────────────────────────────────────────────
(function () {
const viewDataBtn = document.getElementById('viewDataBtn');
const viewPlaybackBtn = document.getElementById('viewPlaybackBtn');
const contentGrid = document.querySelector('.content-grid');
const playbackSection = document.getElementById('playbackSection');
const playbackLoadBtn = document.getElementById('playbackLoadBtn');
const playbackPlayBtn = document.getElementById('playbackPlayBtn');
const playbackScrubber = document.getElementById('playbackScrubber');
const playbackStatus = document.getElementById('playbackStatus');
const playbackAircraftCount = document.getElementById('playbackAircraftCount');
const playbackFrameCount = document.getElementById('playbackFrameCount');
const playbackTimeStart = document.getElementById('playbackTimeStart');
const playbackTimeEnd = document.getElementById('playbackTimeEnd');
const playbackCurrentTime = document.getElementById('playbackCurrentTime');
let pbMap = null;
let buckets = [];
let markers = {};
let frameIndex = 0;
let playing = false;
let playTimer = null;
// ── View toggle ───────────────────────────────────────────────────
viewDataBtn.addEventListener('click', () => {
viewDataBtn.classList.add('active');
viewPlaybackBtn.classList.remove('active');
contentGrid.style.display = '';
playbackSection.style.display = 'none';
stopPlayback();
});
viewPlaybackBtn.addEventListener('click', () => {
viewPlaybackBtn.classList.add('active');
viewDataBtn.classList.remove('active');
contentGrid.style.display = 'none';
playbackSection.style.display = 'flex';
initPlaybackMap();
});
// ── Map init ──────────────────────────────────────────────────────
function initPlaybackMap() {
if (pbMap) { pbMap.invalidateSize(); return; }
pbMap = MapUtils.init('playbackMap', { zoom: 6 });
}
// ── Load data ─────────────────────────────────────────────────────
playbackLoadBtn.addEventListener('click', loadPlayback);
async function loadPlayback() {
if (!pbMap) initPlaybackMap();
stopPlayback();
clearMarkers();
const minutes = document.getElementById('playbackWindowSelect').value;
const bucket = document.getElementById('playbackBucketSelect').value;
playbackStatus.textContent = 'LOADING…';
playbackStatus.className = 'status-pill loading';
try {
const resp = await fetch(`/adsb/history/playback?since_minutes=${minutes}&bucket_seconds=${bucket}`);
if (!resp.ok) throw new Error(resp.statusText);
const data = await resp.json();
buckets = data.buckets || [];
if (!buckets.length) {
playbackStatus.textContent = 'NO DATA';
playbackStatus.className = 'status-pill warn';
playbackFrameCount.textContent = '0';
playbackAircraftCount.textContent = '0';
return;
}
playbackScrubber.max = buckets.length - 1;
playbackScrubber.value = 0;
playbackScrubber.disabled = false;
playbackPlayBtn.disabled = false;
frameIndex = 0;
const startEpoch = buckets[0].t;
const endEpoch = buckets[buckets.length - 1].t;
playbackTimeStart.textContent = epochToStr(startEpoch);
playbackTimeEnd.textContent = epochToStr(endEpoch);
const maxAircraft = Math.max(...buckets.map(b => b.aircraft.length));
playbackFrameCount.textContent = buckets.length;
playbackAircraftCount.textContent = maxAircraft;
playbackStatus.textContent = 'READY';
playbackStatus.className = 'status-pill';
renderFrame(0);
} catch (e) {
playbackStatus.textContent = 'ERROR';
playbackStatus.className = 'status-pill error';
console.error('Playback load error:', e);
}
}
// ── Scrubber ──────────────────────────────────────────────────────
playbackScrubber.addEventListener('input', () => {
frameIndex = parseInt(playbackScrubber.value);
renderFrame(frameIndex);
});
// ── Play / Pause ──────────────────────────────────────────────────
playbackPlayBtn.addEventListener('click', () => {
playing ? stopPlayback() : startPlayback();
});
function startPlayback() {
if (!buckets.length) return;
playing = true;
playbackPlayBtn.textContent = '⏸ Pause';
playbackStatus.textContent = 'PLAYING';
playbackStatus.className = 'status-pill active';
if (frameIndex >= buckets.length - 1) frameIndex = 0;
step();
}
function stopPlayback() {
playing = false;
clearTimeout(playTimer);
playbackPlayBtn.textContent = '▶ Play';
if (buckets.length) {
playbackStatus.textContent = 'PAUSED';
playbackStatus.className = 'status-pill';
}
}
function step() {
if (!playing) return;
renderFrame(frameIndex);
playbackScrubber.value = frameIndex;
if (frameIndex < buckets.length - 1) {
frameIndex++;
const speed = parseInt(document.getElementById('playbackSpeedSelect').value);
const bucketSec = buckets.length > 1 ? (buckets[1].t - buckets[0].t) : 30;
const delay = Math.max(50, (bucketSec / speed) * 1000);
playTimer = setTimeout(step, delay);
} else {
stopPlayback();
playbackStatus.textContent = 'DONE';
}
}
// ── Render a frame ────────────────────────────────────────────────
function renderFrame(idx) {
if (!buckets[idx]) return;
const bucket = buckets[idx];
playbackCurrentTime.textContent = epochToStr(bucket.t);
const seen = new Set();
for (const ac of bucket.aircraft) {
seen.add(ac.icao);
const latlng = [ac.lat, ac.lon];
if (markers[ac.icao]) {
markers[ac.icao].setLatLng(latlng);
markers[ac.icao].setTooltipContent(acLabel(ac));
} else {
const icon = L.divIcon({
className: 'pb-aircraft-icon',
html: headingArrow(ac.hdg),
iconSize: [18, 18],
iconAnchor: [9, 9],
});
const m = L.marker(latlng, { icon })
.bindTooltip(acLabel(ac), { permanent: false, direction: 'top', offset: [0, -10] })
.addTo(pbMap);
markers[ac.icao] = m;
}
}
// Remove aircraft that aren't in this frame
for (const icao of Object.keys(markers)) {
if (!seen.has(icao)) {
pbMap.removeLayer(markers[icao]);
delete markers[icao];
}
}
playbackAircraftCount.textContent = Object.keys(markers).length;
}
function clearMarkers() {
for (const m of Object.values(markers)) { if (pbMap) pbMap.removeLayer(m); }
markers = {};
}
// ── Helpers ───────────────────────────────────────────────────────
function epochToStr(epoch) {
return new Date(epoch * 1000).toISOString().replace('T', ' ').substring(0, 19) + ' UTC';
}
function acLabel(ac) {
const id = ac.callsign || ac.icao;
const alt = ac.alt != null ? ` ${ac.alt.toLocaleString()}ft` : '';
return `<span class="pb-tooltip">${id}${alt}</span>`;
}
function headingArrow(hdg) {
const deg = hdg != null ? hdg : 0;
return `<svg viewBox="0 0 18 18" width="18" height="18" style="transform:rotate(${deg}deg)">
<polygon points="9,1 13,17 9,13 5,17" fill="var(--accent-cyan)" stroke="var(--bg-card)" stroke-width="1"/>
</svg>`;
}
})();
</script>
</body> </body>
</html> </html>
+7
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
from typing import Any from typing import Any
from utils.alerts import get_alert_manager from utils.alerts import get_alert_manager
from utils.mqtt import publish as mqtt_publish
from utils.recording import get_recording_manager from utils.recording import get_recording_manager
from utils.temporal_patterns import get_pattern_detector from utils.temporal_patterns import get_pattern_detector
@@ -54,6 +55,12 @@ def process_event(mode: str, event: dict | Any, event_type: str | None = None) -
# Alert failures should never break streaming # Alert failures should never break streaming
pass pass
try:
mqtt_publish(mode, event, event_type)
except Exception:
# MQTT failures should never break streaming
pass
def _extract_device_id(event: dict) -> str | None: def _extract_device_id(event: dict) -> str | None:
for field in DEVICE_ID_FIELDS: for field in DEVICE_ID_FIELDS:
+107
View File
@@ -0,0 +1,107 @@
"""
Optional MQTT data export.
Publishes decoded events from all modules to a configurable MQTT broker.
Disabled when INTERCEPT_MQTT_BROKER is not set — no broker, no connection,
no error.
Topics follow the pattern:
<prefix>/<module>/<event_type>
Payload is JSON, matching the SSE event structure already used by the
frontend. Configure via environment variables:
INTERCEPT_MQTT_BROKER broker hostname / IP (required to enable)
INTERCEPT_MQTT_PORT port (default 1883)
INTERCEPT_MQTT_USER username (optional)
INTERCEPT_MQTT_PASSWORD password (optional)
INTERCEPT_MQTT_TOPIC_PREFIX topic prefix (default "intercept")
INTERCEPT_MQTT_RETAIN retain flag (default false)
"""
from __future__ import annotations
import json
import logging
import threading
from typing import Any
logger = logging.getLogger(__name__)
_client = None
_lock = threading.Lock()
_enabled: bool | None = None # None = not yet initialised
def _get_config():
import config
return config
def _is_enabled() -> bool:
global _enabled
if _enabled is None:
cfg = _get_config()
_enabled = bool(cfg.MQTT_BROKER)
return _enabled
def _get_client():
"""Return a connected paho MQTT client, creating it on first call."""
global _client
if _client is not None:
return _client
with _lock:
if _client is not None:
return _client
try:
import paho.mqtt.client as mqtt
except ImportError:
logger.warning("paho-mqtt not installed; MQTT export disabled. Install with: pip install paho-mqtt")
return None
cfg = _get_config()
client = mqtt.Client(client_id="intercept", clean_session=True)
if cfg.MQTT_USER:
client.username_pw_set(cfg.MQTT_USER, cfg.MQTT_PASSWORD or None)
try:
client.connect(cfg.MQTT_BROKER, cfg.MQTT_PORT, keepalive=60)
client.loop_start()
logger.info("MQTT connected to %s:%d (prefix=%s)", cfg.MQTT_BROKER, cfg.MQTT_PORT, cfg.MQTT_TOPIC_PREFIX)
_client = client
except Exception as exc:
logger.warning("MQTT connect failed: %s — export disabled until restart", exc)
return None
return _client
def publish(mode: str, event: dict[str, Any], event_type: str | None = None) -> None:
"""Publish a decoded event to the MQTT broker.
Args:
mode: Source module name (e.g. 'aprs', 'adsb', 'pager').
event: The decoded event dict.
event_type: Optional sub-type string (e.g. 'packet', 'aircraft').
"""
if not _is_enabled():
return
client = _get_client()
if client is None:
return
cfg = _get_config()
subtopic = event_type or mode
topic = f"{cfg.MQTT_TOPIC_PREFIX}/{mode}/{subtopic}"
try:
payload = json.dumps(event, default=str)
client.publish(topic, payload, retain=cfg.MQTT_RETAIN)
except Exception as exc:
logger.debug("MQTT publish error on %s: %s", topic, exc)
+9
View File
@@ -27,11 +27,14 @@ from typing import Optional
from .airspy import AirspyCommandBuilder from .airspy import AirspyCommandBuilder
from .base import CommandBuilder, SDRCapabilities, SDRDevice, SDRType from .base import CommandBuilder, SDRCapabilities, SDRDevice, SDRType
from .bladerf import BladeRFCommandBuilder
from .detection import detect_all_devices, invalidate_device_cache, probe_rtlsdr_device from .detection import detect_all_devices, invalidate_device_cache, probe_rtlsdr_device
from .hackrf import HackRFCommandBuilder from .hackrf import HackRFCommandBuilder
from .hydrasdr import HydraSDRCommandBuilder
from .limesdr import LimeSDRCommandBuilder from .limesdr import LimeSDRCommandBuilder
from .rtlsdr import RTLSDRCommandBuilder from .rtlsdr import RTLSDRCommandBuilder
from .sdrplay import SDRPlayCommandBuilder from .sdrplay import SDRPlayCommandBuilder
from .usrp import USRPCommandBuilder
from .validation import ( from .validation import (
SDRValidationError, SDRValidationError,
get_capabilities_for_type, get_capabilities_for_type,
@@ -53,6 +56,9 @@ class SDRFactory:
SDRType.HACKRF: HackRFCommandBuilder, SDRType.HACKRF: HackRFCommandBuilder,
SDRType.AIRSPY: AirspyCommandBuilder, SDRType.AIRSPY: AirspyCommandBuilder,
SDRType.SDRPLAY: SDRPlayCommandBuilder, SDRType.SDRPLAY: SDRPlayCommandBuilder,
SDRType.USRP: USRPCommandBuilder,
SDRType.BLADE_RF: BladeRFCommandBuilder,
SDRType.HYDRA_SDR: HydraSDRCommandBuilder,
} }
@classmethod @classmethod
@@ -211,6 +217,9 @@ __all__ = [
"HackRFCommandBuilder", "HackRFCommandBuilder",
"AirspyCommandBuilder", "AirspyCommandBuilder",
"SDRPlayCommandBuilder", "SDRPlayCommandBuilder",
"USRPCommandBuilder",
"BladeRFCommandBuilder",
"HydraSDRCommandBuilder",
# Validation # Validation
"SDRValidationError", "SDRValidationError",
"validate_frequency", "validate_frequency",
+3 -3
View File
@@ -20,9 +20,9 @@ class SDRType(Enum):
HACKRF = "hackrf" HACKRF = "hackrf"
AIRSPY = "airspy" AIRSPY = "airspy"
SDRPLAY = "sdrplay" SDRPLAY = "sdrplay"
# Future support USRP = "usrp"
# USRP = "usrp" BLADE_RF = "bladerf"
# BLADE_RF = "bladerf" HYDRA_SDR = "hydrasdr"
@dataclass @dataclass
+153
View File
@@ -0,0 +1,153 @@
"""
BladeRF command builder implementation.
Uses SoapySDR (via SoapyBladeRF) for all signal processing tasks.
Targets bladeRF 2.0 micro (47 MHz 6 GHz) but is compatible with
bladeRF x40/x115 (300 MHz 3.8 GHz) — the SoapySDR layer handles
per-device frequency clamping transparently.
Requires: libbladeRF, SoapySDR, SoapyBladeRF module installed.
"""
from __future__ import annotations
from utils.dependencies import get_tool_path
from .base import CommandBuilder, SDRCapabilities, SDRDevice, SDRType
class BladeRFCommandBuilder(CommandBuilder):
"""BladeRF command builder using SoapySDR / SoapyBladeRF."""
CAPABILITIES = SDRCapabilities(
sdr_type=SDRType.BLADE_RF,
freq_min_mhz=47.0, # bladeRF 2.0 micro lower bound
freq_max_mhz=6000.0,
gain_min=0.0,
gain_max=66.0, # LNA (0/3/6) + RXVGA1 (5-30) + RXVGA2 (0-30)
sample_rates=[1000000, 2000000, 4000000, 8000000, 10000000, 20000000, 40000000],
supports_bias_t=False,
supports_ppm=False,
tx_capable=True,
supports_iq_capture=True,
)
def _build_device_string(self, device: SDRDevice) -> str:
"""Build SoapySDR device string for BladeRF."""
if device.serial and device.serial not in ("N/A", "Unknown"):
return f"driver=bladerf,serial={device.serial}"
return "driver=bladerf"
def build_fm_demod_command(
self,
device: SDRDevice,
frequency_mhz: float,
sample_rate: int = 22050,
gain: float | None = None,
ppm: int | None = None,
modulation: str = "fm",
squelch: int | None = None,
bias_t: bool = False,
) -> list[str]:
device_str = self._build_device_string(device)
rx_fm_path = get_tool_path("rx_fm") or "rx_fm"
cmd = [
rx_fm_path,
"-d",
device_str,
"-f",
f"{frequency_mhz}M",
"-M",
modulation,
"-s",
str(sample_rate),
]
if gain is not None and gain > 0:
cmd.extend(["-g", str(int(gain))])
if squelch is not None and squelch > 0:
cmd.extend(["-l", str(squelch)])
cmd.append("-")
return cmd
def build_adsb_command(self, device: SDRDevice, gain: float | None = None, bias_t: bool = False) -> list[str]:
device_str = self._build_device_string(device)
cmd = ["readsb", "--net", "--device-type", "soapysdr", "--device", device_str, "--quiet"]
if gain is not None:
cmd.extend(["--gain", str(int(gain))])
return cmd
def build_ism_command(
self,
device: SDRDevice,
frequency_mhz: float = 433.92,
gain: float | None = None,
ppm: int | None = None,
bias_t: bool = False,
) -> list[str]:
device_str = self._build_device_string(device)
cmd = ["rtl_433", "-d", device_str, "-f", f"{frequency_mhz}M", "-F", "json"]
if gain is not None and gain > 0:
cmd.extend(["-g", str(int(gain))])
return cmd
def build_ais_command(
self,
device: SDRDevice,
gain: float | None = None,
bias_t: bool = False,
tcp_port: int = 10110,
udp_host: str | None = None,
udp_port: int | None = None,
) -> list[str]:
device_str = self._build_device_string(device)
cmd = [
"AIS-catcher",
"-d",
f"soapysdr -d {device_str}",
"-S",
str(tcp_port),
"-o",
"5",
"-q",
]
if gain is not None and gain > 0:
cmd.extend(["-gr", "tuner", str(int(gain))])
if udp_host and udp_port:
cmd.extend(["-u", udp_host, str(udp_port)])
return cmd
def build_iq_capture_command(
self,
device: SDRDevice,
frequency_mhz: float,
sample_rate: int = 2048000,
gain: float | None = None,
ppm: int | None = None,
bias_t: bool = False,
output_format: str = "cu8",
) -> list[str]:
device_str = self._build_device_string(device)
freq_hz = int(frequency_mhz * 1e6)
rx_sdr_path = get_tool_path("rx_sdr") or "rx_sdr"
cmd = [
rx_sdr_path,
"-d",
device_str,
"-f",
str(freq_hz),
"-s",
str(sample_rate),
"-F",
"CU8",
]
if gain is not None and gain > 0:
cmd.extend(["-g", str(int(gain))])
cmd.append("-")
return cmd
def get_capabilities(self) -> SDRCapabilities:
return self.CAPABILITIES
@classmethod
def get_sdr_type(cls) -> SDRType:
return SDRType.BLADE_RF
+9 -3
View File
@@ -49,10 +49,13 @@ def _get_capabilities_for_type(sdr_type: SDRType) -> SDRCapabilities:
"""Get default capabilities for an SDR type.""" """Get default capabilities for an SDR type."""
# Import here to avoid circular imports # Import here to avoid circular imports
from .airspy import AirspyCommandBuilder from .airspy import AirspyCommandBuilder
from .bladerf import BladeRFCommandBuilder
from .hackrf import HackRFCommandBuilder from .hackrf import HackRFCommandBuilder
from .hydrasdr import HydraSDRCommandBuilder
from .limesdr import LimeSDRCommandBuilder from .limesdr import LimeSDRCommandBuilder
from .rtlsdr import RTLSDRCommandBuilder from .rtlsdr import RTLSDRCommandBuilder
from .sdrplay import SDRPlayCommandBuilder from .sdrplay import SDRPlayCommandBuilder
from .usrp import USRPCommandBuilder
builders = { builders = {
SDRType.RTL_SDR: RTLSDRCommandBuilder, SDRType.RTL_SDR: RTLSDRCommandBuilder,
@@ -60,6 +63,9 @@ def _get_capabilities_for_type(sdr_type: SDRType) -> SDRCapabilities:
SDRType.HACKRF: HackRFCommandBuilder, SDRType.HACKRF: HackRFCommandBuilder,
SDRType.AIRSPY: AirspyCommandBuilder, SDRType.AIRSPY: AirspyCommandBuilder,
SDRType.SDRPLAY: SDRPlayCommandBuilder, SDRType.SDRPLAY: SDRPlayCommandBuilder,
SDRType.USRP: USRPCommandBuilder,
SDRType.BLADE_RF: BladeRFCommandBuilder,
SDRType.HYDRA_SDR: HydraSDRCommandBuilder,
} }
builder_class = builders.get(sdr_type) builder_class = builders.get(sdr_type)
@@ -90,9 +96,9 @@ def _driver_to_sdr_type(driver: str) -> SDRType | None:
"airspy": SDRType.AIRSPY, "airspy": SDRType.AIRSPY,
"airspyhf": SDRType.AIRSPY, # Airspy HF+ uses same builder "airspyhf": SDRType.AIRSPY, # Airspy HF+ uses same builder
"sdrplay": SDRType.SDRPLAY, "sdrplay": SDRType.SDRPLAY,
# Future support "uhd": SDRType.USRP,
# 'uhd': SDRType.USRP, "bladerf": SDRType.BLADE_RF,
# 'bladerf': SDRType.BLADE_RF, "hydrasdr": SDRType.HYDRA_SDR,
} }
return mapping.get(driver.lower()) return mapping.get(driver.lower())
+157
View File
@@ -0,0 +1,157 @@
"""
HydraSDR RFOne command builder implementation.
Uses SoapySDR (via SoapyHydraSDR plugin) for all signal processing tasks.
The RFOne is an RX-only receiver covering 24 MHz to 1800 MHz with a
10 MHz instantaneous bandwidth.
Gain is a linear scale 021 (not dB); the SoapyHydraSDR plugin maps this
to the hardware's RF gain stages.
Requires: rfone_host library, SoapySDR, SoapyHydraSDR module installed.
Official: https://github.com/hydrasdr/SoapyHydraSDR
"""
from __future__ import annotations
from utils.dependencies import get_tool_path
from .base import CommandBuilder, SDRCapabilities, SDRDevice, SDRType
class HydraSDRCommandBuilder(CommandBuilder):
"""HydraSDR RFOne command builder using SoapySDR / SoapyHydraSDR."""
CAPABILITIES = SDRCapabilities(
sdr_type=SDRType.HYDRA_SDR,
freq_min_mhz=24.0,
freq_max_mhz=1800.0,
gain_min=0.0,
gain_max=21.0, # linear scale; recommended starting point: 12
sample_rates=[2500000, 5000000, 10000000],
supports_bias_t=False,
supports_ppm=False,
tx_capable=False,
supports_iq_capture=True,
)
def _build_device_string(self, device: SDRDevice) -> str:
"""Build SoapySDR device string for HydraSDR RFOne."""
if device.serial and device.serial not in ("N/A", "Unknown"):
return f"driver=hydrasdr,serial={device.serial}"
return "driver=hydrasdr"
def build_fm_demod_command(
self,
device: SDRDevice,
frequency_mhz: float,
sample_rate: int = 22050,
gain: float | None = None,
ppm: int | None = None,
modulation: str = "fm",
squelch: int | None = None,
bias_t: bool = False,
) -> list[str]:
device_str = self._build_device_string(device)
rx_fm_path = get_tool_path("rx_fm") or "rx_fm"
cmd = [
rx_fm_path,
"-d",
device_str,
"-f",
f"{frequency_mhz}M",
"-M",
modulation,
"-s",
str(sample_rate),
]
if gain is not None and gain > 0:
cmd.extend(["-g", str(int(gain))])
if squelch is not None and squelch > 0:
cmd.extend(["-l", str(squelch)])
cmd.append("-")
return cmd
def build_adsb_command(self, device: SDRDevice, gain: float | None = None, bias_t: bool = False) -> list[str]:
# 1090 MHz is within the RFOne's range (241800 MHz)
device_str = self._build_device_string(device)
cmd = ["readsb", "--net", "--device-type", "soapysdr", "--device", device_str, "--quiet"]
if gain is not None:
cmd.extend(["--gain", str(int(gain))])
return cmd
def build_ism_command(
self,
device: SDRDevice,
frequency_mhz: float = 433.92,
gain: float | None = None,
ppm: int | None = None,
bias_t: bool = False,
) -> list[str]:
device_str = self._build_device_string(device)
cmd = ["rtl_433", "-d", device_str, "-f", f"{frequency_mhz}M", "-F", "json"]
if gain is not None and gain > 0:
cmd.extend(["-g", str(int(gain))])
return cmd
def build_ais_command(
self,
device: SDRDevice,
gain: float | None = None,
bias_t: bool = False,
tcp_port: int = 10110,
udp_host: str | None = None,
udp_port: int | None = None,
) -> list[str]:
device_str = self._build_device_string(device)
cmd = [
"AIS-catcher",
"-d",
f"soapysdr -d {device_str}",
"-S",
str(tcp_port),
"-o",
"5",
"-q",
]
if gain is not None and gain > 0:
cmd.extend(["-gr", "tuner", str(int(gain))])
if udp_host and udp_port:
cmd.extend(["-u", udp_host, str(udp_port)])
return cmd
def build_iq_capture_command(
self,
device: SDRDevice,
frequency_mhz: float,
sample_rate: int = 2048000,
gain: float | None = None,
ppm: int | None = None,
bias_t: bool = False,
output_format: str = "cu8",
) -> list[str]:
device_str = self._build_device_string(device)
freq_hz = int(frequency_mhz * 1e6)
rx_sdr_path = get_tool_path("rx_sdr") or "rx_sdr"
cmd = [
rx_sdr_path,
"-d",
device_str,
"-f",
str(freq_hz),
"-s",
str(sample_rate),
"-F",
"CU8",
]
if gain is not None and gain > 0:
cmd.extend(["-g", str(int(gain))])
cmd.append("-")
return cmd
def get_capabilities(self) -> SDRCapabilities:
return self.CAPABILITIES
@classmethod
def get_sdr_type(cls) -> SDRType:
return SDRType.HYDRA_SDR
+153
View File
@@ -0,0 +1,153 @@
"""
USRP command builder implementation.
Uses SoapySDR (via the UHD driver / SoapyUHD bridge) for all signal
processing tasks. The same rx_fm / rx_sdr / rtl_433 / AIS-catcher
toolchain used by HackRF applies here because they all speak SoapySDR.
Tested target: USRP N200 / B200 / B210 (UHD driver, SoapyUHD bridge).
Requires: uhd, SoapySDR, SoapyUHD installed on the host or in Docker.
"""
from __future__ import annotations
from utils.dependencies import get_tool_path
from .base import CommandBuilder, SDRCapabilities, SDRDevice, SDRType
class USRPCommandBuilder(CommandBuilder):
"""USRP command builder using SoapySDR / UHD."""
CAPABILITIES = SDRCapabilities(
sdr_type=SDRType.USRP,
freq_min_mhz=1.0,
freq_max_mhz=6000.0,
gain_min=0.0,
gain_max=76.0, # typical UHD normalised gain range
sample_rates=[1000000, 2000000, 4000000, 8000000, 16000000, 25000000],
supports_bias_t=False,
supports_ppm=False,
tx_capable=True,
supports_iq_capture=True,
)
def _build_device_string(self, device: SDRDevice) -> str:
"""Build SoapySDR device string for USRP."""
if device.serial and device.serial not in ("N/A", "Unknown"):
return f"driver=uhd,serial={device.serial}"
return "driver=uhd"
def build_fm_demod_command(
self,
device: SDRDevice,
frequency_mhz: float,
sample_rate: int = 22050,
gain: float | None = None,
ppm: int | None = None,
modulation: str = "fm",
squelch: int | None = None,
bias_t: bool = False,
) -> list[str]:
device_str = self._build_device_string(device)
rx_fm_path = get_tool_path("rx_fm") or "rx_fm"
cmd = [
rx_fm_path,
"-d",
device_str,
"-f",
f"{frequency_mhz}M",
"-M",
modulation,
"-s",
str(sample_rate),
]
if gain is not None and gain > 0:
cmd.extend(["-g", str(int(gain))])
if squelch is not None and squelch > 0:
cmd.extend(["-l", str(squelch)])
cmd.append("-")
return cmd
def build_adsb_command(self, device: SDRDevice, gain: float | None = None, bias_t: bool = False) -> list[str]:
device_str = self._build_device_string(device)
cmd = ["readsb", "--net", "--device-type", "soapysdr", "--device", device_str, "--quiet"]
if gain is not None:
cmd.extend(["--gain", str(int(gain))])
return cmd
def build_ism_command(
self,
device: SDRDevice,
frequency_mhz: float = 433.92,
gain: float | None = None,
ppm: int | None = None,
bias_t: bool = False,
) -> list[str]:
device_str = self._build_device_string(device)
cmd = ["rtl_433", "-d", device_str, "-f", f"{frequency_mhz}M", "-F", "json"]
if gain is not None and gain > 0:
cmd.extend(["-g", str(int(gain))])
return cmd
def build_ais_command(
self,
device: SDRDevice,
gain: float | None = None,
bias_t: bool = False,
tcp_port: int = 10110,
udp_host: str | None = None,
udp_port: int | None = None,
) -> list[str]:
device_str = self._build_device_string(device)
cmd = [
"AIS-catcher",
"-d",
f"soapysdr -d {device_str}",
"-S",
str(tcp_port),
"-o",
"5",
"-q",
]
if gain is not None and gain > 0:
cmd.extend(["-gr", "tuner", str(int(gain))])
if udp_host and udp_port:
cmd.extend(["-u", udp_host, str(udp_port)])
return cmd
def build_iq_capture_command(
self,
device: SDRDevice,
frequency_mhz: float,
sample_rate: int = 2048000,
gain: float | None = None,
ppm: int | None = None,
bias_t: bool = False,
output_format: str = "cu8",
) -> list[str]:
device_str = self._build_device_string(device)
freq_hz = int(frequency_mhz * 1e6)
rx_sdr_path = get_tool_path("rx_sdr") or "rx_sdr"
cmd = [
rx_sdr_path,
"-d",
device_str,
"-f",
str(freq_hz),
"-s",
str(sample_rate),
"-F",
"CU8",
]
if gain is not None and gain > 0:
cmd.extend(["-g", str(int(gain))])
cmd.append("-")
return cmd
def get_capabilities(self) -> SDRCapabilities:
return self.CAPABILITIES
@classmethod
def get_sdr_type(cls) -> SDRType:
return SDRType.USRP