feat: add radiosonde weather balloon tracking mode

Integrate radiosonde_auto_rx for automatic weather balloon detection and
decoding on 400-406 MHz. Includes UDP telemetry parsing, Leaflet map with
altitude-colored markers and trajectory tracks, SDR device registry
integration, setup script installation, and Docker support.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Smittix
2026-02-27 10:46:33 +00:00
parent 5aa68a49c6
commit 5b06c57565
12 changed files with 1254 additions and 39 deletions

View File

@@ -0,0 +1,376 @@
<!-- 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&ndash;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&ndash;406 MHz)</option>
<option value="eu">Europe (400&ndash;403 MHz)</option>
<option value="us">US (400&ndash;406 MHz)</option>
<option value="au">Australia (400&ndash;403 MHz)</option>
<option value="custom">Custom&hellip;</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);">&ndash;</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">&mdash;</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 &mdash; 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 &mdash; mount near antenna for best results</li>
<li><strong style="color: var(--text-primary);">Launches:</strong> Typically 2&times;/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&ndash;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,
})
})
.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() {
fetch('/radiosonde/stop', { method: 'POST' })
.then(r => r.json())
.then(() => {
document.getElementById('startRadiosondeBtn').style.display = 'block';
document.getElementById('stopRadiosondeBtn').style.display = 'none';
document.getElementById('radiosondeStatusText').textContent = 'Standby';
document.getElementById('radiosondeStatusText').style.color = 'var(--accent-yellow)';
document.getElementById('radiosondeBalloonCount').textContent = '0';
document.getElementById('radiosondeLastUpdate').textContent = '\u2014';
if (radiosondeEventSource) {
radiosondeEventSource.close();
radiosondeEventSource = null;
}
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();
}
});
}
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(() => {
if (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();
function initRadiosondeMap() {
if (radiosondeMap) return;
const container = document.getElementById('radiosondeMapContainer');
if (!container) return;
radiosondeMap = L.map('radiosondeMapContainer', {
center: [40, -95],
zoom: 4,
zoomControl: true,
});
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
attribution: '&copy; OpenStreetMap &copy; CARTO',
maxZoom: 18,
}).addTo(radiosondeMap);
}
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` : '--';
radiosondeMarkers.get(id).bindPopup(
`<strong>${id}</strong><br>` +
`Type: ${balloon.sonde_type || '--'}<br>` +
`Alt: ${altStr}<br>` +
`Temp: ${tempStr} | Hum: ${humStr}<br>` +
`Vert: ${velStr}<br>` +
(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));
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` : '--';
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>
</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>

View File

@@ -84,6 +84,7 @@
{{ mode_item('ais', 'Vessels', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 18l2 2h14l2-2"/><path d="M5 18v-4a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v4"/><path d="M12 12V6"/><path d="M12 6l4 3"/></svg>', '/ais/dashboard') }}
{{ mode_item('aprs', 'APRS', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/></svg>') }}
{{ mode_item('gps', 'GPS', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="10" r="3"/><path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/></svg>') }}
{{ mode_item('radiosonde', 'Radiosonde', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v6"/><circle cx="12" cy="12" r="4"/><path d="M12 16v6"/><path d="M4.93 4.93l4.24 4.24"/><path d="M14.83 14.83l4.24 4.24"/></svg>') }}
</div>
</div>