Merge remote-tracking branch 'upstream/main'

This commit is contained in:
thatsatechnique
2026-03-06 12:47:51 -08:00
8 changed files with 1145 additions and 262 deletions

View File

@@ -3,6 +3,8 @@
from __future__ import annotations
import json
import csv
import io
import os
import queue
import shutil
@@ -10,7 +12,7 @@ import socket
import subprocess
import threading
import time
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from typing import Any, Generator
from flask import Blueprint, Response, jsonify, make_response, render_template, request
@@ -195,6 +197,40 @@ def _ensure_history_schema() -> None:
logger.warning("ADS-B schema check failed: %s", exc)
MILITARY_ICAO_RANGES = [
(0xADF7C0, 0xADFFFF), # US
(0xAE0000, 0xAEFFFF), # US
(0x3F4000, 0x3F7FFF), # FR
(0x43C000, 0x43CFFF), # UK
(0x3D0000, 0x3DFFFF), # DE
(0x501C00, 0x501FFF), # NATO
]
MILITARY_CALLSIGN_PREFIXES = (
'REACH', 'JAKE', 'DOOM', 'IRON', 'HAWK', 'VIPER', 'COBRA', 'THUNDER',
'SHADOW', 'NIGHT', 'STEEL', 'GRIM', 'REAPER', 'BLADE', 'STRIKE',
'RCH', 'CNV', 'MCH', 'EVAC', 'TOPCAT', 'ASCOT', 'RRR', 'HRK',
'NAVY', 'ARMY', 'USAF', 'RAF', 'RCAF', 'RAAF', 'IAF', 'PAF',
)
def _is_military_aircraft(icao: str, callsign: str | None) -> bool:
"""Return True if the ICAO hex or callsign indicates a military aircraft."""
try:
hex_val = int(icao, 16)
for start, end in MILITARY_ICAO_RANGES:
if start <= hex_val <= end:
return True
except (ValueError, TypeError):
pass
if callsign:
upper = callsign.upper().strip()
for prefix in MILITARY_CALLSIGN_PREFIXES:
if upper.startswith(prefix):
return True
return False
def _parse_int_param(value: str | None, default: int, min_value: int | None = None, max_value: int | None = None) -> int:
try:
parsed = int(value) if value is not None else default
@@ -207,6 +243,137 @@ def _parse_int_param(value: str | None, default: int, min_value: int | None = No
return parsed
def _parse_iso_datetime(value: Any) -> datetime | None:
if not isinstance(value, str):
return None
cleaned = value.strip()
if not cleaned:
return None
if cleaned.endswith('Z'):
cleaned = f"{cleaned[:-1]}+00:00"
try:
parsed = datetime.fromisoformat(cleaned)
except ValueError:
return None
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone(timezone.utc)
def _parse_export_scope(
args: Any,
) -> tuple[str, int, datetime | None, datetime | None]:
scope = str(args.get('scope') or 'window').strip().lower()
if scope not in {'window', 'all', 'custom'}:
scope = 'window'
since_minutes = _parse_int_param(args.get('since_minutes'), 1440, 1, 525600)
start = _parse_iso_datetime(args.get('start'))
end = _parse_iso_datetime(args.get('end'))
if scope == 'custom' and (start is None or end is None or end <= start):
scope = 'window'
return scope, since_minutes, start, end
def _add_time_filter(
*,
where_parts: list[str],
params: list[Any],
scope: str,
timestamp_field: str,
since_minutes: int,
start: datetime | None,
end: datetime | None,
) -> None:
if scope == 'all':
return
if scope == 'custom' and start is not None and end is not None:
where_parts.append(f"{timestamp_field} >= %s AND {timestamp_field} < %s")
params.extend([start, end])
return
where_parts.append(f"{timestamp_field} >= NOW() - INTERVAL %s")
params.append(f'{since_minutes} minutes')
def _serialize_export_value(value: Any) -> Any:
if isinstance(value, datetime):
return value.isoformat()
return value
def _rows_to_serializable(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
return [{key: _serialize_export_value(value) for key, value in row.items()} for row in rows]
def _build_export_csv(
*,
exported_at: str,
scope: str,
since_minutes: int | None,
icao: str,
search: str,
classification: str,
messages: list[dict[str, Any]],
snapshots: list[dict[str, Any]],
sessions: list[dict[str, Any]],
export_type: str,
) -> str:
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(['Exported At', exported_at])
writer.writerow(['Scope', scope])
if since_minutes is not None:
writer.writerow(['Since Minutes', since_minutes])
if icao:
writer.writerow(['ICAO Filter', icao])
if search:
writer.writerow(['Search Filter', search])
if classification != 'all':
writer.writerow(['Classification', classification])
writer.writerow([])
def write_section(title: str, rows: list[dict[str, Any]], columns: list[str]) -> None:
writer.writerow([title])
writer.writerow(columns)
for row in rows:
writer.writerow([_serialize_export_value(row.get(col)) for col in columns])
writer.writerow([])
if export_type in {'messages', 'all'}:
write_section(
'Messages',
messages,
[
'received_at', 'msg_time', 'logged_time', 'icao', 'msg_type', 'callsign',
'altitude', 'speed', 'heading', 'vertical_rate', 'lat', 'lon', 'squawk',
'session_id', 'aircraft_id', 'flight_id', 'source_host', 'raw_line',
],
)
if export_type in {'snapshots', 'all'}:
write_section(
'Snapshots',
snapshots,
[
'captured_at', 'icao', 'callsign', 'registration', 'type_code', 'type_desc',
'altitude', 'speed', 'heading', 'vertical_rate', 'lat', 'lon', 'squawk',
'source_host',
],
)
if export_type in {'sessions', 'all'}:
write_section(
'Sessions',
sessions,
[
'id', 'started_at', 'ended_at', 'device_index', 'sdr_type', 'remote_host',
'remote_port', 'start_source', 'stop_source', 'started_by', 'stopped_by', 'notes',
],
)
return output.getvalue()
def _broadcast_adsb_update(payload: dict[str, Any]) -> None:
"""Fan out a payload to all active ADS-B SSE subscribers."""
with _adsb_stream_subscribers_lock:
@@ -1069,7 +1236,7 @@ def adsb_history_summary():
return jsonify({'error': 'ADS-B history is disabled'}), 503
_ensure_history_schema()
since_minutes = _parse_int_param(request.args.get('since_minutes'), 60, 1, 10080)
since_minutes = _parse_int_param(request.args.get('since_minutes'), 1440, 1, 10080)
window = f'{since_minutes} minutes'
sql = """
@@ -1099,7 +1266,7 @@ def adsb_history_aircraft():
return jsonify({'error': 'ADS-B history is disabled'}), 503
_ensure_history_schema()
since_minutes = _parse_int_param(request.args.get('since_minutes'), 60, 1, 10080)
since_minutes = _parse_int_param(request.args.get('since_minutes'), 1440, 1, 10080)
limit = _parse_int_param(request.args.get('limit'), 200, 1, 2000)
search = (request.args.get('search') or '').strip()
window = f'{since_minutes} minutes'
@@ -1153,7 +1320,7 @@ def adsb_history_timeline():
if not icao:
return jsonify({'error': 'icao is required'}), 400
since_minutes = _parse_int_param(request.args.get('since_minutes'), 60, 1, 10080)
since_minutes = _parse_int_param(request.args.get('since_minutes'), 1440, 1, 10080)
limit = _parse_int_param(request.args.get('limit'), 2000, 1, 20000)
window = f'{since_minutes} minutes'
@@ -1209,6 +1376,256 @@ def adsb_history_messages():
return jsonify({'error': 'History database unavailable'}), 503
@adsb_bp.route('/history/export')
def adsb_history_export():
"""Export ADS-B history data in CSV or JSON format."""
if not ADSB_HISTORY_ENABLED or not PSYCOPG2_AVAILABLE:
return jsonify({'error': 'ADS-B history is disabled'}), 503
_ensure_history_schema()
export_format = str(request.args.get('format') or 'csv').strip().lower()
export_type = str(request.args.get('type') or 'all').strip().lower()
if export_format not in {'csv', 'json'}:
return jsonify({'error': 'format must be csv or json'}), 400
if export_type not in {'messages', 'snapshots', 'sessions', 'all'}:
return jsonify({'error': 'type must be messages, snapshots, sessions, or all'}), 400
scope, since_minutes, start, end = _parse_export_scope(request.args)
icao = (request.args.get('icao') or '').strip().upper()
search = (request.args.get('search') or '').strip()
classification = str(request.args.get('classification') or 'all').strip().lower()
if classification not in {'all', 'military', 'civilian'}:
classification = 'all'
pattern = f'%{search}%'
snapshots: list[dict[str, Any]] = []
messages: list[dict[str, Any]] = []
sessions: list[dict[str, Any]] = []
def _filter_by_classification(
rows: list[dict[str, Any]],
icao_key: str = 'icao',
callsign_key: str = 'callsign',
) -> list[dict[str, Any]]:
if classification == 'all':
return rows
want_military = classification == 'military'
return [
r for r in rows
if _is_military_aircraft(r.get(icao_key, ''), r.get(callsign_key)) == want_military
]
try:
with _get_history_connection() as conn:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
if export_type in {'snapshots', 'all'}:
snapshot_where: list[str] = []
snapshot_params: list[Any] = []
_add_time_filter(
where_parts=snapshot_where,
params=snapshot_params,
scope=scope,
timestamp_field='captured_at',
since_minutes=since_minutes,
start=start,
end=end,
)
if icao:
snapshot_where.append("icao = %s")
snapshot_params.append(icao)
if search:
snapshot_where.append("(icao ILIKE %s OR callsign ILIKE %s OR registration ILIKE %s)")
snapshot_params.extend([pattern, pattern, pattern])
snapshot_sql = """
SELECT captured_at, icao, callsign, registration, type_code, type_desc,
altitude, speed, heading, vertical_rate, lat, lon, squawk, source_host
FROM adsb_snapshots
"""
if snapshot_where:
snapshot_sql += " WHERE " + " AND ".join(snapshot_where)
snapshot_sql += " ORDER BY captured_at DESC"
cur.execute(snapshot_sql, tuple(snapshot_params))
snapshots = _filter_by_classification(cur.fetchall())
if export_type in {'messages', 'all'}:
message_where: list[str] = []
message_params: list[Any] = []
_add_time_filter(
where_parts=message_where,
params=message_params,
scope=scope,
timestamp_field='received_at',
since_minutes=since_minutes,
start=start,
end=end,
)
if icao:
message_where.append("icao = %s")
message_params.append(icao)
if search:
message_where.append("(icao ILIKE %s OR callsign ILIKE %s)")
message_params.extend([pattern, pattern])
message_sql = """
SELECT received_at, msg_time, logged_time, icao, msg_type, callsign,
altitude, speed, heading, vertical_rate, lat, lon, squawk,
session_id, aircraft_id, flight_id, source_host, raw_line
FROM adsb_messages
"""
if message_where:
message_sql += " WHERE " + " AND ".join(message_where)
message_sql += " ORDER BY received_at DESC"
cur.execute(message_sql, tuple(message_params))
messages = _filter_by_classification(cur.fetchall())
if export_type in {'sessions', 'all'}:
session_where: list[str] = []
session_params: list[Any] = []
if scope == 'custom' and start is not None and end is not None:
session_where.append("COALESCE(ended_at, %s) >= %s AND started_at < %s")
session_params.extend([end, start, end])
elif scope == 'window':
session_where.append("COALESCE(ended_at, NOW()) >= NOW() - INTERVAL %s")
session_params.append(f'{since_minutes} minutes')
session_sql = """
SELECT id, started_at, ended_at, device_index, sdr_type, remote_host,
remote_port, start_source, stop_source, started_by, stopped_by, notes
FROM adsb_sessions
"""
if session_where:
session_sql += " WHERE " + " AND ".join(session_where)
session_sql += " ORDER BY started_at DESC"
cur.execute(session_sql, tuple(session_params))
sessions = cur.fetchall()
except Exception as exc:
logger.warning("ADS-B history export failed: %s", exc)
return jsonify({'error': 'History database unavailable'}), 503
exported_at = datetime.now(timezone.utc).isoformat()
timestamp = datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')
filename_scope = 'all' if scope == 'all' else ('custom' if scope == 'custom' else f'{since_minutes}m')
filename = f'adsb_history_{export_type}_{filename_scope}_{timestamp}.{export_format}'
if export_format == 'json':
payload = {
'exported_at': exported_at,
'format': export_format,
'type': export_type,
'scope': scope,
'since_minutes': None if scope != 'window' else since_minutes,
'filters': {
'icao': icao or None,
'search': search or None,
'classification': classification,
'start': start.isoformat() if start else None,
'end': end.isoformat() if end else None,
},
'counts': {
'messages': len(messages),
'snapshots': len(snapshots),
'sessions': len(sessions),
},
'messages': _rows_to_serializable(messages),
'snapshots': _rows_to_serializable(snapshots),
'sessions': _rows_to_serializable(sessions),
}
response = Response(
json.dumps(payload, indent=2, default=str),
mimetype='application/json',
)
response.headers['Content-Disposition'] = f'attachment; filename={filename}'
return response
csv_data = _build_export_csv(
exported_at=exported_at,
scope=scope,
since_minutes=since_minutes if scope == 'window' else None,
icao=icao,
search=search,
classification=classification,
messages=messages,
snapshots=snapshots,
sessions=sessions,
export_type=export_type,
)
response = Response(csv_data, mimetype='text/csv')
response.headers['Content-Disposition'] = f'attachment; filename={filename}'
return response
@adsb_bp.route('/history/prune', methods=['POST'])
def adsb_history_prune():
"""Delete ADS-B history for a selected time range or entire dataset."""
if not ADSB_HISTORY_ENABLED or not PSYCOPG2_AVAILABLE:
return jsonify({'error': 'ADS-B history is disabled'}), 503
_ensure_history_schema()
payload = request.get_json(silent=True) or {}
mode = str(payload.get('mode') or 'range').strip().lower()
if mode not in {'range', 'all'}:
return jsonify({'error': 'mode must be range or all'}), 400
try:
with _get_history_connection() as conn:
with conn.cursor() as cur:
deleted = {'messages': 0, 'snapshots': 0}
if mode == 'all':
cur.execute("DELETE FROM adsb_messages")
deleted['messages'] = max(0, cur.rowcount or 0)
cur.execute("DELETE FROM adsb_snapshots")
deleted['snapshots'] = max(0, cur.rowcount or 0)
return jsonify({
'status': 'ok',
'mode': 'all',
'deleted': deleted,
'total_deleted': deleted['messages'] + deleted['snapshots'],
})
start = _parse_iso_datetime(payload.get('start'))
end = _parse_iso_datetime(payload.get('end'))
if start is None or end is None:
return jsonify({'error': 'start and end ISO datetime values are required'}), 400
if end <= start:
return jsonify({'error': 'end must be after start'}), 400
if end - start > timedelta(days=31):
return jsonify({'error': 'range cannot exceed 31 days'}), 400
cur.execute(
"""
DELETE FROM adsb_messages
WHERE received_at >= %s
AND received_at < %s
""",
(start, end),
)
deleted['messages'] = max(0, cur.rowcount or 0)
cur.execute(
"""
DELETE FROM adsb_snapshots
WHERE captured_at >= %s
AND captured_at < %s
""",
(start, end),
)
deleted['snapshots'] = max(0, cur.rowcount or 0)
return jsonify({
'status': 'ok',
'mode': 'range',
'start': start.isoformat(),
'end': end.isoformat(),
'deleted': deleted,
'total_deleted': deleted['messages'] + deleted['snapshots'],
})
except Exception as exc:
logger.warning("ADS-B history prune failed: %s", exc)
return jsonify({'error': 'History database unavailable'}), 503
# ============================================
# AIRCRAFT DATABASE MANAGEMENT
# ============================================

View File

@@ -957,7 +957,8 @@ install_satdump_from_source_debian() {
) &
progress_pid=$!
if cmake -DCMAKE_BUILD_TYPE=Release -DBUILD_GUI=OFF -DCMAKE_INSTALL_LIBDIR=lib .. >"$build_log" 2>&1 \
if cmake -DCMAKE_BUILD_TYPE=Release -DBUILD_GUI=OFF -DCMAKE_INSTALL_LIBDIR=lib \
-DCMAKE_CXX_FLAGS="-Wno-template-body" .. >"$build_log" 2>&1 \
&& make -j "$(nproc)" >>"$build_log" 2>&1; then
kill $progress_pid 2>/dev/null; wait $progress_pid 2>/dev/null
$SUDO make install >/dev/null 2>&1
@@ -1089,8 +1090,9 @@ install_radiosonde_auto_rx() {
# --- dump1090 (Debian from source) ---
install_dump1090_from_source_debian() {
info "dump1090 not available via APT. Building from source (required)..."
info "dump1090 not available via APT. Building from source (this may take a few minutes)..."
info "Installing build dependencies for dump1090..."
apt_install build-essential git pkg-config \
librtlsdr-dev libusb-1.0-0-dev \
libncurses-dev tcl-dev python3-dev
@@ -1127,6 +1129,7 @@ install_dump1090_from_source_debian() {
tail -20 "$build_log" | while IFS= read -r line; do warn " $line"; done
rm -rf "$tmp_dir/dump1090"
info "Cloning wiedehopf/readsb..."
git clone --depth 1 https://github.com/wiedehopf/readsb.git "$tmp_dir/dump1090" >/dev/null 2>&1 \
|| { fail "Failed to clone wiedehopf/readsb"; exit 1; }
@@ -1461,6 +1464,7 @@ install_tool_dump1090() {
$SUDO rm -f "$dump1090_path"
fi
if ! cmd_exists dump1090 && ! cmd_exists dump1090-mutability; then
info "Checking for dump1090 APT packages..."
apt_try_install_any dump1090-fa dump1090-mutability dump1090 || true
fi
if ! cmd_exists dump1090; then
@@ -1573,7 +1577,18 @@ install_tool_satdump() {
if [[ "$OS" == "macos" ]]; then
install_satdump_macos || warn "SatDump installation failed. Weather satellite decoding will not be available."
else
install_satdump_from_source_debian || warn "SatDump build failed. Weather satellite decoding will not be available."
# Try system package first (available on Ubuntu 24.10+, Debian Trixie+)
if apt-cache show satdump >/dev/null 2>&1; then
info "SatDump is available as a system package — installing via apt..."
if apt_install satdump; then
ok "SatDump installed via apt."
else
warn "apt install failed — falling back to building from source..."
install_satdump_from_source_debian || warn "SatDump build failed. Weather satellite decoding will not be available."
fi
else
install_satdump_from_source_debian || warn "SatDump build failed. Weather satellite decoding will not be available."
fi
fi
else
warn "Skipping SatDump installation. You can install it later if needed."

View File

@@ -269,6 +269,21 @@ body {
min-width: 160px;
}
.data-control-group {
min-width: 320px;
}
.data-actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.data-actions input[type="date"] {
min-width: 150px;
}
.primary-btn {
background: var(--accent-cyan);
border: none;
@@ -285,6 +300,31 @@ body {
box-shadow: 0 6px 14px rgba(74, 158, 255, 0.3);
}
.primary-btn:disabled {
opacity: 0.55;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.warn-btn {
background: var(--accent-amber);
color: #0a0c10;
}
.warn-btn:hover {
box-shadow: 0 6px 14px rgba(214, 168, 94, 0.3);
}
.danger-btn {
background: #d84f63;
color: #f8fafc;
}
.danger-btn:hover {
box-shadow: 0 6px 14px rgba(216, 79, 99, 0.35);
}
.status-pill {
font-family: var(--font-mono);
font-size: 11px;
@@ -296,6 +336,16 @@ body {
letter-spacing: 1px;
}
.status-pill.ok {
border-color: var(--accent-green);
color: var(--accent-green);
}
.status-pill.error {
border-color: #d84f63;
color: #d84f63;
}
.content-grid {
display: grid;
grid-template-columns: minmax(300px, 1fr) minmax(320px, 1fr);
@@ -364,6 +414,37 @@ body {
background: rgba(74, 158, 255, 0.1);
}
.aircraft-row.military {
background: rgba(85, 107, 47, 0.12);
}
.aircraft-row.military:hover {
background: rgba(85, 107, 47, 0.22);
}
.mil-badge,
.civ-badge {
display: inline-block;
font-size: 9px;
font-weight: 700;
letter-spacing: 0.8px;
padding: 2px 6px;
border-radius: 3px;
text-transform: uppercase;
}
.mil-badge {
background: rgba(85, 107, 47, 0.35);
color: #a3b86c;
border: 1px solid rgba(85, 107, 47, 0.6);
}
.civ-badge {
background: rgba(74, 158, 255, 0.15);
color: var(--text-dim);
border: 1px solid rgba(74, 158, 255, 0.25);
}
.mono {
font-family: var(--font-mono);
}
@@ -614,6 +695,15 @@ body {
min-width: 100%;
}
.data-actions {
width: 100%;
}
.data-actions input[type="date"],
.data-actions .primary-btn {
width: 100%;
}
.panel {
min-height: 320px;
}

View File

@@ -87,9 +87,9 @@
<label for="windowSelect">Window</label>
<select id="windowSelect">
<option value="15">15 minutes</option>
<option value="60" selected>1 hour</option>
<option value="60">1 hour</option>
<option value="360">6 hours</option>
<option value="1440">24 hours</option>
<option value="1440" selected>24 hours</option>
<option value="10080">7 days</option>
</select>
</div>
@@ -97,6 +97,14 @@
<label for="searchInput">Search</label>
<input type="text" id="searchInput" placeholder="ICAO / callsign / registration">
</div>
<div class="control-group">
<label for="classSelect">Classification</label>
<select id="classSelect">
<option value="all">All Aircraft</option>
<option value="military">Military</option>
<option value="civilian">Civilian</option>
</select>
</div>
<div class="control-group">
<label for="limitSelect">Limit</label>
<select id="limitSelect">
@@ -106,6 +114,34 @@
<option value="1000">1000</option>
</select>
</div>
<div class="control-group data-control-group">
<label for="pruneDateInput">Data Cleanup</label>
<div class="data-actions">
<input type="date" id="pruneDateInput">
<button class="primary-btn warn-btn" id="pruneDayBtn" type="button">Remove Day</button>
<button class="primary-btn danger-btn" id="clearHistoryBtn" type="button">Clear All</button>
</div>
</div>
<div class="control-group data-control-group">
<label for="exportTypeSelect">Export</label>
<div class="data-actions">
<select id="exportTypeSelect">
<option value="all">All Data</option>
<option value="snapshots">Snapshots</option>
<option value="messages">Messages</option>
<option value="sessions">Sessions</option>
</select>
<select id="exportFormatSelect">
<option value="csv">CSV</option>
<option value="json">JSON</option>
</select>
<select id="exportScopeSelect">
<option value="window">Current Window</option>
<option value="all">Entire History</option>
</select>
<button class="primary-btn" id="exportBtn" type="button">Export</button>
</div>
</div>
<button class="primary-btn" id="refreshBtn">Refresh</button>
<div class="status-pill" id="historyStatus">
{% if history_enabled %}
@@ -128,6 +164,7 @@
<tr>
<th>ICAO</th>
<th>Callsign</th>
<th>Class</th>
<th>Alt</th>
<th>Speed</th>
<th>Last Seen</th>
@@ -135,7 +172,7 @@
</thead>
<tbody id="aircraftTableBody">
<tr class="empty-row">
<td colspan="5">No aircraft in this window</td>
<td colspan="6">No aircraft in this window</td>
</tr>
</tbody>
</table>
@@ -285,6 +322,49 @@
const windowSelect = document.getElementById('windowSelect');
const searchInput = document.getElementById('searchInput');
const limitSelect = document.getElementById('limitSelect');
const classSelect = document.getElementById('classSelect');
const MILITARY_RANGES = [
{ start: 0xADF7C0, end: 0xADFFFF },
{ start: 0xAE0000, end: 0xAEFFFF },
{ start: 0x3F4000, end: 0x3F7FFF },
{ start: 0x43C000, end: 0x43CFFF },
{ start: 0x3D0000, end: 0x3DFFFF },
{ start: 0x501C00, end: 0x501FFF },
];
const MILITARY_PREFIXES = [
'REACH', 'JAKE', 'DOOM', 'IRON', 'HAWK', 'VIPER', 'COBRA', 'THUNDER',
'SHADOW', 'NIGHT', 'STEEL', 'GRIM', 'REAPER', 'BLADE', 'STRIKE',
'RCH', 'CNV', 'MCH', 'EVAC', 'TOPCAT', 'ASCOT', 'RRR', 'HRK',
'NAVY', 'ARMY', 'USAF', 'RAF', 'RCAF', 'RAAF', 'IAF', 'PAF'
];
function isMilitaryAircraft(icao, callsign) {
if (icao) {
const hex = parseInt(icao, 16);
for (const range of MILITARY_RANGES) {
if (hex >= range.start && hex <= range.end) return true;
}
}
if (callsign) {
const upper = callsign.toUpperCase().trim();
for (const prefix of MILITARY_PREFIXES) {
if (upper.startsWith(prefix)) return true;
}
}
return false;
}
const HISTORY_WINDOW_STORAGE_KEY = 'adsbHistoryWindowMinutes';
const VALID_HISTORY_WINDOWS = new Set(['15', '60', '360', '1440', '10080']);
const historyStatus = document.getElementById('historyStatus');
const pruneDateInput = document.getElementById('pruneDateInput');
const pruneDayBtn = document.getElementById('pruneDayBtn');
const clearHistoryBtn = document.getElementById('clearHistoryBtn');
const exportTypeSelect = document.getElementById('exportTypeSelect');
const exportFormatSelect = document.getElementById('exportFormatSelect');
const exportScopeSelect = document.getElementById('exportScopeSelect');
const exportBtn = document.getElementById('exportBtn');
let selectedIcao = '';
let sessionStartAt = null;
@@ -359,6 +439,197 @@
return `${hrs.toString().padStart(2, '0')}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
function toLocalDateInputValue(date) {
const localDate = new Date(date.getTime() - (date.getTimezoneOffset() * 60000));
return localDate.toISOString().slice(0, 10);
}
function setDefaultPruneDate() {
if (!pruneDateInput) {
return;
}
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
pruneDateInput.value = toLocalDateInputValue(yesterday);
}
function setHistoryStatus(text, state = 'neutral') {
if (!historyStatus) {
return;
}
historyStatus.textContent = text;
historyStatus.classList.remove('ok', 'error');
if (state === 'ok') {
historyStatus.classList.add('ok');
} else if (state === 'error') {
historyStatus.classList.add('error');
}
}
function setHistoryActionsDisabled(disabled) {
if (pruneDateInput) {
pruneDateInput.disabled = disabled;
}
if (pruneDayBtn) {
pruneDayBtn.disabled = disabled;
}
if (clearHistoryBtn) {
clearHistoryBtn.disabled = disabled;
}
if (exportTypeSelect) {
exportTypeSelect.disabled = disabled;
}
if (exportFormatSelect) {
exportFormatSelect.disabled = disabled;
}
if (exportScopeSelect) {
exportScopeSelect.disabled = disabled;
}
if (exportBtn) {
exportBtn.disabled = disabled;
}
}
function getDownloadFilename(response, fallback) {
const disposition = response.headers.get('Content-Disposition') || '';
const utfMatch = disposition.match(/filename\*=UTF-8''([^;]+)/i);
if (utfMatch && utfMatch[1]) {
try {
return decodeURIComponent(utfMatch[1]);
} catch {
return utfMatch[1];
}
}
const plainMatch = disposition.match(/filename=\"?([^\";]+)\"?/i);
if (plainMatch && plainMatch[1]) {
return plainMatch[1];
}
return fallback;
}
async function exportHistoryData() {
if (!historyEnabled) {
return;
}
const exportType = exportTypeSelect.value;
const exportFormat = exportFormatSelect.value;
const exportScope = exportScopeSelect.value;
const sinceMinutes = windowSelect.value;
const search = searchInput.value.trim();
const classification = classSelect.value;
const params = new URLSearchParams({
format: exportFormat,
type: exportType,
scope: exportScope,
});
if (exportScope === 'window') {
params.set('since_minutes', sinceMinutes);
}
if (search) {
params.set('search', search);
}
if (classification !== 'all') {
params.set('classification', classification);
}
const fallbackName = `adsb_history_${exportType}.${exportFormat}`;
const exportUrl = `/adsb/history/export?${params.toString()}`;
setHistoryActionsDisabled(true);
try {
const resp = await fetch(exportUrl, { credentials: 'same-origin' });
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.error || 'Export failed');
}
const blob = await resp.blob();
const filename = getDownloadFilename(resp, fallbackName);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
setHistoryStatus(`Export ready: ${filename}`, 'ok');
} catch (error) {
setHistoryStatus(`Export failed: ${error.message || 'unknown error'}`, 'error');
} finally {
setHistoryActionsDisabled(false);
}
}
async function pruneHistory(payload, successPrefix) {
if (!historyEnabled) {
return;
}
setHistoryActionsDisabled(true);
try {
const resp = await fetch('/adsb/history/prune', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify(payload)
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok) {
throw new Error(data.error || 'Failed to prune history');
}
const deleted = data.deleted || {};
const messagesDeleted = Number(deleted.messages || 0);
const snapshotsDeleted = Number(deleted.snapshots || 0);
const totalDeleted = Number(data.total_deleted || (messagesDeleted + snapshotsDeleted));
setHistoryStatus(
`${successPrefix}: ${formatNumber(totalDeleted)} records removed`,
'ok'
);
await refreshAll();
if (selectedIcao && !recentAircraft.some(row => row.icao === selectedIcao)) {
await selectAircraft('');
}
} catch (error) {
setHistoryStatus(`Cleanup failed: ${error.message || 'unknown error'}`, 'error');
} finally {
setHistoryActionsDisabled(false);
}
}
async function removeSelectedDay() {
if (!pruneDateInput || !pruneDateInput.value) {
setHistoryStatus('Select a day first', 'error');
return;
}
const dayStartLocal = new Date(`${pruneDateInput.value}T00:00:00`);
if (Number.isNaN(dayStartLocal.getTime())) {
setHistoryStatus('Invalid day selected', 'error');
return;
}
const dayEndLocal = new Date(dayStartLocal.getTime() + (24 * 60 * 60 * 1000));
const dayLabel = dayStartLocal.toLocaleDateString();
const confirmed = window.confirm(`Delete ADS-B history for ${dayLabel}? This cannot be undone.`);
if (!confirmed) {
return;
}
await pruneHistory(
{
mode: 'range',
start: dayStartLocal.toISOString(),
end: dayEndLocal.toISOString(),
},
`Removed ${dayLabel}`
);
}
async function clearAllHistory() {
const confirmed = window.confirm('Delete ALL ADS-B history records? This cannot be undone.');
if (!confirmed) {
return;
}
await pruneHistory({ mode: 'all' }, 'All history cleared');
}
async function loadSummary() {
const sinceMinutes = windowSelect.value;
const resp = await fetch(`/adsb/history/summary?since_minutes=${sinceMinutes}`);
@@ -379,26 +650,45 @@
const search = encodeURIComponent(searchInput.value.trim());
const resp = await fetch(`/adsb/history/aircraft?since_minutes=${sinceMinutes}&limit=${limit}&search=${search}`);
if (!resp.ok) {
aircraftTableBody.innerHTML = '<tr class="empty-row"><td colspan="5">History database unavailable</td></tr>';
aircraftTableBody.innerHTML = '<tr class="empty-row"><td colspan="6">History database unavailable</td></tr>';
return;
}
const data = await resp.json();
const rows = data.aircraft || [];
let rows = data.aircraft || [];
// Tag each row with military classification
rows.forEach(row => {
row._military = isMilitaryAircraft(row.icao, row.callsign);
});
// Apply classification filter
const classFilter = classSelect.value;
if (classFilter === 'military') {
rows = rows.filter(r => r._military);
} else if (classFilter === 'civilian') {
rows = rows.filter(r => !r._military);
}
recentAircraft = rows;
aircraftCount.textContent = rows.length;
if (!rows.length) {
aircraftTableBody.innerHTML = '<tr class="empty-row"><td colspan="5">No aircraft in this window</td></tr>';
aircraftTableBody.innerHTML = '<tr class="empty-row"><td colspan="6">No aircraft in this window</td></tr>';
return;
}
aircraftTableBody.innerHTML = rows.map(row => `
<tr class="aircraft-row" data-icao="${row.icao}">
aircraftTableBody.innerHTML = rows.map(row => {
const badge = row._military
? '<span class="mil-badge">MIL</span>'
: '<span class="civ-badge">CIV</span>';
return `
<tr class="aircraft-row${row._military ? ' military' : ''}" data-icao="${row.icao}">
<td class="mono">${row.icao}</td>
<td>${valueOrDash(row.callsign)}</td>
<td>${badge}</td>
<td>${valueOrDash(row.altitude)}</td>
<td>${valueOrDash(row.speed)}</td>
<td>${formatTime(row.last_seen)}</td>
</tr>
`).join('');
</tr>`;
}).join('');
document.querySelectorAll('.aircraft-row').forEach((row, index) => {
row.addEventListener('click', () => {
@@ -747,12 +1037,31 @@
}
refreshBtn.addEventListener('click', refreshAll);
windowSelect.addEventListener('change', refreshAll);
windowSelect.addEventListener('change', () => {
const selectedWindow = windowSelect.value;
if (VALID_HISTORY_WINDOWS.has(selectedWindow)) {
localStorage.setItem(HISTORY_WINDOW_STORAGE_KEY, selectedWindow);
}
refreshAll();
});
limitSelect.addEventListener('change', refreshAll);
classSelect.addEventListener('change', refreshAll);
searchInput.addEventListener('input', () => {
clearTimeout(searchInput._debounce);
searchInput._debounce = setTimeout(refreshAll, 350);
});
pruneDayBtn.addEventListener('click', removeSelectedDay);
clearHistoryBtn.addEventListener('click', clearAllHistory);
exportBtn.addEventListener('click', exportHistoryData);
const savedWindow = localStorage.getItem(HISTORY_WINDOW_STORAGE_KEY);
if (savedWindow && VALID_HISTORY_WINDOWS.has(savedWindow)) {
windowSelect.value = savedWindow;
}
setDefaultPruneDate();
if (!historyEnabled) {
setHistoryActionsDisabled(true);
}
refreshAll();
loadSessionDevices();

View File

@@ -17,9 +17,9 @@ def _clear_detection_caches():
yield
@patch('utils.sdr.detection._check_tool', return_value=True)
@patch('utils.sdr.detection.get_tool_path', return_value='/usr/bin/rtl_test')
@patch('utils.sdr.detection.subprocess.run')
def test_detect_rtlsdr_devices_filters_empty_serial_entries(mock_run, _mock_check_tool):
def test_detect_rtlsdr_devices_filters_empty_serial_entries(mock_run, _mock_tool_path):
"""Ignore malformed rtl_test rows that have an empty SN field."""
mock_result = MagicMock()
mock_result.stdout = ""
@@ -40,9 +40,9 @@ def test_detect_rtlsdr_devices_filters_empty_serial_entries(mock_run, _mock_chec
assert devices[0].serial == "1"
@patch('utils.sdr.detection._check_tool', return_value=True)
@patch('utils.sdr.detection.get_tool_path', return_value='/usr/bin/rtl_test')
@patch('utils.sdr.detection.subprocess.run')
def test_detect_rtlsdr_devices_uses_replace_decode_mode(mock_run, _mock_check_tool):
def test_detect_rtlsdr_devices_uses_replace_decode_mode(mock_run, _mock_tool_path):
"""Run rtl_test with tolerant decoding for malformed output bytes."""
mock_result = MagicMock()
mock_result.stdout = ""
@@ -74,9 +74,9 @@ HACKRF_INFO_OUTPUT = (
)
@patch('utils.sdr.detection._check_tool', return_value=True)
@patch('utils.sdr.detection.get_tool_path', return_value='/usr/bin/hackrf_info')
@patch('utils.sdr.detection.subprocess.run')
def test_detect_hackrf_from_stdout(mock_run, _mock_check_tool):
def test_detect_hackrf_from_stdout(mock_run, _mock_tool_path):
"""Parse HackRF device info from stdout."""
mock_result = MagicMock()
mock_result.stdout = HACKRF_INFO_OUTPUT
@@ -92,9 +92,9 @@ def test_detect_hackrf_from_stdout(mock_run, _mock_check_tool):
assert devices[0].index == 0
@patch('utils.sdr.detection._check_tool', return_value=True)
@patch('utils.sdr.detection.get_tool_path', return_value='/usr/bin/hackrf_info')
@patch('utils.sdr.detection.subprocess.run')
def test_detect_hackrf_from_stderr(mock_run, _mock_check_tool):
def test_detect_hackrf_from_stderr(mock_run, _mock_tool_path):
"""Parse HackRF device info when output goes to stderr (newer firmware)."""
mock_result = MagicMock()
mock_result.stdout = ""
@@ -109,9 +109,9 @@ def test_detect_hackrf_from_stderr(mock_run, _mock_check_tool):
assert devices[0].serial == "0000000000000000a06063c8234e925f"
@patch('utils.sdr.detection._check_tool', return_value=True)
@patch('utils.sdr.detection.get_tool_path', return_value='/usr/bin/hackrf_info')
@patch('utils.sdr.detection.subprocess.run')
def test_detect_hackrf_nonzero_exit_with_valid_output(mock_run, _mock_check_tool):
def test_detect_hackrf_nonzero_exit_with_valid_output(mock_run, _mock_tool_path):
"""Parse HackRF info even when hackrf_info exits non-zero (device busy)."""
mock_result = MagicMock()
mock_result.returncode = 1
@@ -125,9 +125,9 @@ def test_detect_hackrf_nonzero_exit_with_valid_output(mock_run, _mock_check_tool
assert devices[0].name == "HackRF One"
@patch('utils.sdr.detection._check_tool', return_value=True)
@patch('utils.sdr.detection.get_tool_path', return_value='/usr/bin/hackrf_info')
@patch('utils.sdr.detection.subprocess.run')
def test_detect_hackrf_fallback_no_serial(mock_run, _mock_check_tool):
def test_detect_hackrf_fallback_no_serial(mock_run, _mock_tool_path):
"""Fallback detection when serial is missing but 'Found HackRF' present."""
mock_result = MagicMock()
mock_result.stdout = "Found HackRF\nBoard ID Number: 2 (HackRF One)\n"
@@ -139,3 +139,24 @@ def test_detect_hackrf_fallback_no_serial(mock_run, _mock_check_tool):
assert len(devices) == 1
assert devices[0].name == "HackRF One"
assert devices[0].serial == "Unknown"
@patch('utils.sdr.detection.get_tool_path', return_value='/usr/bin/hackrf_info')
@patch('utils.sdr.detection.subprocess.run')
def test_detect_hackrf_parses_legacy_serial_format(mock_run, _mock_tool_path):
"""Accept legacy 'Serial Number' casing and spaced hex format."""
mock_result = MagicMock()
mock_result.stdout = (
"Found HackRF\n"
"Index: 0\n"
"Serial Number: 0x00000000 00000000 a06063c8 234e925f\n"
"Board ID Number: 3 (HackRF Pro)\n"
)
mock_result.stderr = ""
mock_run.return_value = mock_result
devices = detect_hackrf_devices()
assert len(devices) == 1
assert devices[0].name == "HackRF Pro"
assert devices[0].serial == "0000000000000000a06063c8234e925f"

View File

@@ -43,15 +43,16 @@ class TestSubGhzManagerInit:
assert status['mode'] == 'idle'
class TestToolDetection:
class TestToolDetection:
def test_check_hackrf_found(self, manager):
with patch('shutil.which', return_value='/usr/bin/hackrf_transfer'):
assert manager.check_hackrf() is True
def test_check_hackrf_not_found(self, manager):
with patch('shutil.which', return_value=None):
manager._hackrf_available = None # reset cache
assert manager.check_hackrf() is False
def test_check_hackrf_not_found(self, manager):
with patch('shutil.which', return_value=None), \
patch('utils.subghz.get_tool_path', return_value=None):
manager._hackrf_available = None # reset cache
assert manager.check_hackrf() is False
def test_check_rtl433_found(self, manager):
with patch('shutil.which', return_value='/usr/bin/rtl_433'):
@@ -62,13 +63,14 @@ class TestToolDetection:
assert manager.check_sweep() is True
class TestReceive:
def test_start_receive_no_hackrf(self, manager):
with patch('shutil.which', return_value=None):
manager._hackrf_available = None
result = manager.start_receive(frequency_hz=433920000)
assert result['status'] == 'error'
assert 'not found' in result['message']
class TestReceive:
def test_start_receive_no_hackrf(self, manager):
with patch('shutil.which', return_value=None), \
patch('utils.subghz.get_tool_path', return_value=None):
manager._hackrf_available = None
result = manager.start_receive(frequency_hz=433920000)
assert result['status'] == 'error'
assert 'not found' in result['message']
def test_start_receive_success(self, manager):
mock_proc = MagicMock()
@@ -164,11 +166,12 @@ class TestTxSafety:
result = SubGhzManager.validate_tx_frequency(500000000) # 500 MHz
assert result is not None
def test_transmit_no_hackrf(self, manager):
with patch('shutil.which', return_value=None):
manager._hackrf_available = None
result = manager.transmit(capture_id='abc123')
assert result['status'] == 'error'
def test_transmit_no_hackrf(self, manager):
with patch('shutil.which', return_value=None), \
patch('utils.subghz.get_tool_path', return_value=None):
manager._hackrf_available = None
result = manager.transmit(capture_id='abc123')
assert result['status'] == 'error'
def test_transmit_capture_not_found(self, manager):
with patch('shutil.which', return_value='/usr/bin/hackrf_transfer'), \
@@ -464,12 +467,13 @@ class TestCaptureLibrary:
assert all(c.fingerprint_group_size == 2 for c in captures)
class TestSweep:
def test_start_sweep_no_tool(self, manager):
with patch('shutil.which', return_value=None):
manager._sweep_available = None
result = manager.start_sweep()
assert result['status'] == 'error'
class TestSweep:
def test_start_sweep_no_tool(self, manager):
with patch('shutil.which', return_value=None), \
patch('utils.subghz.get_tool_path', return_value=None):
manager._sweep_available = None
result = manager.start_sweep()
assert result['status'] == 'error'
def test_start_sweep_success(self, manager):
import time as _time
@@ -494,14 +498,15 @@ class TestSweep:
assert result['status'] == 'not_running'
class TestDecode:
def test_start_decode_no_hackrf(self, manager):
with patch('shutil.which', return_value=None):
manager._hackrf_available = None
manager._rtl433_available = None
result = manager.start_decode(frequency_hz=433920000)
assert result['status'] == 'error'
assert 'hackrf_transfer' in result['message']
class TestDecode:
def test_start_decode_no_hackrf(self, manager):
with patch('shutil.which', return_value=None), \
patch('utils.subghz.get_tool_path', return_value=None):
manager._hackrf_available = None
manager._rtl433_available = None
result = manager.start_decode(frequency_hz=433920000)
assert result['status'] == 'error'
assert 'hackrf_transfer' in result['message']
def test_start_decode_no_rtl433(self, manager):
def which_side_effect(name):
@@ -509,12 +514,13 @@ class TestDecode:
return '/usr/bin/hackrf_transfer'
return None
with patch('shutil.which', side_effect=which_side_effect):
manager._hackrf_available = None
manager._rtl433_available = None
result = manager.start_decode(frequency_hz=433920000)
assert result['status'] == 'error'
assert 'rtl_433' in result['message']
with patch('shutil.which', side_effect=which_side_effect), \
patch('utils.subghz.get_tool_path', return_value=None):
manager._hackrf_available = None
manager._rtl433_available = None
result = manager.start_decode(frequency_hz=433920000)
assert result['status'] == 'error'
assert 'rtl_433' in result['message']
def test_start_decode_success(self, manager):
mock_hackrf_proc = MagicMock()
@@ -537,9 +543,12 @@ class TestDecode:
return mock_hackrf_proc
return mock_rtl433_proc
with patch('shutil.which', return_value='/usr/bin/tool'), \
patch('subprocess.Popen', side_effect=popen_side_effect) as mock_popen, \
patch('utils.subghz.register_process'):
def which_side_effect(name):
return f'/usr/bin/{name}'
with patch('shutil.which', side_effect=which_side_effect), \
patch('subprocess.Popen', side_effect=popen_side_effect) as mock_popen, \
patch('utils.subghz.register_process'):
import time as _time
manager._hackrf_available = None
manager._rtl433_available = None
@@ -556,16 +565,16 @@ class TestDecode:
# Two processes: hackrf_transfer + rtl_433
assert mock_popen.call_count == 2
# Verify hackrf_transfer command
hackrf_cmd = mock_popen.call_args_list[0][0][0]
assert hackrf_cmd[0] == 'hackrf_transfer'
assert '-r' in hackrf_cmd
# Verify rtl_433 command
rtl433_cmd = mock_popen.call_args_list[1][0][0]
assert rtl433_cmd[0] == 'rtl_433'
assert '-r' in rtl433_cmd
assert 'cs8:-' in rtl433_cmd
# Verify hackrf_transfer command
hackrf_cmd = mock_popen.call_args_list[0][0][0]
assert os.path.basename(hackrf_cmd[0]) == 'hackrf_transfer'
assert '-r' in hackrf_cmd
# Verify rtl_433 command
rtl433_cmd = mock_popen.call_args_list[1][0][0]
assert os.path.basename(rtl433_cmd[0]) == 'rtl_433'
assert '-r' in rtl433_cmd
assert 'cs8:-' in rtl433_cmd
# Both processes tracked
assert manager._decode_hackrf_process is mock_hackrf_proc

View File

@@ -6,14 +6,15 @@ Detects RTL-SDR devices via rtl_test and other SDR hardware via SoapySDR.
from __future__ import annotations
import logging
import re
import shutil
import subprocess
import time
from typing import Optional
from .base import SDRCapabilities, SDRDevice, SDRType
import logging
import re
import subprocess
import time
from typing import Optional
from utils.dependencies import get_tool_path
from .base import SDRCapabilities, SDRDevice, SDRType
logger = logging.getLogger(__name__)
@@ -43,12 +44,7 @@ def _hackrf_probe_blocked() -> bool:
return False
def _check_tool(name: str) -> bool:
"""Check if a tool is available in PATH."""
return shutil.which(name) is not None
def _get_capabilities_for_type(sdr_type: SDRType) -> SDRCapabilities:
def _get_capabilities_for_type(sdr_type: SDRType) -> SDRCapabilities:
"""Get default capabilities for an SDR type."""
# Import here to avoid circular imports
from .rtlsdr import RTLSDRCommandBuilder
@@ -100,7 +96,7 @@ def _driver_to_sdr_type(driver: str) -> Optional[SDRType]:
return mapping.get(driver.lower())
def detect_rtlsdr_devices() -> list[SDRDevice]:
def detect_rtlsdr_devices() -> list[SDRDevice]:
"""
Detect RTL-SDR devices using rtl_test.
@@ -109,9 +105,10 @@ def detect_rtlsdr_devices() -> list[SDRDevice]:
"""
devices: list[SDRDevice] = []
if not _check_tool('rtl_test'):
logger.debug("rtl_test not found, skipping RTL-SDR detection")
return devices
rtl_test_path = get_tool_path('rtl_test')
if not rtl_test_path:
logger.debug("rtl_test not found, skipping RTL-SDR detection")
return devices
try:
import os
@@ -122,11 +119,11 @@ def detect_rtlsdr_devices() -> list[SDRDevice]:
lib_paths = ['/usr/local/lib', '/opt/homebrew/lib']
current_ld = env.get('DYLD_LIBRARY_PATH', '')
env['DYLD_LIBRARY_PATH'] = ':'.join(lib_paths + [current_ld] if current_ld else lib_paths)
result = subprocess.run(
['rtl_test', '-t'],
capture_output=True,
text=True,
encoding='utf-8',
result = subprocess.run(
[rtl_test_path, '-t'],
capture_output=True,
text=True,
encoding='utf-8',
errors='replace',
timeout=5,
env=env
@@ -176,13 +173,14 @@ def detect_rtlsdr_devices() -> list[SDRDevice]:
return devices
def _find_soapy_util() -> str | None:
"""Find SoapySDR utility command (name varies by distribution)."""
# Try different command names used across distributions
for cmd in ['SoapySDRUtil', 'soapy_sdr_util', 'soapysdr-util']:
if _check_tool(cmd):
return cmd
return None
def _find_soapy_util() -> str | None:
"""Find SoapySDR utility command (name varies by distribution)."""
# Try different command names used across distributions
for cmd in ['SoapySDRUtil', 'soapy_sdr_util', 'soapysdr-util']:
tool_path = get_tool_path(cmd)
if tool_path:
return tool_path
return None
def _get_soapy_env() -> dict:
@@ -324,7 +322,7 @@ def _add_soapy_device(
))
def detect_hackrf_devices() -> list[SDRDevice]:
def detect_hackrf_devices() -> list[SDRDevice]:
"""
Detect HackRF devices using native hackrf_info tool.
@@ -343,33 +341,46 @@ def detect_hackrf_devices() -> list[SDRDevice]:
devices: list[SDRDevice] = []
if not _check_tool('hackrf_info'):
_hackrf_cache = devices
_hackrf_cache_ts = now
return devices
hackrf_info_path = get_tool_path('hackrf_info')
if not hackrf_info_path:
_hackrf_cache = devices
_hackrf_cache_ts = now
return devices
try:
result = subprocess.run(
['hackrf_info'],
capture_output=True,
text=True,
timeout=5
)
result = subprocess.run(
[hackrf_info_path],
capture_output=True,
text=True,
timeout=5
)
# Combine stdout + stderr: newer firmware may print to stderr,
# and hackrf_info may exit non-zero when device is briefly busy
# but still output valid info.
output = result.stdout + result.stderr
# Parse hackrf_info output
# Extract board name from "Board ID Number: X (Name)" and serial
from .hackrf import HackRFCommandBuilder
serial_pattern = r'Serial number:\s*(\S+)'
board_pattern = r'Board ID Number:\s*\d+\s*\(([^)]+)\)'
serials_found = re.findall(serial_pattern, output)
boards_found = re.findall(board_pattern, output)
output = f"{result.stdout or ''}\n{result.stderr or ''}"
# Parse hackrf_info output
# Extract board name from "Board ID Number: X (Name)" and serial
from .hackrf import HackRFCommandBuilder
serial_pattern = re.compile(
r'^\s*Serial\s+number:\s*(.+)$',
re.IGNORECASE | re.MULTILINE,
)
board_pattern = re.compile(
r'Board\s+ID\s+Number:\s*\d+\s*\(([^)]+)\)',
re.IGNORECASE,
)
serials_found = []
for raw in serial_pattern.findall(output):
# Normalise legacy formats like "0x1234 5678" to plain hex.
serial = re.sub(r'0x', '', raw, flags=re.IGNORECASE)
serial = re.sub(r'[^0-9A-Fa-f]', '', serial)
if serial:
serials_found.append(serial)
boards_found = board_pattern.findall(output)
for i, serial in enumerate(serials_found):
board_name = boards_found[i] if i < len(boards_found) else 'HackRF'
@@ -383,11 +394,11 @@ def detect_hackrf_devices() -> list[SDRDevice]:
))
# Fallback: check if any HackRF found without serial
if not devices and 'Found HackRF' in output:
board_match = re.search(board_pattern, output)
board_name = board_match.group(1) if board_match else 'HackRF'
devices.append(SDRDevice(
sdr_type=SDRType.HACKRF,
if not devices and re.search(r'Found\s+HackRF', output, re.IGNORECASE):
board_match = board_pattern.search(output)
board_name = board_match.group(1) if board_match else 'HackRF'
devices.append(SDRDevice(
sdr_type=SDRType.HACKRF,
index=0,
name=board_name,
serial='Unknown',
@@ -403,7 +414,7 @@ def detect_hackrf_devices() -> list[SDRDevice]:
return devices
def probe_rtlsdr_device(device_index: int) -> str | None:
def probe_rtlsdr_device(device_index: int) -> str | None:
"""Probe whether an RTL-SDR device is available at the USB level.
Runs a quick ``rtl_test`` invocation targeting a single device to
@@ -417,10 +428,11 @@ def probe_rtlsdr_device(device_index: int) -> str | None:
An error message string if the device cannot be opened,
or ``None`` if the device is available.
"""
if not _check_tool('rtl_test'):
# Can't probe without rtl_test — let the caller proceed and
# surface errors from the actual decoder process instead.
return None
rtl_test_path = get_tool_path('rtl_test')
if not rtl_test_path:
# Can't probe without rtl_test — let the caller proceed and
# surface errors from the actual decoder process instead.
return None
try:
import os
@@ -437,11 +449,11 @@ def probe_rtlsdr_device(device_index: int) -> str | None:
# Use Popen with early termination instead of run() with full timeout.
# rtl_test prints device info to stderr quickly, then keeps running
# its test loop. We kill it as soon as we see success or failure.
proc = subprocess.Popen(
['rtl_test', '-d', str(device_index), '-t'],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
proc = subprocess.Popen(
[rtl_test_path, '-d', str(device_index), '-t'],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
env=env,
)

View File

@@ -21,11 +21,12 @@ from datetime import datetime, timezone
from pathlib import Path
from typing import BinaryIO, Callable
import numpy as np
from utils.logging import get_logger
from utils.process import register_process, safe_terminate, unregister_process
from utils.constants import (
import numpy as np
from utils.dependencies import get_tool_path
from utils.logging import get_logger
from utils.process import register_process, safe_terminate, unregister_process
from utils.constants import (
SUBGHZ_TX_ALLOWED_BANDS,
SUBGHZ_FREQ_MIN_MHZ,
SUBGHZ_FREQ_MAX_MHZ,
@@ -187,19 +188,23 @@ class SubGhzManager:
except Exception as e:
logger.error(f"Error in SubGHz callback: {e}")
# ------------------------------------------------------------------
# Tool detection
# ------------------------------------------------------------------
def check_hackrf(self) -> bool:
if self._hackrf_available is None:
self._hackrf_available = shutil.which('hackrf_transfer') is not None
return self._hackrf_available
def check_hackrf_info(self) -> bool:
if self._hackrf_info_available is None:
self._hackrf_info_available = shutil.which('hackrf_info') is not None
return self._hackrf_info_available
# ------------------------------------------------------------------
# Tool detection
# ------------------------------------------------------------------
def _resolve_tool(self, name: str) -> str | None:
"""Resolve executable path via PATH first, then platform-aware fallbacks."""
return shutil.which(name) or get_tool_path(name)
def check_hackrf(self) -> bool:
if self._hackrf_available is None:
self._hackrf_available = self._resolve_tool('hackrf_transfer') is not None
return self._hackrf_available
def check_hackrf_info(self) -> bool:
if self._hackrf_info_available is None:
self._hackrf_info_available = self._resolve_tool('hackrf_info') is not None
return self._hackrf_info_available
def check_hackrf_device(self) -> bool | None:
"""Return True if a HackRF device is detected, False if not, or None if detection unavailable."""
@@ -228,15 +233,15 @@ class SubGhzManager:
return 'HackRF device not detected'
return None
def check_rtl433(self) -> bool:
if self._rtl433_available is None:
self._rtl433_available = shutil.which('rtl_433') is not None
return self._rtl433_available
def check_sweep(self) -> bool:
if self._sweep_available is None:
self._sweep_available = shutil.which('hackrf_sweep') is not None
return self._sweep_available
def check_rtl433(self) -> bool:
if self._rtl433_available is None:
self._rtl433_available = self._resolve_tool('rtl_433') is not None
return self._rtl433_available
def check_sweep(self) -> bool:
if self._sweep_available is None:
self._sweep_available = self._resolve_tool('hackrf_sweep') is not None
return self._sweep_available
# ------------------------------------------------------------------
# Status
@@ -307,23 +312,24 @@ class SubGhzManager:
# RECEIVE (IQ capture via hackrf_transfer -r)
# ------------------------------------------------------------------
def start_receive(
self,
frequency_hz: int,
sample_rate: int = 2000000,
def start_receive(
self,
frequency_hz: int,
sample_rate: int = 2000000,
lna_gain: int = 32,
vga_gain: int = 20,
trigger_enabled: bool = False,
trigger_pre_ms: int = 350,
trigger_post_ms: int = 700,
device_serial: str | None = None,
) -> dict:
# Pre-lock: tool availability & device detection (blocking I/O)
if not self.check_hackrf():
return {'status': 'error', 'message': 'hackrf_transfer not found'}
device_err = self._require_hackrf_device()
if device_err:
return {'status': 'error', 'message': device_err}
trigger_post_ms: int = 700,
device_serial: str | None = None,
) -> dict:
# Pre-lock: tool availability & device detection (blocking I/O)
hackrf_transfer_path = self._resolve_tool('hackrf_transfer')
if not hackrf_transfer_path:
return {'status': 'error', 'message': 'hackrf_transfer not found'}
device_err = self._require_hackrf_device()
if device_err:
return {'status': 'error', 'message': device_err}
with self._lock:
if self.active_mode != 'idle':
@@ -339,11 +345,11 @@ class SubGhzManager:
basename = f"{freq_mhz:.3f}MHz_{ts}"
iq_file = self._captures_dir / f"{basename}.iq"
cmd = [
'hackrf_transfer',
'-r', str(iq_file),
'-f', str(frequency_hz),
'-s', str(sample_rate),
cmd = [
hackrf_transfer_path,
'-r', str(iq_file),
'-f', str(frequency_hz),
'-s', str(sample_rate),
'-l', str(lna_gain),
'-g', str(vga_gain),
]
@@ -1272,23 +1278,25 @@ class SubGhzManager:
# DECODE (hackrf_transfer piped to rtl_433)
# ------------------------------------------------------------------
def start_decode(
self,
frequency_hz: int,
sample_rate: int = 2_000_000,
def start_decode(
self,
frequency_hz: int,
sample_rate: int = 2_000_000,
lna_gain: int = 32,
vga_gain: int = 20,
decode_profile: str = 'weather',
device_serial: str | None = None,
) -> dict:
# Pre-lock: tool availability & device detection (blocking I/O)
if not self.check_hackrf():
return {'status': 'error', 'message': 'hackrf_transfer not found'}
if not self.check_rtl433():
return {'status': 'error', 'message': 'rtl_433 not found'}
device_err = self._require_hackrf_device()
if device_err:
return {'status': 'error', 'message': device_err}
decode_profile: str = 'weather',
device_serial: str | None = None,
) -> dict:
# Pre-lock: tool availability & device detection (blocking I/O)
hackrf_transfer_path = self._resolve_tool('hackrf_transfer')
if not hackrf_transfer_path:
return {'status': 'error', 'message': 'hackrf_transfer not found'}
rtl433_path = self._resolve_tool('rtl_433')
if not rtl433_path:
return {'status': 'error', 'message': 'rtl_433 not found'}
device_err = self._require_hackrf_device()
if device_err:
return {'status': 'error', 'message': device_err}
with self._lock:
if self.active_mode != 'idle':
@@ -1299,25 +1307,25 @@ class SubGhzManager:
requested_sample_rate = int(sample_rate)
stable_sample_rate = max(2_000_000, min(2_000_000, requested_sample_rate))
# Build hackrf_transfer command (producer: raw IQ to stdout)
hackrf_cmd = [
'hackrf_transfer',
'-r', '-',
'-f', str(frequency_hz),
'-s', str(stable_sample_rate),
# Build hackrf_transfer command (producer: raw IQ to stdout)
hackrf_cmd = [
hackrf_transfer_path,
'-r', '-',
'-f', str(frequency_hz),
'-s', str(stable_sample_rate),
'-l', str(max(SUBGHZ_LNA_GAIN_MIN, min(SUBGHZ_LNA_GAIN_MAX, lna_gain))),
'-g', str(max(SUBGHZ_VGA_GAIN_MIN, min(SUBGHZ_VGA_GAIN_MAX, vga_gain))),
]
if device_serial:
hackrf_cmd.extend(['-d', device_serial])
# Build rtl_433 command (consumer: reads IQ from stdin)
# Feed signed 8-bit complex IQ directly from hackrf_transfer.
rtl433_cmd = [
'rtl_433',
'-r', 'cs8:-',
'-s', str(stable_sample_rate),
'-f', str(frequency_hz),
# Build rtl_433 command (consumer: reads IQ from stdin)
# Feed signed 8-bit complex IQ directly from hackrf_transfer.
rtl433_cmd = [
rtl433_path,
'-r', 'cs8:-',
'-s', str(stable_sample_rate),
'-f', str(frequency_hz),
'-F', 'json',
'-F', 'log',
'-M', 'level',
@@ -1936,21 +1944,22 @@ class SubGhzManager:
except OSError as exc:
logger.debug(f"Failed to remove TX temp file {path}: {exc}")
def transmit(
self,
capture_id: str,
tx_gain: int = 20,
def transmit(
self,
capture_id: str,
tx_gain: int = 20,
max_duration: int = 10,
start_seconds: float | None = None,
duration_seconds: float | None = None,
device_serial: str | None = None,
) -> dict:
# Pre-lock: tool availability & device detection (blocking I/O)
if not self.check_hackrf():
return {'status': 'error', 'message': 'hackrf_transfer not found'}
device_err = self._require_hackrf_device()
if device_err:
return {'status': 'error', 'message': device_err}
duration_seconds: float | None = None,
device_serial: str | None = None,
) -> dict:
# Pre-lock: tool availability & device detection (blocking I/O)
hackrf_transfer_path = self._resolve_tool('hackrf_transfer')
if not hackrf_transfer_path:
return {'status': 'error', 'message': 'hackrf_transfer not found'}
device_err = self._require_hackrf_device()
if device_err:
return {'status': 'error', 'message': device_err}
# Pre-lock: capture lookup, validation, and segment I/O (can be large)
capture = self._load_capture(capture_id)
@@ -2046,14 +2055,14 @@ class SubGhzManager:
# Clear any orphaned temp segment from a previous TX attempt.
self._cleanup_tx_temp_file()
if segment_path_for_cleanup:
self._tx_temp_file = segment_path_for_cleanup
cmd = [
'hackrf_transfer',
'-t', str(tx_path),
'-f', str(capture.frequency_hz),
'-s', str(capture.sample_rate),
if segment_path_for_cleanup:
self._tx_temp_file = segment_path_for_cleanup
cmd = [
hackrf_transfer_path,
'-t', str(tx_path),
'-f', str(capture.frequency_hz),
'-s', str(capture.sample_rate),
'-x', str(tx_gain),
]
if device_serial:
@@ -2183,19 +2192,20 @@ class SubGhzManager:
# SWEEP (hackrf_sweep)
# ------------------------------------------------------------------
def start_sweep(
self,
freq_start_mhz: float = 300.0,
freq_end_mhz: float = 928.0,
bin_width: int = 100000,
device_serial: str | None = None,
) -> dict:
# Pre-lock: tool availability & device detection (blocking I/O)
if not self.check_sweep():
return {'status': 'error', 'message': 'hackrf_sweep not found'}
device_err = self._require_hackrf_device()
if device_err:
return {'status': 'error', 'message': device_err}
def start_sweep(
self,
freq_start_mhz: float = 300.0,
freq_end_mhz: float = 928.0,
bin_width: int = 100000,
device_serial: str | None = None,
) -> dict:
# Pre-lock: tool availability & device detection (blocking I/O)
hackrf_sweep_path = self._resolve_tool('hackrf_sweep')
if not hackrf_sweep_path:
return {'status': 'error', 'message': 'hackrf_sweep not found'}
device_err = self._require_hackrf_device()
if device_err:
return {'status': 'error', 'message': device_err}
# Wait for previous sweep thread to exit (blocking) before lock
if self._sweep_thread and self._sweep_thread.is_alive():
@@ -2204,14 +2214,14 @@ class SubGhzManager:
return {'status': 'error', 'message': 'Previous sweep still shutting down'}
with self._lock:
if self.active_mode != 'idle':
return {'status': 'error', 'message': f'Already running: {self.active_mode}'}
cmd = [
'hackrf_sweep',
'-f', f'{int(freq_start_mhz)}:{int(freq_end_mhz)}',
'-w', str(bin_width),
]
if self.active_mode != 'idle':
return {'status': 'error', 'message': f'Already running: {self.active_mode}'}
cmd = [
hackrf_sweep_path,
'-f', f'{int(freq_start_mhz)}:{int(freq_end_mhz)}',
'-w', str(bin_width),
]
if device_serial:
cmd.extend(['-d', device_serial])