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:
James Smith
2026-06-11 16:42:33 +01:00
parent b68a53eb53
commit d4652017f5
27 changed files with 3128 additions and 4029 deletions
+3
View File
@@ -57,6 +57,9 @@ data/weather_sat/
# Radiosonde runtime data (station config, logs)
data/radiosonde/
# Downloaded TLE files (intercept_agent satellite predictor)
data/tle/
# SDR capture files (large IQ recordings)
data/subghz/captures/
+5
View File
@@ -1293,6 +1293,11 @@ def _init_app() -> None:
except Exception as 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()
+1278 -1321
View File
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -160,7 +160,11 @@ exclude = [
testpaths = ["tests"]
python_files = ["test_*.py"]
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]
source = ["app", "routes", "utils", "data"]
+2 -1
View File
@@ -27,7 +27,8 @@ pyserial>=3.5
# Meshtastic mesh network support (optional - only needed for Meshtastic features)
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)
scapy>=2.4.5
+308 -350
View File
File diff suppressed because it is too large Load Diff
+34 -20
View File
@@ -492,14 +492,24 @@ raise SystemExit(0 if sys.version_info >= (3,9) else 1)
PY
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
# 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'
import sys
raise SystemExit(0 if sys.version_info >= (3,13) else 1)
PY
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
}
@@ -537,6 +547,10 @@ install_python_deps() {
# --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
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
warn "pip/setuptools/wheel upgrade failed - continuing with existing versions"
@@ -562,27 +576,27 @@ install_python_deps() {
ok "Core Python packages installed"
info "Installing optional packages..."
# Pure-Python packages: install without --only-binary so they always succeed regardless of platform
for pkg in "flask-sock" "simple-websocket>=0.5.1" "websocket-client>=1.6.0" \
"skyfield>=1.45" "bleak>=0.21.0" "meshtastic>=2.0.0" \
"qrcode[pil]>=7.4" "gunicorn>=21.2.0" "psutil>=5.9.0"; do
pkg_name="${pkg%%[><=]*}"
# Optional package specs come from requirements.txt (single source of truth).
# Packages already installed by the core step above are skipped here.
local core_pkgs="flask flask-wtf flask-compress flask-limiter requests Werkzeug pyserial"
# Heavy compiled packages: install with --only-binary :all: to skip slow
# 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}..."
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)"
fi
done
# 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
done < requirements.txt
ok "Optional packages processed"
echo
}
-115
View File
@@ -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);
}
-31
View File
@@ -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); }
}
+2 -1
View File
@@ -1371,7 +1371,8 @@ const WiFiMode = (function() {
const data = await response.json();
// Handle agent response format (may be nested in 'result')
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) {
renderClientList(clientList, bssid);
+3 -10
View File
@@ -772,8 +772,6 @@
{% include 'partials/modes/tscm.html' %}
{% include 'partials/modes/ais.html' %}
{% include 'partials/modes/drone.html' %}
{% include 'partials/modes/radiosonde.html' %}
@@ -4462,9 +4460,6 @@
sstv_general: () => typeof SSTVGeneral !== 'undefined' && SSTVGeneral.destroy?.(),
websdr: () => typeof WebSDR !== 'undefined' && WebSDR.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; } },
aprs: () => {
if (typeof destroyAprsMode === 'function') {
@@ -4820,7 +4815,6 @@
document.getElementById('wflMode')?.classList.toggle('active', mode === 'wifi_locate');
document.getElementById('aprsMode')?.classList.toggle('active', mode === 'aprs');
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('radiosondeMode')?.classList.toggle('active', mode === 'radiosonde');
document.getElementById('spystationsMode')?.classList.toggle('active', mode === 'spystations');
@@ -5006,7 +5000,7 @@
// Show agent selector for modes that support remote agents
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';
// 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`;
// Update max attribute on all mode gain inputs so constraints match the SDR
const gainMax = caps.gain_max;
['gain', 'sensorGain', 'aisGainInput', 'acarsGainInput', 'aprsStripGain', 'weatherSatGain'].forEach(id => {
['gain', 'sensorGain', 'aprsStripGain', 'weatherSatGain'].forEach(id => {
const el = document.getElementById(id);
if (el) el.max = gainMax;
});
@@ -6633,8 +6627,7 @@
// Warn if any SDR mode is currently running — bias-T is applied at
// start time and cannot be toggled on a running device.
const anyRunning = isRunning || isSensorRunning
|| (typeof isAdsbRunning !== 'undefined' && isAdsbRunning)
|| (typeof isAisRunning !== 'undefined' && isAisRunning);
|| (typeof isAdsbRunning !== 'undefined' && isAdsbRunning);
if (anyRunning) {
showInfo('Bias-T change will take effect after restarting the active SDR mode');
}
-300
View File
@@ -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 &amp; 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) &mdash; 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 ? ' &bull; FL' + escapeHtml(String(p.flight_level)) : ''}${p.destination ? ' &rarr; ' + 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))} &rarr; ${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>
-218
View File
@@ -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) &mdash; 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&ndash;50, designed for 156&ndash;163 MHz band</li>
<li><strong style="color: var(--text-primary);">Discone:</strong> ~$30&ndash;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>
-242
View File
@@ -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 &amp; 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) &mdash; 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>
+5
View File
@@ -7,6 +7,11 @@ from unittest.mock import MagicMock, patch
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 routes import register_blueprints
+142 -132
View File
@@ -25,10 +25,12 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# Fixtures
# =============================================================================
@pytest.fixture
def mode_manager():
"""Create a fresh ModeManager instance for testing."""
from intercept_agent import ModeManager
manager = ModeManager()
yield manager
# Cleanup: stop all modes
@@ -40,15 +42,21 @@ def mode_manager():
@pytest.fixture
def mock_subprocess():
"""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.poll.return_value = None # Process is running
mock_proc.stdout = 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.pid = 12345
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
yield mock_popen, mock_proc
@@ -57,19 +65,19 @@ def mock_subprocess():
def mock_tools():
"""Mock tool availability checks."""
tools = {
'rtl_433': '/usr/bin/rtl_433',
'rtl_fm': '/usr/bin/rtl_fm',
'dump1090': '/usr/bin/dump1090',
'multimon-ng': '/usr/bin/multimon-ng',
'airodump-ng': '/usr/sbin/airodump-ng',
'acarsdec': '/usr/bin/acarsdec',
'AIS-catcher': '/usr/bin/AIS-catcher',
'direwolf': '/usr/bin/direwolf',
'rtlamr': '/usr/bin/rtlamr',
'rtl_tcp': '/usr/bin/rtl_tcp',
'bluetoothctl': '/usr/bin/bluetoothctl',
"rtl_433": "/usr/bin/rtl_433",
"rtl_fm": "/usr/bin/rtl_fm",
"dump1090": "/usr/bin/dump1090",
"multimon-ng": "/usr/bin/multimon-ng",
"airodump-ng": "/usr/sbin/airodump-ng",
"acarsdec": "/usr/bin/acarsdec",
"AIS-catcher": "/usr/bin/AIS-catcher",
"direwolf": "/usr/bin/direwolf",
"rtlamr": "/usr/bin/rtlamr",
"rtl_tcp": "/usr/bin/rtl_tcp",
"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
@@ -77,8 +85,8 @@ def mock_tools():
# SDR Mode List
# =============================================================================
SDR_MODES = ['sensor', 'adsb', 'pager', 'ais', 'acars', 'aprs', 'rtlamr', 'dsc', 'listening_post']
NON_SDR_MODES = ['wifi', 'bluetooth', 'tscm', 'satellite']
SDR_MODES = ["sensor", "adsb", "pager", "ais", "acars", "aprs", "rtlamr", "dsc", "listening_post"]
NON_SDR_MODES = ["wifi", "bluetooth", "tscm", "satellite"]
ALL_MODES = SDR_MODES + NON_SDR_MODES
@@ -86,6 +94,7 @@ ALL_MODES = SDR_MODES + NON_SDR_MODES
# Mode Lifecycle Tests
# =============================================================================
class TestModeLifecycle:
"""Test start/stop lifecycle for all modes."""
@@ -94,99 +103,88 @@ class TestModeLifecycle:
mock_popen, mock_proc = mock_subprocess
# Start
result = mode_manager.start_mode('sensor', {'frequency': '433.92', 'device': '0'})
assert result['status'] == 'started'
assert 'sensor' in mode_manager.running_modes
result = mode_manager.start_mode("sensor", {"frequency": "433.92", "device": "0"})
assert result["status"] == "started"
assert "sensor" in mode_manager.running_modes
# Stop
result = mode_manager.stop_mode('sensor')
assert result['status'] == 'stopped'
assert 'sensor' not in mode_manager.running_modes
result = mode_manager.stop_mode("sensor")
assert result["status"] == "stopped"
assert "sensor" not in mode_manager.running_modes
def test_adsb_mode_lifecycle(self, mode_manager, mock_subprocess, mock_tools):
"""ADS-B mode should start and stop cleanly."""
mock_popen, mock_proc = mock_subprocess
# 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.connect_ex.return_value = 1 # Port not in use
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
assert result['status'] in ['started', 'error']
assert result["status"] in ["started", "error"]
def test_pager_mode_lifecycle(self, mode_manager, mock_subprocess, mock_tools):
"""Pager mode should start and stop cleanly."""
mock_popen, mock_proc = mock_subprocess
result = mode_manager.start_mode('pager', {
'frequency': '929.6125',
'protocols': ['POCSAG512', 'POCSAG1200']
})
assert result['status'] == 'started'
assert 'pager' in mode_manager.running_modes
result = mode_manager.start_mode("pager", {"frequency": "929.6125", "protocols": ["POCSAG512", "POCSAG1200"]})
assert result["status"] == "started"
assert "pager" in mode_manager.running_modes
result = mode_manager.stop_mode('pager')
assert result['status'] == 'stopped'
result = mode_manager.stop_mode("pager")
assert result["status"] == "stopped"
def test_wifi_mode_lifecycle(self, mode_manager, mock_subprocess, mock_tools):
"""WiFi mode should start and stop cleanly."""
mock_popen, mock_proc = mock_subprocess
# Mock glob for CSV file detection
with patch('glob.glob', return_value=[]), patch('tempfile.mkdtemp', return_value='/tmp/test'):
result = mode_manager.start_mode('wifi', {
'interface': 'wlan0',
'scan_type': 'quick'
})
with patch("glob.glob", return_value=[]), patch("tempfile.mkdtemp", return_value="/tmp/test"):
result = mode_manager.start_mode("wifi", {"interface": "wlan0", "scan_type": "quick"})
# 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):
"""Bluetooth mode should start and stop cleanly."""
mock_popen, mock_proc = mock_subprocess
result = mode_manager.start_mode('bluetooth', {'adapter': 'hci0'})
assert result['status'] == 'started'
assert 'bluetooth' in mode_manager.running_modes
result = mode_manager.start_mode("bluetooth", {"adapter": "hci0"})
assert result["status"] == "started"
assert "bluetooth" in mode_manager.running_modes
# Give thread time to start
time.sleep(0.1)
result = mode_manager.stop_mode('bluetooth')
assert result['status'] == 'stopped'
result = mode_manager.stop_mode("bluetooth")
assert result["status"] == "stopped"
def test_satellite_mode_lifecycle(self, mode_manager):
"""Satellite mode should work without SDR."""
# Satellite mode is computational only
result = mode_manager.start_mode('satellite', {
'lat': 33.5,
'lon': -82.1,
'min_elevation': 10
})
assert result['status'] in ['started', 'error'] # May fail if skyfield not installed
# Patch the predictor loop — the real one downloads TLEs from
# CelesTrak and keeps computing passes after the test finishes
with patch.object(type(mode_manager), "_satellite_predictor", MagicMock()):
result = mode_manager.start_mode("satellite", {"lat": 33.5, "lon": -82.1, "min_elevation": 10})
assert result["status"] in ["started", "error"] # May fail if skyfield not installed
def test_tscm_mode_lifecycle(self, mode_manager, mock_subprocess, mock_tools):
"""TSCM mode should start and stop cleanly."""
mock_popen, mock_proc = mock_subprocess
result = mode_manager.start_mode('tscm', {
'wifi': True,
'bluetooth': True,
'rf': False
})
assert result['status'] == 'started'
result = mode_manager.start_mode("tscm", {"wifi": True, "bluetooth": True, "rf": False})
assert result["status"] == "started"
result = mode_manager.stop_mode('tscm')
assert result['status'] == 'stopped'
result = mode_manager.stop_mode("tscm")
assert result["status"] == "stopped"
# =============================================================================
# SDR Conflict Detection Tests
# =============================================================================
class TestSDRConflictDetection:
"""Test SDR device conflict detection."""
@@ -195,25 +193,25 @@ class TestSDRConflictDetection:
mock_popen, mock_proc = mock_subprocess
# Start sensor on device 0
result1 = mode_manager.start_mode('sensor', {'device': '0'})
assert result1['status'] == 'started'
result1 = mode_manager.start_mode("sensor", {"device": "0"})
assert result1["status"] == "started"
# Try to start pager on device 0 - should fail
result2 = mode_manager.start_mode('pager', {'device': '0'})
assert result2['status'] == 'error'
assert 'in use' in result2['message'].lower()
result2 = mode_manager.start_mode("pager", {"device": "0"})
assert result2["status"] == "error"
assert "in use" in result2["message"].lower()
def test_different_device_no_conflict(self, mode_manager, mock_subprocess, mock_tools):
"""Starting SDR modes on different devices should work."""
mock_popen, mock_proc = mock_subprocess
# Start sensor on device 0
result1 = mode_manager.start_mode('sensor', {'device': '0'})
assert result1['status'] == 'started'
result1 = mode_manager.start_mode("sensor", {"device": "0"})
assert result1["status"] == "started"
# Start pager on device 1 - should work
result2 = mode_manager.start_mode('pager', {'device': '1'})
assert result2['status'] == 'started'
result2 = mode_manager.start_mode("pager", {"device": "1"})
assert result2["status"] == "started"
assert len(mode_manager.running_modes) == 2
@@ -222,12 +220,12 @@ class TestSDRConflictDetection:
mock_popen, mock_proc = mock_subprocess
# Start sensor (SDR)
result1 = mode_manager.start_mode('sensor', {'device': '0'})
assert result1['status'] == 'started'
result1 = mode_manager.start_mode("sensor", {"device": "0"})
assert result1["status"] == "started"
# Start bluetooth (non-SDR) - should work
result2 = mode_manager.start_mode('bluetooth', {'adapter': 'hci0'})
assert result2['status'] == 'started'
result2 = mode_manager.start_mode("bluetooth", {"adapter": "hci0"})
assert result2["status"] == "started"
assert len(mode_manager.running_modes) == 2
@@ -239,10 +237,10 @@ class TestSDRConflictDetection:
assert mode_manager.get_sdr_in_use(0) is None
# Start sensor
mode_manager.start_mode('sensor', {'device': '0'})
mode_manager.start_mode("sensor", {"device": "0"})
# 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
@@ -250,67 +248,73 @@ class TestSDRConflictDetection:
# Process Verification Tests
# =============================================================================
class TestProcessVerification:
"""Test process startup verification."""
def test_immediate_process_exit_detected(self, mode_manager, mock_tools):
"""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.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
result = mode_manager.start_mode('sensor', {'device': '0'})
assert result['status'] == 'error'
assert 'sensor' not in mode_manager.running_modes
result = mode_manager.start_mode("sensor", {"device": "0"})
assert result["status"] == "error"
assert "sensor" not in mode_manager.running_modes
def test_running_process_accepted(self, mode_manager, mock_subprocess, mock_tools):
"""Process that stays running should be accepted."""
mock_popen, mock_proc = mock_subprocess
mock_proc.poll.return_value = None # Still running
result = mode_manager.start_mode('sensor', {'device': '0'})
assert result['status'] == 'started'
assert 'sensor' in mode_manager.running_modes
result = mode_manager.start_mode("sensor", {"device": "0"})
assert result["status"] == "started"
assert "sensor" in mode_manager.running_modes
def test_error_message_from_stderr(self, mode_manager, mock_tools):
"""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.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
result = mode_manager.start_mode('sensor', {'device': '0'})
assert result['status'] == 'error'
assert 'usb_claim_interface' in result['message'] or 'failed' in result['message'].lower()
result = mode_manager.start_mode("sensor", {"device": "0"})
assert result["status"] == "error"
assert "usb_claim_interface" in result["message"] or "failed" in result["message"].lower()
# =============================================================================
# Data Snapshot Tests
# =============================================================================
class TestDataSnapshots:
"""Test data snapshot operations."""
def test_get_mode_data_empty(self, mode_manager):
"""get_mode_data for non-running mode should return empty."""
result = mode_manager.get_mode_data('sensor')
assert result['mode'] == 'sensor'
result = mode_manager.get_mode_data("sensor")
assert result["mode"] == "sensor"
# 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):
"""get_mode_data for running mode should return status."""
mock_popen, mock_proc = mock_subprocess
mode_manager.start_mode('sensor', {'device': '0'})
result = mode_manager.get_mode_data('sensor')
mode_manager.start_mode("sensor", {"device": "0"})
result = mode_manager.get_mode_data("sensor")
assert result['mode'] == 'sensor'
assert result["mode"] == "sensor"
# 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):
"""Data queues should respect max size limits."""
@@ -321,7 +325,7 @@ class TestDataSnapshots:
for i in range(150):
if test_queue.full():
test_queue.get_nowait() # Remove old item
test_queue.put_nowait({'index': i})
test_queue.put_nowait({"index": i})
assert test_queue.qsize() <= 100
@@ -330,68 +334,73 @@ class TestDataSnapshots:
# Mode Status Tests
# =============================================================================
class TestModeStatus:
"""Test mode status reporting."""
def test_status_includes_all_modes(self, mode_manager):
"""Status should include all running modes."""
status = mode_manager.get_status()
assert 'running_modes' in status
assert 'running_modes_detail' in status
assert isinstance(status['running_modes'], list)
assert "running_modes" in status
assert "running_modes_detail" in status
assert isinstance(status["running_modes"], list)
def test_running_modes_detail_includes_device(self, mode_manager, mock_subprocess, mock_tools):
"""Running modes detail should include device info."""
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()
assert 'sensor' in status['running_modes_detail']
detail = status['running_modes_detail']['sensor']
assert 'device' in detail or 'params' in detail
assert "sensor" in status["running_modes_detail"]
detail = status["running_modes_detail"]["sensor"]
assert "device" in detail or "params" in detail
# =============================================================================
# Error Handling Tests
# =============================================================================
class TestErrorHandling:
"""Test error handling scenarios."""
def test_missing_tool_returns_error(self, mode_manager):
"""Mode should fail gracefully if required tool is missing."""
with patch('shutil.which', return_value=None):
result = mode_manager.start_mode('sensor', {'device': '0'})
assert result['status'] == 'error'
# get_tool_path checks Homebrew paths via os.path.isfile before
# shutil.which, so patch it too or installed tools are still found
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
msg = result['message'].lower()
assert 'not found' in msg or 'not available' in msg or 'missing' in msg
msg = result["message"].lower()
assert "not found" in msg or "not available" in msg or "missing" in msg
def test_invalid_mode_returns_error(self, mode_manager):
"""Invalid mode name should return error."""
result = mode_manager.start_mode('invalid_mode', {})
assert result['status'] == 'error'
result = mode_manager.start_mode("invalid_mode", {})
assert result["status"] == "error"
def test_double_start_returns_already_running(self, mode_manager, mock_subprocess, mock_tools):
"""Starting already-running mode should return appropriate status."""
mock_popen, mock_proc = mock_subprocess
mode_manager.start_mode('sensor', {'device': '0'})
result = mode_manager.start_mode('sensor', {'device': '0'})
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):
"""Stopping non-running mode should handle gracefully."""
result = mode_manager.stop_mode('sensor')
assert result['status'] in ['stopped', 'not_running']
result = mode_manager.stop_mode("sensor")
assert result["status"] in ["stopped", "not_running"]
# =============================================================================
# Cleanup Tests
# =============================================================================
class TestCleanup:
"""Test mode cleanup on stop."""
@@ -399,8 +408,8 @@ class TestCleanup:
"""Processes should be terminated when mode is stopped."""
mock_popen, mock_proc = mock_subprocess
mode_manager.start_mode('sensor', {'device': '0'})
mode_manager.stop_mode('sensor')
mode_manager.start_mode("sensor", {"device": "0"})
mode_manager.stop_mode("sensor")
# Verify terminate was called
mock_proc.terminate.assert_called()
@@ -409,20 +418,20 @@ class TestCleanup:
"""Output threads should be stopped when mode is stopped."""
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
mode_manager.stop_mode('bluetooth')
mode_manager.stop_mode("bluetooth")
# Thread should no longer be in output_threads or should be stopped
assert 'bluetooth' not in mode_manager.output_threads or \
not mode_manager.output_threads['bluetooth'].is_alive()
assert "bluetooth" not in mode_manager.output_threads or not mode_manager.output_threads["bluetooth"].is_alive()
# =============================================================================
# Multi-Mode Tests
# =============================================================================
class TestMultiMode:
"""Test multiple modes running simultaneously."""
@@ -430,19 +439,19 @@ class TestMultiMode:
"""Multiple non-SDR modes should run simultaneously."""
mock_popen, mock_proc = mock_subprocess
result1 = mode_manager.start_mode('bluetooth', {'adapter': 'hci0'})
result2 = mode_manager.start_mode('tscm', {'wifi': True, 'bluetooth': False})
result1 = mode_manager.start_mode("bluetooth", {"adapter": "hci0"})
result2 = mode_manager.start_mode("tscm", {"wifi": True, "bluetooth": False})
assert result1['status'] == 'started'
assert result2['status'] == 'started'
assert result1["status"] == "started"
assert result2["status"] == "started"
assert len(mode_manager.running_modes) == 2
def test_stop_all_modes(self, mode_manager, mock_subprocess, mock_tools):
"""All modes should stop cleanly."""
mock_popen, mock_proc = mock_subprocess
mode_manager.start_mode('sensor', {'device': '0'})
mode_manager.start_mode('bluetooth', {'adapter': 'hci0'})
mode_manager.start_mode("sensor", {"device": "0"})
mode_manager.start_mode("bluetooth", {"adapter": "hci0"})
# Stop all
for mode in list(mode_manager.running_modes.keys()):
@@ -455,26 +464,27 @@ class TestMultiMode:
# GPS Integration Tests
# =============================================================================
class TestGPSIntegration:
"""Test GPS coordinate integration."""
def test_status_includes_gps_flag(self, mode_manager):
"""Status should indicate GPS availability."""
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):
"""Mode start response should include GPS status."""
mock_popen, mock_proc = mock_subprocess
result = mode_manager.start_mode('sensor', {'device': '0'})
if result['status'] == 'started':
assert 'gps_enabled' in result
result = mode_manager.start_mode("sensor", {"device": "0"})
if result["status"] == "started":
assert "gps_enabled" in result
# =============================================================================
# Run Tests
# =============================================================================
if __name__ == '__main__':
pytest.main([__file__, '-v'])
if __name__ == "__main__":
pytest.main([__file__, "-v"])
+177 -194
View File
@@ -23,18 +23,19 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# Fixtures
# =============================================================================
@pytest.fixture
def setup_db(tmp_path):
"""Set up a temporary database."""
import utils.database as db_module
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
db_module.DB_PATH = test_db_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 = None
@@ -42,7 +43,7 @@ def setup_db(tmp_path):
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 = None
db_module.DB_PATH = original_db_path
@@ -56,7 +57,7 @@ def app(setup_db):
from routes.controller import controller_bp
app = Flask(__name__)
app.config['TESTING'] = True
app.config["TESTING"] = True
app.register_blueprint(controller_bp)
return app
@@ -72,13 +73,14 @@ def client(app):
def sample_agent(setup_db):
"""Create a sample agent in database."""
from utils.database import create_agent
agent_id = create_agent(
name='test-sensor',
base_url='http://192.168.1.50:8020',
api_key='test-key',
description='Test sensor node',
capabilities={'adsb': True, 'wifi': True},
gps_coords={'lat': 40.7128, 'lon': -74.0060}
name="test-sensor",
base_url="http://192.168.1.50:8020",
api_key="test-key",
description="Test sensor node",
capabilities={"adsb": True, "wifi": True},
gps_coords={"lat": 40.7128, "lon": -74.0060},
)
return agent_id
@@ -87,125 +89,125 @@ def sample_agent(setup_db):
# Agent CRUD Tests
# =============================================================================
class TestAgentCRUD:
"""Tests for agent CRUD operations."""
def test_list_agents_empty(self, client):
"""GET /controller/agents should return empty list initially."""
response = client.get('/controller/agents')
response = client.get("/controller/agents")
assert response.status_code == 200
data = json.loads(response.data)
assert data['status'] == 'success'
assert data['agents'] == []
assert data['count'] == 0
assert data["status"] == "success"
assert data["agents"] == []
assert data["count"] == 0
def test_register_agent_success(self, client):
"""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_instance = Mock()
mock_instance.get_capabilities.return_value = {
'modes': {'adsb': True, 'wifi': True},
'devices': [{'name': 'RTL-SDR'}]
"modes": {"adsb": True, "wifi": True},
"devices": [{"name": "RTL-SDR"}],
}
MockClient.return_value = mock_instance
response = client.post('/controller/agents',
response = client.post(
"/controller/agents",
json={
'name': 'new-sensor',
'base_url': 'http://192.168.1.51:8020',
'api_key': 'secret123',
'description': 'New sensor node'
"name": "new-sensor",
"base_url": "http://192.168.1.51:8020",
"api_key": "secret123",
"description": "New sensor node",
},
content_type='application/json'
content_type="application/json",
)
assert response.status_code == 201
data = json.loads(response.data)
assert data['status'] == 'success'
assert data['agent']['name'] == 'new-sensor'
assert data["status"] == "success"
assert data["agent"]["name"] == "new-sensor"
def test_register_agent_missing_name(self, client):
"""POST /controller/agents should reject missing name."""
response = client.post('/controller/agents',
json={'base_url': 'http://localhost:8020'},
content_type='application/json'
response = client.post(
"/controller/agents", json={"base_url": "http://localhost:8020"}, content_type="application/json"
)
assert response.status_code == 400
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):
"""POST /controller/agents should reject missing URL."""
response = client.post('/controller/agents',
json={'name': 'test-sensor'},
content_type='application/json'
)
response = client.post("/controller/agents", json={"name": "test-sensor"}, content_type="application/json")
assert response.status_code == 400
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):
"""POST /controller/agents should reject duplicate name."""
response = client.post('/controller/agents',
response = client.post(
"/controller/agents",
json={
'name': 'test-sensor', # Same as sample_agent
'base_url': 'http://192.168.1.60:8020'
"name": "test-sensor", # Same as sample_agent
"base_url": "http://192.168.1.60:8020",
},
content_type='application/json'
content_type="application/json",
)
assert response.status_code == 409
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):
"""GET /controller/agents should return registered agents."""
response = client.get('/controller/agents')
response = client.get("/controller/agents")
assert response.status_code == 200
data = json.loads(response.data)
assert data['count'] >= 1
assert data["count"] >= 1
names = [a['name'] for a in data['agents']]
assert 'test-sensor' in names
names = [a["name"] for a in data["agents"]]
assert "test-sensor" in names
def test_get_agent_detail(self, client, sample_agent):
"""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
data = json.loads(response.data)
assert data['status'] == 'success'
assert data['agent']['name'] == 'test-sensor'
assert data['agent']['capabilities']['adsb'] is True
assert data["status"] == "success"
assert data["agent"]["name"] == "test-sensor"
assert data["agent"]["capabilities"]["adsb"] is True
def test_get_agent_not_found(self, client):
"""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
def test_update_agent(self, client, sample_agent):
"""PATCH /controller/agents/<id> should update agent."""
response = client.patch(f'/controller/agents/{sample_agent}',
json={'description': 'Updated description'},
content_type='application/json'
response = client.patch(
f"/controller/agents/{sample_agent}",
json={"description": "Updated description"},
content_type="application/json",
)
assert response.status_code == 200
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):
"""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
# Verify deleted
response = client.get(f'/controller/agents/{sample_agent}')
response = client.get(f"/controller/agents/{sample_agent}")
assert response.status_code == 404
@@ -213,345 +215,325 @@ class TestAgentCRUD:
# Proxy Operation Tests
# =============================================================================
class TestProxyOperations:
"""Tests for proxying operations to agents."""
def test_proxy_start_mode(self, client, sample_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.start_mode.return_value = {'status': 'started', 'mode': 'adsb'}
mock_client.start_mode.return_value = {"status": "started", "mode": "adsb"}
mock_create.return_value = mock_client
response = client.post(
f'/controller/agents/{sample_agent}/adsb/start',
json={'device_index': 0},
content_type='application/json'
f"/controller/agents/{sample_agent}/adsb/start",
json={"device_index": 0},
content_type="application/json",
)
assert response.status_code == 200
data = json.loads(response.data)
assert data['status'] == 'success'
assert data['mode'] == 'adsb'
assert data["status"] == "success"
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):
"""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.stop_mode.return_value = {'status': 'stopped'}
mock_client.stop_mode.return_value = {"status": "stopped"}
mock_create.return_value = mock_client
response = client.post(
f'/controller/agents/{sample_agent}/wifi/stop',
content_type='application/json'
)
response = client.post(f"/controller/agents/{sample_agent}/wifi/stop", content_type="application/json")
assert response.status_code == 200
data = json.loads(response.data)
assert data['status'] == 'success'
assert data["status"] == "success"
def test_proxy_get_mode_data(self, client, sample_agent):
"""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.get_mode_data.return_value = {
'mode': 'adsb',
'data': [{'icao': 'ABC123'}]
}
mock_client.get_mode_data.return_value = {"mode": "adsb", "data": [{"icao": "ABC123"}]}
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
data = json.loads(response.data)
assert data['status'] == 'success'
assert 'agent_name' in data
assert data['agent_name'] == 'test-sensor'
assert data["status"] == "success"
assert "agent_name" in data
assert data["agent_name"] == "test-sensor"
def test_proxy_agent_not_found(self, client):
"""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
def test_proxy_connection_error(self, client, sample_agent):
"""Proxy should return 503 when agent unreachable."""
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.start_mode.side_effect = AgentConnectionError("Connection refused")
mock_create.return_value = mock_client
response = client.post(
f'/controller/agents/{sample_agent}/adsb/start',
json={},
content_type='application/json'
f"/controller/agents/{sample_agent}/adsb/start", json={}, content_type="application/json"
)
assert response.status_code == 503
data = json.loads(response.data)
assert 'Cannot connect' in data['message']
assert "Cannot connect" in data["message"]
# =============================================================================
# Push Data Ingestion Tests
# =============================================================================
class TestPushIngestion:
"""Tests for push data ingestion endpoint."""
def test_ingest_success(self, client, sample_agent):
"""POST /controller/api/ingest should store payload."""
payload = {
'agent_name': 'test-sensor',
'scan_type': 'adsb',
'interface': 'rtlsdr0',
'payload': {
'aircraft': [{'icao': 'ABC123', 'altitude': 35000}]
}
"agent_name": "test-sensor",
"scan_type": "adsb",
"interface": "rtlsdr0",
"payload": {"aircraft": [{"icao": "ABC123", "altitude": 35000}]},
}
response = client.post('/controller/api/ingest',
json=payload,
headers={'X-API-Key': 'test-key'},
content_type='application/json'
response = client.post(
"/controller/api/ingest", json=payload, headers={"X-API-Key": "test-key"}, content_type="application/json"
)
assert response.status_code == 202
data = json.loads(response.data)
assert data['status'] == 'accepted'
assert 'payload_id' in data
assert data["status"] == "accepted"
assert "payload_id" in data
def test_ingest_unknown_agent(self, client):
"""POST /controller/api/ingest should reject unknown agent."""
payload = {
'agent_name': 'nonexistent-sensor',
'scan_type': 'adsb',
'payload': {}
}
payload = {"agent_name": "nonexistent-sensor", "scan_type": "adsb", "payload": {}}
response = client.post('/controller/api/ingest',
json=payload,
content_type='application/json'
)
response = client.post("/controller/api/ingest", json=payload, content_type="application/json")
assert response.status_code == 401
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):
"""POST /controller/api/ingest should reject invalid API key."""
payload = {
'agent_name': 'test-sensor',
'scan_type': 'adsb',
'payload': {}
}
payload = {"agent_name": "test-sensor", "scan_type": "adsb", "payload": {}}
response = client.post('/controller/api/ingest',
json=payload,
headers={'X-API-Key': 'wrong-key'},
content_type='application/json'
response = client.post(
"/controller/api/ingest", json=payload, headers={"X-API-Key": "wrong-key"}, content_type="application/json"
)
assert response.status_code == 401
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):
"""POST /controller/api/ingest should require agent_name."""
response = client.post('/controller/api/ingest',
json={'scan_type': 'adsb', 'payload': {}},
content_type='application/json'
response = client.post(
"/controller/api/ingest", json={"scan_type": "adsb", "payload": {}}, content_type="application/json"
)
assert response.status_code == 400
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):
"""GET /controller/api/payloads should return stored payloads."""
# First ingest some data
for i in range(3):
client.post('/controller/api/ingest',
client.post(
"/controller/api/ingest",
json={
'agent_name': 'test-sensor',
'scan_type': 'adsb',
'payload': {'aircraft': [{'icao': f'TEST{i}'}]}
"agent_name": "test-sensor",
"scan_type": "adsb",
"payload": {"aircraft": [{"icao": f"TEST{i}"}]},
},
headers={'X-API-Key': 'test-key'},
content_type='application/json'
headers={"X-API-Key": "test-key"},
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
data = json.loads(response.data)
assert data['count'] == 3
assert data["count"] == 3
def test_get_payloads_filter_by_type(self, client, sample_agent):
"""GET /controller/api/payloads should filter by scan_type."""
# Ingest mixed data
client.post('/controller/api/ingest',
json={'agent_name': 'test-sensor', 'scan_type': 'adsb', 'payload': {}},
headers={'X-API-Key': 'test-key'},
content_type='application/json'
client.post(
"/controller/api/ingest",
json={"agent_name": "test-sensor", "scan_type": "adsb", "payload": {}},
headers={"X-API-Key": "test-key"},
content_type="application/json",
)
client.post('/controller/api/ingest',
json={'agent_name': 'test-sensor', 'scan_type': 'wifi', 'payload': {}},
headers={'X-API-Key': 'test-key'},
content_type='application/json'
client.post(
"/controller/api/ingest",
json={"agent_name": "test-sensor", "scan_type": "wifi", "payload": {}},
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)
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
# =============================================================================
class TestLocationEstimation:
"""Tests for device location estimation (trilateration)."""
def test_add_observation(self, client):
"""POST /controller/api/location/observe should accept observation."""
response = client.post('/controller/api/location/observe',
response = client.post(
"/controller/api/location/observe",
json={
'device_id': 'AA:BB:CC:DD:EE:FF',
'agent_name': 'sensor-1',
'agent_lat': 40.7128,
'agent_lon': -74.0060,
'rssi': -55
"device_id": "AA:BB:CC:DD:EE:FF",
"agent_name": "sensor-1",
"agent_lat": 40.7128,
"agent_lon": -74.0060,
"rssi": -55,
},
content_type='application/json'
content_type="application/json",
)
assert response.status_code == 200
data = json.loads(response.data)
assert data['status'] == 'success'
assert data['device_id'] == 'AA:BB:CC:DD:EE:FF'
assert data["status"] == "success"
assert data["device_id"] == "AA:BB:CC:DD:EE:FF"
def test_add_observation_missing_fields(self, client):
"""POST /controller/api/location/observe should require all fields."""
response = client.post('/controller/api/location/observe',
response = client.post(
"/controller/api/location/observe",
json={
'device_id': 'AA:BB:CC:DD:EE:FF',
'rssi': -55
"device_id": "AA:BB:CC:DD:EE:FF",
"rssi": -55,
# Missing agent_name, agent_lat, agent_lon
},
content_type='application/json'
content_type="application/json",
)
assert response.status_code == 400
def test_estimate_location(self, client):
"""POST /controller/api/location/estimate should compute location."""
response = client.post('/controller/api/location/estimate',
response = client.post(
"/controller/api/location/estimate",
json={
'observations': [
{'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.7120, 'agent_lon': -74.0050, 'rssi': -62, 'agent_name': 'node-3'}
"observations": [
{"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.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
data = json.loads(response.data)
# Should have computed a location
if data['location']:
assert 'lat' in data['location']
assert 'lon' in data['location']
if data["location"]:
assert "latitude" in data["location"]
assert "longitude" in data["location"]
def test_estimate_location_insufficient_data(self, client):
"""Estimation should require at least 2 observations."""
response = client.post('/controller/api/location/estimate',
json={
'observations': [
{'agent_lat': 40.7128, 'agent_lon': -74.0060, 'rssi': -55, 'agent_name': 'node-1'}
]
},
content_type='application/json'
response = client.post(
"/controller/api/location/estimate",
json={"observations": [{"agent_lat": 40.7128, "agent_lon": -74.0060, "rssi": -55, "agent_name": "node-1"}]},
content_type="application/json",
)
assert response.status_code == 400
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):
"""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
data = json.loads(response.data)
assert data['status'] == 'not_found'
assert data['location'] is None
assert data["status"] == "not_found"
assert data["location"] is None
def test_get_all_locations(self, client):
"""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
data = json.loads(response.data)
assert data['status'] == 'success'
assert 'devices' in data
assert data["status"] == "success"
assert "devices" in data
def test_get_devices_near(self, client):
"""GET /controller/api/location/near should find nearby devices."""
response = client.get(
'/controller/api/location/near',
query_string={'lat': 40.7128, 'lon': -74.0060, 'radius': 100}
"/controller/api/location/near", query_string={"lat": 40.7128, "lon": -74.0060, "radius": 100}
)
assert response.status_code == 200
data = json.loads(response.data)
assert data['status'] == 'success'
assert data['center']['lat'] == 40.7128
assert data["status"] == "success"
assert data["center"]["lat"] == 40.7128
# =============================================================================
# Agent Refresh Tests
# =============================================================================
class TestAgentRefresh:
"""Tests for agent refresh operations."""
def test_refresh_agent_success(self, client, sample_agent):
"""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.refresh_metadata.return_value = {
'healthy': True,
'capabilities': {
'modes': {'adsb': True, 'wifi': True, 'bluetooth': True},
'devices': [{'name': 'RTL-SDR V3'}]
"healthy": True,
"capabilities": {
"modes": {"adsb": True, "wifi": True, "bluetooth": True},
"devices": [{"name": "RTL-SDR V3"}],
},
'status': {'running_modes': ['adsb']},
'config': {}
"status": {"running_modes": ["adsb"]},
"config": {},
}
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
data = json.loads(response.data)
assert data['status'] == 'success'
assert data['metadata']['healthy'] is True
assert data["status"] == "success"
assert data["metadata"]["healthy"] is True
def test_refresh_agent_unreachable(self, client, sample_agent):
"""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.refresh_metadata.return_value = {'healthy': False}
mock_client.refresh_metadata.return_value = {"healthy": False}
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
@@ -560,6 +542,7 @@ class TestAgentRefresh:
# SSE Stream Tests
# =============================================================================
class TestSSEStream:
"""Tests for SSE streaming endpoint."""
@@ -567,5 +550,5 @@ class TestSSEStream:
"""GET /controller/stream/all should exist and return SSE."""
# Just verify the endpoint is accessible
# Full SSE testing requires more complex setup
response = client.get('/controller/stream/all')
assert response.content_type == 'text/event-stream'
response = client.get("/controller/stream/all")
assert response.mimetype == "text/event-stream"
+1 -1
View File
@@ -126,7 +126,7 @@ class TestMeshcoreClientStateMachine:
client.get_queue().get_nowait()
# Call on_connected directly (simulating what AsyncWorker would call)
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()
assert event["type"] == "status"
assert event["data"]["state"] == "connected"
+2 -2
View File
@@ -164,7 +164,7 @@ class TestConnectionStateTransitions:
client = MeshcoreClient()
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()
assert event["type"] == "status"
assert event["data"]["state"] == "connected"
@@ -175,7 +175,7 @@ class TestConnectionStateTransitions:
client = MeshcoreClient()
client.on_error("timeout")
assert client.get_state() == ConnectionState.ERROR
assert client.get_state()[0] == ConnectionState.ERROR
event = client.get_queue().get_nowait()
assert event["data"]["state"] == "error"
assert event["data"].get("message") == "timeout"
+1 -1
View File
@@ -25,7 +25,7 @@ def client(app):
@pytest.fixture(autouse=True)
def mock_meshcore_client():
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_nodes.return_value = []
mc.get_repeaters.return_value = []
+17
View File
@@ -8,6 +8,23 @@ from flask import Flask
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
def app():
app = Flask(__name__)
+65 -44
View File
@@ -7,94 +7,115 @@ import pytest
def auth_client(client):
"""Client with logged-in session."""
with client.session_transaction() as sess:
sess['logged_in'] = True
sess["logged_in"] = True
return client
def test_signal_guess_fm_broadcast(auth_client):
"""FM broadcast frequency should return a known signal type."""
resp = auth_client.post('/listening/signal/guess', json={
'frequency_mhz': 98.1,
'modulation': 'wfm',
})
resp = auth_client.post(
"/receiver/signal/guess",
json={
"frequency_mhz": 98.1,
"modulation": "wfm",
},
)
assert resp.status_code == 200
data = resp.get_json()
assert data['status'] == 'ok'
assert data['primary_label']
assert data['confidence'] in ('HIGH', 'MEDIUM', 'LOW')
assert data["status"] == "ok"
assert data["primary_label"]
assert data["confidence"] in ("HIGH", "MEDIUM", "LOW")
def test_signal_guess_airband(auth_client):
"""Airband frequency should be identified."""
resp = auth_client.post('/listening/signal/guess', json={
'frequency_mhz': 121.5,
'modulation': 'am',
})
resp = auth_client.post(
"/receiver/signal/guess",
json={
"frequency_mhz": 121.5,
"modulation": "am",
},
)
assert resp.status_code == 200
data = resp.get_json()
assert data['status'] == 'ok'
assert data['primary_label']
assert data["status"] == "ok"
assert data["primary_label"]
def test_signal_guess_ism_band(auth_client):
"""ISM band frequency (433.92 MHz) should be identified."""
resp = auth_client.post('/listening/signal/guess', json={
'frequency_mhz': 433.92,
})
resp = auth_client.post(
"/receiver/signal/guess",
json={
"frequency_mhz": 433.92,
},
)
assert resp.status_code == 200
data = resp.get_json()
assert data['status'] == 'ok'
assert data['primary_label']
assert data['confidence'] in ('HIGH', 'MEDIUM', 'LOW')
assert data["status"] == "ok"
assert data["primary_label"]
assert data["confidence"] in ("HIGH", "MEDIUM", "LOW")
def test_signal_guess_missing_frequency(auth_client):
"""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
data = resp.get_json()
assert data['status'] == 'error'
assert data["status"] == "error"
def test_signal_guess_invalid_frequency(auth_client):
"""Invalid frequency value should return 400."""
resp = auth_client.post('/listening/signal/guess', json={
'frequency_mhz': 'abc',
})
resp = auth_client.post(
"/receiver/signal/guess",
json={
"frequency_mhz": "abc",
},
)
assert resp.status_code == 400
def test_signal_guess_negative_frequency(auth_client):
"""Negative frequency should return 400."""
resp = auth_client.post('/listening/signal/guess', json={
'frequency_mhz': -5.0,
})
resp = auth_client.post(
"/receiver/signal/guess",
json={
"frequency_mhz": -5.0,
},
)
assert resp.status_code == 400
def test_signal_guess_with_region(auth_client):
"""Specifying region should work."""
resp = auth_client.post('/listening/signal/guess', json={
'frequency_mhz': 462.5625,
'region': 'US',
})
resp = auth_client.post(
"/receiver/signal/guess",
json={
"frequency_mhz": 462.5625,
"region": "US",
},
)
assert resp.status_code == 200
data = resp.get_json()
assert data['status'] == 'ok'
assert data["status"] == "ok"
def test_signal_guess_response_structure(auth_client):
"""Response should have all expected fields."""
resp = auth_client.post('/listening/signal/guess', json={
'frequency_mhz': 146.52,
'modulation': 'fm',
})
resp = auth_client.post(
"/receiver/signal/guess",
json={
"frequency_mhz": 146.52,
"modulation": "fm",
},
)
assert resp.status_code == 200
data = resp.get_json()
assert 'primary_label' in data
assert 'confidence' in data
assert 'alternatives' in data
assert 'explanation' in data
assert 'tags' in data
assert isinstance(data['alternatives'], list)
assert isinstance(data['tags'], list)
assert "primary_label" in data
assert "confidence" in data
assert "alternatives" in data
assert "explanation" in data
assert "tags" in data
assert isinstance(data["alternatives"], list)
assert isinstance(data["tags"], list)
+262 -220
View File
@@ -6,11 +6,14 @@ and image handling.
from __future__ import annotations
import os
import time
from datetime import datetime, timezone
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from utils.weather_sat import (
WEATHER_SATELLITES,
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:
"""Tests for WeatherSatDecoder class."""
def test_decoder_initialization(self):
"""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()
assert decoder.is_running is False
assert decoder.decoder_available == 'satdump'
assert decoder.current_satellite == ''
assert decoder.decoder_available == "satdump"
assert decoder.current_satellite == ""
assert decoder.current_frequency == 0.0
def test_decoder_initialization_no_satdump(self):
"""Decoder should detect when SatDump is unavailable."""
with patch('shutil.which', return_value=None):
with patch("shutil.which", return_value=None):
decoder = WeatherSatDecoder()
assert decoder.decoder_available is None
def test_decoder_custom_output_dir(self):
"""Decoder should accept custom output directory."""
with patch('shutil.which', return_value='/usr/bin/satdump'):
custom_dir = '/tmp/custom_output'
with patch("shutil.which", return_value="/usr/bin/satdump"):
custom_dir = "/tmp/custom_output"
decoder = WeatherSatDecoder(output_dir=custom_dir)
assert decoder._output_dir == Path(custom_dir)
def test_set_callback(self):
"""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()
callback = MagicMock()
decoder.set_callback(callback)
@@ -56,7 +82,7 @@ class TestWeatherSatDecoder:
def test_set_on_complete(self):
"""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()
callback = MagicMock()
decoder.set_on_complete(callback)
@@ -64,44 +90,47 @@ class TestWeatherSatDecoder:
def test_start_no_decoder(self):
"""start() should fail when no decoder available."""
with patch('shutil.which', return_value=None):
with patch("shutil.which", return_value=None):
decoder = WeatherSatDecoder()
callback = MagicMock()
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 error_msg is not None
callback.assert_called()
progress = callback.call_args[0][0]
assert progress.status == 'error'
assert 'SatDump' in progress.message
assert progress.status == "error"
assert "SatDump" in progress.message
def test_start_invalid_satellite(self):
"""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()
callback = MagicMock()
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
callback.assert_called()
progress = callback.call_args[0][0]
assert progress.status == 'error'
assert 'Unknown satellite' in progress.message
assert progress.status == "error"
assert "Unknown satellite" in progress.message
@patch('subprocess.Popen')
@patch('pty.openpty')
@patch('utils.weather_sat.register_process')
@patch("subprocess.Popen")
@patch("pty.openpty")
@patch("utils.weather_sat.register_process")
def test_start_success(self, mock_register, mock_pty, mock_popen):
"""start() should successfully start SatDump."""
with 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)
with (
patch("shutil.which", return_value="/usr/bin/satdump"),
patch("utils.weather_sat.WeatherSatDecoder._resolve_device_id", return_value="0"),
):
# 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.poll.return_value = None
mock_popen.return_value = mock_process
@@ -111,7 +140,7 @@ class TestWeatherSatDecoder:
decoder.set_callback(callback)
success, error_msg = decoder.start(
satellite='NOAA-18',
satellite="NOAA-18",
device_index=0,
gain=40.0,
bias_t=True,
@@ -120,25 +149,27 @@ class TestWeatherSatDecoder:
assert success is True
assert error_msg is None
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_mode == 'APT'
assert decoder.current_mode == "APT"
assert decoder.device_index == 0
mock_popen.assert_called_once()
cmd = mock_popen.call_args[0][0]
assert cmd[0] == 'satdump'
assert 'live' in cmd
assert 'noaa_apt' in cmd
assert '--bias' in cmd
assert cmd[0] == "satdump"
assert "live" in cmd
assert "noaa_apt" in cmd
assert "--bias" in cmd
@patch('subprocess.Popen')
@patch('pty.openpty')
@patch('utils.weather_sat.register_process')
@patch("subprocess.Popen")
@patch("pty.openpty")
@patch("utils.weather_sat.register_process")
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."""
with patch('shutil.which', return_value='/usr/bin/satdump'):
mock_pty.return_value = (10, 11)
with patch("shutil.which", return_value="/usr/bin/satdump"):
# 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.poll.return_value = None
mock_popen.return_value = mock_process
@@ -148,10 +179,10 @@ class TestWeatherSatDecoder:
decoder.set_callback(callback)
success, error_msg = decoder.start(
satellite='NOAA-18',
satellite="NOAA-18",
device_index=0,
gain=40.0,
rtl_tcp_host='192.168.1.100',
rtl_tcp_host="192.168.1.100",
rtl_tcp_port=1234,
)
@@ -160,24 +191,28 @@ class TestWeatherSatDecoder:
mock_popen.assert_called_once()
cmd = mock_popen.call_args[0][0]
assert '--source' in cmd
source_idx = cmd.index('--source')
assert cmd[source_idx + 1] == 'rtltcp'
assert '--ip_address' in cmd
assert '192.168.1.100' in cmd
assert '--port' in cmd
assert '1234' in cmd
assert "--source" in cmd
source_idx = cmd.index("--source")
assert cmd[source_idx + 1] == "rtltcp"
assert "--ip_address" in cmd
assert "192.168.1.100" in cmd
assert "--port" in cmd
assert "1234" in cmd
# Should NOT have --source_id for remote
assert '--source_id' not in cmd
assert "--source_id" not in cmd
@patch('subprocess.Popen')
@patch('pty.openpty')
@patch('utils.weather_sat.register_process')
@patch("subprocess.Popen")
@patch("pty.openpty")
@patch("utils.weather_sat.register_process")
def test_start_rtl_tcp_skips_device_resolve(self, mock_register, mock_pty, mock_popen):
"""start() with rtl_tcp should skip _resolve_device_id."""
with patch('shutil.which', return_value='/usr/bin/satdump'), \
patch('utils.weather_sat.WeatherSatDecoder._resolve_device_id') as mock_resolve:
mock_pty.return_value = (10, 11)
with (
patch("shutil.which", return_value="/usr/bin/satdump"),
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.poll.return_value = None
mock_popen.return_value = mock_process
@@ -185,83 +220,87 @@ class TestWeatherSatDecoder:
decoder = WeatherSatDecoder()
success, _ = decoder.start(
satellite='NOAA-18',
satellite="NOAA-18",
device_index=0,
gain=40.0,
rtl_tcp_host='10.0.0.1',
rtl_tcp_host="10.0.0.1",
)
assert success is True
mock_resolve.assert_not_called()
@patch('subprocess.Popen')
@patch('pty.openpty')
@patch("subprocess.Popen")
@patch("pty.openpty")
def test_start_already_running(self, mock_pty, mock_popen):
"""start() should return True when already running."""
with patch('shutil.which', return_value='/usr/bin/satdump'), \
patch('utils.weather_sat.WeatherSatDecoder._resolve_device_id', return_value='0'):
with (
patch("shutil.which", return_value="/usr/bin/satdump"),
patch("utils.weather_sat.WeatherSatDecoder._resolve_device_id", return_value="0"),
):
decoder = WeatherSatDecoder()
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 error_msg is None
mock_popen.assert_not_called()
@patch('subprocess.Popen')
@patch('pty.openpty')
@patch("subprocess.Popen")
@patch("pty.openpty")
def test_start_exception_handling(self, mock_pty, mock_popen):
"""start() should handle exceptions gracefully."""
with patch('shutil.which', return_value='/usr/bin/satdump'):
mock_pty.return_value = (10, 11)
mock_popen.side_effect = OSError('Device not found')
with patch("shutil.which", return_value="/usr/bin/satdump"):
# 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_popen.side_effect = OSError("Device not found")
decoder = WeatherSatDecoder()
callback = MagicMock()
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 error_msg is not None
assert decoder.is_running is False
callback.assert_called()
progress = callback.call_args[0][0]
assert progress.status == 'error'
assert progress.status == "error"
def test_start_from_file_no_decoder(self):
"""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()
callback = MagicMock()
decoder.set_callback(callback)
success, error_msg = decoder.start_from_file(
satellite='NOAA-18',
input_file='data/test.wav',
satellite="NOAA-18",
input_file="data/test.wav",
)
assert success is False
assert error_msg is not None
callback.assert_called()
@patch('subprocess.Popen')
@patch('pty.openpty')
@patch('pathlib.Path.is_file', return_value=True)
@patch('pathlib.Path.resolve')
@patch("subprocess.Popen")
@patch("pty.openpty")
@patch("pathlib.Path.is_file", return_value=True)
@patch("pathlib.Path.resolve")
def test_start_from_file_success(self, mock_resolve, mock_is_file, mock_pty, mock_popen):
"""start_from_file() should successfully decode from file."""
with patch('shutil.which', return_value='/usr/bin/satdump'), \
patch('utils.weather_sat.register_process'):
with patch("shutil.which", return_value="/usr/bin/satdump"), patch("utils.weather_sat.register_process"):
# Mock path resolution
mock_path = MagicMock()
mock_path.is_relative_to.return_value = True
mock_path.suffix = '.wav'
mock_path.suffix = ".wav"
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.poll.return_value = None # Process still running
mock_popen.return_value = mock_process
@@ -271,27 +310,27 @@ class TestWeatherSatDecoder:
decoder.set_callback(callback)
success, error_msg = decoder.start_from_file(
satellite='NOAA-18',
input_file='data/test.wav',
satellite="NOAA-18",
input_file="data/test.wav",
sample_rate=1000000,
)
assert success is True
assert error_msg is None
assert decoder.is_running is True
assert decoder.current_satellite == 'NOAA-18'
assert decoder.current_satellite == "NOAA-18"
mock_popen.assert_called_once()
cmd = mock_popen.call_args[0][0]
assert cmd[0] == 'satdump'
assert 'noaa_apt' in cmd
assert 'audio_wav' in cmd
assert '--samplerate' in cmd
assert cmd[0] == "satdump"
assert "noaa_apt" in cmd
assert "audio_wav" in cmd
assert "--samplerate" in cmd
@patch('pathlib.Path.resolve')
@patch("pathlib.Path.resolve")
def test_start_from_file_path_traversal(self, mock_resolve):
"""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 = MagicMock()
mock_path.is_relative_to.return_value = False
@@ -302,20 +341,20 @@ class TestWeatherSatDecoder:
decoder.set_callback(callback)
success, error_msg = decoder.start_from_file(
satellite='NOAA-18',
input_file='/etc/passwd',
satellite="NOAA-18",
input_file="/etc/passwd",
)
assert success is False
callback.assert_called()
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.resolve')
@patch("pathlib.Path.is_file", return_value=False)
@patch("pathlib.Path.resolve")
def test_start_from_file_not_found(self, mock_resolve, mock_is_file):
"""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.is_relative_to.return_value = True
mock_resolve.return_value = mock_path
@@ -325,32 +364,32 @@ class TestWeatherSatDecoder:
decoder.set_callback(callback)
success, error_msg = decoder.start_from_file(
satellite='NOAA-18',
input_file='data/missing.wav',
satellite="NOAA-18",
input_file="data/missing.wav",
)
assert success is False
callback.assert_called()
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):
"""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.stop() # Should not raise
@patch('utils.weather_sat.safe_terminate')
@patch("utils.weather_sat.safe_terminate")
def test_stop_running(self, mock_terminate):
"""stop() should terminate process."""
with patch('shutil.which', return_value='/usr/bin/satdump'):
with patch("shutil.which", return_value="/usr/bin/satdump"):
decoder = WeatherSatDecoder()
mock_process = MagicMock()
decoder._process = mock_process
decoder._running = True
decoder._pty_master_fd = 10
with patch('os.close') as mock_close:
with patch("os.close") as mock_close:
decoder.stop()
assert decoder._running is False
@@ -359,21 +398,21 @@ class TestWeatherSatDecoder:
def test_get_images_empty(self):
"""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()
images = decoder.get_images()
assert images == []
@patch('pathlib.Path.glob')
@patch('pathlib.Path.stat')
@patch("pathlib.Path.glob")
@patch("pathlib.Path.stat")
def test_get_images_scans_directory(self, mock_stat, mock_glob):
"""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()
# Mock image files
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_mtime = time.time()
mock_glob.return_value = [mock_file]
@@ -381,39 +420,39 @@ class TestWeatherSatDecoder:
images = decoder.get_images()
assert len(images) == 1
assert images[0].filename == 'NOAA-18_test.png'
assert images[0].satellite == 'NOAA-18'
assert images[0].filename == "NOAA-18_test.png"
assert images[0].satellite == "NOAA-18"
def test_delete_image_success(self):
"""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()
with patch('pathlib.Path.exists', return_value=True), \
patch('pathlib.Path.unlink') as mock_unlink:
result = decoder.delete_image('test.png')
with patch("pathlib.Path.exists", return_value=True), patch("pathlib.Path.unlink") as mock_unlink:
result = decoder.delete_image("test.png")
assert result is True
mock_unlink.assert_called_once()
def test_delete_image_not_found(self):
"""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()
with patch('pathlib.Path.exists', return_value=False):
result = decoder.delete_image('missing.png')
with patch("pathlib.Path.exists", return_value=False):
result = decoder.delete_image("missing.png")
assert result is False
def test_delete_all_images(self):
"""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()
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()
assert count == 3
@@ -422,74 +461,74 @@ class TestWeatherSatDecoder:
def test_get_status_idle(self):
"""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()
status = decoder.get_status()
assert status['available'] is True
assert status['decoder'] == 'satdump'
assert status['running'] is False
assert status['satellite'] == ''
assert status["available"] is True
assert status["decoder"] == "satdump"
assert status["running"] is False
assert status["satellite"] == ""
def test_get_status_running(self):
"""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._running = True
decoder._current_satellite = 'NOAA-18'
decoder._current_satellite = "NOAA-18"
decoder._current_frequency = 137.9125
decoder._current_mode = 'APT'
decoder._current_mode = "APT"
decoder._capture_start_time = time.time() - 60
status = decoder.get_status()
assert status['running'] is True
assert status['satellite'] == 'NOAA-18'
assert status['frequency'] == 137.9125
assert status['mode'] == 'APT'
assert status['elapsed_seconds'] >= 60
assert status["running"] is True
assert status["satellite"] == "NOAA-18"
assert status["frequency"] == 137.9125
assert status["mode"] == "APT"
assert status["elapsed_seconds"] >= 60
def test_classify_log_type_error(self):
"""_classify_log_type() should detect errors."""
assert WeatherSatDecoder._classify_log_type('(E) Error occurred') == 'error'
assert WeatherSatDecoder._classify_log_type('Failed to open device') == 'error'
assert WeatherSatDecoder._classify_log_type("(E) Error occurred") == "error"
assert WeatherSatDecoder._classify_log_type("Failed to open device") == "error"
def test_classify_log_type_progress(self):
"""_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):
"""_classify_log_type() should detect save events."""
assert WeatherSatDecoder._classify_log_type('Saved image: test.png') == 'save'
assert WeatherSatDecoder._classify_log_type('Writing output file') == 'save'
assert WeatherSatDecoder._classify_log_type("Saved image: test.png") == "save"
assert WeatherSatDecoder._classify_log_type("Writing output file") == "save"
def test_classify_log_type_signal(self):
"""_classify_log_type() should detect signal events."""
assert WeatherSatDecoder._classify_log_type('Signal detected') == 'signal'
assert WeatherSatDecoder._classify_log_type('Lock acquired') == 'signal'
assert WeatherSatDecoder._classify_log_type("Signal detected") == "signal"
assert WeatherSatDecoder._classify_log_type("Lock acquired") == "signal"
def test_classify_log_type_warning(self):
"""_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):
"""_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):
"""_resolve_device_id() should extract serial from rtl_test."""
mock_result = MagicMock()
mock_result.stdout = 'Found 1 device(s):\n 0: RTLSDRBlog, SN: 00004000'
mock_result.stderr = ''
mock_result.stdout = "Found 1 device(s):\n 0: RTLSDRBlog, SN: 00004000"
mock_result.stderr = ""
mock_run.return_value = mock_result
serial = WeatherSatDecoder._resolve_device_id(0)
assert serial == '00004000'
assert serial == "00004000"
mock_run.assert_called_once()
@patch('subprocess.run')
@patch("subprocess.run")
def test_resolve_device_id_fallback(self, mock_run):
"""_resolve_device_id() should return None when no serial found."""
mock_run.side_effect = FileNotFoundError
@@ -500,59 +539,59 @@ class TestWeatherSatDecoder:
def test_parse_product_name_rgb(self):
"""_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()
product = decoder._parse_product_name(Path('/tmp/output/rgb_composite.png'))
assert product == 'RGB Composite'
product = decoder._parse_product_name(Path("/tmp/output/rgb_composite.png"))
assert product == "RGB Composite"
def test_parse_product_name_thermal(self):
"""_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()
product = decoder._parse_product_name(Path('/tmp/output/thermal_image.png'))
assert product == 'Thermal'
product = decoder._parse_product_name(Path("/tmp/output/thermal_image.png"))
assert product == "Thermal"
def test_parse_product_name_channel(self):
"""_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()
product = decoder._parse_product_name(Path('/tmp/output/channel_3.png'))
assert product == 'Channel 3'
product = decoder._parse_product_name(Path("/tmp/output/channel_3.png"))
assert product == "Channel 3"
def test_parse_product_name_unknown(self):
"""_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()
product = decoder._parse_product_name(Path('/tmp/output/unknown_image.png'))
assert product == 'unknown_image'
product = decoder._parse_product_name(Path("/tmp/output/unknown_image.png"))
assert product == "unknown_image"
def test_emit_progress(self):
"""_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()
callback = MagicMock()
decoder.set_callback(callback)
progress = CaptureProgress(status='capturing', message='Test')
progress = CaptureProgress(status="capturing", message="Test")
decoder._emit_progress(progress)
callback.assert_called_once_with(progress)
def test_emit_progress_no_callback(self):
"""_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()
progress = CaptureProgress(status='capturing', message='Test')
progress = CaptureProgress(status="capturing", message="Test")
decoder._emit_progress(progress) # Should not raise
def test_emit_progress_callback_exception(self):
"""_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()
callback = MagicMock(side_effect=Exception('Callback error'))
callback = MagicMock(side_effect=Exception("Callback error"))
decoder.set_callback(callback)
progress = CaptureProgress(status='capturing', message='Test')
progress = CaptureProgress(status="capturing", message="Test")
decoder._emit_progress(progress) # Should not raise
@@ -562,26 +601,26 @@ class TestWeatherSatImage:
def test_to_dict(self):
"""WeatherSatImage.to_dict() should serialize correctly."""
image = WeatherSatImage(
filename='test.png',
path=Path('/tmp/test.png'),
satellite='NOAA-18',
mode='APT',
filename="test.png",
path=Path("/tmp/test.png"),
satellite="NOAA-18",
mode="APT",
timestamp=datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc),
frequency=137.9125,
size_bytes=12345,
product='RGB Composite',
product="RGB Composite",
)
data = image.to_dict()
assert data['filename'] == 'test.png'
assert data['satellite'] == 'NOAA-18'
assert data['mode'] == 'APT'
assert data['timestamp'] == '2024-01-01T12:00:00+00:00'
assert data['frequency'] == 137.9125
assert data['size_bytes'] == 12345
assert data['product'] == 'RGB Composite'
assert data['url'] == '/weather-sat/images/test.png'
assert data["filename"] == "test.png"
assert data["satellite"] == "NOAA-18"
assert data["mode"] == "APT"
assert data["timestamp"] == "2024-01-01T12:00:00+00:00"
assert data["frequency"] == 137.9125
assert data["size_bytes"] == 12345
assert data["product"] == "RGB Composite"
assert data["url"] == "/weather-sat/images/test.png"
class TestCaptureProgress:
@@ -589,51 +628,51 @@ class TestCaptureProgress:
def test_to_dict_minimal(self):
"""CaptureProgress.to_dict() with minimal fields."""
progress = CaptureProgress(status='idle')
progress = CaptureProgress(status="idle")
data = progress.to_dict()
assert data['type'] == 'weather_sat_progress'
assert data['status'] == 'idle'
assert data['satellite'] == ''
assert data['message'] == ''
assert data['progress'] == 0
assert data["type"] == "weather_sat_progress"
assert data["status"] == "idle"
assert data["satellite"] == ""
assert data["message"] == ""
assert data["progress"] == 0
def test_to_dict_complete(self):
"""CaptureProgress.to_dict() with all fields."""
image = WeatherSatImage(
filename='test.png',
path=Path('/tmp/test.png'),
satellite='NOAA-18',
mode='APT',
filename="test.png",
path=Path("/tmp/test.png"),
satellite="NOAA-18",
mode="APT",
timestamp=datetime.now(timezone.utc),
frequency=137.9125,
)
progress = CaptureProgress(
status='complete',
satellite='NOAA-18',
status="complete",
satellite="NOAA-18",
frequency=137.9125,
mode='APT',
message='Capture complete',
mode="APT",
message="Capture complete",
progress_percent=100,
elapsed_seconds=600,
image=image,
log_type='info',
capture_phase='complete',
log_type="info",
capture_phase="complete",
)
data = progress.to_dict()
assert data['status'] == 'complete'
assert data['satellite'] == 'NOAA-18'
assert data['frequency'] == 137.9125
assert data['mode'] == 'APT'
assert data['message'] == 'Capture complete'
assert data['progress'] == 100
assert data['elapsed_seconds'] == 600
assert 'image' in data
assert data['log_type'] == 'info'
assert data['capture_phase'] == 'complete'
assert data["status"] == "complete"
assert data["satellite"] == "NOAA-18"
assert data["frequency"] == 137.9125
assert data["mode"] == "APT"
assert data["message"] == "Capture complete"
assert data["progress"] == 100
assert data["elapsed_seconds"] == 600
assert "image" in data
assert data["log_type"] == "info"
assert data["capture_phase"] == "complete"
class TestGlobalFunctions:
@@ -641,8 +680,9 @@ class TestGlobalFunctions:
def test_get_weather_sat_decoder_singleton(self):
"""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
old = mod._decoder
mod._decoder = None
@@ -656,8 +696,9 @@ class TestGlobalFunctions:
def test_is_weather_sat_available_true(self):
"""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
old = mod._decoder
mod._decoder = None
@@ -668,8 +709,9 @@ class TestGlobalFunctions:
def test_is_weather_sat_available_false(self):
"""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
old = mod._decoder
mod._decoder = None
@@ -684,26 +726,26 @@ class TestWeatherSatellitesConstant:
def test_weather_satellites_structure(self):
"""WEATHER_SATELLITES should have correct structure."""
assert 'NOAA-18' in WEATHER_SATELLITES
sat = WEATHER_SATELLITES['NOAA-18']
assert "NOAA-18" in WEATHER_SATELLITES
sat = WEATHER_SATELLITES["NOAA-18"]
assert 'name' in sat
assert 'frequency' in sat
assert 'mode' in sat
assert 'pipeline' in sat
assert 'tle_key' in sat
assert 'description' in sat
assert 'active' in sat
assert "name" in sat
assert "frequency" in sat
assert "mode" in sat
assert "pipeline" in sat
assert "tle_key" in sat
assert "description" in sat
assert "active" in sat
def test_noaa_satellites(self):
"""NOAA satellites should have correct frequencies."""
assert WEATHER_SATELLITES['NOAA-15']['frequency'] == 137.620
assert WEATHER_SATELLITES['NOAA-18']['frequency'] == 137.9125
assert WEATHER_SATELLITES['NOAA-19']['frequency'] == 137.100
assert WEATHER_SATELLITES["NOAA-15"]["frequency"] == 137.620
assert WEATHER_SATELLITES["NOAA-18"]["frequency"] == 137.9125
assert WEATHER_SATELLITES["NOAA-19"]["frequency"] == 137.100
def test_meteor_satellite(self):
"""Meteor satellite should use LRPT mode."""
meteor = WEATHER_SATELLITES['METEOR-M2-3']
assert meteor['mode'] == 'LRPT'
assert meteor['frequency'] == 137.900
assert meteor['pipeline'] == 'meteor_m2-x_lrpt'
meteor = WEATHER_SATELLITES["METEOR-M2-3"]
assert meteor["mode"] == "LRPT"
assert meteor["frequency"] == 137.900
assert meteor["pipeline"] == "meteor_m2-x_lrpt"
File diff suppressed because it is too large Load Diff
+53 -67
View File
@@ -8,7 +8,7 @@ import logging
import requests
logger = logging.getLogger('intercept.agent_client')
logger = logging.getLogger("intercept.agent_client")
class AgentHTTPError(RuntimeError):
@@ -21,18 +21,14 @@ class AgentHTTPError(RuntimeError):
class AgentConnectionError(AgentHTTPError):
"""Exception raised when agent is unreachable."""
pass
class AgentClient:
"""HTTP client for communicating with a remote Intercept agent."""
def __init__(
self,
base_url: str,
api_key: str | None = None,
timeout: float = 60.0
):
def __init__(self, base_url: str, api_key: str | None = None, timeout: float = 60.0):
"""
Initialize agent client.
@@ -41,15 +37,15 @@ class AgentClient:
api_key: Optional API key for authentication
timeout: Request timeout in seconds
"""
self.base_url = base_url.rstrip('/')
self.base_url = base_url.rstrip("/")
self.api_key = api_key
self.timeout = timeout
def _headers(self) -> dict:
"""Get request headers."""
headers = {'Content-Type': 'application/json'}
headers = {"Content-Type": "application/json"}
if self.api_key:
headers['X-API-Key'] = self.api_key
headers["X-API-Key"] = self.api_key
return headers
def _get(self, path: str, params: dict | None = None) -> dict:
@@ -69,12 +65,7 @@ class AgentClient:
"""
url = f"{self.base_url}{path}"
try:
response = requests.get(
url,
headers=self._headers(),
params=params,
timeout=self.timeout
)
response = requests.get(url, headers=self._headers(), params=params, timeout=self.timeout)
response.raise_for_status()
return response.json() if response.content else {}
except requests.ConnectionError as e:
@@ -86,17 +77,17 @@ class AgentClient:
error_msg = f"Agent returned error: {e.response.status_code}"
try:
error_data = e.response.json()
if 'message' in error_data:
error_msg = error_data['message']
elif 'error' in error_data:
error_msg = error_data['error']
if "message" in error_data:
error_msg = error_data["message"]
elif "error" in error_data:
error_msg = error_data["error"]
except Exception:
pass
raise AgentHTTPError(error_msg, status_code=e.response.status_code)
except requests.RequestException as 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.
@@ -111,39 +102,38 @@ class AgentClient:
AgentHTTPError: On HTTP errors
AgentConnectionError: If agent is unreachable
"""
url = f"{self.base_url}{path}"
request_timeout = self.timeout if timeout is None else timeout
try:
response = requests.post(
url,
json=data or {},
headers=self._headers(),
timeout=request_timeout
)
response.raise_for_status()
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")
url = f"{self.base_url}{path}"
request_timeout = self.timeout if timeout is None else timeout
try:
response = requests.post(url, json=data or {}, headers=self._headers(), timeout=request_timeout)
response.raise_for_status()
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:
# Try to extract error message from response body
error_msg = f"Agent returned error: {e.response.status_code}"
try:
error_data = e.response.json()
if 'message' in error_data:
error_msg = error_data['message']
elif 'error' in error_data:
error_msg = error_data['error']
if "message" in error_data:
error_msg = error_data["message"]
elif "error" in error_data:
error_msg = error_data["error"]
except Exception:
pass
raise AgentHTTPError(error_msg, status_code=e.response.status_code)
except requests.RequestException as e:
raise AgentHTTPError(f"Request failed: {e}")
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)
def get(self, path: str, params: dict | None = None) -> dict:
"""Public GET method for arbitrary endpoints."""
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
@@ -156,7 +146,7 @@ class AgentClient:
Returns:
Dict with 'modes' (mode -> bool), 'devices' (list), 'agent_version'
"""
return self._get('/capabilities')
return self._get("/capabilities")
def get_status(self) -> dict:
"""
@@ -165,7 +155,7 @@ class AgentClient:
Returns:
Dict with 'running_modes', 'uptime', 'push_enabled', etc.
"""
return self._get('/status')
return self._get("/status")
def health_check(self) -> bool:
"""
@@ -175,14 +165,14 @@ class AgentClient:
True if agent is reachable and healthy
"""
try:
result = self._get('/health')
return result.get('status') == 'healthy'
result = self._get("/health")
return result.get("status") == "healthy"
except (AgentHTTPError, AgentConnectionError):
return False
def get_config(self) -> dict:
"""Get agent configuration (non-sensitive fields)."""
return self._get('/config')
return self._get("/config")
def update_config(self, **kwargs) -> dict:
"""
@@ -195,7 +185,7 @@ class AgentClient:
Returns:
Updated config
"""
return self._post('/config', kwargs)
return self._post("/config", kwargs)
# =========================================================================
# Mode Operations
@@ -212,9 +202,9 @@ class AgentClient:
Returns:
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.
@@ -224,7 +214,7 @@ class AgentClient:
Returns:
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:
"""
@@ -236,7 +226,7 @@ class AgentClient:
Returns:
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:
"""
@@ -248,7 +238,7 @@ class AgentClient:
Returns:
Data snapshot with 'data' field
"""
return self._get(f'/{mode}/data')
return self._get(f"/{mode}/data")
# =========================================================================
# Convenience Methods
@@ -262,17 +252,17 @@ class AgentClient:
Dict with capabilities, status, and config
"""
metadata = {
'capabilities': None,
'status': None,
'config': None,
'healthy': False,
"capabilities": None,
"status": None,
"config": None,
"healthy": False,
}
try:
metadata['capabilities'] = self.get_capabilities()
metadata['status'] = self.get_status()
metadata['config'] = self.get_config()
metadata['healthy'] = True
metadata["capabilities"] = self.get_capabilities()
metadata["status"] = self.get_status()
metadata["config"] = self.get_config()
metadata["healthy"] = True
except (AgentHTTPError, AgentConnectionError) as e:
logger.warning(f"Failed to refresh agent metadata: {e}")
@@ -292,8 +282,4 @@ def create_client_from_agent(agent: dict) -> AgentClient:
Returns:
Configured AgentClient
"""
return AgentClient(
base_url=agent['base_url'],
api_key=agent.get('api_key'),
timeout=60.0
)
return AgentClient(base_url=agent["base_url"], api_key=agent.get("api_key"), timeout=60.0)
+2 -13
View File
@@ -35,21 +35,10 @@ def is_meshcore_available() -> bool:
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:
"""Return True if this contact is a repeater node."""
if _REPEATER_TYPE is not None:
return contact_dict.get("type") == _REPEATER_TYPE
# Fallback: meshcore repeaters have type==2 by convention
# meshcore exports no ContactType enum (checked through 2.3.7);
# repeaters have type==2 by library convention
return contact_dict.get("type") == 2
+421 -366
View File
File diff suppressed because it is too large Load Diff