mirror of
https://github.com/smittix/intercept.git
synced 2026-06-13 08:13:32 -07:00
8379f42ec3
SSE EventSource connections for AIS, ACARS, VDL2, and radiosonde were not closed when switching modes, causing fd exhaustion after repeated switches. Also fixes socket leaks on exception paths in AIS/ADS-B stream parsers, closes subprocess pipes in safe_terminate/cleanup, and caches skyfield timescale at module level to avoid per-request fd churn. Closes #169 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
459 lines
22 KiB
HTML
459 lines
22 KiB
HTML
<!-- RADIOSONDE WEATHER BALLOON TRACKING MODE -->
|
|
<div id="radiosondeMode" class="mode-content">
|
|
<div class="section">
|
|
<h3>Radiosonde Decoder</h3>
|
|
<div class="info-text" style="margin-bottom: 15px;">
|
|
Track weather balloons via radiosonde telemetry on 400–406 MHz. Decodes position, altitude, temperature, humidity, and pressure.
|
|
</div>
|
|
</div>
|
|
|
|
<div class="section">
|
|
<h3>Settings</h3>
|
|
<div class="form-group">
|
|
<label>Region / Frequency Band</label>
|
|
<select id="radiosondeRegionSelect" onchange="updateRadiosondeFreqRange()">
|
|
<option value="global" selected>Global (400–406 MHz)</option>
|
|
<option value="eu">Europe (400–403 MHz)</option>
|
|
<option value="us">US (400–406 MHz)</option>
|
|
<option value="au">Australia (400–403 MHz)</option>
|
|
<option value="custom">Custom…</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-group" id="radiosondeCustomFreqGroup" style="display: none;">
|
|
<label>Frequency Range (MHz)</label>
|
|
<div style="display: flex; gap: 8px; align-items: center;">
|
|
<input type="number" id="radiosondeFreqMin" value="400.0" min="380" max="410" step="0.1" style="width: 50%;" placeholder="Min">
|
|
<span style="color: var(--text-dim);">–</span>
|
|
<input type="number" id="radiosondeFreqMax" value="406.0" min="380" max="410" step="0.1" style="width: 50%;" placeholder="Max">
|
|
</div>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Gain (dB, 0 = auto)</label>
|
|
<input type="number" id="radiosondeGainInput" value="40" min="0" max="50" placeholder="0-50">
|
|
</div>
|
|
</div>
|
|
|
|
<div class="section">
|
|
<h3>Status</h3>
|
|
<div id="radiosondeStatusDisplay" class="info-text">
|
|
<p>Status: <span id="radiosondeStatusText" style="color: var(--accent-yellow);">Standby</span></p>
|
|
<p>Balloons: <span id="radiosondeBalloonCount">0</span></p>
|
|
<p>Last update: <span id="radiosondeLastUpdate">—</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;">
|
|
400 MHz meteorological band — stock SDR antenna may work for nearby launches
|
|
</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 Quarter-Wave</strong>
|
|
<ul style="margin: 6px 0 0 14px; padding: 0;">
|
|
<li><strong style="color: var(--text-primary);">Element length:</strong> ~18.7 cm (quarter-wave at 400 MHz)</li>
|
|
<li><strong style="color: var(--text-primary);">Material:</strong> Wire or copper rod</li>
|
|
<li><strong style="color: var(--text-primary);">Orientation:</strong> Vertical</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; margin-bottom: 10px;">
|
|
<strong style="color: var(--accent-cyan); font-size: 12px;">Tips</strong>
|
|
<ul style="margin: 6px 0 0 14px; padding: 0;">
|
|
<li><strong style="color: var(--text-primary);">Range:</strong> 200+ km with LNA and good antenna placement</li>
|
|
<li><strong style="color: var(--text-primary);">LNA:</strong> Recommended — mount near antenna for best results</li>
|
|
<li><strong style="color: var(--text-primary);">Launches:</strong> Typically 2×/day at 00Z and 12Z from weather stations</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);">Frequency band</td>
|
|
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">400–406 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;">18.7 cm</td>
|
|
</tr>
|
|
<tr style="border-bottom: 1px solid var(--border-color);">
|
|
<td style="padding: 3px 4px; color: var(--text-dim);">Common types</td>
|
|
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">RS41, RS92, DFM, M10</td>
|
|
</tr>
|
|
<tr style="border-bottom: 1px solid var(--border-color);">
|
|
<td style="padding: 3px 4px; color: var(--text-dim);">Max altitude</td>
|
|
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">~35 km (115,000 ft)</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="padding: 3px 4px; color: var(--text-dim);">Flight duration</td>
|
|
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">~90 min ascent</td>
|
|
</tr>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<button class="run-btn" id="startRadiosondeBtn" onclick="startRadiosondeTracking()">
|
|
Start Radiosonde Tracking
|
|
</button>
|
|
<button class="stop-btn" id="stopRadiosondeBtn" onclick="stopRadiosondeTracking()" style="display: none;">
|
|
Stop Radiosonde Tracking
|
|
</button>
|
|
</div>
|
|
|
|
<script>
|
|
let radiosondeEventSource = null;
|
|
let radiosondeBalloons = {};
|
|
|
|
function updateRadiosondeFreqRange() {
|
|
const region = document.getElementById('radiosondeRegionSelect').value;
|
|
const customGroup = document.getElementById('radiosondeCustomFreqGroup');
|
|
const minInput = document.getElementById('radiosondeFreqMin');
|
|
const maxInput = document.getElementById('radiosondeFreqMax');
|
|
|
|
const presets = {
|
|
global: [400.0, 406.0],
|
|
eu: [400.0, 403.0],
|
|
us: [400.0, 406.0],
|
|
au: [400.0, 403.0],
|
|
};
|
|
|
|
if (region === 'custom') {
|
|
customGroup.style.display = 'block';
|
|
} else {
|
|
customGroup.style.display = 'none';
|
|
if (presets[region]) {
|
|
minInput.value = presets[region][0];
|
|
maxInput.value = presets[region][1];
|
|
}
|
|
}
|
|
}
|
|
|
|
function startRadiosondeTracking() {
|
|
const gain = document.getElementById('radiosondeGainInput').value || '40';
|
|
const device = document.getElementById('deviceSelect')?.value || '0';
|
|
const freqMin = parseFloat(document.getElementById('radiosondeFreqMin').value) || 400.0;
|
|
const freqMax = parseFloat(document.getElementById('radiosondeFreqMax').value) || 406.0;
|
|
|
|
fetch('/radiosonde/start', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
device,
|
|
gain,
|
|
freq_min: freqMin,
|
|
freq_max: freqMax,
|
|
bias_t: typeof getBiasTEnabled === 'function' ? getBiasTEnabled() : false,
|
|
latitude: radiosondeStationLocation.lat,
|
|
longitude: radiosondeStationLocation.lon,
|
|
})
|
|
})
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (data.status === 'started' || data.status === 'already_running') {
|
|
document.getElementById('startRadiosondeBtn').style.display = 'none';
|
|
document.getElementById('stopRadiosondeBtn').style.display = 'block';
|
|
document.getElementById('radiosondeStatusText').textContent = 'Tracking';
|
|
document.getElementById('radiosondeStatusText').style.color = 'var(--accent-green)';
|
|
startRadiosondeSSE();
|
|
} else {
|
|
alert(data.message || 'Failed to start radiosonde tracking');
|
|
}
|
|
})
|
|
.catch(err => alert('Error: ' + err.message));
|
|
}
|
|
|
|
function stopRadiosondeTracking() {
|
|
// Update UI immediately so the user sees feedback
|
|
document.getElementById('startRadiosondeBtn').style.display = 'block';
|
|
document.getElementById('stopRadiosondeBtn').style.display = 'none';
|
|
document.getElementById('radiosondeStatusText').textContent = 'Stopping...';
|
|
document.getElementById('radiosondeStatusText').style.color = 'var(--accent-yellow)';
|
|
|
|
if (radiosondeEventSource) {
|
|
radiosondeEventSource.close();
|
|
radiosondeEventSource = null;
|
|
}
|
|
|
|
fetch('/radiosonde/stop', { method: 'POST' })
|
|
.then(r => r.json())
|
|
.then(() => {
|
|
document.getElementById('radiosondeStatusText').textContent = 'Standby';
|
|
document.getElementById('radiosondeBalloonCount').textContent = '0';
|
|
document.getElementById('radiosondeLastUpdate').textContent = '\u2014';
|
|
radiosondeBalloons = {};
|
|
// Clear map markers
|
|
if (typeof radiosondeMap !== 'undefined' && radiosondeMap) {
|
|
radiosondeMarkers.forEach(m => radiosondeMap.removeLayer(m));
|
|
radiosondeMarkers.clear();
|
|
radiosondeTracks.forEach(t => radiosondeMap.removeLayer(t));
|
|
radiosondeTracks.clear();
|
|
}
|
|
})
|
|
.catch(() => {
|
|
document.getElementById('radiosondeStatusText').textContent = 'Standby';
|
|
});
|
|
}
|
|
|
|
function startRadiosondeSSE() {
|
|
if (radiosondeEventSource) radiosondeEventSource.close();
|
|
|
|
radiosondeEventSource = new EventSource('/radiosonde/stream');
|
|
radiosondeEventSource.onmessage = function(e) {
|
|
try {
|
|
const data = JSON.parse(e.data);
|
|
if (data.type === 'balloon') {
|
|
radiosondeBalloons[data.id] = data;
|
|
document.getElementById('radiosondeBalloonCount').textContent = Object.keys(radiosondeBalloons).length;
|
|
const now = new Date();
|
|
document.getElementById('radiosondeLastUpdate').textContent =
|
|
now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
|
updateRadiosondeMap(data);
|
|
updateRadiosondeCards();
|
|
}
|
|
} catch (err) {}
|
|
};
|
|
|
|
radiosondeEventSource.onerror = function() {
|
|
setTimeout(() => {
|
|
const panel = document.getElementById('radiosondeMode');
|
|
if (panel && panel.classList.contains('active') &&
|
|
document.getElementById('stopRadiosondeBtn').style.display === 'block') {
|
|
startRadiosondeSSE();
|
|
}
|
|
}, 2000);
|
|
};
|
|
}
|
|
|
|
// Map management
|
|
let radiosondeMap = null;
|
|
let radiosondeMarkers = new Map();
|
|
let radiosondeTracks = new Map();
|
|
let radiosondeTrackPoints = new Map();
|
|
let radiosondeStationLocation = { lat: 0, lon: 0 };
|
|
let radiosondeStationMarker = null;
|
|
|
|
function initRadiosondeMap() {
|
|
if (radiosondeMap) return;
|
|
const container = document.getElementById('radiosondeMapContainer');
|
|
if (!container) return;
|
|
|
|
// Resolve observer location
|
|
if (window.ObserverLocation && ObserverLocation.getForModule) {
|
|
radiosondeStationLocation = ObserverLocation.getForModule('radiosonde_observerLocation');
|
|
}
|
|
const hasLocation = radiosondeStationLocation.lat !== 0 || radiosondeStationLocation.lon !== 0;
|
|
|
|
radiosondeMap = L.map('radiosondeMapContainer', {
|
|
center: hasLocation ? [radiosondeStationLocation.lat, radiosondeStationLocation.lon] : [40, -95],
|
|
zoom: hasLocation ? 7 : 4,
|
|
zoomControl: true,
|
|
});
|
|
|
|
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
|
attribution: '© OpenStreetMap © CARTO',
|
|
maxZoom: 18,
|
|
}).addTo(radiosondeMap);
|
|
|
|
// Add station marker if we have a location
|
|
if (hasLocation) {
|
|
radiosondeStationMarker = L.circleMarker(
|
|
[radiosondeStationLocation.lat, radiosondeStationLocation.lon], {
|
|
radius: 8,
|
|
fillColor: '#00e5ff',
|
|
color: '#00e5ff',
|
|
weight: 2,
|
|
opacity: 1,
|
|
fillOpacity: 0.5,
|
|
}).addTo(radiosondeMap);
|
|
radiosondeStationMarker.bindTooltip('Station', { permanent: false, direction: 'top' });
|
|
}
|
|
|
|
// Try GPS for live position updates
|
|
fetch('/gps/position')
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (data.status === 'ok' && data.position && data.position.latitude != null) {
|
|
radiosondeStationLocation = { lat: data.position.latitude, lon: data.position.longitude };
|
|
const ll = [data.position.latitude, data.position.longitude];
|
|
if (radiosondeStationMarker) {
|
|
radiosondeStationMarker.setLatLng(ll);
|
|
} else {
|
|
radiosondeStationMarker = L.circleMarker(ll, {
|
|
radius: 8,
|
|
fillColor: '#00e5ff',
|
|
color: '#00e5ff',
|
|
weight: 2,
|
|
opacity: 1,
|
|
fillOpacity: 0.5,
|
|
}).addTo(radiosondeMap);
|
|
radiosondeStationMarker.bindTooltip('Station (GPS)', { permanent: false, direction: 'top' });
|
|
}
|
|
if (!radiosondeMap._gpsInitialized) {
|
|
radiosondeMap.setView(ll, 7);
|
|
radiosondeMap._gpsInitialized = true;
|
|
}
|
|
// Re-render cards with updated distances
|
|
updateRadiosondeCards();
|
|
}
|
|
})
|
|
.catch(() => {});
|
|
}
|
|
|
|
function updateRadiosondeMap(balloon) {
|
|
if (!radiosondeMap || !balloon.lat || !balloon.lon) return;
|
|
|
|
const id = balloon.id;
|
|
const latlng = [balloon.lat, balloon.lon];
|
|
|
|
// Altitude-based colour coding
|
|
const alt = balloon.alt || 0;
|
|
let colour;
|
|
if (alt < 5000) colour = '#00ff88';
|
|
else if (alt < 15000) colour = '#00ccff';
|
|
else if (alt < 25000) colour = '#ff9900';
|
|
else colour = '#ff3366';
|
|
|
|
// Update or create marker
|
|
if (radiosondeMarkers.has(id)) {
|
|
radiosondeMarkers.get(id).setLatLng(latlng);
|
|
} else {
|
|
const marker = L.circleMarker(latlng, {
|
|
radius: 7,
|
|
color: colour,
|
|
fillColor: colour,
|
|
fillOpacity: 0.8,
|
|
weight: 2,
|
|
}).addTo(radiosondeMap);
|
|
radiosondeMarkers.set(id, marker);
|
|
}
|
|
|
|
// Update marker colour based on altitude
|
|
radiosondeMarkers.get(id).setStyle({ color: colour, fillColor: colour });
|
|
|
|
// Build popup content
|
|
const altStr = alt ? `${Math.round(alt).toLocaleString()} m` : '--';
|
|
const tempStr = balloon.temp != null ? `${balloon.temp.toFixed(1)} °C` : '--';
|
|
const humStr = balloon.humidity != null ? `${balloon.humidity.toFixed(0)}%` : '--';
|
|
const velStr = balloon.vel_v != null ? `${balloon.vel_v.toFixed(1)} m/s` : '--';
|
|
let distStr = '';
|
|
if ((radiosondeStationLocation.lat !== 0 || radiosondeStationLocation.lon !== 0) && balloon.lat && balloon.lon) {
|
|
const distM = radiosondeMap.distance(
|
|
[radiosondeStationLocation.lat, radiosondeStationLocation.lon], latlng);
|
|
distStr = `Dist: ${(distM / 1000).toFixed(1)} km<br>`;
|
|
}
|
|
radiosondeMarkers.get(id).bindPopup(
|
|
`<strong>${id}</strong><br>` +
|
|
`Type: ${balloon.sonde_type || '--'}<br>` +
|
|
`Alt: ${altStr}<br>` +
|
|
`Temp: ${tempStr} | Hum: ${humStr}<br>` +
|
|
`Vert: ${velStr}<br>` +
|
|
distStr +
|
|
(balloon.freq ? `Freq: ${balloon.freq.toFixed(3)} MHz` : '')
|
|
);
|
|
|
|
// Track polyline
|
|
if (!radiosondeTrackPoints.has(id)) {
|
|
radiosondeTrackPoints.set(id, []);
|
|
}
|
|
radiosondeTrackPoints.get(id).push(latlng);
|
|
|
|
if (radiosondeTracks.has(id)) {
|
|
radiosondeTracks.get(id).setLatLngs(radiosondeTrackPoints.get(id));
|
|
} else {
|
|
const track = L.polyline(radiosondeTrackPoints.get(id), {
|
|
color: colour,
|
|
weight: 2,
|
|
opacity: 0.6,
|
|
dashArray: '4 4',
|
|
}).addTo(radiosondeMap);
|
|
radiosondeTracks.set(id, track);
|
|
}
|
|
|
|
// Auto-centre on first balloon
|
|
if (radiosondeMarkers.size === 1) {
|
|
radiosondeMap.setView(latlng, 8);
|
|
}
|
|
}
|
|
|
|
function updateRadiosondeCards() {
|
|
const container = document.getElementById('radiosondeCardContainer');
|
|
if (!container) return;
|
|
|
|
const sorted = Object.values(radiosondeBalloons).sort((a, b) => (b.alt || 0) - (a.alt || 0));
|
|
const hasStation = radiosondeStationLocation.lat !== 0 || radiosondeStationLocation.lon !== 0;
|
|
container.innerHTML = sorted.map(b => {
|
|
const alt = b.alt ? `${Math.round(b.alt).toLocaleString()} m` : '--';
|
|
const temp = b.temp != null ? `${b.temp.toFixed(1)}°C` : '--';
|
|
const hum = b.humidity != null ? `${b.humidity.toFixed(0)}%` : '--';
|
|
const press = b.pressure != null ? `${b.pressure.toFixed(1)} hPa` : '--';
|
|
const vel = b.vel_v != null ? `${b.vel_v > 0 ? '+' : ''}${b.vel_v.toFixed(1)} m/s` : '--';
|
|
const freq = b.freq ? `${b.freq.toFixed(3)} MHz` : '--';
|
|
let dist = '--';
|
|
if (hasStation && b.lat && b.lon && radiosondeMap) {
|
|
const distM = radiosondeMap.distance(
|
|
[radiosondeStationLocation.lat, radiosondeStationLocation.lon],
|
|
[b.lat, b.lon]);
|
|
dist = `${(distM / 1000).toFixed(1)} km`;
|
|
}
|
|
return `
|
|
<div class="radiosonde-card" onclick="radiosondeMap && radiosondeMap.setView([${b.lat || 0}, ${b.lon || 0}], 10)">
|
|
<div class="radiosonde-card-header">
|
|
<span class="radiosonde-serial">${b.id}</span>
|
|
<span class="radiosonde-type">${b.sonde_type || '??'}</span>
|
|
</div>
|
|
<div class="radiosonde-stats">
|
|
<div class="radiosonde-stat">
|
|
<span class="radiosonde-stat-value">${alt}</span>
|
|
<span class="radiosonde-stat-label">ALT</span>
|
|
</div>
|
|
<div class="radiosonde-stat">
|
|
<span class="radiosonde-stat-value">${temp}</span>
|
|
<span class="radiosonde-stat-label">TEMP</span>
|
|
</div>
|
|
<div class="radiosonde-stat">
|
|
<span class="radiosonde-stat-value">${hum}</span>
|
|
<span class="radiosonde-stat-label">HUM</span>
|
|
</div>
|
|
<div class="radiosonde-stat">
|
|
<span class="radiosonde-stat-value">${press}</span>
|
|
<span class="radiosonde-stat-label">PRESS</span>
|
|
</div>
|
|
<div class="radiosonde-stat">
|
|
<span class="radiosonde-stat-value">${vel}</span>
|
|
<span class="radiosonde-stat-label">VERT</span>
|
|
</div>
|
|
<div class="radiosonde-stat">
|
|
<span class="radiosonde-stat-value">${freq}</span>
|
|
<span class="radiosonde-stat-label">FREQ</span>
|
|
</div>
|
|
<div class="radiosonde-stat">
|
|
<span class="radiosonde-stat-value">${dist}</span>
|
|
<span class="radiosonde-stat-label">DIST</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
}
|
|
|
|
// Check initial status on load
|
|
fetch('/radiosonde/status')
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (data.tracking_active) {
|
|
document.getElementById('startRadiosondeBtn').style.display = 'none';
|
|
document.getElementById('stopRadiosondeBtn').style.display = 'block';
|
|
document.getElementById('radiosondeStatusText').textContent = 'Tracking';
|
|
document.getElementById('radiosondeStatusText').style.color = 'var(--accent-green)';
|
|
document.getElementById('radiosondeBalloonCount').textContent = data.balloon_count || 0;
|
|
startRadiosondeSSE();
|
|
}
|
|
})
|
|
.catch(() => {});
|
|
</script>
|