Add SDR device status panel and ADS-B Bias-T toggle

- Add /devices/status endpoint showing which SDR is in use and by what mode
- Add real-time status panel on main dashboard with 5s auto-refresh
- Add Bias-T toggle to ADS-B dashboard with localStorage persistence
- Auto-detect correct dump1090 bias-t flag (--enable-biast vs unsupported)
- Standardize SDR device labels across all pages

Closes #102

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Smittix
2026-02-02 21:36:27 +00:00
parent 334073089f
commit d1f1ce1f4b
6 changed files with 266 additions and 107 deletions
+16
View File
@@ -347,6 +347,22 @@ def get_devices() -> Response:
return jsonify([d.to_dict() for d in devices])
@app.route('/devices/status')
def get_devices_status() -> Response:
"""Get all SDR devices with usage status."""
devices = SDRFactory.detect_devices()
registry = get_sdr_device_status()
result = []
for device in devices:
d = device.to_dict()
d['in_use'] = device.index in registry
d['used_by'] = registry.get(device.index)
result.append(d)
return jsonify(result)
@app.route('/devices/debug')
def get_devices_debug() -> Response:
"""Get detailed SDR device detection diagnostics."""
+18
View File
@@ -814,6 +814,24 @@ body {
color: var(--accent-green);
}
/* Bias-T toggle styling */
.bias-t-label {
display: flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
background: linear-gradient(90deg, rgba(255, 100, 0, 0.15), rgba(255, 100, 0, 0.05));
border: 1px solid var(--accent-orange, #ff6400);
border-radius: 4px;
color: var(--accent-orange, #ff6400);
font-weight: 500;
font-size: 10px;
}
.bias-t-label input[type="checkbox"] {
accent-color: var(--accent-orange, #ff6400);
}
.control-group.airband-group {
background: rgba(245, 158, 11, 0.05);
border-color: rgba(245, 158, 11, 0.2);
+111 -103
View File
@@ -17,15 +17,15 @@
{% else %}
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
{% endif %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/adsb_dashboard.css') }}">
<script>
window.INTERCEPT_SHARED_OBSERVER_LOCATION = {{ shared_observer_location | tojson }};
window.INTERCEPT_ADSB_AUTO_START = {{ adsb_auto_start | tojson }};
</script>
<script src="{{ url_for('static', filename='js/core/observer-location.js') }}"></script>
</head>
{% endif %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/adsb_dashboard.css') }}">
<script>
window.INTERCEPT_SHARED_OBSERVER_LOCATION = {{ shared_observer_location | tojson }};
window.INTERCEPT_ADSB_AUTO_START = {{ adsb_auto_start | tojson }};
</script>
<script src="{{ url_for('static', filename='js/core/observer-location.js') }}"></script>
</head>
<body>
<div class="radar-bg"></div>
<div class="scanline"></div>
@@ -269,6 +269,7 @@
<select id="adsbDeviceSelect" title="SDR device for ADS-B (1090 MHz)">
<option value="0">SDR 0</option>
</select>
<label class="bias-t-label" title="Enable Bias-T power for external LNA/preamp"><input type="checkbox" id="adsbBiasT" onchange="saveAdsbBiasTSetting()"> Bias-T</label>
<button class="start-btn" id="startBtn" onclick="toggleTracking()">START</button>
</div>
</div>
@@ -322,10 +323,23 @@
<script>
// ============================================
// BIAS-T HELPER (reads from main dashboard localStorage)
// BIAS-T HELPER
// ============================================
function getBiasTEnabled() {
return localStorage.getItem('biasTEnabled') === 'true';
return document.getElementById('adsbBiasT')?.checked || false;
}
function saveAdsbBiasTSetting() {
const enabled = document.getElementById('adsbBiasT')?.checked || false;
localStorage.setItem('adsbBiasTEnabled', enabled);
}
function loadAdsbBiasTSetting() {
const saved = localStorage.getItem('adsbBiasTEnabled');
if (saved === 'true') {
const checkbox = document.getElementById('adsbBiasT');
if (checkbox) checkbox.checked = true;
}
}
// ============================================
@@ -522,19 +536,19 @@
}
// Observer location and range rings (load from localStorage or default to London)
let observerLocation = (function() {
if (window.ObserverLocation && ObserverLocation.getForModule) {
return ObserverLocation.getForModule('observerLocation');
}
const saved = localStorage.getItem('observerLocation');
if (saved) {
try {
const parsed = JSON.parse(saved);
if (parsed.lat && parsed.lon) return parsed;
} catch (e) {}
}
return { lat: 51.5074, lon: -0.1278 };
})();
let observerLocation = (function() {
if (window.ObserverLocation && ObserverLocation.getForModule) {
return ObserverLocation.getForModule('observerLocation');
}
const saved = localStorage.getItem('observerLocation');
if (saved) {
try {
const parsed = JSON.parse(saved);
if (parsed.lat && parsed.lon) return parsed;
} catch (e) {}
}
return { lat: 51.5074, lon: -0.1278 };
})();
let rangeRingsLayer = null;
let observerMarker = null;
@@ -1810,12 +1824,12 @@ ACARS: ${r.statistics.acarsMessages} messages`;
observerLocation.lat = lat;
observerLocation.lon = lon;
// Save to localStorage for persistence
if (window.ObserverLocation) {
ObserverLocation.setForModule('observerLocation', observerLocation);
} else {
localStorage.setItem('observerLocation', JSON.stringify(observerLocation));
}
// Save to localStorage for persistence
if (window.ObserverLocation) {
ObserverLocation.setForModule('observerLocation', observerLocation);
} else {
localStorage.setItem('observerLocation', JSON.stringify(observerLocation));
}
if (radarMap) {
radarMap.setView([lat, lon], radarMap.getZoom());
@@ -1842,12 +1856,12 @@ ACARS: ${r.statistics.acarsMessages} messages`;
observerLocation.lat = position.coords.latitude;
observerLocation.lon = position.coords.longitude;
// Save to localStorage for persistence
if (window.ObserverLocation) {
ObserverLocation.setForModule('observerLocation', observerLocation);
} else {
localStorage.setItem('observerLocation', JSON.stringify(observerLocation));
}
// Save to localStorage for persistence
if (window.ObserverLocation) {
ObserverLocation.setForModule('observerLocation', observerLocation);
} else {
localStorage.setItem('observerLocation', JSON.stringify(observerLocation));
}
document.getElementById('obsLat').value = observerLocation.lat.toFixed(4);
document.getElementById('obsLon').value = observerLocation.lon.toFixed(4);
@@ -1936,17 +1950,17 @@ ACARS: ${r.statistics.acarsMessages} messages`;
}
});
function updateLocationFromGps(position) {
observerLocation.lat = position.latitude;
observerLocation.lon = position.longitude;
document.getElementById('obsLat').value = position.latitude.toFixed(4);
document.getElementById('obsLon').value = position.longitude.toFixed(4);
if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
ObserverLocation.setShared({ lat: position.latitude, lon: position.longitude });
}
// Center map on GPS location (on first fix)
if (radarMap && !radarMap._gpsInitialized) {
function updateLocationFromGps(position) {
observerLocation.lat = position.latitude;
observerLocation.lon = position.longitude;
document.getElementById('obsLat').value = position.latitude.toFixed(4);
document.getElementById('obsLon').value = position.longitude.toFixed(4);
if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
ObserverLocation.setShared({ lat: position.latitude, lon: position.longitude });
}
// Center map on GPS location (on first fix)
if (radarMap && !radarMap._gpsInitialized) {
radarMap.setView([position.latitude, position.longitude], radarMap.getZoom());
radarMap._gpsInitialized = true;
// Draw range rings immediately after centering
@@ -2008,6 +2022,9 @@ ACARS: ${r.statistics.acarsMessages} messages`;
const detectionToggle = document.getElementById('detectionSoundToggle');
if (detectionToggle) detectionToggle.checked = detectionSoundEnabled;
// Load Bias-T setting from localStorage
loadAdsbBiasTSetting();
initMap();
initDeviceSelectors();
updateClock();
@@ -2046,27 +2063,18 @@ ACARS: ${r.statistics.acarsMessages} messages`;
} else {
devices.forEach((dev, i) => {
const idx = dev.index !== undefined ? dev.index : i;
// Build descriptive label
const type = dev.sdr_type || dev.driver || 'RTL-SDR';
const typeName = type.toUpperCase().replace('RTLSDR', 'RTL-SDR');
const shortSerial = dev.serial ? ` (${dev.serial.slice(-4)})` : '';
const displayName = `${typeName} #${idx}${shortSerial}`;
const fullName = dev.name || `${typeName} Device ${idx}`;
const tooltip = `${fullName}${dev.serial ? ' - Serial: ' + dev.serial : ''}`;
const displayName = `SDR ${idx}: ${dev.name}`;
// Add to ADS-B selector
const adsbOpt = document.createElement('option');
adsbOpt.value = idx;
adsbOpt.textContent = displayName;
adsbOpt.title = tooltip;
adsbSelect.appendChild(adsbOpt);
// Add to Airband selector
const airbandOpt = document.createElement('option');
airbandOpt.value = idx;
airbandOpt.textContent = displayName;
airbandOpt.title = tooltip;
airbandSelect.appendChild(airbandOpt);
});
@@ -2542,32 +2550,32 @@ sudo make install</code>
}
}
async function syncTrackingStatus() {
// This function checks LOCAL tracking status on page load
// For local mode: auto-start if session is already running OR SDR is available
// For agent mode: don't auto-start (user controls agent tracking)
const useAgent = typeof adsbCurrentAgent !== 'undefined' && adsbCurrentAgent !== 'local';
if (useAgent) {
console.log('[ADS-B] Agent mode on page load - not auto-starting local');
return;
}
try {
const response = await fetch('/adsb/session');
if (!response.ok) {
// No session info - only auto-start if enabled
if (window.INTERCEPT_ADSB_AUTO_START) {
console.log('[ADS-B] No session found, attempting auto-start...');
await tryAutoStartLocal();
} else {
console.log('[ADS-B] No session found; auto-start disabled');
}
return;
}
const data = await response.json();
if (data.tracking_active) {
async function syncTrackingStatus() {
// This function checks LOCAL tracking status on page load
// For local mode: auto-start if session is already running OR SDR is available
// For agent mode: don't auto-start (user controls agent tracking)
const useAgent = typeof adsbCurrentAgent !== 'undefined' && adsbCurrentAgent !== 'local';
if (useAgent) {
console.log('[ADS-B] Agent mode on page load - not auto-starting local');
return;
}
try {
const response = await fetch('/adsb/session');
if (!response.ok) {
// No session info - only auto-start if enabled
if (window.INTERCEPT_ADSB_AUTO_START) {
console.log('[ADS-B] No session found, attempting auto-start...');
await tryAutoStartLocal();
} else {
console.log('[ADS-B] No session found; auto-start disabled');
}
return;
}
const data = await response.json();
if (data.tracking_active) {
// Session is running - auto-connect to stream
console.log('[ADS-B] Local session already active - auto-connecting to stream');
@@ -2603,24 +2611,24 @@ sudo make install</code>
document.getElementById('trackingDot').classList.add('active');
const statusEl = document.getElementById('trackingStatus');
statusEl.textContent = 'TRACKING';
} else {
// Session not active - only auto-start if enabled
if (window.INTERCEPT_ADSB_AUTO_START) {
console.log('[ADS-B] No active session, attempting auto-start...');
await tryAutoStartLocal();
} else {
console.log('[ADS-B] No active session; auto-start disabled');
}
}
} catch (err) {
console.warn('[ADS-B] Failed to sync tracking status:', err);
// Try auto-start only if enabled
if (window.INTERCEPT_ADSB_AUTO_START) {
await tryAutoStartLocal();
}
}
}
} else {
// Session not active - only auto-start if enabled
if (window.INTERCEPT_ADSB_AUTO_START) {
console.log('[ADS-B] No active session, attempting auto-start...');
await tryAutoStartLocal();
} else {
console.log('[ADS-B] No active session; auto-start disabled');
}
}
} catch (err) {
console.warn('[ADS-B] Failed to sync tracking status:', err);
// Try auto-start only if enabled
if (window.INTERCEPT_ADSB_AUTO_START) {
await tryAutoStartLocal();
}
}
}
async function tryAutoStartLocal() {
// Try to auto-start local ADS-B tracking if SDR is available
@@ -4004,7 +4012,7 @@ sudo make install</code>
devices.forEach((d, i) => {
const opt = document.createElement('option');
opt.value = d.index || i;
opt.textContent = `Device ${d.index || i}: ${d.name || d.type || 'SDR'}`;
opt.textContent = `SDR ${d.index || i}: ${d.name || d.type || 'SDR'}`;
select.appendChild(opt);
});
// Default to device 1 if available (device 0 likely used for ADS-B)
@@ -4774,7 +4782,7 @@ sudo make install</code>
devices.forEach(device => {
const opt = document.createElement('option');
opt.value = device.index;
opt.textContent = `Device ${device.index}: ${device.name || device.type || 'SDR'}`;
opt.textContent = `SDR ${device.index}: ${device.name || device.type || 'SDR'}`;
select.appendChild(opt);
});
}
+1 -3
View File
@@ -558,11 +558,9 @@
}
devices.forEach((dev, idx) => {
const index = dev.index !== undefined ? dev.index : idx;
const type = (dev.sdr_type || dev.driver || 'RTL-SDR').toUpperCase();
const serial = dev.serial ? ` (${dev.serial.slice(-4)})` : '';
const opt = document.createElement('option');
opt.value = index;
opt.textContent = `${type} #${index}${serial}`;
opt.textContent = `SDR ${index}: ${dev.name}`;
sessionDeviceSelect.appendChild(opt);
});
sessionDeviceSelect.disabled = false;
+79
View File
@@ -537,6 +537,14 @@
Refresh Devices
</button>
<!-- SDR Device Status -->
<div id="sdrStatusPanel" style="margin-top: 10px; border: 1px solid var(--border-color); border-radius: 4px;">
<div id="sdrStatusList" style="max-height: 150px; overflow-y: auto;"></div>
<div style="padding: 6px 8px; background: var(--bg-tertiary); border-top: 1px solid var(--border-color); font-size: 10px; color: #666;">
Auto-refreshes every 5s
</div>
</div>
<!-- Remote SDR (rtl_tcp) -->
<div class="form-group" style="margin-top: 10px;">
<label class="inline-checkbox">
@@ -2445,6 +2453,9 @@
// Initialize dropdown nav active state
updateDropdownActiveState();
// Start SDR device status polling
startSdrStatusPolling();
});
// Toggle section collapse
@@ -3717,6 +3728,9 @@
// Trigger filter update
onSDRTypeChanged();
// Also refresh SDR status panel
fetchSdrStatus();
})
.catch(err => {
console.error('Failed to refresh devices:', err);
@@ -3725,6 +3739,71 @@
});
}
// SDR Device Status Panel
let sdrStatusPollingInterval = null;
function renderSdrStatus(devices) {
const container = document.getElementById('sdrStatusList');
if (!container) return;
if (!devices || devices.length === 0) {
container.innerHTML = '<div style="padding: 8px; color: #888; font-size: 11px; text-align: center;">No SDR devices detected</div>';
return;
}
const html = devices.map(d => {
const isActive = d.in_use;
const statusDot = isActive
? '<span style="display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: #00ff88; box-shadow: 0 0 6px #00ff88; margin-right: 6px;"></span>'
: '<span style="display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: #555; margin-right: 6px;"></span>';
const modeName = d.used_by ? d.used_by.toUpperCase() : 'IDLE';
const modeColor = isActive ? '#00ff88' : '#666';
const sdrType = (d.sdr_type || 'RTL').toUpperCase().replace('RTLSDR', 'RTL');
return `<div style="display: flex; align-items: center; justify-content: space-between; padding: 6px 8px; border-bottom: 1px solid var(--border-color);">
<div style="display: flex; align-items: center;">
${statusDot}
<span style="font-size: 11px;">#${d.index} ${d.name || 'Unknown'}</span>
</div>
<div style="display: flex; align-items: center; gap: 6px;">
<span style="font-size: 10px; color: ${modeColor}; font-weight: bold;">${modeName}</span>
<span style="font-size: 9px; padding: 1px 4px; background: var(--bg-tertiary); border-radius: 3px; color: #888;">${sdrType}</span>
</div>
</div>`;
}).join('');
container.innerHTML = html;
}
function fetchSdrStatus() {
fetch('/devices/status')
.then(r => r.json())
.then(devices => {
renderSdrStatus(devices);
})
.catch(err => {
console.error('Failed to fetch SDR status:', err);
const container = document.getElementById('sdrStatusList');
if (container) {
container.innerHTML = '<div style="padding: 8px; color: #ff6666; font-size: 11px; text-align: center;">Error loading status</div>';
}
});
}
function startSdrStatusPolling() {
// Initial fetch
fetchSdrStatus();
// Poll every 5 seconds
sdrStatusPollingInterval = setInterval(fetchSdrStatus, 5000);
}
function stopSdrStatusPolling() {
if (sdrStatusPollingInterval) {
clearInterval(sdrStatusPollingInterval);
sdrStatusPollingInterval = null;
}
}
function getSelectedDevice() {
return document.getElementById('deviceSelect').value;
}
+41 -1
View File
@@ -7,11 +7,44 @@ with existing RTL-SDR installations. No SoapySDR dependency required.
from __future__ import annotations
import logging
import subprocess
from typing import Optional
from .base import CommandBuilder, SDRCapabilities, SDRDevice, SDRType
from utils.dependencies import get_tool_path
logger = logging.getLogger('intercept.sdr.rtlsdr')
def _get_dump1090_bias_t_flag(dump1090_path: str) -> Optional[str]:
"""Detect the correct bias-t flag for the installed dump1090 variant.
Different dump1090 forks use different flags:
- dump1090-fa, readsb: --enable-biast (no hyphen before 't')
- dump1090-mutability, original dump1090: no bias-t support
Returns the correct flag string or None if bias-t is not supported.
"""
try:
result = subprocess.run(
[dump1090_path, '--help'],
capture_output=True,
text=True,
timeout=5
)
help_text = result.stdout + result.stderr
# Check for dump1090-fa/readsb style flag (no hyphen)
if '--enable-biast' in help_text:
return '--enable-biast'
# No bias-t support found
return None
except Exception as e:
logger.warning(f"Could not detect dump1090 bias-t support: {e}")
return None
class RTLSDRCommandBuilder(CommandBuilder):
"""RTL-SDR command builder using native rtl_* tools."""
@@ -113,7 +146,14 @@ class RTLSDRCommandBuilder(CommandBuilder):
cmd.extend(['--gain', str(int(gain))])
if bias_t:
cmd.extend(['--enable-bias-t'])
bias_t_flag = _get_dump1090_bias_t_flag(dump1090_path)
if bias_t_flag:
cmd.append(bias_t_flag)
else:
logger.warning(
f"Bias-t requested but {dump1090_path} does not support it. "
"Consider using dump1090-fa or readsb for bias-t support."
)
return cmd