mirror of
https://github.com/smittix/intercept.git
synced 2026-04-24 06:40:00 -07:00
Merge remote-tracking branch 'upstream/main'
This commit is contained in:
425
routes/adsb.py
425
routes/adsb.py
@@ -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
|
||||
# ============================================
|
||||
|
||||
21
setup.sh
21
setup.sh
@@ -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."
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
240
utils/subghz.py
240
utils/subghz.py
@@ -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])
|
||||
|
||||
|
||||
Reference in New Issue
Block a user