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
+421 -4
View File
@@ -3,6 +3,8 @@
from __future__ import annotations from __future__ import annotations
import json import json
import csv
import io
import os import os
import queue import queue
import shutil import shutil
@@ -10,7 +12,7 @@ import socket
import subprocess import subprocess
import threading import threading
import time import time
from datetime import datetime, timezone from datetime import datetime, timedelta, timezone
from typing import Any, Generator from typing import Any, Generator
from flask import Blueprint, Response, jsonify, make_response, render_template, request 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) 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: def _parse_int_param(value: str | None, default: int, min_value: int | None = None, max_value: int | None = None) -> int:
try: try:
parsed = int(value) if value is not None else default 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 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: def _broadcast_adsb_update(payload: dict[str, Any]) -> None:
"""Fan out a payload to all active ADS-B SSE subscribers.""" """Fan out a payload to all active ADS-B SSE subscribers."""
with _adsb_stream_subscribers_lock: with _adsb_stream_subscribers_lock:
@@ -1069,7 +1236,7 @@ def adsb_history_summary():
return jsonify({'error': 'ADS-B history is disabled'}), 503 return jsonify({'error': 'ADS-B history is disabled'}), 503
_ensure_history_schema() _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' window = f'{since_minutes} minutes'
sql = """ sql = """
@@ -1099,7 +1266,7 @@ def adsb_history_aircraft():
return jsonify({'error': 'ADS-B history is disabled'}), 503 return jsonify({'error': 'ADS-B history is disabled'}), 503
_ensure_history_schema() _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) limit = _parse_int_param(request.args.get('limit'), 200, 1, 2000)
search = (request.args.get('search') or '').strip() search = (request.args.get('search') or '').strip()
window = f'{since_minutes} minutes' window = f'{since_minutes} minutes'
@@ -1153,7 +1320,7 @@ def adsb_history_timeline():
if not icao: if not icao:
return jsonify({'error': 'icao is required'}), 400 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) limit = _parse_int_param(request.args.get('limit'), 2000, 1, 20000)
window = f'{since_minutes} minutes' window = f'{since_minutes} minutes'
@@ -1209,6 +1376,256 @@ def adsb_history_messages():
return jsonify({'error': 'History database unavailable'}), 503 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 # AIRCRAFT DATABASE MANAGEMENT
# ============================================ # ============================================
+18 -3
View File
@@ -957,7 +957,8 @@ install_satdump_from_source_debian() {
) & ) &
progress_pid=$! 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 && make -j "$(nproc)" >>"$build_log" 2>&1; then
kill $progress_pid 2>/dev/null; wait $progress_pid 2>/dev/null kill $progress_pid 2>/dev/null; wait $progress_pid 2>/dev/null
$SUDO make install >/dev/null 2>&1 $SUDO make install >/dev/null 2>&1
@@ -1089,8 +1090,9 @@ install_radiosonde_auto_rx() {
# --- dump1090 (Debian from source) --- # --- dump1090 (Debian from source) ---
install_dump1090_from_source_debian() { 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 \ apt_install build-essential git pkg-config \
librtlsdr-dev libusb-1.0-0-dev \ librtlsdr-dev libusb-1.0-0-dev \
libncurses-dev tcl-dev python3-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 tail -20 "$build_log" | while IFS= read -r line; do warn " $line"; done
rm -rf "$tmp_dir/dump1090" 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 \ 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; } || { fail "Failed to clone wiedehopf/readsb"; exit 1; }
@@ -1461,6 +1464,7 @@ install_tool_dump1090() {
$SUDO rm -f "$dump1090_path" $SUDO rm -f "$dump1090_path"
fi fi
if ! cmd_exists dump1090 && ! cmd_exists dump1090-mutability; then 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 apt_try_install_any dump1090-fa dump1090-mutability dump1090 || true
fi fi
if ! cmd_exists dump1090; then if ! cmd_exists dump1090; then
@@ -1573,7 +1577,18 @@ install_tool_satdump() {
if [[ "$OS" == "macos" ]]; then if [[ "$OS" == "macos" ]]; then
install_satdump_macos || warn "SatDump installation failed. Weather satellite decoding will not be available." install_satdump_macos || warn "SatDump installation failed. Weather satellite decoding will not be available."
else 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 fi
else else
warn "Skipping SatDump installation. You can install it later if needed." warn "Skipping SatDump installation. You can install it later if needed."
+90
View File
@@ -269,6 +269,21 @@ body {
min-width: 160px; 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 { .primary-btn {
background: var(--accent-cyan); background: var(--accent-cyan);
border: none; border: none;
@@ -285,6 +300,31 @@ body {
box-shadow: 0 6px 14px rgba(74, 158, 255, 0.3); 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 { .status-pill {
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 11px; font-size: 11px;
@@ -296,6 +336,16 @@ body {
letter-spacing: 1px; 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 { .content-grid {
display: grid; display: grid;
grid-template-columns: minmax(300px, 1fr) minmax(320px, 1fr); grid-template-columns: minmax(300px, 1fr) minmax(320px, 1fr);
@@ -364,6 +414,37 @@ body {
background: rgba(74, 158, 255, 0.1); 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 { .mono {
font-family: var(--font-mono); font-family: var(--font-mono);
} }
@@ -614,6 +695,15 @@ body {
min-width: 100%; min-width: 100%;
} }
.data-actions {
width: 100%;
}
.data-actions input[type="date"],
.data-actions .primary-btn {
width: 100%;
}
.panel { .panel {
min-height: 320px; min-height: 320px;
} }
+320 -11
View File
@@ -87,9 +87,9 @@
<label for="windowSelect">Window</label> <label for="windowSelect">Window</label>
<select id="windowSelect"> <select id="windowSelect">
<option value="15">15 minutes</option> <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="360">6 hours</option>
<option value="1440">24 hours</option> <option value="1440" selected>24 hours</option>
<option value="10080">7 days</option> <option value="10080">7 days</option>
</select> </select>
</div> </div>
@@ -97,6 +97,14 @@
<label for="searchInput">Search</label> <label for="searchInput">Search</label>
<input type="text" id="searchInput" placeholder="ICAO / callsign / registration"> <input type="text" id="searchInput" placeholder="ICAO / callsign / registration">
</div> </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"> <div class="control-group">
<label for="limitSelect">Limit</label> <label for="limitSelect">Limit</label>
<select id="limitSelect"> <select id="limitSelect">
@@ -106,6 +114,34 @@
<option value="1000">1000</option> <option value="1000">1000</option>
</select> </select>
</div> </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> <button class="primary-btn" id="refreshBtn">Refresh</button>
<div class="status-pill" id="historyStatus"> <div class="status-pill" id="historyStatus">
{% if history_enabled %} {% if history_enabled %}
@@ -128,6 +164,7 @@
<tr> <tr>
<th>ICAO</th> <th>ICAO</th>
<th>Callsign</th> <th>Callsign</th>
<th>Class</th>
<th>Alt</th> <th>Alt</th>
<th>Speed</th> <th>Speed</th>
<th>Last Seen</th> <th>Last Seen</th>
@@ -135,7 +172,7 @@
</thead> </thead>
<tbody id="aircraftTableBody"> <tbody id="aircraftTableBody">
<tr class="empty-row"> <tr class="empty-row">
<td colspan="5">No aircraft in this window</td> <td colspan="6">No aircraft in this window</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -285,6 +322,49 @@
const windowSelect = document.getElementById('windowSelect'); const windowSelect = document.getElementById('windowSelect');
const searchInput = document.getElementById('searchInput'); const searchInput = document.getElementById('searchInput');
const limitSelect = document.getElementById('limitSelect'); 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 selectedIcao = '';
let sessionStartAt = null; let sessionStartAt = null;
@@ -359,6 +439,197 @@
return `${hrs.toString().padStart(2, '0')}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; 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() { async function loadSummary() {
const sinceMinutes = windowSelect.value; const sinceMinutes = windowSelect.value;
const resp = await fetch(`/adsb/history/summary?since_minutes=${sinceMinutes}`); const resp = await fetch(`/adsb/history/summary?since_minutes=${sinceMinutes}`);
@@ -379,26 +650,45 @@
const search = encodeURIComponent(searchInput.value.trim()); const search = encodeURIComponent(searchInput.value.trim());
const resp = await fetch(`/adsb/history/aircraft?since_minutes=${sinceMinutes}&limit=${limit}&search=${search}`); const resp = await fetch(`/adsb/history/aircraft?since_minutes=${sinceMinutes}&limit=${limit}&search=${search}`);
if (!resp.ok) { 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; return;
} }
const data = await resp.json(); 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; recentAircraft = rows;
aircraftCount.textContent = rows.length; aircraftCount.textContent = rows.length;
if (!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; return;
} }
aircraftTableBody.innerHTML = rows.map(row => ` aircraftTableBody.innerHTML = rows.map(row => {
<tr class="aircraft-row" data-icao="${row.icao}"> 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 class="mono">${row.icao}</td>
<td>${valueOrDash(row.callsign)}</td> <td>${valueOrDash(row.callsign)}</td>
<td>${badge}</td>
<td>${valueOrDash(row.altitude)}</td> <td>${valueOrDash(row.altitude)}</td>
<td>${valueOrDash(row.speed)}</td> <td>${valueOrDash(row.speed)}</td>
<td>${formatTime(row.last_seen)}</td> <td>${formatTime(row.last_seen)}</td>
</tr> </tr>`;
`).join(''); }).join('');
document.querySelectorAll('.aircraft-row').forEach((row, index) => { document.querySelectorAll('.aircraft-row').forEach((row, index) => {
row.addEventListener('click', () => { row.addEventListener('click', () => {
@@ -747,12 +1037,31 @@
} }
refreshBtn.addEventListener('click', refreshAll); 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); limitSelect.addEventListener('change', refreshAll);
classSelect.addEventListener('change', refreshAll);
searchInput.addEventListener('input', () => { searchInput.addEventListener('input', () => {
clearTimeout(searchInput._debounce); clearTimeout(searchInput._debounce);
searchInput._debounce = setTimeout(refreshAll, 350); 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(); refreshAll();
loadSessionDevices(); loadSessionDevices();
+33 -12
View File
@@ -17,9 +17,9 @@ def _clear_detection_caches():
yield 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') @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.""" """Ignore malformed rtl_test rows that have an empty SN field."""
mock_result = MagicMock() mock_result = MagicMock()
mock_result.stdout = "" 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" 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') @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.""" """Run rtl_test with tolerant decoding for malformed output bytes."""
mock_result = MagicMock() mock_result = MagicMock()
mock_result.stdout = "" 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') @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.""" """Parse HackRF device info from stdout."""
mock_result = MagicMock() mock_result = MagicMock()
mock_result.stdout = HACKRF_INFO_OUTPUT 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 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') @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).""" """Parse HackRF device info when output goes to stderr (newer firmware)."""
mock_result = MagicMock() mock_result = MagicMock()
mock_result.stdout = "" mock_result.stdout = ""
@@ -109,9 +109,9 @@ def test_detect_hackrf_from_stderr(mock_run, _mock_check_tool):
assert devices[0].serial == "0000000000000000a06063c8234e925f" 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') @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).""" """Parse HackRF info even when hackrf_info exits non-zero (device busy)."""
mock_result = MagicMock() mock_result = MagicMock()
mock_result.returncode = 1 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" 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') @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.""" """Fallback detection when serial is missing but 'Found HackRF' present."""
mock_result = MagicMock() mock_result = MagicMock()
mock_result.stdout = "Found HackRF\nBoard ID Number: 2 (HackRF One)\n" 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 len(devices) == 1
assert devices[0].name == "HackRF One" assert devices[0].name == "HackRF One"
assert devices[0].serial == "Unknown" 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"
+59 -50
View File
@@ -43,15 +43,16 @@ class TestSubGhzManagerInit:
assert status['mode'] == 'idle' assert status['mode'] == 'idle'
class TestToolDetection: class TestToolDetection:
def test_check_hackrf_found(self, manager): def test_check_hackrf_found(self, manager):
with patch('shutil.which', return_value='/usr/bin/hackrf_transfer'): with patch('shutil.which', return_value='/usr/bin/hackrf_transfer'):
assert manager.check_hackrf() is True assert manager.check_hackrf() is True
def test_check_hackrf_not_found(self, manager): def test_check_hackrf_not_found(self, manager):
with patch('shutil.which', return_value=None): with patch('shutil.which', return_value=None), \
manager._hackrf_available = None # reset cache patch('utils.subghz.get_tool_path', return_value=None):
assert manager.check_hackrf() is False manager._hackrf_available = None # reset cache
assert manager.check_hackrf() is False
def test_check_rtl433_found(self, manager): def test_check_rtl433_found(self, manager):
with patch('shutil.which', return_value='/usr/bin/rtl_433'): with patch('shutil.which', return_value='/usr/bin/rtl_433'):
@@ -62,13 +63,14 @@ class TestToolDetection:
assert manager.check_sweep() is True assert manager.check_sweep() is True
class TestReceive: class TestReceive:
def test_start_receive_no_hackrf(self, manager): def test_start_receive_no_hackrf(self, manager):
with patch('shutil.which', return_value=None): with patch('shutil.which', return_value=None), \
manager._hackrf_available = None patch('utils.subghz.get_tool_path', return_value=None):
result = manager.start_receive(frequency_hz=433920000) manager._hackrf_available = None
assert result['status'] == 'error' result = manager.start_receive(frequency_hz=433920000)
assert 'not found' in result['message'] assert result['status'] == 'error'
assert 'not found' in result['message']
def test_start_receive_success(self, manager): def test_start_receive_success(self, manager):
mock_proc = MagicMock() mock_proc = MagicMock()
@@ -164,11 +166,12 @@ class TestTxSafety:
result = SubGhzManager.validate_tx_frequency(500000000) # 500 MHz result = SubGhzManager.validate_tx_frequency(500000000) # 500 MHz
assert result is not None assert result is not None
def test_transmit_no_hackrf(self, manager): def test_transmit_no_hackrf(self, manager):
with patch('shutil.which', return_value=None): with patch('shutil.which', return_value=None), \
manager._hackrf_available = None patch('utils.subghz.get_tool_path', return_value=None):
result = manager.transmit(capture_id='abc123') manager._hackrf_available = None
assert result['status'] == 'error' result = manager.transmit(capture_id='abc123')
assert result['status'] == 'error'
def test_transmit_capture_not_found(self, manager): def test_transmit_capture_not_found(self, manager):
with patch('shutil.which', return_value='/usr/bin/hackrf_transfer'), \ 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) assert all(c.fingerprint_group_size == 2 for c in captures)
class TestSweep: class TestSweep:
def test_start_sweep_no_tool(self, manager): def test_start_sweep_no_tool(self, manager):
with patch('shutil.which', return_value=None): with patch('shutil.which', return_value=None), \
manager._sweep_available = None patch('utils.subghz.get_tool_path', return_value=None):
result = manager.start_sweep() manager._sweep_available = None
assert result['status'] == 'error' result = manager.start_sweep()
assert result['status'] == 'error'
def test_start_sweep_success(self, manager): def test_start_sweep_success(self, manager):
import time as _time import time as _time
@@ -494,14 +498,15 @@ class TestSweep:
assert result['status'] == 'not_running' assert result['status'] == 'not_running'
class TestDecode: class TestDecode:
def test_start_decode_no_hackrf(self, manager): def test_start_decode_no_hackrf(self, manager):
with patch('shutil.which', return_value=None): with patch('shutil.which', return_value=None), \
manager._hackrf_available = None patch('utils.subghz.get_tool_path', return_value=None):
manager._rtl433_available = None manager._hackrf_available = None
result = manager.start_decode(frequency_hz=433920000) manager._rtl433_available = None
assert result['status'] == 'error' result = manager.start_decode(frequency_hz=433920000)
assert 'hackrf_transfer' in result['message'] assert result['status'] == 'error'
assert 'hackrf_transfer' in result['message']
def test_start_decode_no_rtl433(self, manager): def test_start_decode_no_rtl433(self, manager):
def which_side_effect(name): def which_side_effect(name):
@@ -509,12 +514,13 @@ class TestDecode:
return '/usr/bin/hackrf_transfer' return '/usr/bin/hackrf_transfer'
return None return None
with patch('shutil.which', side_effect=which_side_effect): with patch('shutil.which', side_effect=which_side_effect), \
manager._hackrf_available = None patch('utils.subghz.get_tool_path', return_value=None):
manager._rtl433_available = None manager._hackrf_available = None
result = manager.start_decode(frequency_hz=433920000) manager._rtl433_available = None
assert result['status'] == 'error' result = manager.start_decode(frequency_hz=433920000)
assert 'rtl_433' in result['message'] assert result['status'] == 'error'
assert 'rtl_433' in result['message']
def test_start_decode_success(self, manager): def test_start_decode_success(self, manager):
mock_hackrf_proc = MagicMock() mock_hackrf_proc = MagicMock()
@@ -537,9 +543,12 @@ class TestDecode:
return mock_hackrf_proc return mock_hackrf_proc
return mock_rtl433_proc return mock_rtl433_proc
with patch('shutil.which', return_value='/usr/bin/tool'), \ def which_side_effect(name):
patch('subprocess.Popen', side_effect=popen_side_effect) as mock_popen, \ return f'/usr/bin/{name}'
patch('utils.subghz.register_process'):
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 import time as _time
manager._hackrf_available = None manager._hackrf_available = None
manager._rtl433_available = None manager._rtl433_available = None
@@ -556,16 +565,16 @@ class TestDecode:
# Two processes: hackrf_transfer + rtl_433 # Two processes: hackrf_transfer + rtl_433
assert mock_popen.call_count == 2 assert mock_popen.call_count == 2
# Verify hackrf_transfer command # Verify hackrf_transfer command
hackrf_cmd = mock_popen.call_args_list[0][0][0] hackrf_cmd = mock_popen.call_args_list[0][0][0]
assert hackrf_cmd[0] == 'hackrf_transfer' assert os.path.basename(hackrf_cmd[0]) == 'hackrf_transfer'
assert '-r' in hackrf_cmd assert '-r' in hackrf_cmd
# Verify rtl_433 command # Verify rtl_433 command
rtl433_cmd = mock_popen.call_args_list[1][0][0] rtl433_cmd = mock_popen.call_args_list[1][0][0]
assert rtl433_cmd[0] == 'rtl_433' assert os.path.basename(rtl433_cmd[0]) == 'rtl_433'
assert '-r' in rtl433_cmd assert '-r' in rtl433_cmd
assert 'cs8:-' in rtl433_cmd assert 'cs8:-' in rtl433_cmd
# Both processes tracked # Both processes tracked
assert manager._decode_hackrf_process is mock_hackrf_proc assert manager._decode_hackrf_process is mock_hackrf_proc
+79 -67
View File
@@ -6,14 +6,15 @@ Detects RTL-SDR devices via rtl_test and other SDR hardware via SoapySDR.
from __future__ import annotations from __future__ import annotations
import logging import logging
import re import re
import shutil import subprocess
import subprocess import time
import time from typing import Optional
from typing import Optional
from utils.dependencies import get_tool_path
from .base import SDRCapabilities, SDRDevice, SDRType
from .base import SDRCapabilities, SDRDevice, SDRType
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -43,12 +44,7 @@ def _hackrf_probe_blocked() -> bool:
return False return False
def _check_tool(name: str) -> bool: def _get_capabilities_for_type(sdr_type: SDRType) -> SDRCapabilities:
"""Check if a tool is available in PATH."""
return shutil.which(name) is not None
def _get_capabilities_for_type(sdr_type: SDRType) -> SDRCapabilities:
"""Get default capabilities for an SDR type.""" """Get default capabilities for an SDR type."""
# Import here to avoid circular imports # Import here to avoid circular imports
from .rtlsdr import RTLSDRCommandBuilder from .rtlsdr import RTLSDRCommandBuilder
@@ -100,7 +96,7 @@ def _driver_to_sdr_type(driver: str) -> Optional[SDRType]:
return mapping.get(driver.lower()) return mapping.get(driver.lower())
def detect_rtlsdr_devices() -> list[SDRDevice]: def detect_rtlsdr_devices() -> list[SDRDevice]:
""" """
Detect RTL-SDR devices using rtl_test. Detect RTL-SDR devices using rtl_test.
@@ -109,9 +105,10 @@ def detect_rtlsdr_devices() -> list[SDRDevice]:
""" """
devices: list[SDRDevice] = [] devices: list[SDRDevice] = []
if not _check_tool('rtl_test'): rtl_test_path = get_tool_path('rtl_test')
logger.debug("rtl_test not found, skipping RTL-SDR detection") if not rtl_test_path:
return devices logger.debug("rtl_test not found, skipping RTL-SDR detection")
return devices
try: try:
import os import os
@@ -122,11 +119,11 @@ def detect_rtlsdr_devices() -> list[SDRDevice]:
lib_paths = ['/usr/local/lib', '/opt/homebrew/lib'] lib_paths = ['/usr/local/lib', '/opt/homebrew/lib']
current_ld = env.get('DYLD_LIBRARY_PATH', '') current_ld = env.get('DYLD_LIBRARY_PATH', '')
env['DYLD_LIBRARY_PATH'] = ':'.join(lib_paths + [current_ld] if current_ld else lib_paths) env['DYLD_LIBRARY_PATH'] = ':'.join(lib_paths + [current_ld] if current_ld else lib_paths)
result = subprocess.run( result = subprocess.run(
['rtl_test', '-t'], [rtl_test_path, '-t'],
capture_output=True, capture_output=True,
text=True, text=True,
encoding='utf-8', encoding='utf-8',
errors='replace', errors='replace',
timeout=5, timeout=5,
env=env env=env
@@ -176,13 +173,14 @@ def detect_rtlsdr_devices() -> list[SDRDevice]:
return devices return devices
def _find_soapy_util() -> str | None: def _find_soapy_util() -> str | None:
"""Find SoapySDR utility command (name varies by distribution).""" """Find SoapySDR utility command (name varies by distribution)."""
# Try different command names used across distributions # Try different command names used across distributions
for cmd in ['SoapySDRUtil', 'soapy_sdr_util', 'soapysdr-util']: for cmd in ['SoapySDRUtil', 'soapy_sdr_util', 'soapysdr-util']:
if _check_tool(cmd): tool_path = get_tool_path(cmd)
return cmd if tool_path:
return None return tool_path
return None
def _get_soapy_env() -> dict: 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. Detect HackRF devices using native hackrf_info tool.
@@ -343,33 +341,46 @@ def detect_hackrf_devices() -> list[SDRDevice]:
devices: list[SDRDevice] = [] devices: list[SDRDevice] = []
if not _check_tool('hackrf_info'): hackrf_info_path = get_tool_path('hackrf_info')
_hackrf_cache = devices if not hackrf_info_path:
_hackrf_cache_ts = now _hackrf_cache = devices
return devices _hackrf_cache_ts = now
return devices
try: try:
result = subprocess.run( result = subprocess.run(
['hackrf_info'], [hackrf_info_path],
capture_output=True, capture_output=True,
text=True, text=True,
timeout=5 timeout=5
) )
# Combine stdout + stderr: newer firmware may print to stderr, # Combine stdout + stderr: newer firmware may print to stderr,
# and hackrf_info may exit non-zero when device is briefly busy # and hackrf_info may exit non-zero when device is briefly busy
# but still output valid info. # but still output valid info.
output = result.stdout + result.stderr output = f"{result.stdout or ''}\n{result.stderr or ''}"
# Parse hackrf_info output # Parse hackrf_info output
# Extract board name from "Board ID Number: X (Name)" and serial # Extract board name from "Board ID Number: X (Name)" and serial
from .hackrf import HackRFCommandBuilder from .hackrf import HackRFCommandBuilder
serial_pattern = r'Serial number:\s*(\S+)' serial_pattern = re.compile(
board_pattern = r'Board ID Number:\s*\d+\s*\(([^)]+)\)' r'^\s*Serial\s+number:\s*(.+)$',
re.IGNORECASE | re.MULTILINE,
serials_found = re.findall(serial_pattern, output) )
boards_found = re.findall(board_pattern, output) 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): for i, serial in enumerate(serials_found):
board_name = boards_found[i] if i < len(boards_found) else 'HackRF' 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 # Fallback: check if any HackRF found without serial
if not devices and 'Found HackRF' in output: if not devices and re.search(r'Found\s+HackRF', output, re.IGNORECASE):
board_match = re.search(board_pattern, output) board_match = board_pattern.search(output)
board_name = board_match.group(1) if board_match else 'HackRF' board_name = board_match.group(1) if board_match else 'HackRF'
devices.append(SDRDevice( devices.append(SDRDevice(
sdr_type=SDRType.HACKRF, sdr_type=SDRType.HACKRF,
index=0, index=0,
name=board_name, name=board_name,
serial='Unknown', serial='Unknown',
@@ -403,7 +414,7 @@ def detect_hackrf_devices() -> list[SDRDevice]:
return devices 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. """Probe whether an RTL-SDR device is available at the USB level.
Runs a quick ``rtl_test`` invocation targeting a single device to 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, An error message string if the device cannot be opened,
or ``None`` if the device is available. or ``None`` if the device is available.
""" """
if not _check_tool('rtl_test'): rtl_test_path = get_tool_path('rtl_test')
# Can't probe without rtl_test — let the caller proceed and if not rtl_test_path:
# surface errors from the actual decoder process instead. # Can't probe without rtl_test — let the caller proceed and
return None # surface errors from the actual decoder process instead.
return None
try: try:
import os 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. # Use Popen with early termination instead of run() with full timeout.
# rtl_test prints device info to stderr quickly, then keeps running # 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. # its test loop. We kill it as soon as we see success or failure.
proc = subprocess.Popen( proc = subprocess.Popen(
['rtl_test', '-d', str(device_index), '-t'], [rtl_test_path, '-d', str(device_index), '-t'],
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, stderr=subprocess.PIPE,
text=True, text=True,
env=env, env=env,
) )
+125 -115
View File
@@ -21,11 +21,12 @@ from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import BinaryIO, Callable from typing import BinaryIO, Callable
import numpy as np import numpy as np
from utils.logging import get_logger from utils.dependencies import get_tool_path
from utils.process import register_process, safe_terminate, unregister_process from utils.logging import get_logger
from utils.constants import ( from utils.process import register_process, safe_terminate, unregister_process
from utils.constants import (
SUBGHZ_TX_ALLOWED_BANDS, SUBGHZ_TX_ALLOWED_BANDS,
SUBGHZ_FREQ_MIN_MHZ, SUBGHZ_FREQ_MIN_MHZ,
SUBGHZ_FREQ_MAX_MHZ, SUBGHZ_FREQ_MAX_MHZ,
@@ -187,19 +188,23 @@ class SubGhzManager:
except Exception as e: except Exception as e:
logger.error(f"Error in SubGHz callback: {e}") logger.error(f"Error in SubGHz callback: {e}")
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Tool detection # Tool detection
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def check_hackrf(self) -> bool: def _resolve_tool(self, name: str) -> str | None:
if self._hackrf_available is None: """Resolve executable path via PATH first, then platform-aware fallbacks."""
self._hackrf_available = shutil.which('hackrf_transfer') is not None return shutil.which(name) or get_tool_path(name)
return self._hackrf_available
def check_hackrf(self) -> bool:
def check_hackrf_info(self) -> bool: if self._hackrf_available is None:
if self._hackrf_info_available is None: self._hackrf_available = self._resolve_tool('hackrf_transfer') is not None
self._hackrf_info_available = shutil.which('hackrf_info') is not None return self._hackrf_available
return self._hackrf_info_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: def check_hackrf_device(self) -> bool | None:
"""Return True if a HackRF device is detected, False if not, or None if detection unavailable.""" """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 'HackRF device not detected'
return None return None
def check_rtl433(self) -> bool: def check_rtl433(self) -> bool:
if self._rtl433_available is None: if self._rtl433_available is None:
self._rtl433_available = shutil.which('rtl_433') is not None self._rtl433_available = self._resolve_tool('rtl_433') is not None
return self._rtl433_available return self._rtl433_available
def check_sweep(self) -> bool: def check_sweep(self) -> bool:
if self._sweep_available is None: if self._sweep_available is None:
self._sweep_available = shutil.which('hackrf_sweep') is not None self._sweep_available = self._resolve_tool('hackrf_sweep') is not None
return self._sweep_available return self._sweep_available
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Status # Status
@@ -307,23 +312,24 @@ class SubGhzManager:
# RECEIVE (IQ capture via hackrf_transfer -r) # RECEIVE (IQ capture via hackrf_transfer -r)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def start_receive( def start_receive(
self, self,
frequency_hz: int, frequency_hz: int,
sample_rate: int = 2000000, sample_rate: int = 2000000,
lna_gain: int = 32, lna_gain: int = 32,
vga_gain: int = 20, vga_gain: int = 20,
trigger_enabled: bool = False, trigger_enabled: bool = False,
trigger_pre_ms: int = 350, trigger_pre_ms: int = 350,
trigger_post_ms: int = 700, trigger_post_ms: int = 700,
device_serial: str | None = None, device_serial: str | None = None,
) -> dict: ) -> dict:
# Pre-lock: tool availability & device detection (blocking I/O) # Pre-lock: tool availability & device detection (blocking I/O)
if not self.check_hackrf(): hackrf_transfer_path = self._resolve_tool('hackrf_transfer')
return {'status': 'error', 'message': 'hackrf_transfer not found'} if not hackrf_transfer_path:
device_err = self._require_hackrf_device() return {'status': 'error', 'message': 'hackrf_transfer not found'}
if device_err: device_err = self._require_hackrf_device()
return {'status': 'error', 'message': device_err} if device_err:
return {'status': 'error', 'message': device_err}
with self._lock: with self._lock:
if self.active_mode != 'idle': if self.active_mode != 'idle':
@@ -339,11 +345,11 @@ class SubGhzManager:
basename = f"{freq_mhz:.3f}MHz_{ts}" basename = f"{freq_mhz:.3f}MHz_{ts}"
iq_file = self._captures_dir / f"{basename}.iq" iq_file = self._captures_dir / f"{basename}.iq"
cmd = [ cmd = [
'hackrf_transfer', hackrf_transfer_path,
'-r', str(iq_file), '-r', str(iq_file),
'-f', str(frequency_hz), '-f', str(frequency_hz),
'-s', str(sample_rate), '-s', str(sample_rate),
'-l', str(lna_gain), '-l', str(lna_gain),
'-g', str(vga_gain), '-g', str(vga_gain),
] ]
@@ -1272,23 +1278,25 @@ class SubGhzManager:
# DECODE (hackrf_transfer piped to rtl_433) # DECODE (hackrf_transfer piped to rtl_433)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def start_decode( def start_decode(
self, self,
frequency_hz: int, frequency_hz: int,
sample_rate: int = 2_000_000, sample_rate: int = 2_000_000,
lna_gain: int = 32, lna_gain: int = 32,
vga_gain: int = 20, vga_gain: int = 20,
decode_profile: str = 'weather', decode_profile: str = 'weather',
device_serial: str | None = None, device_serial: str | None = None,
) -> dict: ) -> dict:
# Pre-lock: tool availability & device detection (blocking I/O) # Pre-lock: tool availability & device detection (blocking I/O)
if not self.check_hackrf(): hackrf_transfer_path = self._resolve_tool('hackrf_transfer')
return {'status': 'error', 'message': 'hackrf_transfer not found'} if not hackrf_transfer_path:
if not self.check_rtl433(): return {'status': 'error', 'message': 'hackrf_transfer not found'}
return {'status': 'error', 'message': 'rtl_433 not found'} rtl433_path = self._resolve_tool('rtl_433')
device_err = self._require_hackrf_device() if not rtl433_path:
if device_err: return {'status': 'error', 'message': 'rtl_433 not found'}
return {'status': 'error', 'message': device_err} device_err = self._require_hackrf_device()
if device_err:
return {'status': 'error', 'message': device_err}
with self._lock: with self._lock:
if self.active_mode != 'idle': if self.active_mode != 'idle':
@@ -1299,25 +1307,25 @@ class SubGhzManager:
requested_sample_rate = int(sample_rate) requested_sample_rate = int(sample_rate)
stable_sample_rate = max(2_000_000, min(2_000_000, requested_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) # Build hackrf_transfer command (producer: raw IQ to stdout)
hackrf_cmd = [ hackrf_cmd = [
'hackrf_transfer', hackrf_transfer_path,
'-r', '-', '-r', '-',
'-f', str(frequency_hz), '-f', str(frequency_hz),
'-s', str(stable_sample_rate), '-s', str(stable_sample_rate),
'-l', str(max(SUBGHZ_LNA_GAIN_MIN, min(SUBGHZ_LNA_GAIN_MAX, lna_gain))), '-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))), '-g', str(max(SUBGHZ_VGA_GAIN_MIN, min(SUBGHZ_VGA_GAIN_MAX, vga_gain))),
] ]
if device_serial: if device_serial:
hackrf_cmd.extend(['-d', device_serial]) hackrf_cmd.extend(['-d', device_serial])
# Build rtl_433 command (consumer: reads IQ from stdin) # Build rtl_433 command (consumer: reads IQ from stdin)
# Feed signed 8-bit complex IQ directly from hackrf_transfer. # Feed signed 8-bit complex IQ directly from hackrf_transfer.
rtl433_cmd = [ rtl433_cmd = [
'rtl_433', rtl433_path,
'-r', 'cs8:-', '-r', 'cs8:-',
'-s', str(stable_sample_rate), '-s', str(stable_sample_rate),
'-f', str(frequency_hz), '-f', str(frequency_hz),
'-F', 'json', '-F', 'json',
'-F', 'log', '-F', 'log',
'-M', 'level', '-M', 'level',
@@ -1936,21 +1944,22 @@ class SubGhzManager:
except OSError as exc: except OSError as exc:
logger.debug(f"Failed to remove TX temp file {path}: {exc}") logger.debug(f"Failed to remove TX temp file {path}: {exc}")
def transmit( def transmit(
self, self,
capture_id: str, capture_id: str,
tx_gain: int = 20, tx_gain: int = 20,
max_duration: int = 10, max_duration: int = 10,
start_seconds: float | None = None, start_seconds: float | None = None,
duration_seconds: float | None = None, duration_seconds: float | None = None,
device_serial: str | None = None, device_serial: str | None = None,
) -> dict: ) -> dict:
# Pre-lock: tool availability & device detection (blocking I/O) # Pre-lock: tool availability & device detection (blocking I/O)
if not self.check_hackrf(): hackrf_transfer_path = self._resolve_tool('hackrf_transfer')
return {'status': 'error', 'message': 'hackrf_transfer not found'} if not hackrf_transfer_path:
device_err = self._require_hackrf_device() return {'status': 'error', 'message': 'hackrf_transfer not found'}
if device_err: device_err = self._require_hackrf_device()
return {'status': 'error', 'message': device_err} if device_err:
return {'status': 'error', 'message': device_err}
# Pre-lock: capture lookup, validation, and segment I/O (can be large) # Pre-lock: capture lookup, validation, and segment I/O (can be large)
capture = self._load_capture(capture_id) capture = self._load_capture(capture_id)
@@ -2046,14 +2055,14 @@ class SubGhzManager:
# Clear any orphaned temp segment from a previous TX attempt. # Clear any orphaned temp segment from a previous TX attempt.
self._cleanup_tx_temp_file() self._cleanup_tx_temp_file()
if segment_path_for_cleanup: if segment_path_for_cleanup:
self._tx_temp_file = segment_path_for_cleanup self._tx_temp_file = segment_path_for_cleanup
cmd = [ cmd = [
'hackrf_transfer', hackrf_transfer_path,
'-t', str(tx_path), '-t', str(tx_path),
'-f', str(capture.frequency_hz), '-f', str(capture.frequency_hz),
'-s', str(capture.sample_rate), '-s', str(capture.sample_rate),
'-x', str(tx_gain), '-x', str(tx_gain),
] ]
if device_serial: if device_serial:
@@ -2183,19 +2192,20 @@ class SubGhzManager:
# SWEEP (hackrf_sweep) # SWEEP (hackrf_sweep)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def start_sweep( def start_sweep(
self, self,
freq_start_mhz: float = 300.0, freq_start_mhz: float = 300.0,
freq_end_mhz: float = 928.0, freq_end_mhz: float = 928.0,
bin_width: int = 100000, bin_width: int = 100000,
device_serial: str | None = None, device_serial: str | None = None,
) -> dict: ) -> dict:
# Pre-lock: tool availability & device detection (blocking I/O) # Pre-lock: tool availability & device detection (blocking I/O)
if not self.check_sweep(): hackrf_sweep_path = self._resolve_tool('hackrf_sweep')
return {'status': 'error', 'message': 'hackrf_sweep not found'} if not hackrf_sweep_path:
device_err = self._require_hackrf_device() return {'status': 'error', 'message': 'hackrf_sweep not found'}
if device_err: device_err = self._require_hackrf_device()
return {'status': 'error', 'message': device_err} if device_err:
return {'status': 'error', 'message': device_err}
# Wait for previous sweep thread to exit (blocking) before lock # Wait for previous sweep thread to exit (blocking) before lock
if self._sweep_thread and self._sweep_thread.is_alive(): 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'} return {'status': 'error', 'message': 'Previous sweep still shutting down'}
with self._lock: with self._lock:
if self.active_mode != 'idle': if self.active_mode != 'idle':
return {'status': 'error', 'message': f'Already running: {self.active_mode}'} return {'status': 'error', 'message': f'Already running: {self.active_mode}'}
cmd = [ cmd = [
'hackrf_sweep', hackrf_sweep_path,
'-f', f'{int(freq_start_mhz)}:{int(freq_end_mhz)}', '-f', f'{int(freq_start_mhz)}:{int(freq_end_mhz)}',
'-w', str(bin_width), '-w', str(bin_width),
] ]
if device_serial: if device_serial:
cmd.extend(['-d', device_serial]) cmd.extend(['-d', device_serial])