mirror of
https://github.com/smittix/intercept.git
synced 2026-04-24 06:40:00 -07:00
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:
376
templates/partials/modes/radiosonde.html
Normal file
376
templates/partials/modes/radiosonde.html
Normal 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–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,
|
||||
})
|
||||
})
|
||||
.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: '© OpenStreetMap © 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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user