mirror of
https://github.com/smittix/intercept.git
synced 2026-07-06 00:28:12 -07:00
fix: stabilize test suite and repair frontend/backend wiring
- meshcore pin >=2.3.0 (EventType.STATS_CORE floor); setup.sh derives optional packages from requirements.txt; Python 3.10 warning - agent-mode wifi clients proxy route + bare-array response handling - remove dead AIS/ACARS/VDL2 SPA wiring and orphaned partials/CSS - agent TLE download to data/tle/ (was littering repo root as gp.php) - gate deferred background init off under pytest (mock-pollution race) - complete Popen mocks (context manager protocol, communicate tuples) - real pipe fds in weather-sat decoder tests (fd 10/11 collision caused 10s SQLite stalls); satellite tests no longer rewrite data/satellites.py - register 'live' pytest marker, excluded by default - update stale test assertions to current APIs Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -57,6 +57,9 @@ data/weather_sat/
|
|||||||
# Radiosonde runtime data (station config, logs)
|
# Radiosonde runtime data (station config, logs)
|
||||||
data/radiosonde/
|
data/radiosonde/
|
||||||
|
|
||||||
|
# Downloaded TLE files (intercept_agent satellite predictor)
|
||||||
|
data/tle/
|
||||||
|
|
||||||
# SDR capture files (large IQ recordings)
|
# SDR capture files (large IQ recordings)
|
||||||
data/subghz/captures/
|
data/subghz/captures/
|
||||||
|
|
||||||
|
|||||||
@@ -1293,6 +1293,11 @@ def _init_app() -> None:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Ground station scheduler init failed: {e}")
|
logger.warning(f"Ground station scheduler init failed: {e}")
|
||||||
|
|
||||||
|
# Skip background init when disabled (set by tests — the deferred thread
|
||||||
|
# fires mid-session and its subprocess/DB cleanup races with test mocks)
|
||||||
|
if os.environ.get("INTERCEPT_SKIP_DEFERRED_INIT") == "1":
|
||||||
|
return
|
||||||
|
|
||||||
threading.Thread(target=_deferred_init, daemon=True).start()
|
threading.Thread(target=_deferred_init, daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+1278
-1321
File diff suppressed because it is too large
Load Diff
+5
-1
@@ -160,7 +160,11 @@ exclude = [
|
|||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
python_files = ["test_*.py"]
|
python_files = ["test_*.py"]
|
||||||
python_functions = ["test_*"]
|
python_functions = ["test_*"]
|
||||||
addopts = "-v --tb=short"
|
# 'live' tests drive real SDR hardware — run explicitly with: pytest -m live
|
||||||
|
addopts = "-v --tb=short -m 'not live'"
|
||||||
|
markers = [
|
||||||
|
"live: tests that require real SDR hardware and run live decoders",
|
||||||
|
]
|
||||||
|
|
||||||
[tool.coverage.run]
|
[tool.coverage.run]
|
||||||
source = ["app", "routes", "utils", "data"]
|
source = ["app", "routes", "utils", "data"]
|
||||||
|
|||||||
+2
-1
@@ -27,7 +27,8 @@ pyserial>=3.5
|
|||||||
|
|
||||||
# Meshtastic mesh network support (optional - only needed for Meshtastic features)
|
# Meshtastic mesh network support (optional - only needed for Meshtastic features)
|
||||||
meshtastic>=2.0.0
|
meshtastic>=2.0.0
|
||||||
meshcore>=1.0.0
|
# meshcore 2.3.0+ required for EventType.STATS_CORE; needs Python 3.10+
|
||||||
|
meshcore>=2.3.0
|
||||||
|
|
||||||
# Deauthentication attack detection (optional - for WiFi TSCM)
|
# Deauthentication attack detection (optional - for WiFi TSCM)
|
||||||
scapy>=2.4.5
|
scapy>=2.4.5
|
||||||
|
|||||||
+308
-350
File diff suppressed because it is too large
Load Diff
@@ -492,14 +492,24 @@ raise SystemExit(0 if sys.version_info >= (3,9) else 1)
|
|||||||
PY
|
PY
|
||||||
ok "Python version OK (>= 3.9)"
|
ok "Python version OK (>= 3.9)"
|
||||||
|
|
||||||
|
# meshcore (MeshCore mesh networking) requires Python 3.10+
|
||||||
|
if python3 - <<'PY'
|
||||||
|
import sys
|
||||||
|
raise SystemExit(0 if sys.version_info < (3,10) else 1)
|
||||||
|
PY
|
||||||
|
then
|
||||||
|
warn "Python 3.9 detected: MeshCore support requires Python 3.10+ and will be unavailable."
|
||||||
|
fi
|
||||||
|
|
||||||
# Python 3.13+ warning: some packages (gevent, numpy, scipy) may not have
|
# Python 3.13+ warning: some packages (gevent, numpy, scipy) may not have
|
||||||
# pre-built wheels yet and will be skipped to avoid hanging on compilation.
|
# pre-built wheels yet; prefer wheels over source builds to avoid hanging
|
||||||
|
# on compilation (--prefer-binary is added to PIP_OPTS in install_python_deps).
|
||||||
if python3 - <<'PY'
|
if python3 - <<'PY'
|
||||||
import sys
|
import sys
|
||||||
raise SystemExit(0 if sys.version_info >= (3,13) else 1)
|
raise SystemExit(0 if sys.version_info >= (3,13) else 1)
|
||||||
PY
|
PY
|
||||||
then
|
then
|
||||||
warn "Python 3.13+ detected: optional packages without pre-built wheels will be skipped (--prefer-binary)."
|
warn "Python 3.13+ detected: preferring pre-built wheels over source builds (--prefer-binary)."
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -537,6 +547,10 @@ install_python_deps() {
|
|||||||
# --no-cache-dir avoids pip hanging on a corrupt/stale HTTP cache (cachecontrol .pyc issue)
|
# --no-cache-dir avoids pip hanging on a corrupt/stale HTTP cache (cachecontrol .pyc issue)
|
||||||
# --timeout prevents pip from hanging indefinitely on slow/unresponsive PyPI connections
|
# --timeout prevents pip from hanging indefinitely on slow/unresponsive PyPI connections
|
||||||
local PIP_OPTS="--no-cache-dir --timeout 120"
|
local PIP_OPTS="--no-cache-dir --timeout 120"
|
||||||
|
# Python 3.13+: prefer wheels over source builds (see check_python_version warning)
|
||||||
|
if "$PY" -c 'import sys; raise SystemExit(0 if sys.version_info >= (3,13) else 1)'; then
|
||||||
|
PIP_OPTS="$PIP_OPTS --prefer-binary"
|
||||||
|
fi
|
||||||
|
|
||||||
if ! $PIP install $PIP_OPTS --upgrade pip setuptools wheel; then
|
if ! $PIP install $PIP_OPTS --upgrade pip setuptools wheel; then
|
||||||
warn "pip/setuptools/wheel upgrade failed - continuing with existing versions"
|
warn "pip/setuptools/wheel upgrade failed - continuing with existing versions"
|
||||||
@@ -562,27 +576,27 @@ install_python_deps() {
|
|||||||
ok "Core Python packages installed"
|
ok "Core Python packages installed"
|
||||||
|
|
||||||
info "Installing optional packages..."
|
info "Installing optional packages..."
|
||||||
# Pure-Python packages: install without --only-binary so they always succeed regardless of platform
|
# Optional package specs come from requirements.txt (single source of truth).
|
||||||
for pkg in "flask-sock" "simple-websocket>=0.5.1" "websocket-client>=1.6.0" \
|
# Packages already installed by the core step above are skipped here.
|
||||||
"skyfield>=1.45" "bleak>=0.21.0" "meshtastic>=2.0.0" \
|
local core_pkgs="flask flask-wtf flask-compress flask-limiter requests Werkzeug pyserial"
|
||||||
"qrcode[pil]>=7.4" "gunicorn>=21.2.0" "psutil>=5.9.0"; do
|
# Heavy compiled packages: install with --only-binary :all: to skip slow
|
||||||
pkg_name="${pkg%%[><=]*}"
|
# source compilation on RPi. Everything else installs without it (note:
|
||||||
|
# transitive deps may still be compiled, e.g. meshcore -> pycryptodome;
|
||||||
|
# failures are tolerated since these features are optional).
|
||||||
|
local binary_only_pkgs="numpy scipy Pillow psycopg2-binary scapy cryptography gevent"
|
||||||
|
local pkg pkg_name extra_opts
|
||||||
|
while IFS= read -r pkg; do
|
||||||
|
pkg="${pkg%"${pkg##*[![:space:]]}"}" # trim trailing whitespace
|
||||||
|
[[ -z "$pkg" || "$pkg" == \#* ]] && continue
|
||||||
|
pkg_name="${pkg%%[><=[]*}"
|
||||||
|
case " $core_pkgs " in *" $pkg_name "*) continue ;; esac
|
||||||
|
extra_opts=""
|
||||||
|
case " $binary_only_pkgs " in *" $pkg_name "*) extra_opts="--only-binary :all:" ;; esac
|
||||||
info " Installing ${pkg_name}..."
|
info " Installing ${pkg_name}..."
|
||||||
if ! $PIP install $PIP_OPTS "$pkg"; then
|
if ! $PIP install $PIP_OPTS $extra_opts "$pkg"; then
|
||||||
warn "${pkg_name} failed to install (optional - related features may be unavailable)"
|
warn "${pkg_name} failed to install (optional - related features may be unavailable)"
|
||||||
fi
|
fi
|
||||||
done
|
done < requirements.txt
|
||||||
# Compiled packages: use --only-binary :all: to skip slow source compilation on RPi
|
|
||||||
for pkg in "numpy>=1.24.0" "scipy>=1.10.0" "Pillow>=9.0.0" \
|
|
||||||
"psycopg2-binary>=2.9.9" "scapy>=2.4.5" "cryptography>=41.0.0" \
|
|
||||||
"gevent>=23.9.0"; do
|
|
||||||
pkg_name="${pkg%%[><=]*}"
|
|
||||||
info " Installing ${pkg_name}..."
|
|
||||||
# --only-binary :all: prevents source compilation hangs for heavy packages
|
|
||||||
if ! $PIP install $PIP_OPTS --only-binary :all: "$pkg"; then
|
|
||||||
warn "${pkg_name} failed to install (optional - related features may be unavailable)"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
ok "Optional packages processed"
|
ok "Optional packages processed"
|
||||||
echo
|
echo
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,115 +0,0 @@
|
|||||||
/* ACARS Sidebar Styles */
|
|
||||||
@keyframes pulse {
|
|
||||||
0%, 100% { opacity: 0.3; transform: scale(0.8); }
|
|
||||||
50% { opacity: 1; transform: scale(1); }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Main ACARS Sidebar (Collapsible) */
|
|
||||||
.main-acars-sidebar {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
background: var(--bg-panel);
|
|
||||||
border-left: 1px solid var(--border-color);
|
|
||||||
}
|
|
||||||
.main-acars-collapse-btn {
|
|
||||||
width: 24px;
|
|
||||||
min-width: 24px;
|
|
||||||
background: rgba(0,0,0,0.4);
|
|
||||||
border: none;
|
|
||||||
border-right: 1px solid var(--border-color);
|
|
||||||
color: var(--accent-cyan);
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 5px;
|
|
||||||
padding: 6px 0;
|
|
||||||
transition: background 0.2s;
|
|
||||||
}
|
|
||||||
.main-acars-collapse-btn:hover {
|
|
||||||
background: rgba(var(--accent-cyan-rgb), 0.15);
|
|
||||||
}
|
|
||||||
.main-acars-collapse-label {
|
|
||||||
writing-mode: vertical-rl;
|
|
||||||
text-orientation: mixed;
|
|
||||||
font-size: 8px;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 1px;
|
|
||||||
}
|
|
||||||
.main-acars-sidebar.collapsed .main-acars-collapse-label { display: block; }
|
|
||||||
.main-acars-sidebar:not(.collapsed) .main-acars-collapse-label { display: none; }
|
|
||||||
#mainAcarsCollapseIcon {
|
|
||||||
font-size: 10px;
|
|
||||||
transition: transform 0.3s;
|
|
||||||
}
|
|
||||||
.main-acars-sidebar.collapsed #mainAcarsCollapseIcon {
|
|
||||||
transform: rotate(180deg);
|
|
||||||
}
|
|
||||||
.main-acars-content {
|
|
||||||
width: 196px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow: hidden;
|
|
||||||
transition: width 0.3s ease, opacity 0.2s ease;
|
|
||||||
}
|
|
||||||
.main-acars-sidebar.collapsed .main-acars-content {
|
|
||||||
width: 0;
|
|
||||||
opacity: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
.main-acars-messages {
|
|
||||||
max-height: 350px;
|
|
||||||
}
|
|
||||||
.main-acars-msg {
|
|
||||||
padding: 6px 8px;
|
|
||||||
border-bottom: 1px solid var(--border-color);
|
|
||||||
animation: fadeInMsg 0.3s ease;
|
|
||||||
}
|
|
||||||
.main-acars-msg:hover {
|
|
||||||
background: rgba(var(--accent-cyan-rgb), 0.05);
|
|
||||||
}
|
|
||||||
@keyframes fadeInMsg {
|
|
||||||
from { opacity: 0; transform: translateY(-3px); }
|
|
||||||
to { opacity: 1; transform: translateY(0); }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ACARS Status Indicator */
|
|
||||||
.acars-status-dot.listening {
|
|
||||||
background: var(--accent-cyan) !important;
|
|
||||||
animation: acars-pulse 1.5s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
.acars-status-dot.receiving {
|
|
||||||
background: var(--accent-green) !important;
|
|
||||||
}
|
|
||||||
.acars-status-dot.error {
|
|
||||||
background: var(--accent-red) !important;
|
|
||||||
}
|
|
||||||
@keyframes acars-pulse {
|
|
||||||
0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(var(--accent-cyan-rgb), 0.7); }
|
|
||||||
50% { opacity: 0.6; box-shadow: 0 0 6px 3px rgba(var(--accent-cyan-rgb), 0.3); }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ACARS Standalone Message Feed */
|
|
||||||
.acars-message-feed {
|
|
||||||
scrollbar-width: thin;
|
|
||||||
scrollbar-color: var(--border-color) transparent;
|
|
||||||
}
|
|
||||||
.acars-message-feed::-webkit-scrollbar {
|
|
||||||
width: 4px;
|
|
||||||
}
|
|
||||||
.acars-message-feed::-webkit-scrollbar-thumb {
|
|
||||||
background: var(--border-color);
|
|
||||||
border-radius: 2px;
|
|
||||||
}
|
|
||||||
.acars-feed-card {
|
|
||||||
transition: background 0.15s;
|
|
||||||
}
|
|
||||||
.acars-feed-card:hover {
|
|
||||||
background: rgba(var(--accent-cyan-rgb), 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Clickable ACARS sidebar messages (linked to tracked aircraft) */
|
|
||||||
.acars-message-item[style*="cursor: pointer"]:hover {
|
|
||||||
background: rgba(var(--accent-cyan-rgb), 0.1);
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
/* VDL2 Mode Styles */
|
|
||||||
|
|
||||||
/* VDL2 Status Indicator */
|
|
||||||
.vdl2-status-dot.listening {
|
|
||||||
background: var(--accent-cyan) !important;
|
|
||||||
animation: vdl2-pulse 1.5s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
.vdl2-status-dot.receiving {
|
|
||||||
background: var(--accent-green) !important;
|
|
||||||
}
|
|
||||||
.vdl2-status-dot.error {
|
|
||||||
background: var(--accent-red) !important;
|
|
||||||
}
|
|
||||||
@keyframes vdl2-pulse {
|
|
||||||
0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(var(--accent-cyan-rgb), 0.7); }
|
|
||||||
50% { opacity: 0.6; box-shadow: 0 0 6px 3px rgba(var(--accent-cyan-rgb), 0.3); }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* VDL2 message animation */
|
|
||||||
.vdl2-msg {
|
|
||||||
padding: 6px 8px;
|
|
||||||
border-bottom: 1px solid var(--border-color);
|
|
||||||
animation: vdl2FadeIn 0.3s ease;
|
|
||||||
}
|
|
||||||
.vdl2-msg:hover {
|
|
||||||
background: rgba(var(--accent-cyan-rgb), 0.05);
|
|
||||||
}
|
|
||||||
@keyframes vdl2FadeIn {
|
|
||||||
from { opacity: 0; transform: translateY(-3px); }
|
|
||||||
to { opacity: 1; transform: translateY(0); }
|
|
||||||
}
|
|
||||||
@@ -1371,7 +1371,8 @@ const WiFiMode = (function() {
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
// Handle agent response format (may be nested in 'result')
|
// Handle agent response format (may be nested in 'result')
|
||||||
const result = isAgentMode && data.result ? data.result : data;
|
const result = isAgentMode && data.result ? data.result : data;
|
||||||
const clientList = result.clients || [];
|
// /wifi/v2/clients returns a bare array; tolerate {clients: [...]} too
|
||||||
|
const clientList = Array.isArray(result) ? result : (result.clients || []);
|
||||||
|
|
||||||
if (clientList.length > 0) {
|
if (clientList.length > 0) {
|
||||||
renderClientList(clientList, bssid);
|
renderClientList(clientList, bssid);
|
||||||
|
|||||||
+3
-10
@@ -772,8 +772,6 @@
|
|||||||
|
|
||||||
{% include 'partials/modes/tscm.html' %}
|
{% include 'partials/modes/tscm.html' %}
|
||||||
|
|
||||||
{% include 'partials/modes/ais.html' %}
|
|
||||||
|
|
||||||
{% include 'partials/modes/drone.html' %}
|
{% include 'partials/modes/drone.html' %}
|
||||||
|
|
||||||
{% include 'partials/modes/radiosonde.html' %}
|
{% include 'partials/modes/radiosonde.html' %}
|
||||||
@@ -4462,9 +4460,6 @@
|
|||||||
sstv_general: () => typeof SSTVGeneral !== 'undefined' && SSTVGeneral.destroy?.(),
|
sstv_general: () => typeof SSTVGeneral !== 'undefined' && SSTVGeneral.destroy?.(),
|
||||||
websdr: () => typeof WebSDR !== 'undefined' && WebSDR.destroy?.(),
|
websdr: () => typeof WebSDR !== 'undefined' && WebSDR.destroy?.(),
|
||||||
spystations: () => typeof SpyStations !== 'undefined' && SpyStations.destroy?.(),
|
spystations: () => typeof SpyStations !== 'undefined' && SpyStations.destroy?.(),
|
||||||
ais: () => { if (aisEventSource) { aisEventSource.close(); aisEventSource = null; } },
|
|
||||||
acars: () => { if (acarsMainEventSource) { acarsMainEventSource.close(); acarsMainEventSource = null; } },
|
|
||||||
vdl2: () => { if (vdl2MainEventSource) { vdl2MainEventSource.close(); vdl2MainEventSource = null; } },
|
|
||||||
radiosonde: () => { if (radiosondeEventSource) { radiosondeEventSource.close(); radiosondeEventSource = null; } },
|
radiosonde: () => { if (radiosondeEventSource) { radiosondeEventSource.close(); radiosondeEventSource = null; } },
|
||||||
aprs: () => {
|
aprs: () => {
|
||||||
if (typeof destroyAprsMode === 'function') {
|
if (typeof destroyAprsMode === 'function') {
|
||||||
@@ -4820,7 +4815,6 @@
|
|||||||
document.getElementById('wflMode')?.classList.toggle('active', mode === 'wifi_locate');
|
document.getElementById('wflMode')?.classList.toggle('active', mode === 'wifi_locate');
|
||||||
document.getElementById('aprsMode')?.classList.toggle('active', mode === 'aprs');
|
document.getElementById('aprsMode')?.classList.toggle('active', mode === 'aprs');
|
||||||
document.getElementById('tscmMode')?.classList.toggle('active', mode === 'tscm');
|
document.getElementById('tscmMode')?.classList.toggle('active', mode === 'tscm');
|
||||||
document.getElementById('aisMode')?.classList.toggle('active', mode === 'ais');
|
|
||||||
document.getElementById('droneMode')?.classList.toggle('active', mode === 'drone');
|
document.getElementById('droneMode')?.classList.toggle('active', mode === 'drone');
|
||||||
document.getElementById('radiosondeMode')?.classList.toggle('active', mode === 'radiosonde');
|
document.getElementById('radiosondeMode')?.classList.toggle('active', mode === 'radiosonde');
|
||||||
document.getElementById('spystationsMode')?.classList.toggle('active', mode === 'spystations');
|
document.getElementById('spystationsMode')?.classList.toggle('active', mode === 'spystations');
|
||||||
@@ -5006,7 +5000,7 @@
|
|||||||
|
|
||||||
// Show agent selector for modes that support remote agents
|
// Show agent selector for modes that support remote agents
|
||||||
const agentSection = document.getElementById('agentSection');
|
const agentSection = document.getElementById('agentSection');
|
||||||
const agentModes = ['pager', 'sensor', 'rtlamr', 'aprs', 'wifi', 'bluetooth', 'aircraft', 'tscm', 'ais'];
|
const agentModes = ['pager', 'sensor', 'rtlamr', 'aprs', 'wifi', 'bluetooth', 'aircraft', 'tscm'];
|
||||||
if (agentSection) agentSection.style.display = agentModes.includes(mode) ? 'block' : 'none';
|
if (agentSection) agentSection.style.display = agentModes.includes(mode) ? 'block' : 'none';
|
||||||
|
|
||||||
// Show RTL-SDR device section for modes that use it
|
// Show RTL-SDR device section for modes that use it
|
||||||
@@ -6505,7 +6499,7 @@
|
|||||||
document.getElementById('capGainRange').textContent = `${caps.gain_min}-${caps.gain_max} dB`;
|
document.getElementById('capGainRange').textContent = `${caps.gain_min}-${caps.gain_max} dB`;
|
||||||
// Update max attribute on all mode gain inputs so constraints match the SDR
|
// Update max attribute on all mode gain inputs so constraints match the SDR
|
||||||
const gainMax = caps.gain_max;
|
const gainMax = caps.gain_max;
|
||||||
['gain', 'sensorGain', 'aisGainInput', 'acarsGainInput', 'aprsStripGain', 'weatherSatGain'].forEach(id => {
|
['gain', 'sensorGain', 'aprsStripGain', 'weatherSatGain'].forEach(id => {
|
||||||
const el = document.getElementById(id);
|
const el = document.getElementById(id);
|
||||||
if (el) el.max = gainMax;
|
if (el) el.max = gainMax;
|
||||||
});
|
});
|
||||||
@@ -6633,8 +6627,7 @@
|
|||||||
// Warn if any SDR mode is currently running — bias-T is applied at
|
// Warn if any SDR mode is currently running — bias-T is applied at
|
||||||
// start time and cannot be toggled on a running device.
|
// start time and cannot be toggled on a running device.
|
||||||
const anyRunning = isRunning || isSensorRunning
|
const anyRunning = isRunning || isSensorRunning
|
||||||
|| (typeof isAdsbRunning !== 'undefined' && isAdsbRunning)
|
|| (typeof isAdsbRunning !== 'undefined' && isAdsbRunning);
|
||||||
|| (typeof isAisRunning !== 'undefined' && isAisRunning);
|
|
||||||
if (anyRunning) {
|
if (anyRunning) {
|
||||||
showInfo('Bias-T change will take effect after restarting the active SDR mode');
|
showInfo('Bias-T change will take effect after restarting the active SDR mode');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,300 +0,0 @@
|
|||||||
<!-- ACARS AIRCRAFT MESSAGING MODE -->
|
|
||||||
<div id="acarsMode" class="mode-content" style="display: none;">
|
|
||||||
<div class="section">
|
|
||||||
<h3>ACARS Messaging</h3>
|
|
||||||
<div class="info-text" style="margin-bottom: 15px;">
|
|
||||||
Decode ACARS (Aircraft Communications Addressing and Reporting System) messages on VHF frequencies (~129-131 MHz). Captures flight data, weather reports, position updates, and operational messages from aircraft.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="section">
|
|
||||||
<h3>Region & Frequencies</h3>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Region</label>
|
|
||||||
<select id="acarsRegionSelect" onchange="updateAcarsMainFreqs()" style="width: 100%;">
|
|
||||||
<option value="na">North America</option>
|
|
||||||
<option value="eu">Europe</option>
|
|
||||||
<option value="ap">Asia-Pacific</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div id="acarsMainFreqSelector" style="display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 10px; font-size: 11px;">
|
|
||||||
<!-- Populated by JS -->
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Gain (dB, 0 = auto)</label>
|
|
||||||
<input type="number" id="acarsGainInput" value="40" min="0" max="50" placeholder="0-50">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="section">
|
|
||||||
<h3>Status</h3>
|
|
||||||
<div id="acarsStatusDisplay" class="info-text">
|
|
||||||
<p>Status: <span id="acarsStatusText" style="color: var(--accent-yellow);">Standby</span></p>
|
|
||||||
<p>Messages: <span id="acarsMessageCount">0</span></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Antenna Guide -->
|
|
||||||
<div class="section">
|
|
||||||
<h3>Antenna Guide</h3>
|
|
||||||
<div style="font-size: 11px; color: var(--text-dim); line-height: 1.5;">
|
|
||||||
<p style="margin-bottom: 8px; color: var(--accent-cyan); font-weight: 600;">
|
|
||||||
VHF Airband (~130 MHz) — stock SDR antenna may work at close range
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
|
|
||||||
<strong style="color: var(--accent-cyan); font-size: 12px;">Simple Dipole</strong>
|
|
||||||
<ul style="margin: 6px 0 0 14px; padding: 0;">
|
|
||||||
<li><strong style="color: var(--text-primary);">Element length:</strong> ~57 cm each (quarter-wave at 130 MHz)</li>
|
|
||||||
<li><strong style="color: var(--text-primary);">Material:</strong> Wire, coat hanger, or copper rod</li>
|
|
||||||
<li><strong style="color: var(--text-primary);">Orientation:</strong> Vertical (airband is vertically polarized)</li>
|
|
||||||
<li><strong style="color: var(--text-primary);">Placement:</strong> Outdoors, as high as possible with clear sky view</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px;">
|
|
||||||
<strong style="color: var(--accent-cyan); font-size: 12px;">Quick Reference</strong>
|
|
||||||
<table style="width: 100%; margin-top: 6px; font-size: 10px; border-collapse: collapse;">
|
|
||||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
|
||||||
<td style="padding: 3px 4px; color: var(--text-dim);">Primary (N. America)</td>
|
|
||||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">131.550 / 130.025 MHz</td>
|
|
||||||
</tr>
|
|
||||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
|
||||||
<td style="padding: 3px 4px; color: var(--text-dim);">Quarter-wave length</td>
|
|
||||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">57 cm</td>
|
|
||||||
</tr>
|
|
||||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
|
||||||
<td style="padding: 3px 4px; color: var(--text-dim);">Modulation</td>
|
|
||||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">AM MSK 2400 baud</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 3px 4px; color: var(--text-dim);">Polarization</td>
|
|
||||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">Vertical</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button class="run-btn" id="startAcarsBtn" onclick="startAcarsMode()">
|
|
||||||
Start ACARS
|
|
||||||
</button>
|
|
||||||
<button class="stop-btn" id="stopAcarsBtn" onclick="stopAcarsMode()" style="display: none;">
|
|
||||||
Stop ACARS
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Live Message Feed -->
|
|
||||||
<div class="section" id="acarsMessageFeedSection" style="margin-top: 15px;">
|
|
||||||
<h3>Message Feed</h3>
|
|
||||||
<div id="acarsMessageFeed" class="acars-message-feed" style="max-height: 400px; overflow-y: auto; font-size: 11px;">
|
|
||||||
<div style="color: var(--text-muted); font-style: italic; padding: 10px 0;">Start ACARS to see live messages</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
let acarsMainEventSource = null;
|
|
||||||
let acarsMainMsgCount = 0;
|
|
||||||
|
|
||||||
const acarsMainFrequencies = {
|
|
||||||
'na': ['131.550', '130.025', '129.125'],
|
|
||||||
'eu': ['131.525', '131.725', '131.550'],
|
|
||||||
'ap': ['131.550', '131.450']
|
|
||||||
};
|
|
||||||
|
|
||||||
function updateAcarsMainFreqs() {
|
|
||||||
const region = document.getElementById('acarsRegionSelect').value;
|
|
||||||
const freqs = acarsMainFrequencies[region] || acarsMainFrequencies['na'];
|
|
||||||
const container = document.getElementById('acarsMainFreqSelector');
|
|
||||||
|
|
||||||
const previouslyChecked = new Set();
|
|
||||||
container.querySelectorAll('input:checked').forEach(cb => previouslyChecked.add(cb.value));
|
|
||||||
|
|
||||||
container.innerHTML = freqs.map((freq, i) => {
|
|
||||||
const checked = previouslyChecked.size === 0 || previouslyChecked.has(freq) ? 'checked' : '';
|
|
||||||
return `
|
|
||||||
<label style="display: flex; align-items: center; gap: 3px; padding: 2px 6px; background: var(--bg-secondary); border-radius: 3px; cursor: pointer;">
|
|
||||||
<input type="checkbox" class="acars-main-freq-cb" value="${freq}" ${checked} style="margin: 0; cursor: pointer;">
|
|
||||||
<span>${freq}</span>
|
|
||||||
</label>
|
|
||||||
`;
|
|
||||||
}).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function getAcarsMainSelectedFreqs() {
|
|
||||||
const checkboxes = document.querySelectorAll('.acars-main-freq-cb:checked');
|
|
||||||
const selected = Array.from(checkboxes).map(cb => cb.value);
|
|
||||||
if (selected.length === 0) {
|
|
||||||
const region = document.getElementById('acarsRegionSelect').value;
|
|
||||||
return acarsMainFrequencies[region] || acarsMainFrequencies['na'];
|
|
||||||
}
|
|
||||||
return selected;
|
|
||||||
}
|
|
||||||
|
|
||||||
function startAcarsMode() {
|
|
||||||
const gain = document.getElementById('acarsGainInput').value || '40';
|
|
||||||
const device = document.getElementById('deviceSelect')?.value || '0';
|
|
||||||
const frequencies = getAcarsMainSelectedFreqs();
|
|
||||||
|
|
||||||
fetch('/acars/start', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ device, gain, frequencies })
|
|
||||||
})
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(data => {
|
|
||||||
if (data.status === 'started') {
|
|
||||||
document.getElementById('startAcarsBtn').style.display = 'none';
|
|
||||||
document.getElementById('stopAcarsBtn').style.display = 'block';
|
|
||||||
document.getElementById('acarsStatusText').textContent = 'Listening';
|
|
||||||
document.getElementById('acarsStatusText').style.color = 'var(--accent-green)';
|
|
||||||
acarsMainMsgCount = 0;
|
|
||||||
startAcarsMainSSE();
|
|
||||||
} else {
|
|
||||||
alert(data.message || 'Failed to start ACARS');
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(err => alert('Error: ' + err.message));
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopAcarsMode() {
|
|
||||||
fetch('/acars/stop', { method: 'POST' })
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(() => {
|
|
||||||
document.getElementById('startAcarsBtn').style.display = 'block';
|
|
||||||
document.getElementById('stopAcarsBtn').style.display = 'none';
|
|
||||||
document.getElementById('acarsStatusText').textContent = 'Standby';
|
|
||||||
document.getElementById('acarsStatusText').style.color = 'var(--accent-yellow)';
|
|
||||||
if (acarsMainEventSource) {
|
|
||||||
acarsMainEventSource.close();
|
|
||||||
acarsMainEventSource = null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function acarsMainTypeBadge(type) {
|
|
||||||
const colors = {
|
|
||||||
position: '#00ff88', engine_data: '#ff9500', weather: '#00d4ff',
|
|
||||||
ats: '#ffdd00', cpdlc: '#b388ff', oooi: '#4fc3f7', squawk: '#ff6b6b',
|
|
||||||
link_test: '#666', handshake: '#555', other: '#888'
|
|
||||||
};
|
|
||||||
const labels = {
|
|
||||||
position: 'POS', engine_data: 'ENG', weather: 'WX', ats: 'ATS',
|
|
||||||
cpdlc: 'CPDLC', oooi: 'OOOI', squawk: 'SQK', link_test: 'LINK',
|
|
||||||
handshake: 'HSHK', other: 'MSG'
|
|
||||||
};
|
|
||||||
const color = colors[type] || '#888';
|
|
||||||
const lbl = labels[type] || 'MSG';
|
|
||||||
return `<span style="display:inline-block;padding:1px 5px;border-radius:3px;font-size:8px;font-weight:700;color:#000;background:${color};">${lbl}</span>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderAcarsMainCard(data) {
|
|
||||||
const flight = escapeHtml(data.flight || 'UNKNOWN');
|
|
||||||
const tail = escapeHtml(data.tail || data.reg || '');
|
|
||||||
const type = data.message_type || 'other';
|
|
||||||
const badge = acarsMainTypeBadge(type);
|
|
||||||
const desc = escapeHtml(data.label_description || (data.label ? 'Label: ' + data.label : ''));
|
|
||||||
const text = data.text || data.msg || '';
|
|
||||||
const truncText = escapeHtml(text.length > 200 ? text.substring(0, 200) + '...' : text);
|
|
||||||
const time = typeof InterceptTime !== 'undefined'
|
|
||||||
? InterceptTime.shortTime(new Date()) + InterceptTime.tzSuffix()
|
|
||||||
: new Date().toLocaleTimeString();
|
|
||||||
|
|
||||||
let parsedHtml = '';
|
|
||||||
if (data.parsed) {
|
|
||||||
const p = data.parsed;
|
|
||||||
if (type === 'position' && p.lat !== undefined) {
|
|
||||||
parsedHtml = `<div style="color:var(--accent-green);margin-top:2px;font-size:10px;">${p.lat.toFixed(4)}, ${p.lon.toFixed(4)}${p.flight_level ? ' • FL' + escapeHtml(String(p.flight_level)) : ''}${p.destination ? ' → ' + escapeHtml(String(p.destination)) : ''}</div>`;
|
|
||||||
} else if (type === 'engine_data') {
|
|
||||||
const parts = [];
|
|
||||||
Object.keys(p).forEach(k => {
|
|
||||||
const val = typeof p[k] === 'object' ? p[k].value : p[k];
|
|
||||||
parts.push(escapeHtml(k) + ': ' + escapeHtml(String(val)));
|
|
||||||
});
|
|
||||||
if (parts.length) parsedHtml = `<div style="color:#ff9500;margin-top:2px;font-size:10px;">${parts.slice(0, 4).join(' | ')}</div>`;
|
|
||||||
} else if (type === 'oooi' && p.origin) {
|
|
||||||
parsedHtml = `<div style="color:var(--accent-cyan);margin-top:2px;font-size:10px;">${escapeHtml(String(p.origin))} → ${escapeHtml(String(p.destination))}${p.out ? ' | OUT ' + escapeHtml(String(p.out)) : ''}${p.off ? ' OFF ' + escapeHtml(String(p.off)) : ''}${p.on ? ' ON ' + escapeHtml(String(p.on)) : ''}${p['in'] ? ' IN ' + escapeHtml(String(p['in'])) : ''}</div>`;
|
|
||||||
} else if (type === 'weather' && (p.wind_speed || p.temperature)) {
|
|
||||||
const wx = [];
|
|
||||||
if (p.wind_speed) wx.push('Wind ' + escapeHtml(String(p.wind_speed)) + (p.wind_dir ? '/' + escapeHtml(String(p.wind_dir)) : ''));
|
|
||||||
if (p.temperature) wx.push(escapeHtml(String(p.temperature)) + '°C');
|
|
||||||
if (p.turbulence) wx.push('Turb: ' + escapeHtml(String(p.turbulence)));
|
|
||||||
if (wx.length) parsedHtml = `<div style="color:#00d4ff;margin-top:2px;font-size:10px;">${wx.join(' | ')}</div>`;
|
|
||||||
} else if (type === 'cpdlc') {
|
|
||||||
const cpdlcText = p.message || p.text || '';
|
|
||||||
if (cpdlcText) parsedHtml = `<div style="color:#b388ff;margin-top:2px;font-size:10px;font-weight:600;">${escapeHtml(String(cpdlcText))}</div>`;
|
|
||||||
} else if (type === 'squawk' && p.squawk) {
|
|
||||||
parsedHtml = `<div style="color:#ff6b6b;margin-top:2px;font-size:10px;font-weight:600;">Squawk: ${escapeHtml(String(p.squawk))}</div>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return `<div class="acars-feed-card" style="padding:6px 8px;border-bottom:1px solid var(--border-color);animation:fadeInMsg 0.3s ease;">
|
|
||||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:2px;">
|
|
||||||
<span style="color:var(--accent-cyan);font-weight:bold;">${flight}${tail ? ' <span style="color:var(--text-muted);font-weight:normal;font-size:9px;">(' + tail + ')</span>' : ''}</span>
|
|
||||||
<span style="color:var(--text-muted);font-size:9px;">${time}</span>
|
|
||||||
</div>
|
|
||||||
<div style="margin-top:2px;">${badge} <span style="color:var(--text-primary);">${desc}</span></div>
|
|
||||||
${parsedHtml}
|
|
||||||
${truncText && type !== 'link_test' && type !== 'handshake' ? `<div style="color:var(--text-dim);font-family:var(--font-mono);font-size:9px;margin-top:3px;word-break:break-all;">${truncText}</div>` : ''}
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function startAcarsMainSSE() {
|
|
||||||
if (acarsMainEventSource) acarsMainEventSource.close();
|
|
||||||
|
|
||||||
const feed = document.getElementById('acarsMessageFeed');
|
|
||||||
if (feed && feed.querySelector('[style*="font-style: italic"]')) {
|
|
||||||
feed.innerHTML = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
acarsMainEventSource = new EventSource('/acars/stream');
|
|
||||||
acarsMainEventSource.onmessage = function(e) {
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(e.data);
|
|
||||||
if (data.type === 'acars') {
|
|
||||||
acarsMainMsgCount++;
|
|
||||||
document.getElementById('acarsMessageCount').textContent = acarsMainMsgCount;
|
|
||||||
|
|
||||||
// Add to message feed
|
|
||||||
const feed = document.getElementById('acarsMessageFeed');
|
|
||||||
if (feed) {
|
|
||||||
feed.insertAdjacentHTML('afterbegin', renderAcarsMainCard(data));
|
|
||||||
// Keep max 30 messages for RPi performance
|
|
||||||
while (feed.children.length > 30) {
|
|
||||||
feed.removeChild(feed.lastChild);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {}
|
|
||||||
};
|
|
||||||
|
|
||||||
acarsMainEventSource.onerror = function() {
|
|
||||||
setTimeout(() => {
|
|
||||||
const panel = document.getElementById('acarsMode');
|
|
||||||
if (panel && panel.classList.contains('active') &&
|
|
||||||
document.getElementById('stopAcarsBtn').style.display === 'block') {
|
|
||||||
startAcarsMainSSE();
|
|
||||||
}
|
|
||||||
}, 2000);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check initial status
|
|
||||||
fetch('/acars/status')
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(data => {
|
|
||||||
if (data.running) {
|
|
||||||
document.getElementById('startAcarsBtn').style.display = 'none';
|
|
||||||
document.getElementById('stopAcarsBtn').style.display = 'block';
|
|
||||||
document.getElementById('acarsStatusText').textContent = 'Listening';
|
|
||||||
document.getElementById('acarsStatusText').style.color = 'var(--accent-green)';
|
|
||||||
document.getElementById('acarsMessageCount').textContent = data.message_count || 0;
|
|
||||||
acarsMainMsgCount = data.message_count || 0;
|
|
||||||
startAcarsMainSSE();
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => {});
|
|
||||||
|
|
||||||
// Initialize frequency checkboxes
|
|
||||||
document.addEventListener('DOMContentLoaded', () => updateAcarsMainFreqs());
|
|
||||||
</script>
|
|
||||||
@@ -1,218 +0,0 @@
|
|||||||
<!-- AIS VESSEL TRACKING MODE -->
|
|
||||||
<div id="aisMode" class="mode-content" style="display: none;">
|
|
||||||
<div class="section">
|
|
||||||
<h3>AIS Vessel Tracking</h3>
|
|
||||||
<div class="info-text" style="margin-bottom: 15px;">
|
|
||||||
Track ships and vessels via AIS (Automatic Identification System) on 161.975 / 162.025 MHz.
|
|
||||||
</div>
|
|
||||||
<a href="/ais/dashboard" target="_blank" class="run-btn" style="display: inline-block; text-decoration: none; text-align: center; margin-bottom: 15px;">
|
|
||||||
Open AIS Dashboard
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="section">
|
|
||||||
<h3>Settings</h3>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Gain (dB, 0 = auto)</label>
|
|
||||||
<input type="number" id="aisGainInput" value="40" min="0" max="50" placeholder="0-50">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="section">
|
|
||||||
<h3>NMEA UDP Forward</h3>
|
|
||||||
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-bottom: 8px;">
|
|
||||||
Forward NMEA 0183 sentences to an external app (e.g. OpenCPN). Leave host blank to disable.
|
|
||||||
</p>
|
|
||||||
<div style="display: flex; gap: 8px;">
|
|
||||||
<div style="flex: 2;">
|
|
||||||
<label style="font-size: 10px; color: var(--text-dim);">Host</label>
|
|
||||||
<input type="text" id="aisUdpHost" placeholder="e.g. 192.168.1.10" style="width: 100%;">
|
|
||||||
</div>
|
|
||||||
<div style="flex: 1;">
|
|
||||||
<label style="font-size: 10px; color: var(--text-dim);">Port</label>
|
|
||||||
<input type="number" id="aisUdpPort" value="10110" min="1" max="65535" style="width: 100%;">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="section">
|
|
||||||
<h3>Status</h3>
|
|
||||||
<div id="aisStatusDisplay" class="info-text">
|
|
||||||
<p>Status: <span id="aisStatusText" style="color: var(--accent-yellow);">Standby</span></p>
|
|
||||||
<p>Vessels: <span id="aisVesselCount">0</span></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Antenna Guide -->
|
|
||||||
<div class="section">
|
|
||||||
<h3>Antenna Guide</h3>
|
|
||||||
<div style="font-size: 11px; color: var(--text-dim); line-height: 1.5;">
|
|
||||||
<p style="margin-bottom: 8px; color: var(--accent-cyan); font-weight: 600;">
|
|
||||||
Marine VHF band (162 MHz) — stock SDR antenna will NOT work well
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
|
|
||||||
<strong style="color: var(--accent-cyan); font-size: 12px;">Simple Dipole (Cheapest)</strong>
|
|
||||||
<ul style="margin: 6px 0 0 14px; padding: 0;">
|
|
||||||
<li><strong style="color: var(--text-primary);">Element length:</strong> ~46 cm each (quarter-wave at 162 MHz)</li>
|
|
||||||
<li><strong style="color: var(--text-primary);">Material:</strong> Wire, coat hanger, or copper rod</li>
|
|
||||||
<li><strong style="color: var(--text-primary);">Orientation:</strong> Vertical (AIS is vertically polarized)</li>
|
|
||||||
<li><strong style="color: var(--text-primary);">Placement:</strong> As high as possible with clear view of the water/harbor</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
|
|
||||||
<strong style="color: var(--accent-cyan); font-size: 12px;">Commercial Options</strong>
|
|
||||||
<ul style="margin: 6px 0 0 14px; padding: 0;">
|
|
||||||
<li><strong style="color: var(--text-primary);">Marine VHF whip:</strong> ~$20–50, designed for 156–163 MHz band</li>
|
|
||||||
<li><strong style="color: var(--text-primary);">Discone:</strong> ~$30–50, wideband coverage including marine VHF</li>
|
|
||||||
<li><strong style="color: var(--text-primary);">Collinear:</strong> Higher gain (~6 dBi), best for coastal monitoring</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
|
|
||||||
<strong style="color: var(--accent-cyan); font-size: 12px;">Placement Tips</strong>
|
|
||||||
<ul style="margin: 6px 0 0 14px; padding: 0;">
|
|
||||||
<li><strong style="color: var(--text-primary);">Height is critical:</strong> AIS is line-of-sight. Roof or mast mount is ideal</li>
|
|
||||||
<li><strong style="color: var(--text-primary);">Range:</strong> At 10m height, expect ~25 NM (46 km) range over water</li>
|
|
||||||
<li><strong style="color: var(--text-primary);">LNA:</strong> Nooelec Lana or similar broadband LNA, mount at antenna</li>
|
|
||||||
<li><strong style="color: var(--text-primary);">Coax:</strong> Keep cable short. RG-58 loses ~4 dB per 10m at 162 MHz</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px;">
|
|
||||||
<strong style="color: var(--accent-cyan); font-size: 12px;">Quick Reference</strong>
|
|
||||||
<table style="width: 100%; margin-top: 6px; font-size: 10px; border-collapse: collapse;">
|
|
||||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
|
||||||
<td style="padding: 3px 4px; color: var(--text-dim);">AIS Channel A</td>
|
|
||||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">161.975 MHz</td>
|
|
||||||
</tr>
|
|
||||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
|
||||||
<td style="padding: 3px 4px; color: var(--text-dim);">AIS Channel B</td>
|
|
||||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">162.025 MHz</td>
|
|
||||||
</tr>
|
|
||||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
|
||||||
<td style="padding: 3px 4px; color: var(--text-dim);">Quarter-wave length</td>
|
|
||||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">46 cm</td>
|
|
||||||
</tr>
|
|
||||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
|
||||||
<td style="padding: 3px 4px; color: var(--text-dim);">Modulation</td>
|
|
||||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">GMSK 9600 baud</td>
|
|
||||||
</tr>
|
|
||||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
|
||||||
<td style="padding: 3px 4px; color: var(--text-dim);">Bandwidth</td>
|
|
||||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">25 kHz</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 3px 4px; color: var(--text-dim);">Polarization</td>
|
|
||||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">Vertical</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button class="run-btn" id="startAisBtn" onclick="startAisTracking()">
|
|
||||||
Start AIS Tracking
|
|
||||||
</button>
|
|
||||||
<button class="stop-btn" id="stopAisBtn" onclick="stopAisTracking()" style="display: none;">
|
|
||||||
Stop AIS Tracking
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
let aisEventSource = null;
|
|
||||||
let aisVessels = {};
|
|
||||||
|
|
||||||
function startAisTracking() {
|
|
||||||
const gain = document.getElementById('aisGainInput').value || '40';
|
|
||||||
const device = document.getElementById('deviceSelect')?.value || '0';
|
|
||||||
const udpHost = document.getElementById('aisUdpHost').value.trim();
|
|
||||||
const udpPort = parseInt(document.getElementById('aisUdpPort').value) || 10110;
|
|
||||||
|
|
||||||
const body = {
|
|
||||||
device, gain,
|
|
||||||
bias_t: typeof getBiasTEnabled === 'function' ? getBiasTEnabled() : false,
|
|
||||||
};
|
|
||||||
if (udpHost) {
|
|
||||||
body.udp_host = udpHost;
|
|
||||||
body.udp_port = udpPort;
|
|
||||||
}
|
|
||||||
|
|
||||||
fetch('/ais/start', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(body)
|
|
||||||
})
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(data => {
|
|
||||||
if (data.status === 'started' || data.status === 'already_running') {
|
|
||||||
document.getElementById('startAisBtn').style.display = 'none';
|
|
||||||
document.getElementById('stopAisBtn').style.display = 'block';
|
|
||||||
document.getElementById('aisStatusText').textContent = 'Tracking';
|
|
||||||
document.getElementById('aisStatusText').style.color = 'var(--accent-green)';
|
|
||||||
startAisSSE();
|
|
||||||
} else {
|
|
||||||
alert(data.message || 'Failed to start AIS tracking');
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(err => alert('Error: ' + err.message));
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopAisTracking() {
|
|
||||||
fetch('/ais/stop', { method: 'POST' })
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(() => {
|
|
||||||
document.getElementById('startAisBtn').style.display = 'block';
|
|
||||||
document.getElementById('stopAisBtn').style.display = 'none';
|
|
||||||
document.getElementById('aisStatusText').textContent = 'Standby';
|
|
||||||
document.getElementById('aisStatusText').style.color = 'var(--accent-yellow)';
|
|
||||||
document.getElementById('aisVesselCount').textContent = '0';
|
|
||||||
if (aisEventSource) {
|
|
||||||
aisEventSource.close();
|
|
||||||
aisEventSource = null;
|
|
||||||
}
|
|
||||||
aisVessels = {};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function startAisSSE() {
|
|
||||||
if (aisEventSource) aisEventSource.close();
|
|
||||||
|
|
||||||
aisEventSource = new EventSource('/ais/stream');
|
|
||||||
aisEventSource.onmessage = function(e) {
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(e.data);
|
|
||||||
if (data.type === 'vessel') {
|
|
||||||
aisVessels[data.mmsi] = data;
|
|
||||||
document.getElementById('aisVesselCount').textContent = Object.keys(aisVessels).length;
|
|
||||||
}
|
|
||||||
} catch (err) {}
|
|
||||||
};
|
|
||||||
|
|
||||||
aisEventSource.onerror = function() {
|
|
||||||
setTimeout(() => {
|
|
||||||
const panel = document.getElementById('aisMode');
|
|
||||||
if (panel && panel.classList.contains('active') &&
|
|
||||||
document.getElementById('stopAisBtn').style.display === 'block') {
|
|
||||||
startAisSSE();
|
|
||||||
}
|
|
||||||
}, 2000);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check initial status
|
|
||||||
fetch('/ais/status')
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(data => {
|
|
||||||
if (data.tracking_active) {
|
|
||||||
document.getElementById('startAisBtn').style.display = 'none';
|
|
||||||
document.getElementById('stopAisBtn').style.display = 'block';
|
|
||||||
document.getElementById('aisStatusText').textContent = 'Tracking';
|
|
||||||
document.getElementById('aisStatusText').style.color = 'var(--accent-green)';
|
|
||||||
document.getElementById('aisVesselCount').textContent = data.vessel_count || 0;
|
|
||||||
startAisSSE();
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => {});
|
|
||||||
</script>
|
|
||||||
@@ -1,242 +0,0 @@
|
|||||||
<!-- VDL2 AIRCRAFT DATALINK MODE -->
|
|
||||||
<div id="vdl2Mode" class="mode-content" style="display: none;">
|
|
||||||
<div class="section">
|
|
||||||
<h3>VDL2 Datalink</h3>
|
|
||||||
<div class="info-text" style="margin-bottom: 15px;">
|
|
||||||
Decode VDL Mode 2 (VHF Digital Link) messages on ~136 MHz. VDL2 is the digital successor to ACARS, using D8PSK modulation for higher throughput aircraft datalink communications.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="section">
|
|
||||||
<h3>Region & Frequencies</h3>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Region</label>
|
|
||||||
<select id="vdl2RegionSelect" onchange="updateVdl2MainFreqs()" style="width: 100%;">
|
|
||||||
<option value="na">North America</option>
|
|
||||||
<option value="eu">Europe</option>
|
|
||||||
<option value="ap">Asia-Pacific</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div id="vdl2MainFreqSelector" style="display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 10px; font-size: 11px;">
|
|
||||||
<!-- Populated by JS -->
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Gain (dB, 0 = auto)</label>
|
|
||||||
<input type="number" id="vdl2GainInput" value="40" min="0" max="50" placeholder="0-50">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="section">
|
|
||||||
<h3>Status</h3>
|
|
||||||
<div id="vdl2StatusDisplay" class="info-text">
|
|
||||||
<p>Status: <span id="vdl2StatusText" style="color: var(--accent-yellow);">Standby</span></p>
|
|
||||||
<p>Messages: <span id="vdl2MessageCount">0</span></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Antenna Guide -->
|
|
||||||
<div class="section">
|
|
||||||
<h3>Antenna Guide</h3>
|
|
||||||
<div style="font-size: 11px; color: var(--text-dim); line-height: 1.5;">
|
|
||||||
<p style="margin-bottom: 8px; color: var(--accent-cyan); font-weight: 600;">
|
|
||||||
VHF Airband (~137 MHz) — stock SDR antenna may work at close range
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
|
|
||||||
<strong style="color: var(--accent-cyan); font-size: 12px;">Simple Dipole</strong>
|
|
||||||
<ul style="margin: 6px 0 0 14px; padding: 0;">
|
|
||||||
<li><strong style="color: var(--text-primary);">Element length:</strong> ~55 cm each (quarter-wave at 137 MHz)</li>
|
|
||||||
<li><strong style="color: var(--text-primary);">Material:</strong> Wire, coat hanger, or copper rod</li>
|
|
||||||
<li><strong style="color: var(--text-primary);">Orientation:</strong> Vertical (airband is vertically polarized)</li>
|
|
||||||
<li><strong style="color: var(--text-primary);">Placement:</strong> Outdoors, as high as possible with clear sky view</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px;">
|
|
||||||
<strong style="color: var(--accent-cyan); font-size: 12px;">Quick Reference</strong>
|
|
||||||
<table style="width: 100%; margin-top: 6px; font-size: 10px; border-collapse: collapse;">
|
|
||||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
|
||||||
<td style="padding: 3px 4px; color: var(--text-dim);">Primary (worldwide)</td>
|
|
||||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">136.975 MHz</td>
|
|
||||||
</tr>
|
|
||||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
|
||||||
<td style="padding: 3px 4px; color: var(--text-dim);">Quarter-wave length</td>
|
|
||||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">55 cm</td>
|
|
||||||
</tr>
|
|
||||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
|
||||||
<td style="padding: 3px 4px; color: var(--text-dim);">Modulation</td>
|
|
||||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">D8PSK 31.5 kbps</td>
|
|
||||||
</tr>
|
|
||||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
|
||||||
<td style="padding: 3px 4px; color: var(--text-dim);">Bandwidth</td>
|
|
||||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">25 kHz</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 3px 4px; color: var(--text-dim);">Polarization</td>
|
|
||||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">Vertical</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button class="run-btn" id="startVdl2Btn" onclick="startVdl2Mode()">
|
|
||||||
Start VDL2
|
|
||||||
</button>
|
|
||||||
<button class="stop-btn" id="stopVdl2Btn" onclick="stopVdl2Mode()" style="display: none;">
|
|
||||||
Stop VDL2
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
let vdl2MainEventSource = null;
|
|
||||||
let vdl2MainMsgCount = 0;
|
|
||||||
|
|
||||||
// VDL2 frequencies in Hz (as required by dumpvdl2)
|
|
||||||
const vdl2MainFrequencies = {
|
|
||||||
'na': ['136975000', '136100000', '136650000', '136700000', '136800000'],
|
|
||||||
'eu': ['136975000', '136675000', '136725000', '136775000', '136825000'],
|
|
||||||
'ap': ['136975000', '136900000']
|
|
||||||
};
|
|
||||||
|
|
||||||
// Display-friendly MHz labels
|
|
||||||
const vdl2FreqLabels = {
|
|
||||||
'136975000': '136.975',
|
|
||||||
'136100000': '136.100',
|
|
||||||
'136650000': '136.650',
|
|
||||||
'136700000': '136.700',
|
|
||||||
'136800000': '136.800',
|
|
||||||
'136675000': '136.675',
|
|
||||||
'136725000': '136.725',
|
|
||||||
'136775000': '136.775',
|
|
||||||
'136825000': '136.825',
|
|
||||||
'136900000': '136.900'
|
|
||||||
};
|
|
||||||
|
|
||||||
function updateVdl2MainFreqs() {
|
|
||||||
const region = document.getElementById('vdl2RegionSelect').value;
|
|
||||||
const freqs = vdl2MainFrequencies[region] || vdl2MainFrequencies['na'];
|
|
||||||
const container = document.getElementById('vdl2MainFreqSelector');
|
|
||||||
|
|
||||||
const previouslyChecked = new Set();
|
|
||||||
container.querySelectorAll('input:checked').forEach(cb => previouslyChecked.add(cb.value));
|
|
||||||
|
|
||||||
container.innerHTML = freqs.map(freq => {
|
|
||||||
const checked = previouslyChecked.size === 0 || previouslyChecked.has(freq) ? 'checked' : '';
|
|
||||||
const label = vdl2FreqLabels[freq] || freq;
|
|
||||||
return `
|
|
||||||
<label style="display: flex; align-items: center; gap: 3px; padding: 2px 6px; background: var(--bg-secondary); border-radius: 3px; cursor: pointer;">
|
|
||||||
<input type="checkbox" class="vdl2-main-freq-cb" value="${freq}" ${checked} style="margin: 0; cursor: pointer;">
|
|
||||||
<span>${label}</span>
|
|
||||||
</label>
|
|
||||||
`;
|
|
||||||
}).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function getVdl2MainSelectedFreqs() {
|
|
||||||
const checkboxes = document.querySelectorAll('.vdl2-main-freq-cb:checked');
|
|
||||||
const selected = Array.from(checkboxes).map(cb => cb.value);
|
|
||||||
if (selected.length === 0) {
|
|
||||||
const region = document.getElementById('vdl2RegionSelect').value;
|
|
||||||
return vdl2MainFrequencies[region] || vdl2MainFrequencies['na'];
|
|
||||||
}
|
|
||||||
return selected;
|
|
||||||
}
|
|
||||||
|
|
||||||
function startVdl2Mode() {
|
|
||||||
const gain = document.getElementById('vdl2GainInput').value || '40';
|
|
||||||
const device = document.getElementById('deviceSelect')?.value || '0';
|
|
||||||
const frequencies = getVdl2MainSelectedFreqs();
|
|
||||||
|
|
||||||
fetch('/vdl2/start', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ device, gain, frequencies })
|
|
||||||
})
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(data => {
|
|
||||||
if (data.status === 'started') {
|
|
||||||
document.getElementById('startVdl2Btn').style.display = 'none';
|
|
||||||
document.getElementById('stopVdl2Btn').style.display = 'block';
|
|
||||||
document.getElementById('vdl2StatusText').textContent = 'Listening';
|
|
||||||
document.getElementById('vdl2StatusText').style.color = 'var(--accent-green)';
|
|
||||||
vdl2MainMsgCount = 0;
|
|
||||||
startVdl2MainSSE();
|
|
||||||
} else {
|
|
||||||
alert(data.message || 'Failed to start VDL2');
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(err => alert('Error: ' + err.message));
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopVdl2Mode() {
|
|
||||||
fetch('/vdl2/stop', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ source: 'vdl2_mode' })
|
|
||||||
})
|
|
||||||
.then(async (r) => {
|
|
||||||
const text = await r.text();
|
|
||||||
const data = text ? JSON.parse(text) : {};
|
|
||||||
if (!r.ok || (data.status !== 'stopped' && data.status !== 'success')) {
|
|
||||||
throw new Error(data.message || `HTTP ${r.status}`);
|
|
||||||
}
|
|
||||||
return data;
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
document.getElementById('startVdl2Btn').style.display = 'block';
|
|
||||||
document.getElementById('stopVdl2Btn').style.display = 'none';
|
|
||||||
document.getElementById('vdl2StatusText').textContent = 'Standby';
|
|
||||||
document.getElementById('vdl2StatusText').style.color = 'var(--accent-yellow)';
|
|
||||||
if (vdl2MainEventSource) {
|
|
||||||
vdl2MainEventSource.close();
|
|
||||||
vdl2MainEventSource = null;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(err => alert('Failed to stop VDL2: ' + err.message));
|
|
||||||
}
|
|
||||||
|
|
||||||
function startVdl2MainSSE() {
|
|
||||||
if (vdl2MainEventSource) vdl2MainEventSource.close();
|
|
||||||
|
|
||||||
vdl2MainEventSource = new EventSource('/vdl2/stream');
|
|
||||||
vdl2MainEventSource.onmessage = function(e) {
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(e.data);
|
|
||||||
if (data.type === 'vdl2') {
|
|
||||||
vdl2MainMsgCount++;
|
|
||||||
document.getElementById('vdl2MessageCount').textContent = vdl2MainMsgCount;
|
|
||||||
}
|
|
||||||
} catch (err) {}
|
|
||||||
};
|
|
||||||
|
|
||||||
vdl2MainEventSource.onerror = function() {
|
|
||||||
setTimeout(() => {
|
|
||||||
const panel = document.getElementById('vdl2Mode');
|
|
||||||
if (panel && panel.classList.contains('active') &&
|
|
||||||
document.getElementById('stopVdl2Btn').style.display === 'block') {
|
|
||||||
startVdl2MainSSE();
|
|
||||||
}
|
|
||||||
}, 2000);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check initial status
|
|
||||||
fetch('/vdl2/status')
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(data => {
|
|
||||||
if (data.running) {
|
|
||||||
document.getElementById('startVdl2Btn').style.display = 'none';
|
|
||||||
document.getElementById('stopVdl2Btn').style.display = 'block';
|
|
||||||
document.getElementById('vdl2StatusText').textContent = 'Listening';
|
|
||||||
document.getElementById('vdl2StatusText').style.color = 'var(--accent-green)';
|
|
||||||
document.getElementById('vdl2MessageCount').textContent = data.message_count || 0;
|
|
||||||
vdl2MainMsgCount = data.message_count || 0;
|
|
||||||
startVdl2MainSSE();
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => {});
|
|
||||||
|
|
||||||
// Initialize frequency checkboxes
|
|
||||||
document.addEventListener('DOMContentLoaded', () => updateVdl2MainFreqs());
|
|
||||||
</script>
|
|
||||||
@@ -7,6 +7,11 @@ from unittest.mock import MagicMock, patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
# Must be set before importing app: stops the deferred background-init
|
||||||
|
# thread, whose subprocess/DB cleanup fires mid-session and races with
|
||||||
|
# test mocks (e.g. a patched subprocess.Popen catching its pkill call)
|
||||||
|
os.environ.setdefault("INTERCEPT_SKIP_DEFERRED_INIT", "1")
|
||||||
|
|
||||||
from app import app as flask_app
|
from app import app as flask_app
|
||||||
from routes import register_blueprints
|
from routes import register_blueprints
|
||||||
|
|
||||||
|
|||||||
+142
-132
@@ -25,10 +25,12 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|||||||
# Fixtures
|
# Fixtures
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def mode_manager():
|
def mode_manager():
|
||||||
"""Create a fresh ModeManager instance for testing."""
|
"""Create a fresh ModeManager instance for testing."""
|
||||||
from intercept_agent import ModeManager
|
from intercept_agent import ModeManager
|
||||||
|
|
||||||
manager = ModeManager()
|
manager = ModeManager()
|
||||||
yield manager
|
yield manager
|
||||||
# Cleanup: stop all modes
|
# Cleanup: stop all modes
|
||||||
@@ -40,15 +42,21 @@ def mode_manager():
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def mock_subprocess():
|
def mock_subprocess():
|
||||||
"""Mock subprocess.Popen for controlled testing."""
|
"""Mock subprocess.Popen for controlled testing."""
|
||||||
with patch('subprocess.Popen') as mock_popen:
|
with patch("subprocess.Popen") as mock_popen:
|
||||||
mock_proc = MagicMock()
|
mock_proc = MagicMock()
|
||||||
mock_proc.poll.return_value = None # Process is running
|
mock_proc.poll.return_value = None # Process is running
|
||||||
mock_proc.stdout = MagicMock()
|
mock_proc.stdout = MagicMock()
|
||||||
mock_proc.stderr = MagicMock()
|
mock_proc.stderr = MagicMock()
|
||||||
mock_proc.stderr.read.return_value = b''
|
mock_proc.stderr.read.return_value = b""
|
||||||
mock_proc.stdin = MagicMock()
|
mock_proc.stdin = MagicMock()
|
||||||
mock_proc.pid = 12345
|
mock_proc.pid = 12345
|
||||||
mock_proc.wait.return_value = 0
|
mock_proc.wait.return_value = 0
|
||||||
|
# subprocess.run() is built on Popen: it enters the context manager
|
||||||
|
# and unpacks communicate(); detect_capabilities() calls it with
|
||||||
|
# text=True, so return strings
|
||||||
|
mock_proc.communicate.return_value = ("", "")
|
||||||
|
mock_proc.returncode = 0
|
||||||
|
mock_proc.__enter__.return_value = mock_proc
|
||||||
mock_popen.return_value = mock_proc
|
mock_popen.return_value = mock_proc
|
||||||
yield mock_popen, mock_proc
|
yield mock_popen, mock_proc
|
||||||
|
|
||||||
@@ -57,19 +65,19 @@ def mock_subprocess():
|
|||||||
def mock_tools():
|
def mock_tools():
|
||||||
"""Mock tool availability checks."""
|
"""Mock tool availability checks."""
|
||||||
tools = {
|
tools = {
|
||||||
'rtl_433': '/usr/bin/rtl_433',
|
"rtl_433": "/usr/bin/rtl_433",
|
||||||
'rtl_fm': '/usr/bin/rtl_fm',
|
"rtl_fm": "/usr/bin/rtl_fm",
|
||||||
'dump1090': '/usr/bin/dump1090',
|
"dump1090": "/usr/bin/dump1090",
|
||||||
'multimon-ng': '/usr/bin/multimon-ng',
|
"multimon-ng": "/usr/bin/multimon-ng",
|
||||||
'airodump-ng': '/usr/sbin/airodump-ng',
|
"airodump-ng": "/usr/sbin/airodump-ng",
|
||||||
'acarsdec': '/usr/bin/acarsdec',
|
"acarsdec": "/usr/bin/acarsdec",
|
||||||
'AIS-catcher': '/usr/bin/AIS-catcher',
|
"AIS-catcher": "/usr/bin/AIS-catcher",
|
||||||
'direwolf': '/usr/bin/direwolf',
|
"direwolf": "/usr/bin/direwolf",
|
||||||
'rtlamr': '/usr/bin/rtlamr',
|
"rtlamr": "/usr/bin/rtlamr",
|
||||||
'rtl_tcp': '/usr/bin/rtl_tcp',
|
"rtl_tcp": "/usr/bin/rtl_tcp",
|
||||||
'bluetoothctl': '/usr/bin/bluetoothctl',
|
"bluetoothctl": "/usr/bin/bluetoothctl",
|
||||||
}
|
}
|
||||||
with patch('shutil.which', side_effect=lambda x: tools.get(x)):
|
with patch("shutil.which", side_effect=lambda x: tools.get(x)):
|
||||||
yield tools
|
yield tools
|
||||||
|
|
||||||
|
|
||||||
@@ -77,8 +85,8 @@ def mock_tools():
|
|||||||
# SDR Mode List
|
# SDR Mode List
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
SDR_MODES = ['sensor', 'adsb', 'pager', 'ais', 'acars', 'aprs', 'rtlamr', 'dsc', 'listening_post']
|
SDR_MODES = ["sensor", "adsb", "pager", "ais", "acars", "aprs", "rtlamr", "dsc", "listening_post"]
|
||||||
NON_SDR_MODES = ['wifi', 'bluetooth', 'tscm', 'satellite']
|
NON_SDR_MODES = ["wifi", "bluetooth", "tscm", "satellite"]
|
||||||
ALL_MODES = SDR_MODES + NON_SDR_MODES
|
ALL_MODES = SDR_MODES + NON_SDR_MODES
|
||||||
|
|
||||||
|
|
||||||
@@ -86,6 +94,7 @@ ALL_MODES = SDR_MODES + NON_SDR_MODES
|
|||||||
# Mode Lifecycle Tests
|
# Mode Lifecycle Tests
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
class TestModeLifecycle:
|
class TestModeLifecycle:
|
||||||
"""Test start/stop lifecycle for all modes."""
|
"""Test start/stop lifecycle for all modes."""
|
||||||
|
|
||||||
@@ -94,99 +103,88 @@ class TestModeLifecycle:
|
|||||||
mock_popen, mock_proc = mock_subprocess
|
mock_popen, mock_proc = mock_subprocess
|
||||||
|
|
||||||
# Start
|
# Start
|
||||||
result = mode_manager.start_mode('sensor', {'frequency': '433.92', 'device': '0'})
|
result = mode_manager.start_mode("sensor", {"frequency": "433.92", "device": "0"})
|
||||||
assert result['status'] == 'started'
|
assert result["status"] == "started"
|
||||||
assert 'sensor' in mode_manager.running_modes
|
assert "sensor" in mode_manager.running_modes
|
||||||
|
|
||||||
# Stop
|
# Stop
|
||||||
result = mode_manager.stop_mode('sensor')
|
result = mode_manager.stop_mode("sensor")
|
||||||
assert result['status'] == 'stopped'
|
assert result["status"] == "stopped"
|
||||||
assert 'sensor' not in mode_manager.running_modes
|
assert "sensor" not in mode_manager.running_modes
|
||||||
|
|
||||||
def test_adsb_mode_lifecycle(self, mode_manager, mock_subprocess, mock_tools):
|
def test_adsb_mode_lifecycle(self, mode_manager, mock_subprocess, mock_tools):
|
||||||
"""ADS-B mode should start and stop cleanly."""
|
"""ADS-B mode should start and stop cleanly."""
|
||||||
mock_popen, mock_proc = mock_subprocess
|
mock_popen, mock_proc = mock_subprocess
|
||||||
|
|
||||||
# Mock socket for SBS connection check
|
# Mock socket for SBS connection check
|
||||||
with patch('socket.socket') as mock_socket:
|
with patch("socket.socket") as mock_socket:
|
||||||
mock_sock = MagicMock()
|
mock_sock = MagicMock()
|
||||||
mock_sock.connect_ex.return_value = 1 # Port not in use
|
mock_sock.connect_ex.return_value = 1 # Port not in use
|
||||||
mock_socket.return_value = mock_sock
|
mock_socket.return_value = mock_sock
|
||||||
|
|
||||||
result = mode_manager.start_mode('adsb', {'device': '0', 'gain': '40'})
|
result = mode_manager.start_mode("adsb", {"device": "0", "gain": "40"})
|
||||||
# May fail due to SBS port check, but shouldn't crash
|
# May fail due to SBS port check, but shouldn't crash
|
||||||
assert result['status'] in ['started', 'error']
|
assert result["status"] in ["started", "error"]
|
||||||
|
|
||||||
def test_pager_mode_lifecycle(self, mode_manager, mock_subprocess, mock_tools):
|
def test_pager_mode_lifecycle(self, mode_manager, mock_subprocess, mock_tools):
|
||||||
"""Pager mode should start and stop cleanly."""
|
"""Pager mode should start and stop cleanly."""
|
||||||
mock_popen, mock_proc = mock_subprocess
|
mock_popen, mock_proc = mock_subprocess
|
||||||
|
|
||||||
result = mode_manager.start_mode('pager', {
|
result = mode_manager.start_mode("pager", {"frequency": "929.6125", "protocols": ["POCSAG512", "POCSAG1200"]})
|
||||||
'frequency': '929.6125',
|
assert result["status"] == "started"
|
||||||
'protocols': ['POCSAG512', 'POCSAG1200']
|
assert "pager" in mode_manager.running_modes
|
||||||
})
|
|
||||||
assert result['status'] == 'started'
|
|
||||||
assert 'pager' in mode_manager.running_modes
|
|
||||||
|
|
||||||
result = mode_manager.stop_mode('pager')
|
result = mode_manager.stop_mode("pager")
|
||||||
assert result['status'] == 'stopped'
|
assert result["status"] == "stopped"
|
||||||
|
|
||||||
def test_wifi_mode_lifecycle(self, mode_manager, mock_subprocess, mock_tools):
|
def test_wifi_mode_lifecycle(self, mode_manager, mock_subprocess, mock_tools):
|
||||||
"""WiFi mode should start and stop cleanly."""
|
"""WiFi mode should start and stop cleanly."""
|
||||||
mock_popen, mock_proc = mock_subprocess
|
mock_popen, mock_proc = mock_subprocess
|
||||||
|
|
||||||
# Mock glob for CSV file detection
|
# Mock glob for CSV file detection
|
||||||
with patch('glob.glob', return_value=[]), patch('tempfile.mkdtemp', return_value='/tmp/test'):
|
with patch("glob.glob", return_value=[]), patch("tempfile.mkdtemp", return_value="/tmp/test"):
|
||||||
result = mode_manager.start_mode('wifi', {
|
result = mode_manager.start_mode("wifi", {"interface": "wlan0", "scan_type": "quick"})
|
||||||
'interface': 'wlan0',
|
|
||||||
'scan_type': 'quick'
|
|
||||||
})
|
|
||||||
# Quick scan returns data directly
|
# Quick scan returns data directly
|
||||||
assert result['status'] in ['started', 'error', 'success']
|
assert result["status"] in ["started", "error", "success"]
|
||||||
|
|
||||||
def test_bluetooth_mode_lifecycle(self, mode_manager, mock_subprocess, mock_tools):
|
def test_bluetooth_mode_lifecycle(self, mode_manager, mock_subprocess, mock_tools):
|
||||||
"""Bluetooth mode should start and stop cleanly."""
|
"""Bluetooth mode should start and stop cleanly."""
|
||||||
mock_popen, mock_proc = mock_subprocess
|
mock_popen, mock_proc = mock_subprocess
|
||||||
|
|
||||||
result = mode_manager.start_mode('bluetooth', {'adapter': 'hci0'})
|
result = mode_manager.start_mode("bluetooth", {"adapter": "hci0"})
|
||||||
assert result['status'] == 'started'
|
assert result["status"] == "started"
|
||||||
assert 'bluetooth' in mode_manager.running_modes
|
assert "bluetooth" in mode_manager.running_modes
|
||||||
|
|
||||||
# Give thread time to start
|
# Give thread time to start
|
||||||
time.sleep(0.1)
|
time.sleep(0.1)
|
||||||
|
|
||||||
result = mode_manager.stop_mode('bluetooth')
|
result = mode_manager.stop_mode("bluetooth")
|
||||||
assert result['status'] == 'stopped'
|
assert result["status"] == "stopped"
|
||||||
|
|
||||||
def test_satellite_mode_lifecycle(self, mode_manager):
|
def test_satellite_mode_lifecycle(self, mode_manager):
|
||||||
"""Satellite mode should work without SDR."""
|
"""Satellite mode should work without SDR."""
|
||||||
# Satellite mode is computational only
|
# Patch the predictor loop — the real one downloads TLEs from
|
||||||
result = mode_manager.start_mode('satellite', {
|
# CelesTrak and keeps computing passes after the test finishes
|
||||||
'lat': 33.5,
|
with patch.object(type(mode_manager), "_satellite_predictor", MagicMock()):
|
||||||
'lon': -82.1,
|
result = mode_manager.start_mode("satellite", {"lat": 33.5, "lon": -82.1, "min_elevation": 10})
|
||||||
'min_elevation': 10
|
assert result["status"] in ["started", "error"] # May fail if skyfield not installed
|
||||||
})
|
|
||||||
assert result['status'] in ['started', 'error'] # May fail if skyfield not installed
|
|
||||||
|
|
||||||
def test_tscm_mode_lifecycle(self, mode_manager, mock_subprocess, mock_tools):
|
def test_tscm_mode_lifecycle(self, mode_manager, mock_subprocess, mock_tools):
|
||||||
"""TSCM mode should start and stop cleanly."""
|
"""TSCM mode should start and stop cleanly."""
|
||||||
mock_popen, mock_proc = mock_subprocess
|
mock_popen, mock_proc = mock_subprocess
|
||||||
|
|
||||||
result = mode_manager.start_mode('tscm', {
|
result = mode_manager.start_mode("tscm", {"wifi": True, "bluetooth": True, "rf": False})
|
||||||
'wifi': True,
|
assert result["status"] == "started"
|
||||||
'bluetooth': True,
|
|
||||||
'rf': False
|
|
||||||
})
|
|
||||||
assert result['status'] == 'started'
|
|
||||||
|
|
||||||
result = mode_manager.stop_mode('tscm')
|
result = mode_manager.stop_mode("tscm")
|
||||||
assert result['status'] == 'stopped'
|
assert result["status"] == "stopped"
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# SDR Conflict Detection Tests
|
# SDR Conflict Detection Tests
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
class TestSDRConflictDetection:
|
class TestSDRConflictDetection:
|
||||||
"""Test SDR device conflict detection."""
|
"""Test SDR device conflict detection."""
|
||||||
|
|
||||||
@@ -195,25 +193,25 @@ class TestSDRConflictDetection:
|
|||||||
mock_popen, mock_proc = mock_subprocess
|
mock_popen, mock_proc = mock_subprocess
|
||||||
|
|
||||||
# Start sensor on device 0
|
# Start sensor on device 0
|
||||||
result1 = mode_manager.start_mode('sensor', {'device': '0'})
|
result1 = mode_manager.start_mode("sensor", {"device": "0"})
|
||||||
assert result1['status'] == 'started'
|
assert result1["status"] == "started"
|
||||||
|
|
||||||
# Try to start pager on device 0 - should fail
|
# Try to start pager on device 0 - should fail
|
||||||
result2 = mode_manager.start_mode('pager', {'device': '0'})
|
result2 = mode_manager.start_mode("pager", {"device": "0"})
|
||||||
assert result2['status'] == 'error'
|
assert result2["status"] == "error"
|
||||||
assert 'in use' in result2['message'].lower()
|
assert "in use" in result2["message"].lower()
|
||||||
|
|
||||||
def test_different_device_no_conflict(self, mode_manager, mock_subprocess, mock_tools):
|
def test_different_device_no_conflict(self, mode_manager, mock_subprocess, mock_tools):
|
||||||
"""Starting SDR modes on different devices should work."""
|
"""Starting SDR modes on different devices should work."""
|
||||||
mock_popen, mock_proc = mock_subprocess
|
mock_popen, mock_proc = mock_subprocess
|
||||||
|
|
||||||
# Start sensor on device 0
|
# Start sensor on device 0
|
||||||
result1 = mode_manager.start_mode('sensor', {'device': '0'})
|
result1 = mode_manager.start_mode("sensor", {"device": "0"})
|
||||||
assert result1['status'] == 'started'
|
assert result1["status"] == "started"
|
||||||
|
|
||||||
# Start pager on device 1 - should work
|
# Start pager on device 1 - should work
|
||||||
result2 = mode_manager.start_mode('pager', {'device': '1'})
|
result2 = mode_manager.start_mode("pager", {"device": "1"})
|
||||||
assert result2['status'] == 'started'
|
assert result2["status"] == "started"
|
||||||
|
|
||||||
assert len(mode_manager.running_modes) == 2
|
assert len(mode_manager.running_modes) == 2
|
||||||
|
|
||||||
@@ -222,12 +220,12 @@ class TestSDRConflictDetection:
|
|||||||
mock_popen, mock_proc = mock_subprocess
|
mock_popen, mock_proc = mock_subprocess
|
||||||
|
|
||||||
# Start sensor (SDR)
|
# Start sensor (SDR)
|
||||||
result1 = mode_manager.start_mode('sensor', {'device': '0'})
|
result1 = mode_manager.start_mode("sensor", {"device": "0"})
|
||||||
assert result1['status'] == 'started'
|
assert result1["status"] == "started"
|
||||||
|
|
||||||
# Start bluetooth (non-SDR) - should work
|
# Start bluetooth (non-SDR) - should work
|
||||||
result2 = mode_manager.start_mode('bluetooth', {'adapter': 'hci0'})
|
result2 = mode_manager.start_mode("bluetooth", {"adapter": "hci0"})
|
||||||
assert result2['status'] == 'started'
|
assert result2["status"] == "started"
|
||||||
|
|
||||||
assert len(mode_manager.running_modes) == 2
|
assert len(mode_manager.running_modes) == 2
|
||||||
|
|
||||||
@@ -239,10 +237,10 @@ class TestSDRConflictDetection:
|
|||||||
assert mode_manager.get_sdr_in_use(0) is None
|
assert mode_manager.get_sdr_in_use(0) is None
|
||||||
|
|
||||||
# Start sensor
|
# Start sensor
|
||||||
mode_manager.start_mode('sensor', {'device': '0'})
|
mode_manager.start_mode("sensor", {"device": "0"})
|
||||||
|
|
||||||
# Device 0 now in use by sensor
|
# Device 0 now in use by sensor
|
||||||
assert mode_manager.get_sdr_in_use(0) == 'sensor'
|
assert mode_manager.get_sdr_in_use(0) == "sensor"
|
||||||
assert mode_manager.get_sdr_in_use(1) is None
|
assert mode_manager.get_sdr_in_use(1) is None
|
||||||
|
|
||||||
|
|
||||||
@@ -250,67 +248,73 @@ class TestSDRConflictDetection:
|
|||||||
# Process Verification Tests
|
# Process Verification Tests
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
class TestProcessVerification:
|
class TestProcessVerification:
|
||||||
"""Test process startup verification."""
|
"""Test process startup verification."""
|
||||||
|
|
||||||
def test_immediate_process_exit_detected(self, mode_manager, mock_tools):
|
def test_immediate_process_exit_detected(self, mode_manager, mock_tools):
|
||||||
"""Process that exits immediately should return error."""
|
"""Process that exits immediately should return error."""
|
||||||
with patch('subprocess.Popen') as mock_popen:
|
with patch("subprocess.Popen") as mock_popen:
|
||||||
mock_proc = MagicMock()
|
mock_proc = MagicMock()
|
||||||
mock_proc.poll.return_value = 1 # Process exited
|
mock_proc.poll.return_value = 1 # Process exited
|
||||||
mock_proc.stderr.read.return_value = b'device busy'
|
mock_proc.stderr.read.return_value = b"device busy"
|
||||||
|
mock_proc.communicate.return_value = ("", "")
|
||||||
|
mock_proc.__enter__.return_value = mock_proc
|
||||||
mock_popen.return_value = mock_proc
|
mock_popen.return_value = mock_proc
|
||||||
|
|
||||||
result = mode_manager.start_mode('sensor', {'device': '0'})
|
result = mode_manager.start_mode("sensor", {"device": "0"})
|
||||||
assert result['status'] == 'error'
|
assert result["status"] == "error"
|
||||||
assert 'sensor' not in mode_manager.running_modes
|
assert "sensor" not in mode_manager.running_modes
|
||||||
|
|
||||||
def test_running_process_accepted(self, mode_manager, mock_subprocess, mock_tools):
|
def test_running_process_accepted(self, mode_manager, mock_subprocess, mock_tools):
|
||||||
"""Process that stays running should be accepted."""
|
"""Process that stays running should be accepted."""
|
||||||
mock_popen, mock_proc = mock_subprocess
|
mock_popen, mock_proc = mock_subprocess
|
||||||
mock_proc.poll.return_value = None # Still running
|
mock_proc.poll.return_value = None # Still running
|
||||||
|
|
||||||
result = mode_manager.start_mode('sensor', {'device': '0'})
|
result = mode_manager.start_mode("sensor", {"device": "0"})
|
||||||
assert result['status'] == 'started'
|
assert result["status"] == "started"
|
||||||
assert 'sensor' in mode_manager.running_modes
|
assert "sensor" in mode_manager.running_modes
|
||||||
|
|
||||||
def test_error_message_from_stderr(self, mode_manager, mock_tools):
|
def test_error_message_from_stderr(self, mode_manager, mock_tools):
|
||||||
"""Error message should include stderr output."""
|
"""Error message should include stderr output."""
|
||||||
with patch('subprocess.Popen') as mock_popen:
|
with patch("subprocess.Popen") as mock_popen:
|
||||||
mock_proc = MagicMock()
|
mock_proc = MagicMock()
|
||||||
mock_proc.poll.return_value = 1
|
mock_proc.poll.return_value = 1
|
||||||
mock_proc.stderr.read.return_value = b'usb_claim_interface error -6'
|
mock_proc.stderr.read.return_value = b"usb_claim_interface error -6"
|
||||||
|
mock_proc.communicate.return_value = ("", "")
|
||||||
|
mock_proc.__enter__.return_value = mock_proc
|
||||||
mock_popen.return_value = mock_proc
|
mock_popen.return_value = mock_proc
|
||||||
|
|
||||||
result = mode_manager.start_mode('sensor', {'device': '0'})
|
result = mode_manager.start_mode("sensor", {"device": "0"})
|
||||||
assert result['status'] == 'error'
|
assert result["status"] == "error"
|
||||||
assert 'usb_claim_interface' in result['message'] or 'failed' in result['message'].lower()
|
assert "usb_claim_interface" in result["message"] or "failed" in result["message"].lower()
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Data Snapshot Tests
|
# Data Snapshot Tests
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
class TestDataSnapshots:
|
class TestDataSnapshots:
|
||||||
"""Test data snapshot operations."""
|
"""Test data snapshot operations."""
|
||||||
|
|
||||||
def test_get_mode_data_empty(self, mode_manager):
|
def test_get_mode_data_empty(self, mode_manager):
|
||||||
"""get_mode_data for non-running mode should return empty."""
|
"""get_mode_data for non-running mode should return empty."""
|
||||||
result = mode_manager.get_mode_data('sensor')
|
result = mode_manager.get_mode_data("sensor")
|
||||||
assert result['mode'] == 'sensor'
|
assert result["mode"] == "sensor"
|
||||||
# Mode not running - should have empty data or 'running' field
|
# Mode not running - should have empty data or 'running' field
|
||||||
assert result.get('running') is False or result.get('data') == [] or 'status' in result
|
assert result.get("running") is False or result.get("data") == [] or "status" in result
|
||||||
|
|
||||||
def test_get_mode_data_running(self, mode_manager, mock_subprocess, mock_tools):
|
def test_get_mode_data_running(self, mode_manager, mock_subprocess, mock_tools):
|
||||||
"""get_mode_data for running mode should return status."""
|
"""get_mode_data for running mode should return status."""
|
||||||
mock_popen, mock_proc = mock_subprocess
|
mock_popen, mock_proc = mock_subprocess
|
||||||
|
|
||||||
mode_manager.start_mode('sensor', {'device': '0'})
|
mode_manager.start_mode("sensor", {"device": "0"})
|
||||||
result = mode_manager.get_mode_data('sensor')
|
result = mode_manager.get_mode_data("sensor")
|
||||||
|
|
||||||
assert result['mode'] == 'sensor'
|
assert result["mode"] == "sensor"
|
||||||
# Mode is running - should indicate running status
|
# Mode is running - should indicate running status
|
||||||
assert result.get('running') is True or 'data' in result or 'status' in result
|
assert result.get("running") is True or "data" in result or "status" in result
|
||||||
|
|
||||||
def test_data_queue_limit(self, mode_manager):
|
def test_data_queue_limit(self, mode_manager):
|
||||||
"""Data queues should respect max size limits."""
|
"""Data queues should respect max size limits."""
|
||||||
@@ -321,7 +325,7 @@ class TestDataSnapshots:
|
|||||||
for i in range(150):
|
for i in range(150):
|
||||||
if test_queue.full():
|
if test_queue.full():
|
||||||
test_queue.get_nowait() # Remove old item
|
test_queue.get_nowait() # Remove old item
|
||||||
test_queue.put_nowait({'index': i})
|
test_queue.put_nowait({"index": i})
|
||||||
|
|
||||||
assert test_queue.qsize() <= 100
|
assert test_queue.qsize() <= 100
|
||||||
|
|
||||||
@@ -330,68 +334,73 @@ class TestDataSnapshots:
|
|||||||
# Mode Status Tests
|
# Mode Status Tests
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
class TestModeStatus:
|
class TestModeStatus:
|
||||||
"""Test mode status reporting."""
|
"""Test mode status reporting."""
|
||||||
|
|
||||||
def test_status_includes_all_modes(self, mode_manager):
|
def test_status_includes_all_modes(self, mode_manager):
|
||||||
"""Status should include all running modes."""
|
"""Status should include all running modes."""
|
||||||
status = mode_manager.get_status()
|
status = mode_manager.get_status()
|
||||||
assert 'running_modes' in status
|
assert "running_modes" in status
|
||||||
assert 'running_modes_detail' in status
|
assert "running_modes_detail" in status
|
||||||
assert isinstance(status['running_modes'], list)
|
assert isinstance(status["running_modes"], list)
|
||||||
|
|
||||||
def test_running_modes_detail_includes_device(self, mode_manager, mock_subprocess, mock_tools):
|
def test_running_modes_detail_includes_device(self, mode_manager, mock_subprocess, mock_tools):
|
||||||
"""Running modes detail should include device info."""
|
"""Running modes detail should include device info."""
|
||||||
mock_popen, mock_proc = mock_subprocess
|
mock_popen, mock_proc = mock_subprocess
|
||||||
|
|
||||||
mode_manager.start_mode('sensor', {'device': '0'})
|
mode_manager.start_mode("sensor", {"device": "0"})
|
||||||
status = mode_manager.get_status()
|
status = mode_manager.get_status()
|
||||||
|
|
||||||
assert 'sensor' in status['running_modes_detail']
|
assert "sensor" in status["running_modes_detail"]
|
||||||
detail = status['running_modes_detail']['sensor']
|
detail = status["running_modes_detail"]["sensor"]
|
||||||
assert 'device' in detail or 'params' in detail
|
assert "device" in detail or "params" in detail
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Error Handling Tests
|
# Error Handling Tests
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
class TestErrorHandling:
|
class TestErrorHandling:
|
||||||
"""Test error handling scenarios."""
|
"""Test error handling scenarios."""
|
||||||
|
|
||||||
def test_missing_tool_returns_error(self, mode_manager):
|
def test_missing_tool_returns_error(self, mode_manager):
|
||||||
"""Mode should fail gracefully if required tool is missing."""
|
"""Mode should fail gracefully if required tool is missing."""
|
||||||
with patch('shutil.which', return_value=None):
|
# get_tool_path checks Homebrew paths via os.path.isfile before
|
||||||
result = mode_manager.start_mode('sensor', {'device': '0'})
|
# shutil.which, so patch it too or installed tools are still found
|
||||||
assert result['status'] == 'error'
|
with patch("utils.dependencies.get_tool_path", return_value=None), patch("shutil.which", return_value=None):
|
||||||
|
result = mode_manager.start_mode("sensor", {"device": "0"})
|
||||||
|
assert result["status"] == "error"
|
||||||
# Error message may vary - check for common patterns
|
# Error message may vary - check for common patterns
|
||||||
msg = result['message'].lower()
|
msg = result["message"].lower()
|
||||||
assert 'not found' in msg or 'not available' in msg or 'missing' in msg
|
assert "not found" in msg or "not available" in msg or "missing" in msg
|
||||||
|
|
||||||
def test_invalid_mode_returns_error(self, mode_manager):
|
def test_invalid_mode_returns_error(self, mode_manager):
|
||||||
"""Invalid mode name should return error."""
|
"""Invalid mode name should return error."""
|
||||||
result = mode_manager.start_mode('invalid_mode', {})
|
result = mode_manager.start_mode("invalid_mode", {})
|
||||||
assert result['status'] == 'error'
|
assert result["status"] == "error"
|
||||||
|
|
||||||
def test_double_start_returns_already_running(self, mode_manager, mock_subprocess, mock_tools):
|
def test_double_start_returns_already_running(self, mode_manager, mock_subprocess, mock_tools):
|
||||||
"""Starting already-running mode should return appropriate status."""
|
"""Starting already-running mode should return appropriate status."""
|
||||||
mock_popen, mock_proc = mock_subprocess
|
mock_popen, mock_proc = mock_subprocess
|
||||||
|
|
||||||
mode_manager.start_mode('sensor', {'device': '0'})
|
mode_manager.start_mode("sensor", {"device": "0"})
|
||||||
result = mode_manager.start_mode('sensor', {'device': '0'})
|
result = mode_manager.start_mode("sensor", {"device": "0"})
|
||||||
|
|
||||||
assert result['status'] in ['already_running', 'error']
|
assert result["status"] in ["already_running", "error"]
|
||||||
|
|
||||||
def test_stop_non_running_mode(self, mode_manager):
|
def test_stop_non_running_mode(self, mode_manager):
|
||||||
"""Stopping non-running mode should handle gracefully."""
|
"""Stopping non-running mode should handle gracefully."""
|
||||||
result = mode_manager.stop_mode('sensor')
|
result = mode_manager.stop_mode("sensor")
|
||||||
assert result['status'] in ['stopped', 'not_running']
|
assert result["status"] in ["stopped", "not_running"]
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Cleanup Tests
|
# Cleanup Tests
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
class TestCleanup:
|
class TestCleanup:
|
||||||
"""Test mode cleanup on stop."""
|
"""Test mode cleanup on stop."""
|
||||||
|
|
||||||
@@ -399,8 +408,8 @@ class TestCleanup:
|
|||||||
"""Processes should be terminated when mode is stopped."""
|
"""Processes should be terminated when mode is stopped."""
|
||||||
mock_popen, mock_proc = mock_subprocess
|
mock_popen, mock_proc = mock_subprocess
|
||||||
|
|
||||||
mode_manager.start_mode('sensor', {'device': '0'})
|
mode_manager.start_mode("sensor", {"device": "0"})
|
||||||
mode_manager.stop_mode('sensor')
|
mode_manager.stop_mode("sensor")
|
||||||
|
|
||||||
# Verify terminate was called
|
# Verify terminate was called
|
||||||
mock_proc.terminate.assert_called()
|
mock_proc.terminate.assert_called()
|
||||||
@@ -409,20 +418,20 @@ class TestCleanup:
|
|||||||
"""Output threads should be stopped when mode is stopped."""
|
"""Output threads should be stopped when mode is stopped."""
|
||||||
mock_popen, mock_proc = mock_subprocess
|
mock_popen, mock_proc = mock_subprocess
|
||||||
|
|
||||||
mode_manager.start_mode('bluetooth', {'adapter': 'hci0'})
|
mode_manager.start_mode("bluetooth", {"adapter": "hci0"})
|
||||||
time.sleep(0.1) # Let thread start
|
time.sleep(0.1) # Let thread start
|
||||||
|
|
||||||
mode_manager.stop_mode('bluetooth')
|
mode_manager.stop_mode("bluetooth")
|
||||||
|
|
||||||
# Thread should no longer be in output_threads or should be stopped
|
# Thread should no longer be in output_threads or should be stopped
|
||||||
assert 'bluetooth' not in mode_manager.output_threads or \
|
assert "bluetooth" not in mode_manager.output_threads or not mode_manager.output_threads["bluetooth"].is_alive()
|
||||||
not mode_manager.output_threads['bluetooth'].is_alive()
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Multi-Mode Tests
|
# Multi-Mode Tests
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
class TestMultiMode:
|
class TestMultiMode:
|
||||||
"""Test multiple modes running simultaneously."""
|
"""Test multiple modes running simultaneously."""
|
||||||
|
|
||||||
@@ -430,19 +439,19 @@ class TestMultiMode:
|
|||||||
"""Multiple non-SDR modes should run simultaneously."""
|
"""Multiple non-SDR modes should run simultaneously."""
|
||||||
mock_popen, mock_proc = mock_subprocess
|
mock_popen, mock_proc = mock_subprocess
|
||||||
|
|
||||||
result1 = mode_manager.start_mode('bluetooth', {'adapter': 'hci0'})
|
result1 = mode_manager.start_mode("bluetooth", {"adapter": "hci0"})
|
||||||
result2 = mode_manager.start_mode('tscm', {'wifi': True, 'bluetooth': False})
|
result2 = mode_manager.start_mode("tscm", {"wifi": True, "bluetooth": False})
|
||||||
|
|
||||||
assert result1['status'] == 'started'
|
assert result1["status"] == "started"
|
||||||
assert result2['status'] == 'started'
|
assert result2["status"] == "started"
|
||||||
assert len(mode_manager.running_modes) == 2
|
assert len(mode_manager.running_modes) == 2
|
||||||
|
|
||||||
def test_stop_all_modes(self, mode_manager, mock_subprocess, mock_tools):
|
def test_stop_all_modes(self, mode_manager, mock_subprocess, mock_tools):
|
||||||
"""All modes should stop cleanly."""
|
"""All modes should stop cleanly."""
|
||||||
mock_popen, mock_proc = mock_subprocess
|
mock_popen, mock_proc = mock_subprocess
|
||||||
|
|
||||||
mode_manager.start_mode('sensor', {'device': '0'})
|
mode_manager.start_mode("sensor", {"device": "0"})
|
||||||
mode_manager.start_mode('bluetooth', {'adapter': 'hci0'})
|
mode_manager.start_mode("bluetooth", {"adapter": "hci0"})
|
||||||
|
|
||||||
# Stop all
|
# Stop all
|
||||||
for mode in list(mode_manager.running_modes.keys()):
|
for mode in list(mode_manager.running_modes.keys()):
|
||||||
@@ -455,26 +464,27 @@ class TestMultiMode:
|
|||||||
# GPS Integration Tests
|
# GPS Integration Tests
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
class TestGPSIntegration:
|
class TestGPSIntegration:
|
||||||
"""Test GPS coordinate integration."""
|
"""Test GPS coordinate integration."""
|
||||||
|
|
||||||
def test_status_includes_gps_flag(self, mode_manager):
|
def test_status_includes_gps_flag(self, mode_manager):
|
||||||
"""Status should indicate GPS availability."""
|
"""Status should indicate GPS availability."""
|
||||||
status = mode_manager.get_status()
|
status = mode_manager.get_status()
|
||||||
assert 'gps' in status
|
assert "gps" in status
|
||||||
|
|
||||||
def test_mode_start_includes_gps_flag(self, mode_manager, mock_subprocess, mock_tools):
|
def test_mode_start_includes_gps_flag(self, mode_manager, mock_subprocess, mock_tools):
|
||||||
"""Mode start response should include GPS status."""
|
"""Mode start response should include GPS status."""
|
||||||
mock_popen, mock_proc = mock_subprocess
|
mock_popen, mock_proc = mock_subprocess
|
||||||
|
|
||||||
result = mode_manager.start_mode('sensor', {'device': '0'})
|
result = mode_manager.start_mode("sensor", {"device": "0"})
|
||||||
if result['status'] == 'started':
|
if result["status"] == "started":
|
||||||
assert 'gps_enabled' in result
|
assert "gps_enabled" in result
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Run Tests
|
# Run Tests
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == "__main__":
|
||||||
pytest.main([__file__, '-v'])
|
pytest.main([__file__, "-v"])
|
||||||
|
|||||||
+177
-194
@@ -23,18 +23,19 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|||||||
# Fixtures
|
# Fixtures
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def setup_db(tmp_path):
|
def setup_db(tmp_path):
|
||||||
"""Set up a temporary database."""
|
"""Set up a temporary database."""
|
||||||
import utils.database as db_module
|
import utils.database as db_module
|
||||||
from utils.database import init_db
|
from utils.database import init_db
|
||||||
|
|
||||||
test_db_path = tmp_path / 'test.db'
|
test_db_path = tmp_path / "test.db"
|
||||||
original_db_path = db_module.DB_PATH
|
original_db_path = db_module.DB_PATH
|
||||||
db_module.DB_PATH = test_db_path
|
db_module.DB_PATH = test_db_path
|
||||||
db_module.DB_DIR = tmp_path
|
db_module.DB_DIR = tmp_path
|
||||||
|
|
||||||
if hasattr(db_module._local, 'connection') and db_module._local.connection:
|
if hasattr(db_module._local, "connection") and db_module._local.connection:
|
||||||
db_module._local.connection.close()
|
db_module._local.connection.close()
|
||||||
db_module._local.connection = None
|
db_module._local.connection = None
|
||||||
|
|
||||||
@@ -42,7 +43,7 @@ def setup_db(tmp_path):
|
|||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
if hasattr(db_module._local, 'connection') and db_module._local.connection:
|
if hasattr(db_module._local, "connection") and db_module._local.connection:
|
||||||
db_module._local.connection.close()
|
db_module._local.connection.close()
|
||||||
db_module._local.connection = None
|
db_module._local.connection = None
|
||||||
db_module.DB_PATH = original_db_path
|
db_module.DB_PATH = original_db_path
|
||||||
@@ -56,7 +57,7 @@ def app(setup_db):
|
|||||||
from routes.controller import controller_bp
|
from routes.controller import controller_bp
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
app.config['TESTING'] = True
|
app.config["TESTING"] = True
|
||||||
app.register_blueprint(controller_bp)
|
app.register_blueprint(controller_bp)
|
||||||
|
|
||||||
return app
|
return app
|
||||||
@@ -72,13 +73,14 @@ def client(app):
|
|||||||
def sample_agent(setup_db):
|
def sample_agent(setup_db):
|
||||||
"""Create a sample agent in database."""
|
"""Create a sample agent in database."""
|
||||||
from utils.database import create_agent
|
from utils.database import create_agent
|
||||||
|
|
||||||
agent_id = create_agent(
|
agent_id = create_agent(
|
||||||
name='test-sensor',
|
name="test-sensor",
|
||||||
base_url='http://192.168.1.50:8020',
|
base_url="http://192.168.1.50:8020",
|
||||||
api_key='test-key',
|
api_key="test-key",
|
||||||
description='Test sensor node',
|
description="Test sensor node",
|
||||||
capabilities={'adsb': True, 'wifi': True},
|
capabilities={"adsb": True, "wifi": True},
|
||||||
gps_coords={'lat': 40.7128, 'lon': -74.0060}
|
gps_coords={"lat": 40.7128, "lon": -74.0060},
|
||||||
)
|
)
|
||||||
return agent_id
|
return agent_id
|
||||||
|
|
||||||
@@ -87,125 +89,125 @@ def sample_agent(setup_db):
|
|||||||
# Agent CRUD Tests
|
# Agent CRUD Tests
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
class TestAgentCRUD:
|
class TestAgentCRUD:
|
||||||
"""Tests for agent CRUD operations."""
|
"""Tests for agent CRUD operations."""
|
||||||
|
|
||||||
def test_list_agents_empty(self, client):
|
def test_list_agents_empty(self, client):
|
||||||
"""GET /controller/agents should return empty list initially."""
|
"""GET /controller/agents should return empty list initially."""
|
||||||
response = client.get('/controller/agents')
|
response = client.get("/controller/agents")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
data = json.loads(response.data)
|
data = json.loads(response.data)
|
||||||
assert data['status'] == 'success'
|
assert data["status"] == "success"
|
||||||
assert data['agents'] == []
|
assert data["agents"] == []
|
||||||
assert data['count'] == 0
|
assert data["count"] == 0
|
||||||
|
|
||||||
def test_register_agent_success(self, client):
|
def test_register_agent_success(self, client):
|
||||||
"""POST /controller/agents should register new agent."""
|
"""POST /controller/agents should register new agent."""
|
||||||
with patch('routes.controller.AgentClient') as MockClient:
|
with patch("routes.controller.AgentClient") as MockClient:
|
||||||
# Mock successful capability fetch
|
# Mock successful capability fetch
|
||||||
mock_instance = Mock()
|
mock_instance = Mock()
|
||||||
mock_instance.get_capabilities.return_value = {
|
mock_instance.get_capabilities.return_value = {
|
||||||
'modes': {'adsb': True, 'wifi': True},
|
"modes": {"adsb": True, "wifi": True},
|
||||||
'devices': [{'name': 'RTL-SDR'}]
|
"devices": [{"name": "RTL-SDR"}],
|
||||||
}
|
}
|
||||||
MockClient.return_value = mock_instance
|
MockClient.return_value = mock_instance
|
||||||
|
|
||||||
response = client.post('/controller/agents',
|
response = client.post(
|
||||||
|
"/controller/agents",
|
||||||
json={
|
json={
|
||||||
'name': 'new-sensor',
|
"name": "new-sensor",
|
||||||
'base_url': 'http://192.168.1.51:8020',
|
"base_url": "http://192.168.1.51:8020",
|
||||||
'api_key': 'secret123',
|
"api_key": "secret123",
|
||||||
'description': 'New sensor node'
|
"description": "New sensor node",
|
||||||
},
|
},
|
||||||
content_type='application/json'
|
content_type="application/json",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 201
|
assert response.status_code == 201
|
||||||
data = json.loads(response.data)
|
data = json.loads(response.data)
|
||||||
assert data['status'] == 'success'
|
assert data["status"] == "success"
|
||||||
assert data['agent']['name'] == 'new-sensor'
|
assert data["agent"]["name"] == "new-sensor"
|
||||||
|
|
||||||
def test_register_agent_missing_name(self, client):
|
def test_register_agent_missing_name(self, client):
|
||||||
"""POST /controller/agents should reject missing name."""
|
"""POST /controller/agents should reject missing name."""
|
||||||
response = client.post('/controller/agents',
|
response = client.post(
|
||||||
json={'base_url': 'http://localhost:8020'},
|
"/controller/agents", json={"base_url": "http://localhost:8020"}, content_type="application/json"
|
||||||
content_type='application/json'
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
data = json.loads(response.data)
|
data = json.loads(response.data)
|
||||||
assert 'name is required' in data['message']
|
assert "name is required" in data["message"]
|
||||||
|
|
||||||
def test_register_agent_missing_url(self, client):
|
def test_register_agent_missing_url(self, client):
|
||||||
"""POST /controller/agents should reject missing URL."""
|
"""POST /controller/agents should reject missing URL."""
|
||||||
response = client.post('/controller/agents',
|
response = client.post("/controller/agents", json={"name": "test-sensor"}, content_type="application/json")
|
||||||
json={'name': 'test-sensor'},
|
|
||||||
content_type='application/json'
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
data = json.loads(response.data)
|
data = json.loads(response.data)
|
||||||
assert 'Base URL is required' in data['message']
|
assert "Base URL is required" in data["message"]
|
||||||
|
|
||||||
def test_register_agent_duplicate_name(self, client, sample_agent):
|
def test_register_agent_duplicate_name(self, client, sample_agent):
|
||||||
"""POST /controller/agents should reject duplicate name."""
|
"""POST /controller/agents should reject duplicate name."""
|
||||||
response = client.post('/controller/agents',
|
response = client.post(
|
||||||
|
"/controller/agents",
|
||||||
json={
|
json={
|
||||||
'name': 'test-sensor', # Same as sample_agent
|
"name": "test-sensor", # Same as sample_agent
|
||||||
'base_url': 'http://192.168.1.60:8020'
|
"base_url": "http://192.168.1.60:8020",
|
||||||
},
|
},
|
||||||
content_type='application/json'
|
content_type="application/json",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 409
|
assert response.status_code == 409
|
||||||
data = json.loads(response.data)
|
data = json.loads(response.data)
|
||||||
assert 'already exists' in data['message']
|
assert "already exists" in data["message"]
|
||||||
|
|
||||||
def test_list_agents_with_agents(self, client, sample_agent):
|
def test_list_agents_with_agents(self, client, sample_agent):
|
||||||
"""GET /controller/agents should return registered agents."""
|
"""GET /controller/agents should return registered agents."""
|
||||||
response = client.get('/controller/agents')
|
response = client.get("/controller/agents")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
data = json.loads(response.data)
|
data = json.loads(response.data)
|
||||||
assert data['count'] >= 1
|
assert data["count"] >= 1
|
||||||
|
|
||||||
names = [a['name'] for a in data['agents']]
|
names = [a["name"] for a in data["agents"]]
|
||||||
assert 'test-sensor' in names
|
assert "test-sensor" in names
|
||||||
|
|
||||||
def test_get_agent_detail(self, client, sample_agent):
|
def test_get_agent_detail(self, client, sample_agent):
|
||||||
"""GET /controller/agents/<id> should return agent details."""
|
"""GET /controller/agents/<id> should return agent details."""
|
||||||
response = client.get(f'/controller/agents/{sample_agent}')
|
response = client.get(f"/controller/agents/{sample_agent}")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
data = json.loads(response.data)
|
data = json.loads(response.data)
|
||||||
assert data['status'] == 'success'
|
assert data["status"] == "success"
|
||||||
assert data['agent']['name'] == 'test-sensor'
|
assert data["agent"]["name"] == "test-sensor"
|
||||||
assert data['agent']['capabilities']['adsb'] is True
|
assert data["agent"]["capabilities"]["adsb"] is True
|
||||||
|
|
||||||
def test_get_agent_not_found(self, client):
|
def test_get_agent_not_found(self, client):
|
||||||
"""GET /controller/agents/<id> should return 404 for missing agent."""
|
"""GET /controller/agents/<id> should return 404 for missing agent."""
|
||||||
response = client.get('/controller/agents/99999')
|
response = client.get("/controller/agents/99999")
|
||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
|
|
||||||
def test_update_agent(self, client, sample_agent):
|
def test_update_agent(self, client, sample_agent):
|
||||||
"""PATCH /controller/agents/<id> should update agent."""
|
"""PATCH /controller/agents/<id> should update agent."""
|
||||||
response = client.patch(f'/controller/agents/{sample_agent}',
|
response = client.patch(
|
||||||
json={'description': 'Updated description'},
|
f"/controller/agents/{sample_agent}",
|
||||||
content_type='application/json'
|
json={"description": "Updated description"},
|
||||||
|
content_type="application/json",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
data = json.loads(response.data)
|
data = json.loads(response.data)
|
||||||
assert data['agent']['description'] == 'Updated description'
|
assert data["agent"]["description"] == "Updated description"
|
||||||
|
|
||||||
def test_delete_agent(self, client, sample_agent):
|
def test_delete_agent(self, client, sample_agent):
|
||||||
"""DELETE /controller/agents/<id> should remove agent."""
|
"""DELETE /controller/agents/<id> should remove agent."""
|
||||||
response = client.delete(f'/controller/agents/{sample_agent}')
|
response = client.delete(f"/controller/agents/{sample_agent}")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
# Verify deleted
|
# Verify deleted
|
||||||
response = client.get(f'/controller/agents/{sample_agent}')
|
response = client.get(f"/controller/agents/{sample_agent}")
|
||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
@@ -213,345 +215,325 @@ class TestAgentCRUD:
|
|||||||
# Proxy Operation Tests
|
# Proxy Operation Tests
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
class TestProxyOperations:
|
class TestProxyOperations:
|
||||||
"""Tests for proxying operations to agents."""
|
"""Tests for proxying operations to agents."""
|
||||||
|
|
||||||
def test_proxy_start_mode(self, client, sample_agent):
|
def test_proxy_start_mode(self, client, sample_agent):
|
||||||
"""POST /controller/agents/<id>/<mode>/start should proxy to agent."""
|
"""POST /controller/agents/<id>/<mode>/start should proxy to agent."""
|
||||||
with patch('routes.controller.create_client_from_agent') as mock_create:
|
with patch("routes.controller.create_client_from_agent") as mock_create:
|
||||||
mock_client = Mock()
|
mock_client = Mock()
|
||||||
mock_client.start_mode.return_value = {'status': 'started', 'mode': 'adsb'}
|
mock_client.start_mode.return_value = {"status": "started", "mode": "adsb"}
|
||||||
mock_create.return_value = mock_client
|
mock_create.return_value = mock_client
|
||||||
|
|
||||||
response = client.post(
|
response = client.post(
|
||||||
f'/controller/agents/{sample_agent}/adsb/start',
|
f"/controller/agents/{sample_agent}/adsb/start",
|
||||||
json={'device_index': 0},
|
json={"device_index": 0},
|
||||||
content_type='application/json'
|
content_type="application/json",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
data = json.loads(response.data)
|
data = json.loads(response.data)
|
||||||
assert data['status'] == 'success'
|
assert data["status"] == "success"
|
||||||
assert data['mode'] == 'adsb'
|
assert data["mode"] == "adsb"
|
||||||
|
|
||||||
mock_client.start_mode.assert_called_once_with('adsb', {'device_index': 0})
|
mock_client.start_mode.assert_called_once_with("adsb", {"device_index": 0})
|
||||||
|
|
||||||
def test_proxy_stop_mode(self, client, sample_agent):
|
def test_proxy_stop_mode(self, client, sample_agent):
|
||||||
"""POST /controller/agents/<id>/<mode>/stop should proxy to agent."""
|
"""POST /controller/agents/<id>/<mode>/stop should proxy to agent."""
|
||||||
with patch('routes.controller.create_client_from_agent') as mock_create:
|
with patch("routes.controller.create_client_from_agent") as mock_create:
|
||||||
mock_client = Mock()
|
mock_client = Mock()
|
||||||
mock_client.stop_mode.return_value = {'status': 'stopped'}
|
mock_client.stop_mode.return_value = {"status": "stopped"}
|
||||||
mock_create.return_value = mock_client
|
mock_create.return_value = mock_client
|
||||||
|
|
||||||
response = client.post(
|
response = client.post(f"/controller/agents/{sample_agent}/wifi/stop", content_type="application/json")
|
||||||
f'/controller/agents/{sample_agent}/wifi/stop',
|
|
||||||
content_type='application/json'
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
data = json.loads(response.data)
|
data = json.loads(response.data)
|
||||||
assert data['status'] == 'success'
|
assert data["status"] == "success"
|
||||||
|
|
||||||
def test_proxy_get_mode_data(self, client, sample_agent):
|
def test_proxy_get_mode_data(self, client, sample_agent):
|
||||||
"""GET /controller/agents/<id>/<mode>/data should return data."""
|
"""GET /controller/agents/<id>/<mode>/data should return data."""
|
||||||
with patch('routes.controller.create_client_from_agent') as mock_create:
|
with patch("routes.controller.create_client_from_agent") as mock_create:
|
||||||
mock_client = Mock()
|
mock_client = Mock()
|
||||||
mock_client.get_mode_data.return_value = {
|
mock_client.get_mode_data.return_value = {"mode": "adsb", "data": [{"icao": "ABC123"}]}
|
||||||
'mode': 'adsb',
|
|
||||||
'data': [{'icao': 'ABC123'}]
|
|
||||||
}
|
|
||||||
mock_create.return_value = mock_client
|
mock_create.return_value = mock_client
|
||||||
|
|
||||||
response = client.get(f'/controller/agents/{sample_agent}/adsb/data')
|
response = client.get(f"/controller/agents/{sample_agent}/adsb/data")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
data = json.loads(response.data)
|
data = json.loads(response.data)
|
||||||
assert data['status'] == 'success'
|
assert data["status"] == "success"
|
||||||
assert 'agent_name' in data
|
assert "agent_name" in data
|
||||||
assert data['agent_name'] == 'test-sensor'
|
assert data["agent_name"] == "test-sensor"
|
||||||
|
|
||||||
def test_proxy_agent_not_found(self, client):
|
def test_proxy_agent_not_found(self, client):
|
||||||
"""Proxy operations should return 404 for missing agent."""
|
"""Proxy operations should return 404 for missing agent."""
|
||||||
response = client.post('/controller/agents/99999/adsb/start')
|
response = client.post("/controller/agents/99999/adsb/start")
|
||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
|
|
||||||
def test_proxy_connection_error(self, client, sample_agent):
|
def test_proxy_connection_error(self, client, sample_agent):
|
||||||
"""Proxy should return 503 when agent unreachable."""
|
"""Proxy should return 503 when agent unreachable."""
|
||||||
from utils.agent_client import AgentConnectionError
|
from utils.agent_client import AgentConnectionError
|
||||||
|
|
||||||
with patch('routes.controller.create_client_from_agent') as mock_create:
|
with patch("routes.controller.create_client_from_agent") as mock_create:
|
||||||
mock_client = Mock()
|
mock_client = Mock()
|
||||||
mock_client.start_mode.side_effect = AgentConnectionError("Connection refused")
|
mock_client.start_mode.side_effect = AgentConnectionError("Connection refused")
|
||||||
mock_create.return_value = mock_client
|
mock_create.return_value = mock_client
|
||||||
|
|
||||||
response = client.post(
|
response = client.post(
|
||||||
f'/controller/agents/{sample_agent}/adsb/start',
|
f"/controller/agents/{sample_agent}/adsb/start", json={}, content_type="application/json"
|
||||||
json={},
|
|
||||||
content_type='application/json'
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 503
|
assert response.status_code == 503
|
||||||
data = json.loads(response.data)
|
data = json.loads(response.data)
|
||||||
assert 'Cannot connect' in data['message']
|
assert "Cannot connect" in data["message"]
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Push Data Ingestion Tests
|
# Push Data Ingestion Tests
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
class TestPushIngestion:
|
class TestPushIngestion:
|
||||||
"""Tests for push data ingestion endpoint."""
|
"""Tests for push data ingestion endpoint."""
|
||||||
|
|
||||||
def test_ingest_success(self, client, sample_agent):
|
def test_ingest_success(self, client, sample_agent):
|
||||||
"""POST /controller/api/ingest should store payload."""
|
"""POST /controller/api/ingest should store payload."""
|
||||||
payload = {
|
payload = {
|
||||||
'agent_name': 'test-sensor',
|
"agent_name": "test-sensor",
|
||||||
'scan_type': 'adsb',
|
"scan_type": "adsb",
|
||||||
'interface': 'rtlsdr0',
|
"interface": "rtlsdr0",
|
||||||
'payload': {
|
"payload": {"aircraft": [{"icao": "ABC123", "altitude": 35000}]},
|
||||||
'aircraft': [{'icao': 'ABC123', 'altitude': 35000}]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
response = client.post('/controller/api/ingest',
|
response = client.post(
|
||||||
json=payload,
|
"/controller/api/ingest", json=payload, headers={"X-API-Key": "test-key"}, content_type="application/json"
|
||||||
headers={'X-API-Key': 'test-key'},
|
|
||||||
content_type='application/json'
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 202
|
assert response.status_code == 202
|
||||||
data = json.loads(response.data)
|
data = json.loads(response.data)
|
||||||
assert data['status'] == 'accepted'
|
assert data["status"] == "accepted"
|
||||||
assert 'payload_id' in data
|
assert "payload_id" in data
|
||||||
|
|
||||||
def test_ingest_unknown_agent(self, client):
|
def test_ingest_unknown_agent(self, client):
|
||||||
"""POST /controller/api/ingest should reject unknown agent."""
|
"""POST /controller/api/ingest should reject unknown agent."""
|
||||||
payload = {
|
payload = {"agent_name": "nonexistent-sensor", "scan_type": "adsb", "payload": {}}
|
||||||
'agent_name': 'nonexistent-sensor',
|
|
||||||
'scan_type': 'adsb',
|
|
||||||
'payload': {}
|
|
||||||
}
|
|
||||||
|
|
||||||
response = client.post('/controller/api/ingest',
|
response = client.post("/controller/api/ingest", json=payload, content_type="application/json")
|
||||||
json=payload,
|
|
||||||
content_type='application/json'
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 401
|
assert response.status_code == 401
|
||||||
data = json.loads(response.data)
|
data = json.loads(response.data)
|
||||||
assert 'Unknown agent' in data['message']
|
assert "Unknown agent" in data["message"]
|
||||||
|
|
||||||
def test_ingest_invalid_api_key(self, client, sample_agent):
|
def test_ingest_invalid_api_key(self, client, sample_agent):
|
||||||
"""POST /controller/api/ingest should reject invalid API key."""
|
"""POST /controller/api/ingest should reject invalid API key."""
|
||||||
payload = {
|
payload = {"agent_name": "test-sensor", "scan_type": "adsb", "payload": {}}
|
||||||
'agent_name': 'test-sensor',
|
|
||||||
'scan_type': 'adsb',
|
|
||||||
'payload': {}
|
|
||||||
}
|
|
||||||
|
|
||||||
response = client.post('/controller/api/ingest',
|
response = client.post(
|
||||||
json=payload,
|
"/controller/api/ingest", json=payload, headers={"X-API-Key": "wrong-key"}, content_type="application/json"
|
||||||
headers={'X-API-Key': 'wrong-key'},
|
|
||||||
content_type='application/json'
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 401
|
assert response.status_code == 401
|
||||||
data = json.loads(response.data)
|
data = json.loads(response.data)
|
||||||
assert 'Invalid API key' in data['message']
|
assert "Invalid API key" in data["message"]
|
||||||
|
|
||||||
def test_ingest_missing_agent_name(self, client):
|
def test_ingest_missing_agent_name(self, client):
|
||||||
"""POST /controller/api/ingest should require agent_name."""
|
"""POST /controller/api/ingest should require agent_name."""
|
||||||
response = client.post('/controller/api/ingest',
|
response = client.post(
|
||||||
json={'scan_type': 'adsb', 'payload': {}},
|
"/controller/api/ingest", json={"scan_type": "adsb", "payload": {}}, content_type="application/json"
|
||||||
content_type='application/json'
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
data = json.loads(response.data)
|
data = json.loads(response.data)
|
||||||
assert 'agent_name required' in data['message']
|
assert "agent_name required" in data["message"]
|
||||||
|
|
||||||
def test_get_payloads(self, client, sample_agent):
|
def test_get_payloads(self, client, sample_agent):
|
||||||
"""GET /controller/api/payloads should return stored payloads."""
|
"""GET /controller/api/payloads should return stored payloads."""
|
||||||
# First ingest some data
|
# First ingest some data
|
||||||
for i in range(3):
|
for i in range(3):
|
||||||
client.post('/controller/api/ingest',
|
client.post(
|
||||||
|
"/controller/api/ingest",
|
||||||
json={
|
json={
|
||||||
'agent_name': 'test-sensor',
|
"agent_name": "test-sensor",
|
||||||
'scan_type': 'adsb',
|
"scan_type": "adsb",
|
||||||
'payload': {'aircraft': [{'icao': f'TEST{i}'}]}
|
"payload": {"aircraft": [{"icao": f"TEST{i}"}]},
|
||||||
},
|
},
|
||||||
headers={'X-API-Key': 'test-key'},
|
headers={"X-API-Key": "test-key"},
|
||||||
content_type='application/json'
|
content_type="application/json",
|
||||||
)
|
)
|
||||||
|
|
||||||
response = client.get(f'/controller/api/payloads?agent_id={sample_agent}')
|
response = client.get(f"/controller/api/payloads?agent_id={sample_agent}")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
data = json.loads(response.data)
|
data = json.loads(response.data)
|
||||||
assert data['count'] == 3
|
assert data["count"] == 3
|
||||||
|
|
||||||
def test_get_payloads_filter_by_type(self, client, sample_agent):
|
def test_get_payloads_filter_by_type(self, client, sample_agent):
|
||||||
"""GET /controller/api/payloads should filter by scan_type."""
|
"""GET /controller/api/payloads should filter by scan_type."""
|
||||||
# Ingest mixed data
|
# Ingest mixed data
|
||||||
client.post('/controller/api/ingest',
|
client.post(
|
||||||
json={'agent_name': 'test-sensor', 'scan_type': 'adsb', 'payload': {}},
|
"/controller/api/ingest",
|
||||||
headers={'X-API-Key': 'test-key'},
|
json={"agent_name": "test-sensor", "scan_type": "adsb", "payload": {}},
|
||||||
content_type='application/json'
|
headers={"X-API-Key": "test-key"},
|
||||||
|
content_type="application/json",
|
||||||
)
|
)
|
||||||
client.post('/controller/api/ingest',
|
client.post(
|
||||||
json={'agent_name': 'test-sensor', 'scan_type': 'wifi', 'payload': {}},
|
"/controller/api/ingest",
|
||||||
headers={'X-API-Key': 'test-key'},
|
json={"agent_name": "test-sensor", "scan_type": "wifi", "payload": {}},
|
||||||
content_type='application/json'
|
headers={"X-API-Key": "test-key"},
|
||||||
|
content_type="application/json",
|
||||||
)
|
)
|
||||||
|
|
||||||
response = client.get('/controller/api/payloads?scan_type=adsb')
|
response = client.get("/controller/api/payloads?scan_type=adsb")
|
||||||
data = json.loads(response.data)
|
data = json.loads(response.data)
|
||||||
|
|
||||||
assert all(p['scan_type'] == 'adsb' for p in data['payloads'])
|
assert all(p["scan_type"] == "adsb" for p in data["payloads"])
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Location Estimation Tests
|
# Location Estimation Tests
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
class TestLocationEstimation:
|
class TestLocationEstimation:
|
||||||
"""Tests for device location estimation (trilateration)."""
|
"""Tests for device location estimation (trilateration)."""
|
||||||
|
|
||||||
def test_add_observation(self, client):
|
def test_add_observation(self, client):
|
||||||
"""POST /controller/api/location/observe should accept observation."""
|
"""POST /controller/api/location/observe should accept observation."""
|
||||||
response = client.post('/controller/api/location/observe',
|
response = client.post(
|
||||||
|
"/controller/api/location/observe",
|
||||||
json={
|
json={
|
||||||
'device_id': 'AA:BB:CC:DD:EE:FF',
|
"device_id": "AA:BB:CC:DD:EE:FF",
|
||||||
'agent_name': 'sensor-1',
|
"agent_name": "sensor-1",
|
||||||
'agent_lat': 40.7128,
|
"agent_lat": 40.7128,
|
||||||
'agent_lon': -74.0060,
|
"agent_lon": -74.0060,
|
||||||
'rssi': -55
|
"rssi": -55,
|
||||||
},
|
},
|
||||||
content_type='application/json'
|
content_type="application/json",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
data = json.loads(response.data)
|
data = json.loads(response.data)
|
||||||
assert data['status'] == 'success'
|
assert data["status"] == "success"
|
||||||
assert data['device_id'] == 'AA:BB:CC:DD:EE:FF'
|
assert data["device_id"] == "AA:BB:CC:DD:EE:FF"
|
||||||
|
|
||||||
def test_add_observation_missing_fields(self, client):
|
def test_add_observation_missing_fields(self, client):
|
||||||
"""POST /controller/api/location/observe should require all fields."""
|
"""POST /controller/api/location/observe should require all fields."""
|
||||||
response = client.post('/controller/api/location/observe',
|
response = client.post(
|
||||||
|
"/controller/api/location/observe",
|
||||||
json={
|
json={
|
||||||
'device_id': 'AA:BB:CC:DD:EE:FF',
|
"device_id": "AA:BB:CC:DD:EE:FF",
|
||||||
'rssi': -55
|
"rssi": -55,
|
||||||
# Missing agent_name, agent_lat, agent_lon
|
# Missing agent_name, agent_lat, agent_lon
|
||||||
},
|
},
|
||||||
content_type='application/json'
|
content_type="application/json",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
|
|
||||||
def test_estimate_location(self, client):
|
def test_estimate_location(self, client):
|
||||||
"""POST /controller/api/location/estimate should compute location."""
|
"""POST /controller/api/location/estimate should compute location."""
|
||||||
response = client.post('/controller/api/location/estimate',
|
response = client.post(
|
||||||
|
"/controller/api/location/estimate",
|
||||||
json={
|
json={
|
||||||
'observations': [
|
"observations": [
|
||||||
{'agent_lat': 40.7128, 'agent_lon': -74.0060, 'rssi': -55, 'agent_name': 'node-1'},
|
{"agent_lat": 40.7128, "agent_lon": -74.0060, "rssi": -55, "agent_name": "node-1"},
|
||||||
{'agent_lat': 40.7135, 'agent_lon': -74.0055, 'rssi': -70, 'agent_name': 'node-2'},
|
{"agent_lat": 40.7135, "agent_lon": -74.0055, "rssi": -70, "agent_name": "node-2"},
|
||||||
{'agent_lat': 40.7120, 'agent_lon': -74.0050, 'rssi': -62, 'agent_name': 'node-3'}
|
{"agent_lat": 40.7120, "agent_lon": -74.0050, "rssi": -62, "agent_name": "node-3"},
|
||||||
],
|
],
|
||||||
'environment': 'outdoor'
|
"environment": "outdoor",
|
||||||
},
|
},
|
||||||
content_type='application/json'
|
content_type="application/json",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
data = json.loads(response.data)
|
data = json.loads(response.data)
|
||||||
# Should have computed a location
|
# Should have computed a location
|
||||||
if data['location']:
|
if data["location"]:
|
||||||
assert 'lat' in data['location']
|
assert "latitude" in data["location"]
|
||||||
assert 'lon' in data['location']
|
assert "longitude" in data["location"]
|
||||||
|
|
||||||
def test_estimate_location_insufficient_data(self, client):
|
def test_estimate_location_insufficient_data(self, client):
|
||||||
"""Estimation should require at least 2 observations."""
|
"""Estimation should require at least 2 observations."""
|
||||||
response = client.post('/controller/api/location/estimate',
|
response = client.post(
|
||||||
json={
|
"/controller/api/location/estimate",
|
||||||
'observations': [
|
json={"observations": [{"agent_lat": 40.7128, "agent_lon": -74.0060, "rssi": -55, "agent_name": "node-1"}]},
|
||||||
{'agent_lat': 40.7128, 'agent_lon': -74.0060, 'rssi': -55, 'agent_name': 'node-1'}
|
content_type="application/json",
|
||||||
]
|
|
||||||
},
|
|
||||||
content_type='application/json'
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
data = json.loads(response.data)
|
data = json.loads(response.data)
|
||||||
assert 'At least 2' in data['message']
|
assert "At least 2" in data["message"]
|
||||||
|
|
||||||
def test_get_device_location_not_found(self, client):
|
def test_get_device_location_not_found(self, client):
|
||||||
"""GET /controller/api/location/<device_id> returns not_found for unknown device."""
|
"""GET /controller/api/location/<device_id> returns not_found for unknown device."""
|
||||||
response = client.get('/controller/api/location/unknown-device')
|
response = client.get("/controller/api/location/unknown-device")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
data = json.loads(response.data)
|
data = json.loads(response.data)
|
||||||
assert data['status'] == 'not_found'
|
assert data["status"] == "not_found"
|
||||||
assert data['location'] is None
|
assert data["location"] is None
|
||||||
|
|
||||||
def test_get_all_locations(self, client):
|
def test_get_all_locations(self, client):
|
||||||
"""GET /controller/api/location/all should return all estimates."""
|
"""GET /controller/api/location/all should return all estimates."""
|
||||||
response = client.get('/controller/api/location/all')
|
response = client.get("/controller/api/location/all")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
data = json.loads(response.data)
|
data = json.loads(response.data)
|
||||||
assert data['status'] == 'success'
|
assert data["status"] == "success"
|
||||||
assert 'devices' in data
|
assert "devices" in data
|
||||||
|
|
||||||
def test_get_devices_near(self, client):
|
def test_get_devices_near(self, client):
|
||||||
"""GET /controller/api/location/near should find nearby devices."""
|
"""GET /controller/api/location/near should find nearby devices."""
|
||||||
response = client.get(
|
response = client.get(
|
||||||
'/controller/api/location/near',
|
"/controller/api/location/near", query_string={"lat": 40.7128, "lon": -74.0060, "radius": 100}
|
||||||
query_string={'lat': 40.7128, 'lon': -74.0060, 'radius': 100}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
data = json.loads(response.data)
|
data = json.loads(response.data)
|
||||||
assert data['status'] == 'success'
|
assert data["status"] == "success"
|
||||||
assert data['center']['lat'] == 40.7128
|
assert data["center"]["lat"] == 40.7128
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Agent Refresh Tests
|
# Agent Refresh Tests
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
class TestAgentRefresh:
|
class TestAgentRefresh:
|
||||||
"""Tests for agent refresh operations."""
|
"""Tests for agent refresh operations."""
|
||||||
|
|
||||||
def test_refresh_agent_success(self, client, sample_agent):
|
def test_refresh_agent_success(self, client, sample_agent):
|
||||||
"""POST /controller/agents/<id>/refresh should update metadata."""
|
"""POST /controller/agents/<id>/refresh should update metadata."""
|
||||||
with patch('routes.controller.create_client_from_agent') as mock_create:
|
with patch("routes.controller.create_client_from_agent") as mock_create:
|
||||||
mock_client = Mock()
|
mock_client = Mock()
|
||||||
mock_client.refresh_metadata.return_value = {
|
mock_client.refresh_metadata.return_value = {
|
||||||
'healthy': True,
|
"healthy": True,
|
||||||
'capabilities': {
|
"capabilities": {
|
||||||
'modes': {'adsb': True, 'wifi': True, 'bluetooth': True},
|
"modes": {"adsb": True, "wifi": True, "bluetooth": True},
|
||||||
'devices': [{'name': 'RTL-SDR V3'}]
|
"devices": [{"name": "RTL-SDR V3"}],
|
||||||
},
|
},
|
||||||
'status': {'running_modes': ['adsb']},
|
"status": {"running_modes": ["adsb"]},
|
||||||
'config': {}
|
"config": {},
|
||||||
}
|
}
|
||||||
mock_create.return_value = mock_client
|
mock_create.return_value = mock_client
|
||||||
|
|
||||||
response = client.post(f'/controller/agents/{sample_agent}/refresh')
|
response = client.post(f"/controller/agents/{sample_agent}/refresh")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
data = json.loads(response.data)
|
data = json.loads(response.data)
|
||||||
assert data['status'] == 'success'
|
assert data["status"] == "success"
|
||||||
assert data['metadata']['healthy'] is True
|
assert data["metadata"]["healthy"] is True
|
||||||
|
|
||||||
def test_refresh_agent_unreachable(self, client, sample_agent):
|
def test_refresh_agent_unreachable(self, client, sample_agent):
|
||||||
"""POST /controller/agents/<id>/refresh should return 503 if unreachable."""
|
"""POST /controller/agents/<id>/refresh should return 503 if unreachable."""
|
||||||
with patch('routes.controller.create_client_from_agent') as mock_create:
|
with patch("routes.controller.create_client_from_agent") as mock_create:
|
||||||
mock_client = Mock()
|
mock_client = Mock()
|
||||||
mock_client.refresh_metadata.return_value = {'healthy': False}
|
mock_client.refresh_metadata.return_value = {"healthy": False}
|
||||||
mock_create.return_value = mock_client
|
mock_create.return_value = mock_client
|
||||||
|
|
||||||
response = client.post(f'/controller/agents/{sample_agent}/refresh')
|
response = client.post(f"/controller/agents/{sample_agent}/refresh")
|
||||||
|
|
||||||
assert response.status_code == 503
|
assert response.status_code == 503
|
||||||
|
|
||||||
@@ -560,6 +542,7 @@ class TestAgentRefresh:
|
|||||||
# SSE Stream Tests
|
# SSE Stream Tests
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
class TestSSEStream:
|
class TestSSEStream:
|
||||||
"""Tests for SSE streaming endpoint."""
|
"""Tests for SSE streaming endpoint."""
|
||||||
|
|
||||||
@@ -567,5 +550,5 @@ class TestSSEStream:
|
|||||||
"""GET /controller/stream/all should exist and return SSE."""
|
"""GET /controller/stream/all should exist and return SSE."""
|
||||||
# Just verify the endpoint is accessible
|
# Just verify the endpoint is accessible
|
||||||
# Full SSE testing requires more complex setup
|
# Full SSE testing requires more complex setup
|
||||||
response = client.get('/controller/stream/all')
|
response = client.get("/controller/stream/all")
|
||||||
assert response.content_type == 'text/event-stream'
|
assert response.mimetype == "text/event-stream"
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ class TestMeshcoreClientStateMachine:
|
|||||||
client.get_queue().get_nowait()
|
client.get_queue().get_nowait()
|
||||||
# Call on_connected directly (simulating what AsyncWorker would call)
|
# Call on_connected directly (simulating what AsyncWorker would call)
|
||||||
client.on_connected(transport="serial", device="/dev/ttyUSB0")
|
client.on_connected(transport="serial", device="/dev/ttyUSB0")
|
||||||
assert client.get_state() == ConnectionState.CONNECTED
|
assert client.get_state()[0] == ConnectionState.CONNECTED
|
||||||
event = client.get_queue().get_nowait()
|
event = client.get_queue().get_nowait()
|
||||||
assert event["type"] == "status"
|
assert event["type"] == "status"
|
||||||
assert event["data"]["state"] == "connected"
|
assert event["data"]["state"] == "connected"
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ class TestConnectionStateTransitions:
|
|||||||
client = MeshcoreClient()
|
client = MeshcoreClient()
|
||||||
client.on_connected(transport="serial", device="/dev/ttyUSB0")
|
client.on_connected(transport="serial", device="/dev/ttyUSB0")
|
||||||
|
|
||||||
assert client.get_state() == ConnectionState.CONNECTED
|
assert client.get_state()[0] == ConnectionState.CONNECTED
|
||||||
event = client.get_queue().get_nowait()
|
event = client.get_queue().get_nowait()
|
||||||
assert event["type"] == "status"
|
assert event["type"] == "status"
|
||||||
assert event["data"]["state"] == "connected"
|
assert event["data"]["state"] == "connected"
|
||||||
@@ -175,7 +175,7 @@ class TestConnectionStateTransitions:
|
|||||||
client = MeshcoreClient()
|
client = MeshcoreClient()
|
||||||
client.on_error("timeout")
|
client.on_error("timeout")
|
||||||
|
|
||||||
assert client.get_state() == ConnectionState.ERROR
|
assert client.get_state()[0] == ConnectionState.ERROR
|
||||||
event = client.get_queue().get_nowait()
|
event = client.get_queue().get_nowait()
|
||||||
assert event["data"]["state"] == "error"
|
assert event["data"]["state"] == "error"
|
||||||
assert event["data"].get("message") == "timeout"
|
assert event["data"].get("message") == "timeout"
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ def client(app):
|
|||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def mock_meshcore_client():
|
def mock_meshcore_client():
|
||||||
mc = MagicMock()
|
mc = MagicMock()
|
||||||
mc.get_state.return_value = MagicMock(value="disconnected")
|
mc.get_state.return_value = (MagicMock(value="disconnected"), None)
|
||||||
mc.get_messages.return_value = []
|
mc.get_messages.return_value = []
|
||||||
mc.get_nodes.return_value = []
|
mc.get_nodes.return_value = []
|
||||||
mc.get_repeaters.return_value = []
|
mc.get_repeaters.return_value = []
|
||||||
|
|||||||
@@ -8,6 +8,23 @@ from flask import Flask
|
|||||||
from routes.satellite import satellite_bp
|
from routes.satellite import satellite_bp
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _isolate_tle_state(monkeypatch):
|
||||||
|
"""Keep TLE updates off the real data/satellites.py and reset the cache.
|
||||||
|
|
||||||
|
Without this, the update-tle test rewrites the tracked data file on
|
||||||
|
every run and leaks 'ISS' into the module-global cache, breaking
|
||||||
|
later tests that depend on cache contents.
|
||||||
|
"""
|
||||||
|
import routes.satellite as sat
|
||||||
|
|
||||||
|
monkeypatch.setattr(sat, "_persist_tle_cache", lambda: None)
|
||||||
|
saved = dict(sat._tle_cache)
|
||||||
|
yield
|
||||||
|
sat._tle_cache.clear()
|
||||||
|
sat._tle_cache.update(saved)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def app():
|
def app():
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
|||||||
@@ -7,94 +7,115 @@ import pytest
|
|||||||
def auth_client(client):
|
def auth_client(client):
|
||||||
"""Client with logged-in session."""
|
"""Client with logged-in session."""
|
||||||
with client.session_transaction() as sess:
|
with client.session_transaction() as sess:
|
||||||
sess['logged_in'] = True
|
sess["logged_in"] = True
|
||||||
return client
|
return client
|
||||||
|
|
||||||
|
|
||||||
def test_signal_guess_fm_broadcast(auth_client):
|
def test_signal_guess_fm_broadcast(auth_client):
|
||||||
"""FM broadcast frequency should return a known signal type."""
|
"""FM broadcast frequency should return a known signal type."""
|
||||||
resp = auth_client.post('/listening/signal/guess', json={
|
resp = auth_client.post(
|
||||||
'frequency_mhz': 98.1,
|
"/receiver/signal/guess",
|
||||||
'modulation': 'wfm',
|
json={
|
||||||
})
|
"frequency_mhz": 98.1,
|
||||||
|
"modulation": "wfm",
|
||||||
|
},
|
||||||
|
)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
data = resp.get_json()
|
data = resp.get_json()
|
||||||
assert data['status'] == 'ok'
|
assert data["status"] == "ok"
|
||||||
assert data['primary_label']
|
assert data["primary_label"]
|
||||||
assert data['confidence'] in ('HIGH', 'MEDIUM', 'LOW')
|
assert data["confidence"] in ("HIGH", "MEDIUM", "LOW")
|
||||||
|
|
||||||
|
|
||||||
def test_signal_guess_airband(auth_client):
|
def test_signal_guess_airband(auth_client):
|
||||||
"""Airband frequency should be identified."""
|
"""Airband frequency should be identified."""
|
||||||
resp = auth_client.post('/listening/signal/guess', json={
|
resp = auth_client.post(
|
||||||
'frequency_mhz': 121.5,
|
"/receiver/signal/guess",
|
||||||
'modulation': 'am',
|
json={
|
||||||
})
|
"frequency_mhz": 121.5,
|
||||||
|
"modulation": "am",
|
||||||
|
},
|
||||||
|
)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
data = resp.get_json()
|
data = resp.get_json()
|
||||||
assert data['status'] == 'ok'
|
assert data["status"] == "ok"
|
||||||
assert data['primary_label']
|
assert data["primary_label"]
|
||||||
|
|
||||||
|
|
||||||
def test_signal_guess_ism_band(auth_client):
|
def test_signal_guess_ism_band(auth_client):
|
||||||
"""ISM band frequency (433.92 MHz) should be identified."""
|
"""ISM band frequency (433.92 MHz) should be identified."""
|
||||||
resp = auth_client.post('/listening/signal/guess', json={
|
resp = auth_client.post(
|
||||||
'frequency_mhz': 433.92,
|
"/receiver/signal/guess",
|
||||||
})
|
json={
|
||||||
|
"frequency_mhz": 433.92,
|
||||||
|
},
|
||||||
|
)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
data = resp.get_json()
|
data = resp.get_json()
|
||||||
assert data['status'] == 'ok'
|
assert data["status"] == "ok"
|
||||||
assert data['primary_label']
|
assert data["primary_label"]
|
||||||
assert data['confidence'] in ('HIGH', 'MEDIUM', 'LOW')
|
assert data["confidence"] in ("HIGH", "MEDIUM", "LOW")
|
||||||
|
|
||||||
|
|
||||||
def test_signal_guess_missing_frequency(auth_client):
|
def test_signal_guess_missing_frequency(auth_client):
|
||||||
"""Missing frequency should return 400."""
|
"""Missing frequency should return 400."""
|
||||||
resp = auth_client.post('/listening/signal/guess', json={})
|
resp = auth_client.post("/receiver/signal/guess", json={})
|
||||||
assert resp.status_code == 400
|
assert resp.status_code == 400
|
||||||
data = resp.get_json()
|
data = resp.get_json()
|
||||||
assert data['status'] == 'error'
|
assert data["status"] == "error"
|
||||||
|
|
||||||
|
|
||||||
def test_signal_guess_invalid_frequency(auth_client):
|
def test_signal_guess_invalid_frequency(auth_client):
|
||||||
"""Invalid frequency value should return 400."""
|
"""Invalid frequency value should return 400."""
|
||||||
resp = auth_client.post('/listening/signal/guess', json={
|
resp = auth_client.post(
|
||||||
'frequency_mhz': 'abc',
|
"/receiver/signal/guess",
|
||||||
})
|
json={
|
||||||
|
"frequency_mhz": "abc",
|
||||||
|
},
|
||||||
|
)
|
||||||
assert resp.status_code == 400
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
def test_signal_guess_negative_frequency(auth_client):
|
def test_signal_guess_negative_frequency(auth_client):
|
||||||
"""Negative frequency should return 400."""
|
"""Negative frequency should return 400."""
|
||||||
resp = auth_client.post('/listening/signal/guess', json={
|
resp = auth_client.post(
|
||||||
'frequency_mhz': -5.0,
|
"/receiver/signal/guess",
|
||||||
})
|
json={
|
||||||
|
"frequency_mhz": -5.0,
|
||||||
|
},
|
||||||
|
)
|
||||||
assert resp.status_code == 400
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
def test_signal_guess_with_region(auth_client):
|
def test_signal_guess_with_region(auth_client):
|
||||||
"""Specifying region should work."""
|
"""Specifying region should work."""
|
||||||
resp = auth_client.post('/listening/signal/guess', json={
|
resp = auth_client.post(
|
||||||
'frequency_mhz': 462.5625,
|
"/receiver/signal/guess",
|
||||||
'region': 'US',
|
json={
|
||||||
})
|
"frequency_mhz": 462.5625,
|
||||||
|
"region": "US",
|
||||||
|
},
|
||||||
|
)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
data = resp.get_json()
|
data = resp.get_json()
|
||||||
assert data['status'] == 'ok'
|
assert data["status"] == "ok"
|
||||||
|
|
||||||
|
|
||||||
def test_signal_guess_response_structure(auth_client):
|
def test_signal_guess_response_structure(auth_client):
|
||||||
"""Response should have all expected fields."""
|
"""Response should have all expected fields."""
|
||||||
resp = auth_client.post('/listening/signal/guess', json={
|
resp = auth_client.post(
|
||||||
'frequency_mhz': 146.52,
|
"/receiver/signal/guess",
|
||||||
'modulation': 'fm',
|
json={
|
||||||
})
|
"frequency_mhz": 146.52,
|
||||||
|
"modulation": "fm",
|
||||||
|
},
|
||||||
|
)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
data = resp.get_json()
|
data = resp.get_json()
|
||||||
assert 'primary_label' in data
|
assert "primary_label" in data
|
||||||
assert 'confidence' in data
|
assert "confidence" in data
|
||||||
assert 'alternatives' in data
|
assert "alternatives" in data
|
||||||
assert 'explanation' in data
|
assert "explanation" in data
|
||||||
assert 'tags' in data
|
assert "tags" in data
|
||||||
assert isinstance(data['alternatives'], list)
|
assert isinstance(data["alternatives"], list)
|
||||||
assert isinstance(data['tags'], list)
|
assert isinstance(data["tags"], list)
|
||||||
|
|||||||
+262
-220
@@ -6,11 +6,14 @@ and image handling.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from utils.weather_sat import (
|
from utils.weather_sat import (
|
||||||
WEATHER_SATELLITES,
|
WEATHER_SATELLITES,
|
||||||
CaptureProgress,
|
CaptureProgress,
|
||||||
@@ -21,34 +24,57 @@ from utils.weather_sat import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _stop_decoder_threads():
|
||||||
|
"""Stop watcher/reader threads leaked by tests that call start().
|
||||||
|
|
||||||
|
Leaked threads keep scanning the output dir and contend for the SQLite
|
||||||
|
lock, slowing every later test in the session. Full stop() is unsafe
|
||||||
|
here: it would os.close() the mocked pty fds (10, 11), which are real
|
||||||
|
fds of the pytest process.
|
||||||
|
"""
|
||||||
|
created: list[WeatherSatDecoder] = []
|
||||||
|
orig_init = WeatherSatDecoder.__init__
|
||||||
|
|
||||||
|
def tracking_init(self, *args, **kwargs):
|
||||||
|
orig_init(self, *args, **kwargs)
|
||||||
|
created.append(self)
|
||||||
|
|
||||||
|
with patch.object(WeatherSatDecoder, "__init__", tracking_init):
|
||||||
|
yield
|
||||||
|
for decoder in created:
|
||||||
|
decoder._running = False
|
||||||
|
decoder._stop_event.set()
|
||||||
|
|
||||||
|
|
||||||
class TestWeatherSatDecoder:
|
class TestWeatherSatDecoder:
|
||||||
"""Tests for WeatherSatDecoder class."""
|
"""Tests for WeatherSatDecoder class."""
|
||||||
|
|
||||||
def test_decoder_initialization(self):
|
def test_decoder_initialization(self):
|
||||||
"""Decoder should initialize with default output directory."""
|
"""Decoder should initialize with default output directory."""
|
||||||
with patch('shutil.which', return_value='/usr/bin/satdump'):
|
with patch("shutil.which", return_value="/usr/bin/satdump"):
|
||||||
decoder = WeatherSatDecoder()
|
decoder = WeatherSatDecoder()
|
||||||
assert decoder.is_running is False
|
assert decoder.is_running is False
|
||||||
assert decoder.decoder_available == 'satdump'
|
assert decoder.decoder_available == "satdump"
|
||||||
assert decoder.current_satellite == ''
|
assert decoder.current_satellite == ""
|
||||||
assert decoder.current_frequency == 0.0
|
assert decoder.current_frequency == 0.0
|
||||||
|
|
||||||
def test_decoder_initialization_no_satdump(self):
|
def test_decoder_initialization_no_satdump(self):
|
||||||
"""Decoder should detect when SatDump is unavailable."""
|
"""Decoder should detect when SatDump is unavailable."""
|
||||||
with patch('shutil.which', return_value=None):
|
with patch("shutil.which", return_value=None):
|
||||||
decoder = WeatherSatDecoder()
|
decoder = WeatherSatDecoder()
|
||||||
assert decoder.decoder_available is None
|
assert decoder.decoder_available is None
|
||||||
|
|
||||||
def test_decoder_custom_output_dir(self):
|
def test_decoder_custom_output_dir(self):
|
||||||
"""Decoder should accept custom output directory."""
|
"""Decoder should accept custom output directory."""
|
||||||
with patch('shutil.which', return_value='/usr/bin/satdump'):
|
with patch("shutil.which", return_value="/usr/bin/satdump"):
|
||||||
custom_dir = '/tmp/custom_output'
|
custom_dir = "/tmp/custom_output"
|
||||||
decoder = WeatherSatDecoder(output_dir=custom_dir)
|
decoder = WeatherSatDecoder(output_dir=custom_dir)
|
||||||
assert decoder._output_dir == Path(custom_dir)
|
assert decoder._output_dir == Path(custom_dir)
|
||||||
|
|
||||||
def test_set_callback(self):
|
def test_set_callback(self):
|
||||||
"""Decoder should accept progress callback."""
|
"""Decoder should accept progress callback."""
|
||||||
with patch('shutil.which', return_value='/usr/bin/satdump'):
|
with patch("shutil.which", return_value="/usr/bin/satdump"):
|
||||||
decoder = WeatherSatDecoder()
|
decoder = WeatherSatDecoder()
|
||||||
callback = MagicMock()
|
callback = MagicMock()
|
||||||
decoder.set_callback(callback)
|
decoder.set_callback(callback)
|
||||||
@@ -56,7 +82,7 @@ class TestWeatherSatDecoder:
|
|||||||
|
|
||||||
def test_set_on_complete(self):
|
def test_set_on_complete(self):
|
||||||
"""Decoder should accept on_complete callback."""
|
"""Decoder should accept on_complete callback."""
|
||||||
with patch('shutil.which', return_value='/usr/bin/satdump'):
|
with patch("shutil.which", return_value="/usr/bin/satdump"):
|
||||||
decoder = WeatherSatDecoder()
|
decoder = WeatherSatDecoder()
|
||||||
callback = MagicMock()
|
callback = MagicMock()
|
||||||
decoder.set_on_complete(callback)
|
decoder.set_on_complete(callback)
|
||||||
@@ -64,44 +90,47 @@ class TestWeatherSatDecoder:
|
|||||||
|
|
||||||
def test_start_no_decoder(self):
|
def test_start_no_decoder(self):
|
||||||
"""start() should fail when no decoder available."""
|
"""start() should fail when no decoder available."""
|
||||||
with patch('shutil.which', return_value=None):
|
with patch("shutil.which", return_value=None):
|
||||||
decoder = WeatherSatDecoder()
|
decoder = WeatherSatDecoder()
|
||||||
callback = MagicMock()
|
callback = MagicMock()
|
||||||
decoder.set_callback(callback)
|
decoder.set_callback(callback)
|
||||||
|
|
||||||
success, error_msg = decoder.start(satellite='NOAA-18', device_index=0, gain=40.0)
|
success, error_msg = decoder.start(satellite="NOAA-18", device_index=0, gain=40.0)
|
||||||
|
|
||||||
assert success is False
|
assert success is False
|
||||||
assert error_msg is not None
|
assert error_msg is not None
|
||||||
callback.assert_called()
|
callback.assert_called()
|
||||||
progress = callback.call_args[0][0]
|
progress = callback.call_args[0][0]
|
||||||
assert progress.status == 'error'
|
assert progress.status == "error"
|
||||||
assert 'SatDump' in progress.message
|
assert "SatDump" in progress.message
|
||||||
|
|
||||||
def test_start_invalid_satellite(self):
|
def test_start_invalid_satellite(self):
|
||||||
"""start() should fail with invalid satellite."""
|
"""start() should fail with invalid satellite."""
|
||||||
with patch('shutil.which', return_value='/usr/bin/satdump'):
|
with patch("shutil.which", return_value="/usr/bin/satdump"):
|
||||||
decoder = WeatherSatDecoder()
|
decoder = WeatherSatDecoder()
|
||||||
callback = MagicMock()
|
callback = MagicMock()
|
||||||
decoder.set_callback(callback)
|
decoder.set_callback(callback)
|
||||||
|
|
||||||
success, error_msg = decoder.start(satellite='FAKE-SAT', device_index=0, gain=40.0)
|
success, error_msg = decoder.start(satellite="FAKE-SAT", device_index=0, gain=40.0)
|
||||||
|
|
||||||
assert success is False
|
assert success is False
|
||||||
callback.assert_called()
|
callback.assert_called()
|
||||||
progress = callback.call_args[0][0]
|
progress = callback.call_args[0][0]
|
||||||
assert progress.status == 'error'
|
assert progress.status == "error"
|
||||||
assert 'Unknown satellite' in progress.message
|
assert "Unknown satellite" in progress.message
|
||||||
|
|
||||||
@patch('subprocess.Popen')
|
@patch("subprocess.Popen")
|
||||||
@patch('pty.openpty')
|
@patch("pty.openpty")
|
||||||
@patch('utils.weather_sat.register_process')
|
@patch("utils.weather_sat.register_process")
|
||||||
def test_start_success(self, mock_register, mock_pty, mock_popen):
|
def test_start_success(self, mock_register, mock_pty, mock_popen):
|
||||||
"""start() should successfully start SatDump."""
|
"""start() should successfully start SatDump."""
|
||||||
with patch('shutil.which', return_value='/usr/bin/satdump'), \
|
with (
|
||||||
patch('utils.weather_sat.WeatherSatDecoder._resolve_device_id', return_value='0'):
|
patch("shutil.which", return_value="/usr/bin/satdump"),
|
||||||
|
patch("utils.weather_sat.WeatherSatDecoder._resolve_device_id", return_value="0"),
|
||||||
mock_pty.return_value = (10, 11)
|
):
|
||||||
|
# Use a real pipe — hardcoded fds like (10, 11) collide with fds
|
||||||
|
# the test process actually has open (DB, log files, capture)
|
||||||
|
mock_pty.return_value = os.pipe()
|
||||||
mock_process = MagicMock()
|
mock_process = MagicMock()
|
||||||
mock_process.poll.return_value = None
|
mock_process.poll.return_value = None
|
||||||
mock_popen.return_value = mock_process
|
mock_popen.return_value = mock_process
|
||||||
@@ -111,7 +140,7 @@ class TestWeatherSatDecoder:
|
|||||||
decoder.set_callback(callback)
|
decoder.set_callback(callback)
|
||||||
|
|
||||||
success, error_msg = decoder.start(
|
success, error_msg = decoder.start(
|
||||||
satellite='NOAA-18',
|
satellite="NOAA-18",
|
||||||
device_index=0,
|
device_index=0,
|
||||||
gain=40.0,
|
gain=40.0,
|
||||||
bias_t=True,
|
bias_t=True,
|
||||||
@@ -120,25 +149,27 @@ class TestWeatherSatDecoder:
|
|||||||
assert success is True
|
assert success is True
|
||||||
assert error_msg is None
|
assert error_msg is None
|
||||||
assert decoder.is_running is True
|
assert decoder.is_running is True
|
||||||
assert decoder.current_satellite == 'NOAA-18'
|
assert decoder.current_satellite == "NOAA-18"
|
||||||
assert decoder.current_frequency == 137.9125
|
assert decoder.current_frequency == 137.9125
|
||||||
assert decoder.current_mode == 'APT'
|
assert decoder.current_mode == "APT"
|
||||||
assert decoder.device_index == 0
|
assert decoder.device_index == 0
|
||||||
|
|
||||||
mock_popen.assert_called_once()
|
mock_popen.assert_called_once()
|
||||||
cmd = mock_popen.call_args[0][0]
|
cmd = mock_popen.call_args[0][0]
|
||||||
assert cmd[0] == 'satdump'
|
assert cmd[0] == "satdump"
|
||||||
assert 'live' in cmd
|
assert "live" in cmd
|
||||||
assert 'noaa_apt' in cmd
|
assert "noaa_apt" in cmd
|
||||||
assert '--bias' in cmd
|
assert "--bias" in cmd
|
||||||
|
|
||||||
@patch('subprocess.Popen')
|
@patch("subprocess.Popen")
|
||||||
@patch('pty.openpty')
|
@patch("pty.openpty")
|
||||||
@patch('utils.weather_sat.register_process')
|
@patch("utils.weather_sat.register_process")
|
||||||
def test_start_rtl_tcp_uses_rtltcp_source(self, mock_register, mock_pty, mock_popen):
|
def test_start_rtl_tcp_uses_rtltcp_source(self, mock_register, mock_pty, mock_popen):
|
||||||
"""start() with rtl_tcp should use --source rtltcp instead of rtlsdr."""
|
"""start() with rtl_tcp should use --source rtltcp instead of rtlsdr."""
|
||||||
with patch('shutil.which', return_value='/usr/bin/satdump'):
|
with patch("shutil.which", return_value="/usr/bin/satdump"):
|
||||||
mock_pty.return_value = (10, 11)
|
# Use a real pipe — hardcoded fds like (10, 11) collide with fds
|
||||||
|
# the test process actually has open (DB, log files, capture)
|
||||||
|
mock_pty.return_value = os.pipe()
|
||||||
mock_process = MagicMock()
|
mock_process = MagicMock()
|
||||||
mock_process.poll.return_value = None
|
mock_process.poll.return_value = None
|
||||||
mock_popen.return_value = mock_process
|
mock_popen.return_value = mock_process
|
||||||
@@ -148,10 +179,10 @@ class TestWeatherSatDecoder:
|
|||||||
decoder.set_callback(callback)
|
decoder.set_callback(callback)
|
||||||
|
|
||||||
success, error_msg = decoder.start(
|
success, error_msg = decoder.start(
|
||||||
satellite='NOAA-18',
|
satellite="NOAA-18",
|
||||||
device_index=0,
|
device_index=0,
|
||||||
gain=40.0,
|
gain=40.0,
|
||||||
rtl_tcp_host='192.168.1.100',
|
rtl_tcp_host="192.168.1.100",
|
||||||
rtl_tcp_port=1234,
|
rtl_tcp_port=1234,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -160,24 +191,28 @@ class TestWeatherSatDecoder:
|
|||||||
|
|
||||||
mock_popen.assert_called_once()
|
mock_popen.assert_called_once()
|
||||||
cmd = mock_popen.call_args[0][0]
|
cmd = mock_popen.call_args[0][0]
|
||||||
assert '--source' in cmd
|
assert "--source" in cmd
|
||||||
source_idx = cmd.index('--source')
|
source_idx = cmd.index("--source")
|
||||||
assert cmd[source_idx + 1] == 'rtltcp'
|
assert cmd[source_idx + 1] == "rtltcp"
|
||||||
assert '--ip_address' in cmd
|
assert "--ip_address" in cmd
|
||||||
assert '192.168.1.100' in cmd
|
assert "192.168.1.100" in cmd
|
||||||
assert '--port' in cmd
|
assert "--port" in cmd
|
||||||
assert '1234' in cmd
|
assert "1234" in cmd
|
||||||
# Should NOT have --source_id for remote
|
# Should NOT have --source_id for remote
|
||||||
assert '--source_id' not in cmd
|
assert "--source_id" not in cmd
|
||||||
|
|
||||||
@patch('subprocess.Popen')
|
@patch("subprocess.Popen")
|
||||||
@patch('pty.openpty')
|
@patch("pty.openpty")
|
||||||
@patch('utils.weather_sat.register_process')
|
@patch("utils.weather_sat.register_process")
|
||||||
def test_start_rtl_tcp_skips_device_resolve(self, mock_register, mock_pty, mock_popen):
|
def test_start_rtl_tcp_skips_device_resolve(self, mock_register, mock_pty, mock_popen):
|
||||||
"""start() with rtl_tcp should skip _resolve_device_id."""
|
"""start() with rtl_tcp should skip _resolve_device_id."""
|
||||||
with patch('shutil.which', return_value='/usr/bin/satdump'), \
|
with (
|
||||||
patch('utils.weather_sat.WeatherSatDecoder._resolve_device_id') as mock_resolve:
|
patch("shutil.which", return_value="/usr/bin/satdump"),
|
||||||
mock_pty.return_value = (10, 11)
|
patch("utils.weather_sat.WeatherSatDecoder._resolve_device_id") as mock_resolve,
|
||||||
|
):
|
||||||
|
# Use a real pipe — hardcoded fds like (10, 11) collide with fds
|
||||||
|
# the test process actually has open (DB, log files, capture)
|
||||||
|
mock_pty.return_value = os.pipe()
|
||||||
mock_process = MagicMock()
|
mock_process = MagicMock()
|
||||||
mock_process.poll.return_value = None
|
mock_process.poll.return_value = None
|
||||||
mock_popen.return_value = mock_process
|
mock_popen.return_value = mock_process
|
||||||
@@ -185,83 +220,87 @@ class TestWeatherSatDecoder:
|
|||||||
decoder = WeatherSatDecoder()
|
decoder = WeatherSatDecoder()
|
||||||
|
|
||||||
success, _ = decoder.start(
|
success, _ = decoder.start(
|
||||||
satellite='NOAA-18',
|
satellite="NOAA-18",
|
||||||
device_index=0,
|
device_index=0,
|
||||||
gain=40.0,
|
gain=40.0,
|
||||||
rtl_tcp_host='10.0.0.1',
|
rtl_tcp_host="10.0.0.1",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert success is True
|
assert success is True
|
||||||
mock_resolve.assert_not_called()
|
mock_resolve.assert_not_called()
|
||||||
|
|
||||||
@patch('subprocess.Popen')
|
@patch("subprocess.Popen")
|
||||||
@patch('pty.openpty')
|
@patch("pty.openpty")
|
||||||
def test_start_already_running(self, mock_pty, mock_popen):
|
def test_start_already_running(self, mock_pty, mock_popen):
|
||||||
"""start() should return True when already running."""
|
"""start() should return True when already running."""
|
||||||
with patch('shutil.which', return_value='/usr/bin/satdump'), \
|
with (
|
||||||
patch('utils.weather_sat.WeatherSatDecoder._resolve_device_id', return_value='0'):
|
patch("shutil.which", return_value="/usr/bin/satdump"),
|
||||||
|
patch("utils.weather_sat.WeatherSatDecoder._resolve_device_id", return_value="0"),
|
||||||
|
):
|
||||||
decoder = WeatherSatDecoder()
|
decoder = WeatherSatDecoder()
|
||||||
decoder._running = True
|
decoder._running = True
|
||||||
|
|
||||||
success, error_msg = decoder.start(satellite='NOAA-18', device_index=0, gain=40.0)
|
success, error_msg = decoder.start(satellite="NOAA-18", device_index=0, gain=40.0)
|
||||||
|
|
||||||
assert success is True
|
assert success is True
|
||||||
assert error_msg is None
|
assert error_msg is None
|
||||||
mock_popen.assert_not_called()
|
mock_popen.assert_not_called()
|
||||||
|
|
||||||
@patch('subprocess.Popen')
|
@patch("subprocess.Popen")
|
||||||
@patch('pty.openpty')
|
@patch("pty.openpty")
|
||||||
def test_start_exception_handling(self, mock_pty, mock_popen):
|
def test_start_exception_handling(self, mock_pty, mock_popen):
|
||||||
"""start() should handle exceptions gracefully."""
|
"""start() should handle exceptions gracefully."""
|
||||||
with patch('shutil.which', return_value='/usr/bin/satdump'):
|
with patch("shutil.which", return_value="/usr/bin/satdump"):
|
||||||
mock_pty.return_value = (10, 11)
|
# Use a real pipe — hardcoded fds like (10, 11) collide with fds
|
||||||
mock_popen.side_effect = OSError('Device not found')
|
# the test process actually has open (DB, log files, capture)
|
||||||
|
mock_pty.return_value = os.pipe()
|
||||||
|
mock_popen.side_effect = OSError("Device not found")
|
||||||
|
|
||||||
decoder = WeatherSatDecoder()
|
decoder = WeatherSatDecoder()
|
||||||
callback = MagicMock()
|
callback = MagicMock()
|
||||||
decoder.set_callback(callback)
|
decoder.set_callback(callback)
|
||||||
|
|
||||||
success, error_msg = decoder.start(satellite='NOAA-18', device_index=0, gain=40.0)
|
success, error_msg = decoder.start(satellite="NOAA-18", device_index=0, gain=40.0)
|
||||||
|
|
||||||
assert success is False
|
assert success is False
|
||||||
assert error_msg is not None
|
assert error_msg is not None
|
||||||
assert decoder.is_running is False
|
assert decoder.is_running is False
|
||||||
callback.assert_called()
|
callback.assert_called()
|
||||||
progress = callback.call_args[0][0]
|
progress = callback.call_args[0][0]
|
||||||
assert progress.status == 'error'
|
assert progress.status == "error"
|
||||||
|
|
||||||
def test_start_from_file_no_decoder(self):
|
def test_start_from_file_no_decoder(self):
|
||||||
"""start_from_file() should fail when no decoder available."""
|
"""start_from_file() should fail when no decoder available."""
|
||||||
with patch('shutil.which', return_value=None):
|
with patch("shutil.which", return_value=None):
|
||||||
decoder = WeatherSatDecoder()
|
decoder = WeatherSatDecoder()
|
||||||
callback = MagicMock()
|
callback = MagicMock()
|
||||||
decoder.set_callback(callback)
|
decoder.set_callback(callback)
|
||||||
|
|
||||||
success, error_msg = decoder.start_from_file(
|
success, error_msg = decoder.start_from_file(
|
||||||
satellite='NOAA-18',
|
satellite="NOAA-18",
|
||||||
input_file='data/test.wav',
|
input_file="data/test.wav",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert success is False
|
assert success is False
|
||||||
assert error_msg is not None
|
assert error_msg is not None
|
||||||
callback.assert_called()
|
callback.assert_called()
|
||||||
|
|
||||||
@patch('subprocess.Popen')
|
@patch("subprocess.Popen")
|
||||||
@patch('pty.openpty')
|
@patch("pty.openpty")
|
||||||
@patch('pathlib.Path.is_file', return_value=True)
|
@patch("pathlib.Path.is_file", return_value=True)
|
||||||
@patch('pathlib.Path.resolve')
|
@patch("pathlib.Path.resolve")
|
||||||
def test_start_from_file_success(self, mock_resolve, mock_is_file, mock_pty, mock_popen):
|
def test_start_from_file_success(self, mock_resolve, mock_is_file, mock_pty, mock_popen):
|
||||||
"""start_from_file() should successfully decode from file."""
|
"""start_from_file() should successfully decode from file."""
|
||||||
with patch('shutil.which', return_value='/usr/bin/satdump'), \
|
with patch("shutil.which", return_value="/usr/bin/satdump"), patch("utils.weather_sat.register_process"):
|
||||||
patch('utils.weather_sat.register_process'):
|
|
||||||
|
|
||||||
# Mock path resolution
|
# Mock path resolution
|
||||||
mock_path = MagicMock()
|
mock_path = MagicMock()
|
||||||
mock_path.is_relative_to.return_value = True
|
mock_path.is_relative_to.return_value = True
|
||||||
mock_path.suffix = '.wav'
|
mock_path.suffix = ".wav"
|
||||||
mock_resolve.return_value = mock_path
|
mock_resolve.return_value = mock_path
|
||||||
|
|
||||||
mock_pty.return_value = (10, 11)
|
# Use a real pipe — hardcoded fds like (10, 11) collide with fds
|
||||||
|
# the test process actually has open (DB, log files, capture)
|
||||||
|
mock_pty.return_value = os.pipe()
|
||||||
mock_process = MagicMock()
|
mock_process = MagicMock()
|
||||||
mock_process.poll.return_value = None # Process still running
|
mock_process.poll.return_value = None # Process still running
|
||||||
mock_popen.return_value = mock_process
|
mock_popen.return_value = mock_process
|
||||||
@@ -271,27 +310,27 @@ class TestWeatherSatDecoder:
|
|||||||
decoder.set_callback(callback)
|
decoder.set_callback(callback)
|
||||||
|
|
||||||
success, error_msg = decoder.start_from_file(
|
success, error_msg = decoder.start_from_file(
|
||||||
satellite='NOAA-18',
|
satellite="NOAA-18",
|
||||||
input_file='data/test.wav',
|
input_file="data/test.wav",
|
||||||
sample_rate=1000000,
|
sample_rate=1000000,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert success is True
|
assert success is True
|
||||||
assert error_msg is None
|
assert error_msg is None
|
||||||
assert decoder.is_running is True
|
assert decoder.is_running is True
|
||||||
assert decoder.current_satellite == 'NOAA-18'
|
assert decoder.current_satellite == "NOAA-18"
|
||||||
|
|
||||||
mock_popen.assert_called_once()
|
mock_popen.assert_called_once()
|
||||||
cmd = mock_popen.call_args[0][0]
|
cmd = mock_popen.call_args[0][0]
|
||||||
assert cmd[0] == 'satdump'
|
assert cmd[0] == "satdump"
|
||||||
assert 'noaa_apt' in cmd
|
assert "noaa_apt" in cmd
|
||||||
assert 'audio_wav' in cmd
|
assert "audio_wav" in cmd
|
||||||
assert '--samplerate' in cmd
|
assert "--samplerate" in cmd
|
||||||
|
|
||||||
@patch('pathlib.Path.resolve')
|
@patch("pathlib.Path.resolve")
|
||||||
def test_start_from_file_path_traversal(self, mock_resolve):
|
def test_start_from_file_path_traversal(self, mock_resolve):
|
||||||
"""start_from_file() should block path traversal."""
|
"""start_from_file() should block path traversal."""
|
||||||
with patch('shutil.which', return_value='/usr/bin/satdump'):
|
with patch("shutil.which", return_value="/usr/bin/satdump"):
|
||||||
# Mock path outside allowed directory
|
# Mock path outside allowed directory
|
||||||
mock_path = MagicMock()
|
mock_path = MagicMock()
|
||||||
mock_path.is_relative_to.return_value = False
|
mock_path.is_relative_to.return_value = False
|
||||||
@@ -302,20 +341,20 @@ class TestWeatherSatDecoder:
|
|||||||
decoder.set_callback(callback)
|
decoder.set_callback(callback)
|
||||||
|
|
||||||
success, error_msg = decoder.start_from_file(
|
success, error_msg = decoder.start_from_file(
|
||||||
satellite='NOAA-18',
|
satellite="NOAA-18",
|
||||||
input_file='/etc/passwd',
|
input_file="/etc/passwd",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert success is False
|
assert success is False
|
||||||
callback.assert_called()
|
callback.assert_called()
|
||||||
progress = callback.call_args[0][0]
|
progress = callback.call_args[0][0]
|
||||||
assert 'data/ directory' in progress.message
|
assert "must be under INTERCEPT data" in progress.message
|
||||||
|
|
||||||
@patch('pathlib.Path.is_file', return_value=False)
|
@patch("pathlib.Path.is_file", return_value=False)
|
||||||
@patch('pathlib.Path.resolve')
|
@patch("pathlib.Path.resolve")
|
||||||
def test_start_from_file_not_found(self, mock_resolve, mock_is_file):
|
def test_start_from_file_not_found(self, mock_resolve, mock_is_file):
|
||||||
"""start_from_file() should fail when file not found."""
|
"""start_from_file() should fail when file not found."""
|
||||||
with patch('shutil.which', return_value='/usr/bin/satdump'):
|
with patch("shutil.which", return_value="/usr/bin/satdump"):
|
||||||
mock_path = MagicMock()
|
mock_path = MagicMock()
|
||||||
mock_path.is_relative_to.return_value = True
|
mock_path.is_relative_to.return_value = True
|
||||||
mock_resolve.return_value = mock_path
|
mock_resolve.return_value = mock_path
|
||||||
@@ -325,32 +364,32 @@ class TestWeatherSatDecoder:
|
|||||||
decoder.set_callback(callback)
|
decoder.set_callback(callback)
|
||||||
|
|
||||||
success, error_msg = decoder.start_from_file(
|
success, error_msg = decoder.start_from_file(
|
||||||
satellite='NOAA-18',
|
satellite="NOAA-18",
|
||||||
input_file='data/missing.wav',
|
input_file="data/missing.wav",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert success is False
|
assert success is False
|
||||||
callback.assert_called()
|
callback.assert_called()
|
||||||
progress = callback.call_args[0][0]
|
progress = callback.call_args[0][0]
|
||||||
assert 'not found' in progress.message.lower()
|
assert "not found" in progress.message.lower()
|
||||||
|
|
||||||
def test_stop_not_running(self):
|
def test_stop_not_running(self):
|
||||||
"""stop() should be safe when not running."""
|
"""stop() should be safe when not running."""
|
||||||
with patch('shutil.which', return_value='/usr/bin/satdump'):
|
with patch("shutil.which", return_value="/usr/bin/satdump"):
|
||||||
decoder = WeatherSatDecoder()
|
decoder = WeatherSatDecoder()
|
||||||
decoder.stop() # Should not raise
|
decoder.stop() # Should not raise
|
||||||
|
|
||||||
@patch('utils.weather_sat.safe_terminate')
|
@patch("utils.weather_sat.safe_terminate")
|
||||||
def test_stop_running(self, mock_terminate):
|
def test_stop_running(self, mock_terminate):
|
||||||
"""stop() should terminate process."""
|
"""stop() should terminate process."""
|
||||||
with patch('shutil.which', return_value='/usr/bin/satdump'):
|
with patch("shutil.which", return_value="/usr/bin/satdump"):
|
||||||
decoder = WeatherSatDecoder()
|
decoder = WeatherSatDecoder()
|
||||||
mock_process = MagicMock()
|
mock_process = MagicMock()
|
||||||
decoder._process = mock_process
|
decoder._process = mock_process
|
||||||
decoder._running = True
|
decoder._running = True
|
||||||
decoder._pty_master_fd = 10
|
decoder._pty_master_fd = 10
|
||||||
|
|
||||||
with patch('os.close') as mock_close:
|
with patch("os.close") as mock_close:
|
||||||
decoder.stop()
|
decoder.stop()
|
||||||
|
|
||||||
assert decoder._running is False
|
assert decoder._running is False
|
||||||
@@ -359,21 +398,21 @@ class TestWeatherSatDecoder:
|
|||||||
|
|
||||||
def test_get_images_empty(self):
|
def test_get_images_empty(self):
|
||||||
"""get_images() should return empty list initially."""
|
"""get_images() should return empty list initially."""
|
||||||
with patch('shutil.which', return_value='/usr/bin/satdump'):
|
with patch("shutil.which", return_value="/usr/bin/satdump"):
|
||||||
decoder = WeatherSatDecoder()
|
decoder = WeatherSatDecoder()
|
||||||
images = decoder.get_images()
|
images = decoder.get_images()
|
||||||
assert images == []
|
assert images == []
|
||||||
|
|
||||||
@patch('pathlib.Path.glob')
|
@patch("pathlib.Path.glob")
|
||||||
@patch('pathlib.Path.stat')
|
@patch("pathlib.Path.stat")
|
||||||
def test_get_images_scans_directory(self, mock_stat, mock_glob):
|
def test_get_images_scans_directory(self, mock_stat, mock_glob):
|
||||||
"""get_images() should scan output directory."""
|
"""get_images() should scan output directory."""
|
||||||
with patch('shutil.which', return_value='/usr/bin/satdump'):
|
with patch("shutil.which", return_value="/usr/bin/satdump"):
|
||||||
decoder = WeatherSatDecoder()
|
decoder = WeatherSatDecoder()
|
||||||
|
|
||||||
# Mock image files
|
# Mock image files
|
||||||
mock_file = MagicMock()
|
mock_file = MagicMock()
|
||||||
mock_file.name = 'NOAA-18_test.png'
|
mock_file.name = "NOAA-18_test.png"
|
||||||
mock_file.stat.return_value.st_size = 10000
|
mock_file.stat.return_value.st_size = 10000
|
||||||
mock_file.stat.return_value.st_mtime = time.time()
|
mock_file.stat.return_value.st_mtime = time.time()
|
||||||
mock_glob.return_value = [mock_file]
|
mock_glob.return_value = [mock_file]
|
||||||
@@ -381,39 +420,39 @@ class TestWeatherSatDecoder:
|
|||||||
images = decoder.get_images()
|
images = decoder.get_images()
|
||||||
|
|
||||||
assert len(images) == 1
|
assert len(images) == 1
|
||||||
assert images[0].filename == 'NOAA-18_test.png'
|
assert images[0].filename == "NOAA-18_test.png"
|
||||||
assert images[0].satellite == 'NOAA-18'
|
assert images[0].satellite == "NOAA-18"
|
||||||
|
|
||||||
def test_delete_image_success(self):
|
def test_delete_image_success(self):
|
||||||
"""delete_image() should delete file."""
|
"""delete_image() should delete file."""
|
||||||
with patch('shutil.which', return_value='/usr/bin/satdump'):
|
with patch("shutil.which", return_value="/usr/bin/satdump"):
|
||||||
decoder = WeatherSatDecoder()
|
decoder = WeatherSatDecoder()
|
||||||
|
|
||||||
with patch('pathlib.Path.exists', return_value=True), \
|
with patch("pathlib.Path.exists", return_value=True), patch("pathlib.Path.unlink") as mock_unlink:
|
||||||
patch('pathlib.Path.unlink') as mock_unlink:
|
result = decoder.delete_image("test.png")
|
||||||
|
|
||||||
result = decoder.delete_image('test.png')
|
|
||||||
|
|
||||||
assert result is True
|
assert result is True
|
||||||
mock_unlink.assert_called_once()
|
mock_unlink.assert_called_once()
|
||||||
|
|
||||||
def test_delete_image_not_found(self):
|
def test_delete_image_not_found(self):
|
||||||
"""delete_image() should return False for non-existent file."""
|
"""delete_image() should return False for non-existent file."""
|
||||||
with patch('shutil.which', return_value='/usr/bin/satdump'):
|
with patch("shutil.which", return_value="/usr/bin/satdump"):
|
||||||
decoder = WeatherSatDecoder()
|
decoder = WeatherSatDecoder()
|
||||||
|
|
||||||
with patch('pathlib.Path.exists', return_value=False):
|
with patch("pathlib.Path.exists", return_value=False):
|
||||||
result = decoder.delete_image('missing.png')
|
result = decoder.delete_image("missing.png")
|
||||||
|
|
||||||
assert result is False
|
assert result is False
|
||||||
|
|
||||||
def test_delete_all_images(self):
|
def test_delete_all_images(self):
|
||||||
"""delete_all_images() should delete all images."""
|
"""delete_all_images() should delete all images."""
|
||||||
with patch('shutil.which', return_value='/usr/bin/satdump'):
|
with patch("shutil.which", return_value="/usr/bin/satdump"):
|
||||||
decoder = WeatherSatDecoder()
|
decoder = WeatherSatDecoder()
|
||||||
|
|
||||||
mock_files = [MagicMock() for _ in range(3)]
|
mock_files = [MagicMock() for _ in range(3)]
|
||||||
with patch('pathlib.Path.glob', return_value=mock_files):
|
# delete_all_images globs three extensions; return files for the
|
||||||
|
# first pattern only so each mock is deleted exactly once
|
||||||
|
with patch("pathlib.Path.glob", side_effect=[mock_files, [], []]):
|
||||||
count = decoder.delete_all_images()
|
count = decoder.delete_all_images()
|
||||||
|
|
||||||
assert count == 3
|
assert count == 3
|
||||||
@@ -422,74 +461,74 @@ class TestWeatherSatDecoder:
|
|||||||
|
|
||||||
def test_get_status_idle(self):
|
def test_get_status_idle(self):
|
||||||
"""get_status() should return idle status."""
|
"""get_status() should return idle status."""
|
||||||
with patch('shutil.which', return_value='/usr/bin/satdump'):
|
with patch("shutil.which", return_value="/usr/bin/satdump"):
|
||||||
decoder = WeatherSatDecoder()
|
decoder = WeatherSatDecoder()
|
||||||
status = decoder.get_status()
|
status = decoder.get_status()
|
||||||
|
|
||||||
assert status['available'] is True
|
assert status["available"] is True
|
||||||
assert status['decoder'] == 'satdump'
|
assert status["decoder"] == "satdump"
|
||||||
assert status['running'] is False
|
assert status["running"] is False
|
||||||
assert status['satellite'] == ''
|
assert status["satellite"] == ""
|
||||||
|
|
||||||
def test_get_status_running(self):
|
def test_get_status_running(self):
|
||||||
"""get_status() should return running status."""
|
"""get_status() should return running status."""
|
||||||
with patch('shutil.which', return_value='/usr/bin/satdump'):
|
with patch("shutil.which", return_value="/usr/bin/satdump"):
|
||||||
decoder = WeatherSatDecoder()
|
decoder = WeatherSatDecoder()
|
||||||
decoder._running = True
|
decoder._running = True
|
||||||
decoder._current_satellite = 'NOAA-18'
|
decoder._current_satellite = "NOAA-18"
|
||||||
decoder._current_frequency = 137.9125
|
decoder._current_frequency = 137.9125
|
||||||
decoder._current_mode = 'APT'
|
decoder._current_mode = "APT"
|
||||||
decoder._capture_start_time = time.time() - 60
|
decoder._capture_start_time = time.time() - 60
|
||||||
|
|
||||||
status = decoder.get_status()
|
status = decoder.get_status()
|
||||||
|
|
||||||
assert status['running'] is True
|
assert status["running"] is True
|
||||||
assert status['satellite'] == 'NOAA-18'
|
assert status["satellite"] == "NOAA-18"
|
||||||
assert status['frequency'] == 137.9125
|
assert status["frequency"] == 137.9125
|
||||||
assert status['mode'] == 'APT'
|
assert status["mode"] == "APT"
|
||||||
assert status['elapsed_seconds'] >= 60
|
assert status["elapsed_seconds"] >= 60
|
||||||
|
|
||||||
def test_classify_log_type_error(self):
|
def test_classify_log_type_error(self):
|
||||||
"""_classify_log_type() should detect errors."""
|
"""_classify_log_type() should detect errors."""
|
||||||
assert WeatherSatDecoder._classify_log_type('(E) Error occurred') == 'error'
|
assert WeatherSatDecoder._classify_log_type("(E) Error occurred") == "error"
|
||||||
assert WeatherSatDecoder._classify_log_type('Failed to open device') == 'error'
|
assert WeatherSatDecoder._classify_log_type("Failed to open device") == "error"
|
||||||
|
|
||||||
def test_classify_log_type_progress(self):
|
def test_classify_log_type_progress(self):
|
||||||
"""_classify_log_type() should detect progress."""
|
"""_classify_log_type() should detect progress."""
|
||||||
assert WeatherSatDecoder._classify_log_type('Progress: 50%') == 'progress'
|
assert WeatherSatDecoder._classify_log_type("Progress: 50%") == "progress"
|
||||||
|
|
||||||
def test_classify_log_type_save(self):
|
def test_classify_log_type_save(self):
|
||||||
"""_classify_log_type() should detect save events."""
|
"""_classify_log_type() should detect save events."""
|
||||||
assert WeatherSatDecoder._classify_log_type('Saved image: test.png') == 'save'
|
assert WeatherSatDecoder._classify_log_type("Saved image: test.png") == "save"
|
||||||
assert WeatherSatDecoder._classify_log_type('Writing output file') == 'save'
|
assert WeatherSatDecoder._classify_log_type("Writing output file") == "save"
|
||||||
|
|
||||||
def test_classify_log_type_signal(self):
|
def test_classify_log_type_signal(self):
|
||||||
"""_classify_log_type() should detect signal events."""
|
"""_classify_log_type() should detect signal events."""
|
||||||
assert WeatherSatDecoder._classify_log_type('Signal detected') == 'signal'
|
assert WeatherSatDecoder._classify_log_type("Signal detected") == "signal"
|
||||||
assert WeatherSatDecoder._classify_log_type('Lock acquired') == 'signal'
|
assert WeatherSatDecoder._classify_log_type("Lock acquired") == "signal"
|
||||||
|
|
||||||
def test_classify_log_type_warning(self):
|
def test_classify_log_type_warning(self):
|
||||||
"""_classify_log_type() should detect warnings."""
|
"""_classify_log_type() should detect warnings."""
|
||||||
assert WeatherSatDecoder._classify_log_type('(W) Low signal quality') == 'warning'
|
assert WeatherSatDecoder._classify_log_type("(W) Low signal quality") == "warning"
|
||||||
|
|
||||||
def test_classify_log_type_debug(self):
|
def test_classify_log_type_debug(self):
|
||||||
"""_classify_log_type() should detect debug messages."""
|
"""_classify_log_type() should detect debug messages."""
|
||||||
assert WeatherSatDecoder._classify_log_type('(D) Debug info') == 'debug'
|
assert WeatherSatDecoder._classify_log_type("(D) Debug info") == "debug"
|
||||||
|
|
||||||
@patch('subprocess.run')
|
@patch("subprocess.run")
|
||||||
def test_resolve_device_id_success(self, mock_run):
|
def test_resolve_device_id_success(self, mock_run):
|
||||||
"""_resolve_device_id() should extract serial from rtl_test."""
|
"""_resolve_device_id() should extract serial from rtl_test."""
|
||||||
mock_result = MagicMock()
|
mock_result = MagicMock()
|
||||||
mock_result.stdout = 'Found 1 device(s):\n 0: RTLSDRBlog, SN: 00004000'
|
mock_result.stdout = "Found 1 device(s):\n 0: RTLSDRBlog, SN: 00004000"
|
||||||
mock_result.stderr = ''
|
mock_result.stderr = ""
|
||||||
mock_run.return_value = mock_result
|
mock_run.return_value = mock_result
|
||||||
|
|
||||||
serial = WeatherSatDecoder._resolve_device_id(0)
|
serial = WeatherSatDecoder._resolve_device_id(0)
|
||||||
|
|
||||||
assert serial == '00004000'
|
assert serial == "00004000"
|
||||||
mock_run.assert_called_once()
|
mock_run.assert_called_once()
|
||||||
|
|
||||||
@patch('subprocess.run')
|
@patch("subprocess.run")
|
||||||
def test_resolve_device_id_fallback(self, mock_run):
|
def test_resolve_device_id_fallback(self, mock_run):
|
||||||
"""_resolve_device_id() should return None when no serial found."""
|
"""_resolve_device_id() should return None when no serial found."""
|
||||||
mock_run.side_effect = FileNotFoundError
|
mock_run.side_effect = FileNotFoundError
|
||||||
@@ -500,59 +539,59 @@ class TestWeatherSatDecoder:
|
|||||||
|
|
||||||
def test_parse_product_name_rgb(self):
|
def test_parse_product_name_rgb(self):
|
||||||
"""_parse_product_name() should identify RGB composite."""
|
"""_parse_product_name() should identify RGB composite."""
|
||||||
with patch('shutil.which', return_value='/usr/bin/satdump'):
|
with patch("shutil.which", return_value="/usr/bin/satdump"):
|
||||||
decoder = WeatherSatDecoder()
|
decoder = WeatherSatDecoder()
|
||||||
product = decoder._parse_product_name(Path('/tmp/output/rgb_composite.png'))
|
product = decoder._parse_product_name(Path("/tmp/output/rgb_composite.png"))
|
||||||
assert product == 'RGB Composite'
|
assert product == "RGB Composite"
|
||||||
|
|
||||||
def test_parse_product_name_thermal(self):
|
def test_parse_product_name_thermal(self):
|
||||||
"""_parse_product_name() should identify thermal imagery."""
|
"""_parse_product_name() should identify thermal imagery."""
|
||||||
with patch('shutil.which', return_value='/usr/bin/satdump'):
|
with patch("shutil.which", return_value="/usr/bin/satdump"):
|
||||||
decoder = WeatherSatDecoder()
|
decoder = WeatherSatDecoder()
|
||||||
product = decoder._parse_product_name(Path('/tmp/output/thermal_image.png'))
|
product = decoder._parse_product_name(Path("/tmp/output/thermal_image.png"))
|
||||||
assert product == 'Thermal'
|
assert product == "Thermal"
|
||||||
|
|
||||||
def test_parse_product_name_channel(self):
|
def test_parse_product_name_channel(self):
|
||||||
"""_parse_product_name() should identify channel images."""
|
"""_parse_product_name() should identify channel images."""
|
||||||
with patch('shutil.which', return_value='/usr/bin/satdump'):
|
with patch("shutil.which", return_value="/usr/bin/satdump"):
|
||||||
decoder = WeatherSatDecoder()
|
decoder = WeatherSatDecoder()
|
||||||
product = decoder._parse_product_name(Path('/tmp/output/channel_3.png'))
|
product = decoder._parse_product_name(Path("/tmp/output/channel_3.png"))
|
||||||
assert product == 'Channel 3'
|
assert product == "Channel 3"
|
||||||
|
|
||||||
def test_parse_product_name_unknown(self):
|
def test_parse_product_name_unknown(self):
|
||||||
"""_parse_product_name() should return stem for unknown products."""
|
"""_parse_product_name() should return stem for unknown products."""
|
||||||
with patch('shutil.which', return_value='/usr/bin/satdump'):
|
with patch("shutil.which", return_value="/usr/bin/satdump"):
|
||||||
decoder = WeatherSatDecoder()
|
decoder = WeatherSatDecoder()
|
||||||
product = decoder._parse_product_name(Path('/tmp/output/unknown_image.png'))
|
product = decoder._parse_product_name(Path("/tmp/output/unknown_image.png"))
|
||||||
assert product == 'unknown_image'
|
assert product == "unknown_image"
|
||||||
|
|
||||||
def test_emit_progress(self):
|
def test_emit_progress(self):
|
||||||
"""_emit_progress() should call callback."""
|
"""_emit_progress() should call callback."""
|
||||||
with patch('shutil.which', return_value='/usr/bin/satdump'):
|
with patch("shutil.which", return_value="/usr/bin/satdump"):
|
||||||
decoder = WeatherSatDecoder()
|
decoder = WeatherSatDecoder()
|
||||||
callback = MagicMock()
|
callback = MagicMock()
|
||||||
decoder.set_callback(callback)
|
decoder.set_callback(callback)
|
||||||
|
|
||||||
progress = CaptureProgress(status='capturing', message='Test')
|
progress = CaptureProgress(status="capturing", message="Test")
|
||||||
decoder._emit_progress(progress)
|
decoder._emit_progress(progress)
|
||||||
|
|
||||||
callback.assert_called_once_with(progress)
|
callback.assert_called_once_with(progress)
|
||||||
|
|
||||||
def test_emit_progress_no_callback(self):
|
def test_emit_progress_no_callback(self):
|
||||||
"""_emit_progress() should handle missing callback."""
|
"""_emit_progress() should handle missing callback."""
|
||||||
with patch('shutil.which', return_value='/usr/bin/satdump'):
|
with patch("shutil.which", return_value="/usr/bin/satdump"):
|
||||||
decoder = WeatherSatDecoder()
|
decoder = WeatherSatDecoder()
|
||||||
progress = CaptureProgress(status='capturing', message='Test')
|
progress = CaptureProgress(status="capturing", message="Test")
|
||||||
decoder._emit_progress(progress) # Should not raise
|
decoder._emit_progress(progress) # Should not raise
|
||||||
|
|
||||||
def test_emit_progress_callback_exception(self):
|
def test_emit_progress_callback_exception(self):
|
||||||
"""_emit_progress() should handle callback exceptions."""
|
"""_emit_progress() should handle callback exceptions."""
|
||||||
with patch('shutil.which', return_value='/usr/bin/satdump'):
|
with patch("shutil.which", return_value="/usr/bin/satdump"):
|
||||||
decoder = WeatherSatDecoder()
|
decoder = WeatherSatDecoder()
|
||||||
callback = MagicMock(side_effect=Exception('Callback error'))
|
callback = MagicMock(side_effect=Exception("Callback error"))
|
||||||
decoder.set_callback(callback)
|
decoder.set_callback(callback)
|
||||||
|
|
||||||
progress = CaptureProgress(status='capturing', message='Test')
|
progress = CaptureProgress(status="capturing", message="Test")
|
||||||
decoder._emit_progress(progress) # Should not raise
|
decoder._emit_progress(progress) # Should not raise
|
||||||
|
|
||||||
|
|
||||||
@@ -562,26 +601,26 @@ class TestWeatherSatImage:
|
|||||||
def test_to_dict(self):
|
def test_to_dict(self):
|
||||||
"""WeatherSatImage.to_dict() should serialize correctly."""
|
"""WeatherSatImage.to_dict() should serialize correctly."""
|
||||||
image = WeatherSatImage(
|
image = WeatherSatImage(
|
||||||
filename='test.png',
|
filename="test.png",
|
||||||
path=Path('/tmp/test.png'),
|
path=Path("/tmp/test.png"),
|
||||||
satellite='NOAA-18',
|
satellite="NOAA-18",
|
||||||
mode='APT',
|
mode="APT",
|
||||||
timestamp=datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc),
|
timestamp=datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc),
|
||||||
frequency=137.9125,
|
frequency=137.9125,
|
||||||
size_bytes=12345,
|
size_bytes=12345,
|
||||||
product='RGB Composite',
|
product="RGB Composite",
|
||||||
)
|
)
|
||||||
|
|
||||||
data = image.to_dict()
|
data = image.to_dict()
|
||||||
|
|
||||||
assert data['filename'] == 'test.png'
|
assert data["filename"] == "test.png"
|
||||||
assert data['satellite'] == 'NOAA-18'
|
assert data["satellite"] == "NOAA-18"
|
||||||
assert data['mode'] == 'APT'
|
assert data["mode"] == "APT"
|
||||||
assert data['timestamp'] == '2024-01-01T12:00:00+00:00'
|
assert data["timestamp"] == "2024-01-01T12:00:00+00:00"
|
||||||
assert data['frequency'] == 137.9125
|
assert data["frequency"] == 137.9125
|
||||||
assert data['size_bytes'] == 12345
|
assert data["size_bytes"] == 12345
|
||||||
assert data['product'] == 'RGB Composite'
|
assert data["product"] == "RGB Composite"
|
||||||
assert data['url'] == '/weather-sat/images/test.png'
|
assert data["url"] == "/weather-sat/images/test.png"
|
||||||
|
|
||||||
|
|
||||||
class TestCaptureProgress:
|
class TestCaptureProgress:
|
||||||
@@ -589,51 +628,51 @@ class TestCaptureProgress:
|
|||||||
|
|
||||||
def test_to_dict_minimal(self):
|
def test_to_dict_minimal(self):
|
||||||
"""CaptureProgress.to_dict() with minimal fields."""
|
"""CaptureProgress.to_dict() with minimal fields."""
|
||||||
progress = CaptureProgress(status='idle')
|
progress = CaptureProgress(status="idle")
|
||||||
data = progress.to_dict()
|
data = progress.to_dict()
|
||||||
|
|
||||||
assert data['type'] == 'weather_sat_progress'
|
assert data["type"] == "weather_sat_progress"
|
||||||
assert data['status'] == 'idle'
|
assert data["status"] == "idle"
|
||||||
assert data['satellite'] == ''
|
assert data["satellite"] == ""
|
||||||
assert data['message'] == ''
|
assert data["message"] == ""
|
||||||
assert data['progress'] == 0
|
assert data["progress"] == 0
|
||||||
|
|
||||||
def test_to_dict_complete(self):
|
def test_to_dict_complete(self):
|
||||||
"""CaptureProgress.to_dict() with all fields."""
|
"""CaptureProgress.to_dict() with all fields."""
|
||||||
image = WeatherSatImage(
|
image = WeatherSatImage(
|
||||||
filename='test.png',
|
filename="test.png",
|
||||||
path=Path('/tmp/test.png'),
|
path=Path("/tmp/test.png"),
|
||||||
satellite='NOAA-18',
|
satellite="NOAA-18",
|
||||||
mode='APT',
|
mode="APT",
|
||||||
timestamp=datetime.now(timezone.utc),
|
timestamp=datetime.now(timezone.utc),
|
||||||
frequency=137.9125,
|
frequency=137.9125,
|
||||||
)
|
)
|
||||||
|
|
||||||
progress = CaptureProgress(
|
progress = CaptureProgress(
|
||||||
status='complete',
|
status="complete",
|
||||||
satellite='NOAA-18',
|
satellite="NOAA-18",
|
||||||
frequency=137.9125,
|
frequency=137.9125,
|
||||||
mode='APT',
|
mode="APT",
|
||||||
message='Capture complete',
|
message="Capture complete",
|
||||||
progress_percent=100,
|
progress_percent=100,
|
||||||
elapsed_seconds=600,
|
elapsed_seconds=600,
|
||||||
image=image,
|
image=image,
|
||||||
log_type='info',
|
log_type="info",
|
||||||
capture_phase='complete',
|
capture_phase="complete",
|
||||||
)
|
)
|
||||||
|
|
||||||
data = progress.to_dict()
|
data = progress.to_dict()
|
||||||
|
|
||||||
assert data['status'] == 'complete'
|
assert data["status"] == "complete"
|
||||||
assert data['satellite'] == 'NOAA-18'
|
assert data["satellite"] == "NOAA-18"
|
||||||
assert data['frequency'] == 137.9125
|
assert data["frequency"] == 137.9125
|
||||||
assert data['mode'] == 'APT'
|
assert data["mode"] == "APT"
|
||||||
assert data['message'] == 'Capture complete'
|
assert data["message"] == "Capture complete"
|
||||||
assert data['progress'] == 100
|
assert data["progress"] == 100
|
||||||
assert data['elapsed_seconds'] == 600
|
assert data["elapsed_seconds"] == 600
|
||||||
assert 'image' in data
|
assert "image" in data
|
||||||
assert data['log_type'] == 'info'
|
assert data["log_type"] == "info"
|
||||||
assert data['capture_phase'] == 'complete'
|
assert data["capture_phase"] == "complete"
|
||||||
|
|
||||||
|
|
||||||
class TestGlobalFunctions:
|
class TestGlobalFunctions:
|
||||||
@@ -641,8 +680,9 @@ class TestGlobalFunctions:
|
|||||||
|
|
||||||
def test_get_weather_sat_decoder_singleton(self):
|
def test_get_weather_sat_decoder_singleton(self):
|
||||||
"""get_weather_sat_decoder() should return singleton."""
|
"""get_weather_sat_decoder() should return singleton."""
|
||||||
with patch('shutil.which', return_value='/usr/bin/satdump'):
|
with patch("shutil.which", return_value="/usr/bin/satdump"):
|
||||||
import utils.weather_sat as mod
|
import utils.weather_sat as mod
|
||||||
|
|
||||||
old = mod._decoder
|
old = mod._decoder
|
||||||
mod._decoder = None
|
mod._decoder = None
|
||||||
|
|
||||||
@@ -656,8 +696,9 @@ class TestGlobalFunctions:
|
|||||||
|
|
||||||
def test_is_weather_sat_available_true(self):
|
def test_is_weather_sat_available_true(self):
|
||||||
"""is_weather_sat_available() should return True when available."""
|
"""is_weather_sat_available() should return True when available."""
|
||||||
with patch('shutil.which', return_value='/usr/bin/satdump'):
|
with patch("shutil.which", return_value="/usr/bin/satdump"):
|
||||||
import utils.weather_sat as mod
|
import utils.weather_sat as mod
|
||||||
|
|
||||||
old = mod._decoder
|
old = mod._decoder
|
||||||
mod._decoder = None
|
mod._decoder = None
|
||||||
|
|
||||||
@@ -668,8 +709,9 @@ class TestGlobalFunctions:
|
|||||||
|
|
||||||
def test_is_weather_sat_available_false(self):
|
def test_is_weather_sat_available_false(self):
|
||||||
"""is_weather_sat_available() should return False when unavailable."""
|
"""is_weather_sat_available() should return False when unavailable."""
|
||||||
with patch('shutil.which', return_value=None):
|
with patch("shutil.which", return_value=None):
|
||||||
import utils.weather_sat as mod
|
import utils.weather_sat as mod
|
||||||
|
|
||||||
old = mod._decoder
|
old = mod._decoder
|
||||||
mod._decoder = None
|
mod._decoder = None
|
||||||
|
|
||||||
@@ -684,26 +726,26 @@ class TestWeatherSatellitesConstant:
|
|||||||
|
|
||||||
def test_weather_satellites_structure(self):
|
def test_weather_satellites_structure(self):
|
||||||
"""WEATHER_SATELLITES should have correct structure."""
|
"""WEATHER_SATELLITES should have correct structure."""
|
||||||
assert 'NOAA-18' in WEATHER_SATELLITES
|
assert "NOAA-18" in WEATHER_SATELLITES
|
||||||
sat = WEATHER_SATELLITES['NOAA-18']
|
sat = WEATHER_SATELLITES["NOAA-18"]
|
||||||
|
|
||||||
assert 'name' in sat
|
assert "name" in sat
|
||||||
assert 'frequency' in sat
|
assert "frequency" in sat
|
||||||
assert 'mode' in sat
|
assert "mode" in sat
|
||||||
assert 'pipeline' in sat
|
assert "pipeline" in sat
|
||||||
assert 'tle_key' in sat
|
assert "tle_key" in sat
|
||||||
assert 'description' in sat
|
assert "description" in sat
|
||||||
assert 'active' in sat
|
assert "active" in sat
|
||||||
|
|
||||||
def test_noaa_satellites(self):
|
def test_noaa_satellites(self):
|
||||||
"""NOAA satellites should have correct frequencies."""
|
"""NOAA satellites should have correct frequencies."""
|
||||||
assert WEATHER_SATELLITES['NOAA-15']['frequency'] == 137.620
|
assert WEATHER_SATELLITES["NOAA-15"]["frequency"] == 137.620
|
||||||
assert WEATHER_SATELLITES['NOAA-18']['frequency'] == 137.9125
|
assert WEATHER_SATELLITES["NOAA-18"]["frequency"] == 137.9125
|
||||||
assert WEATHER_SATELLITES['NOAA-19']['frequency'] == 137.100
|
assert WEATHER_SATELLITES["NOAA-19"]["frequency"] == 137.100
|
||||||
|
|
||||||
def test_meteor_satellite(self):
|
def test_meteor_satellite(self):
|
||||||
"""Meteor satellite should use LRPT mode."""
|
"""Meteor satellite should use LRPT mode."""
|
||||||
meteor = WEATHER_SATELLITES['METEOR-M2-3']
|
meteor = WEATHER_SATELLITES["METEOR-M2-3"]
|
||||||
assert meteor['mode'] == 'LRPT'
|
assert meteor["mode"] == "LRPT"
|
||||||
assert meteor['frequency'] == 137.900
|
assert meteor["frequency"] == 137.900
|
||||||
assert meteor['pipeline'] == 'meteor_m2-x_lrpt'
|
assert meteor["pipeline"] == "meteor_m2-x_lrpt"
|
||||||
|
|||||||
+340
-379
File diff suppressed because it is too large
Load Diff
+53
-67
@@ -8,7 +8,7 @@ import logging
|
|||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
logger = logging.getLogger('intercept.agent_client')
|
logger = logging.getLogger("intercept.agent_client")
|
||||||
|
|
||||||
|
|
||||||
class AgentHTTPError(RuntimeError):
|
class AgentHTTPError(RuntimeError):
|
||||||
@@ -21,18 +21,14 @@ class AgentHTTPError(RuntimeError):
|
|||||||
|
|
||||||
class AgentConnectionError(AgentHTTPError):
|
class AgentConnectionError(AgentHTTPError):
|
||||||
"""Exception raised when agent is unreachable."""
|
"""Exception raised when agent is unreachable."""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class AgentClient:
|
class AgentClient:
|
||||||
"""HTTP client for communicating with a remote Intercept agent."""
|
"""HTTP client for communicating with a remote Intercept agent."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(self, base_url: str, api_key: str | None = None, timeout: float = 60.0):
|
||||||
self,
|
|
||||||
base_url: str,
|
|
||||||
api_key: str | None = None,
|
|
||||||
timeout: float = 60.0
|
|
||||||
):
|
|
||||||
"""
|
"""
|
||||||
Initialize agent client.
|
Initialize agent client.
|
||||||
|
|
||||||
@@ -41,15 +37,15 @@ class AgentClient:
|
|||||||
api_key: Optional API key for authentication
|
api_key: Optional API key for authentication
|
||||||
timeout: Request timeout in seconds
|
timeout: Request timeout in seconds
|
||||||
"""
|
"""
|
||||||
self.base_url = base_url.rstrip('/')
|
self.base_url = base_url.rstrip("/")
|
||||||
self.api_key = api_key
|
self.api_key = api_key
|
||||||
self.timeout = timeout
|
self.timeout = timeout
|
||||||
|
|
||||||
def _headers(self) -> dict:
|
def _headers(self) -> dict:
|
||||||
"""Get request headers."""
|
"""Get request headers."""
|
||||||
headers = {'Content-Type': 'application/json'}
|
headers = {"Content-Type": "application/json"}
|
||||||
if self.api_key:
|
if self.api_key:
|
||||||
headers['X-API-Key'] = self.api_key
|
headers["X-API-Key"] = self.api_key
|
||||||
return headers
|
return headers
|
||||||
|
|
||||||
def _get(self, path: str, params: dict | None = None) -> dict:
|
def _get(self, path: str, params: dict | None = None) -> dict:
|
||||||
@@ -69,12 +65,7 @@ class AgentClient:
|
|||||||
"""
|
"""
|
||||||
url = f"{self.base_url}{path}"
|
url = f"{self.base_url}{path}"
|
||||||
try:
|
try:
|
||||||
response = requests.get(
|
response = requests.get(url, headers=self._headers(), params=params, timeout=self.timeout)
|
||||||
url,
|
|
||||||
headers=self._headers(),
|
|
||||||
params=params,
|
|
||||||
timeout=self.timeout
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
return response.json() if response.content else {}
|
return response.json() if response.content else {}
|
||||||
except requests.ConnectionError as e:
|
except requests.ConnectionError as e:
|
||||||
@@ -86,17 +77,17 @@ class AgentClient:
|
|||||||
error_msg = f"Agent returned error: {e.response.status_code}"
|
error_msg = f"Agent returned error: {e.response.status_code}"
|
||||||
try:
|
try:
|
||||||
error_data = e.response.json()
|
error_data = e.response.json()
|
||||||
if 'message' in error_data:
|
if "message" in error_data:
|
||||||
error_msg = error_data['message']
|
error_msg = error_data["message"]
|
||||||
elif 'error' in error_data:
|
elif "error" in error_data:
|
||||||
error_msg = error_data['error']
|
error_msg = error_data["error"]
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
raise AgentHTTPError(error_msg, status_code=e.response.status_code)
|
raise AgentHTTPError(error_msg, status_code=e.response.status_code)
|
||||||
except requests.RequestException as e:
|
except requests.RequestException as e:
|
||||||
raise AgentHTTPError(f"Request failed: {e}")
|
raise AgentHTTPError(f"Request failed: {e}")
|
||||||
|
|
||||||
def _post(self, path: str, data: dict | None = None, timeout: float | None = None) -> dict:
|
def _post(self, path: str, data: dict | None = None, timeout: float | None = None) -> dict:
|
||||||
"""
|
"""
|
||||||
Perform POST request to agent.
|
Perform POST request to agent.
|
||||||
|
|
||||||
@@ -111,39 +102,38 @@ class AgentClient:
|
|||||||
AgentHTTPError: On HTTP errors
|
AgentHTTPError: On HTTP errors
|
||||||
AgentConnectionError: If agent is unreachable
|
AgentConnectionError: If agent is unreachable
|
||||||
"""
|
"""
|
||||||
url = f"{self.base_url}{path}"
|
url = f"{self.base_url}{path}"
|
||||||
request_timeout = self.timeout if timeout is None else timeout
|
request_timeout = self.timeout if timeout is None else timeout
|
||||||
try:
|
try:
|
||||||
response = requests.post(
|
response = requests.post(url, json=data or {}, headers=self._headers(), timeout=request_timeout)
|
||||||
url,
|
response.raise_for_status()
|
||||||
json=data or {},
|
return response.json() if response.content else {}
|
||||||
headers=self._headers(),
|
except requests.ConnectionError as e:
|
||||||
timeout=request_timeout
|
raise AgentConnectionError(f"Cannot connect to agent at {self.base_url}: {e}")
|
||||||
)
|
except requests.Timeout:
|
||||||
response.raise_for_status()
|
raise AgentConnectionError(f"Request to agent timed out after {request_timeout}s")
|
||||||
return response.json() if response.content else {}
|
|
||||||
except requests.ConnectionError as e:
|
|
||||||
raise AgentConnectionError(f"Cannot connect to agent at {self.base_url}: {e}")
|
|
||||||
except requests.Timeout:
|
|
||||||
raise AgentConnectionError(f"Request to agent timed out after {request_timeout}s")
|
|
||||||
except requests.HTTPError as e:
|
except requests.HTTPError as e:
|
||||||
# Try to extract error message from response body
|
# Try to extract error message from response body
|
||||||
error_msg = f"Agent returned error: {e.response.status_code}"
|
error_msg = f"Agent returned error: {e.response.status_code}"
|
||||||
try:
|
try:
|
||||||
error_data = e.response.json()
|
error_data = e.response.json()
|
||||||
if 'message' in error_data:
|
if "message" in error_data:
|
||||||
error_msg = error_data['message']
|
error_msg = error_data["message"]
|
||||||
elif 'error' in error_data:
|
elif "error" in error_data:
|
||||||
error_msg = error_data['error']
|
error_msg = error_data["error"]
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
raise AgentHTTPError(error_msg, status_code=e.response.status_code)
|
raise AgentHTTPError(error_msg, status_code=e.response.status_code)
|
||||||
except requests.RequestException as e:
|
except requests.RequestException as e:
|
||||||
raise AgentHTTPError(f"Request failed: {e}")
|
raise AgentHTTPError(f"Request failed: {e}")
|
||||||
|
|
||||||
def post(self, path: str, data: dict | None = None, timeout: float | None = None) -> dict:
|
def get(self, path: str, params: dict | None = None) -> dict:
|
||||||
"""Public POST method for arbitrary endpoints."""
|
"""Public GET method for arbitrary endpoints."""
|
||||||
return self._post(path, data, timeout=timeout)
|
return self._get(path, params)
|
||||||
|
|
||||||
|
def post(self, path: str, data: dict | None = None, timeout: float | None = None) -> dict:
|
||||||
|
"""Public POST method for arbitrary endpoints."""
|
||||||
|
return self._post(path, data, timeout=timeout)
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Capability & Status
|
# Capability & Status
|
||||||
@@ -156,7 +146,7 @@ class AgentClient:
|
|||||||
Returns:
|
Returns:
|
||||||
Dict with 'modes' (mode -> bool), 'devices' (list), 'agent_version'
|
Dict with 'modes' (mode -> bool), 'devices' (list), 'agent_version'
|
||||||
"""
|
"""
|
||||||
return self._get('/capabilities')
|
return self._get("/capabilities")
|
||||||
|
|
||||||
def get_status(self) -> dict:
|
def get_status(self) -> dict:
|
||||||
"""
|
"""
|
||||||
@@ -165,7 +155,7 @@ class AgentClient:
|
|||||||
Returns:
|
Returns:
|
||||||
Dict with 'running_modes', 'uptime', 'push_enabled', etc.
|
Dict with 'running_modes', 'uptime', 'push_enabled', etc.
|
||||||
"""
|
"""
|
||||||
return self._get('/status')
|
return self._get("/status")
|
||||||
|
|
||||||
def health_check(self) -> bool:
|
def health_check(self) -> bool:
|
||||||
"""
|
"""
|
||||||
@@ -175,14 +165,14 @@ class AgentClient:
|
|||||||
True if agent is reachable and healthy
|
True if agent is reachable and healthy
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
result = self._get('/health')
|
result = self._get("/health")
|
||||||
return result.get('status') == 'healthy'
|
return result.get("status") == "healthy"
|
||||||
except (AgentHTTPError, AgentConnectionError):
|
except (AgentHTTPError, AgentConnectionError):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def get_config(self) -> dict:
|
def get_config(self) -> dict:
|
||||||
"""Get agent configuration (non-sensitive fields)."""
|
"""Get agent configuration (non-sensitive fields)."""
|
||||||
return self._get('/config')
|
return self._get("/config")
|
||||||
|
|
||||||
def update_config(self, **kwargs) -> dict:
|
def update_config(self, **kwargs) -> dict:
|
||||||
"""
|
"""
|
||||||
@@ -195,7 +185,7 @@ class AgentClient:
|
|||||||
Returns:
|
Returns:
|
||||||
Updated config
|
Updated config
|
||||||
"""
|
"""
|
||||||
return self._post('/config', kwargs)
|
return self._post("/config", kwargs)
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Mode Operations
|
# Mode Operations
|
||||||
@@ -212,9 +202,9 @@ class AgentClient:
|
|||||||
Returns:
|
Returns:
|
||||||
Start result with 'status' field
|
Start result with 'status' field
|
||||||
"""
|
"""
|
||||||
return self._post(f'/{mode}/start', params or {})
|
return self._post(f"/{mode}/start", params or {})
|
||||||
|
|
||||||
def stop_mode(self, mode: str, timeout: float = 8.0) -> dict:
|
def stop_mode(self, mode: str, timeout: float = 8.0) -> dict:
|
||||||
"""
|
"""
|
||||||
Stop a running mode on the agent.
|
Stop a running mode on the agent.
|
||||||
|
|
||||||
@@ -224,7 +214,7 @@ class AgentClient:
|
|||||||
Returns:
|
Returns:
|
||||||
Stop result with 'status' field
|
Stop result with 'status' field
|
||||||
"""
|
"""
|
||||||
return self._post(f'/{mode}/stop', timeout=timeout)
|
return self._post(f"/{mode}/stop", timeout=timeout)
|
||||||
|
|
||||||
def get_mode_status(self, mode: str) -> dict:
|
def get_mode_status(self, mode: str) -> dict:
|
||||||
"""
|
"""
|
||||||
@@ -236,7 +226,7 @@ class AgentClient:
|
|||||||
Returns:
|
Returns:
|
||||||
Mode status with 'running' field
|
Mode status with 'running' field
|
||||||
"""
|
"""
|
||||||
return self._get(f'/{mode}/status')
|
return self._get(f"/{mode}/status")
|
||||||
|
|
||||||
def get_mode_data(self, mode: str) -> dict:
|
def get_mode_data(self, mode: str) -> dict:
|
||||||
"""
|
"""
|
||||||
@@ -248,7 +238,7 @@ class AgentClient:
|
|||||||
Returns:
|
Returns:
|
||||||
Data snapshot with 'data' field
|
Data snapshot with 'data' field
|
||||||
"""
|
"""
|
||||||
return self._get(f'/{mode}/data')
|
return self._get(f"/{mode}/data")
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Convenience Methods
|
# Convenience Methods
|
||||||
@@ -262,17 +252,17 @@ class AgentClient:
|
|||||||
Dict with capabilities, status, and config
|
Dict with capabilities, status, and config
|
||||||
"""
|
"""
|
||||||
metadata = {
|
metadata = {
|
||||||
'capabilities': None,
|
"capabilities": None,
|
||||||
'status': None,
|
"status": None,
|
||||||
'config': None,
|
"config": None,
|
||||||
'healthy': False,
|
"healthy": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
metadata['capabilities'] = self.get_capabilities()
|
metadata["capabilities"] = self.get_capabilities()
|
||||||
metadata['status'] = self.get_status()
|
metadata["status"] = self.get_status()
|
||||||
metadata['config'] = self.get_config()
|
metadata["config"] = self.get_config()
|
||||||
metadata['healthy'] = True
|
metadata["healthy"] = True
|
||||||
except (AgentHTTPError, AgentConnectionError) as e:
|
except (AgentHTTPError, AgentConnectionError) as e:
|
||||||
logger.warning(f"Failed to refresh agent metadata: {e}")
|
logger.warning(f"Failed to refresh agent metadata: {e}")
|
||||||
|
|
||||||
@@ -292,8 +282,4 @@ def create_client_from_agent(agent: dict) -> AgentClient:
|
|||||||
Returns:
|
Returns:
|
||||||
Configured AgentClient
|
Configured AgentClient
|
||||||
"""
|
"""
|
||||||
return AgentClient(
|
return AgentClient(base_url=agent["base_url"], api_key=agent.get("api_key"), timeout=60.0)
|
||||||
base_url=agent['base_url'],
|
|
||||||
api_key=agent.get('api_key'),
|
|
||||||
timeout=60.0
|
|
||||||
)
|
|
||||||
|
|||||||
+2
-13
@@ -35,21 +35,10 @@ def is_meshcore_available() -> bool:
|
|||||||
return HAS_MESHCORE
|
return HAS_MESHCORE
|
||||||
|
|
||||||
|
|
||||||
# Try to import ContactType for repeater detection
|
|
||||||
try:
|
|
||||||
from meshcore import ContactType as _ContactType
|
|
||||||
|
|
||||||
_REPEATER_TYPE = getattr(_ContactType, "REPEATER", None)
|
|
||||||
except Exception:
|
|
||||||
_ContactType = None
|
|
||||||
_REPEATER_TYPE = None
|
|
||||||
|
|
||||||
|
|
||||||
def _is_repeater_contact(contact_dict: dict) -> bool:
|
def _is_repeater_contact(contact_dict: dict) -> bool:
|
||||||
"""Return True if this contact is a repeater node."""
|
"""Return True if this contact is a repeater node."""
|
||||||
if _REPEATER_TYPE is not None:
|
# meshcore exports no ContactType enum (checked through 2.3.7);
|
||||||
return contact_dict.get("type") == _REPEATER_TYPE
|
# repeaters have type==2 by library convention
|
||||||
# Fallback: meshcore repeaters have type==2 by convention
|
|
||||||
return contact_dict.get("type") == 2
|
return contact_dict.get("type") == 2
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+421
-366
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user