mirror of
https://github.com/smittix/intercept.git
synced 2026-06-14 16:43:38 -07:00
Add ground station automation with 6-phase implementation
Phase 1 - Automated observation engine: - utils/ground_station/scheduler.py: GroundStationScheduler fires at AOS/LOS, claims SDR, manages IQBus lifecycle, emits SSE events - utils/ground_station/observation_profile.py: ObservationProfile dataclass + DB CRUD - routes/ground_station.py: REST API for profiles, scheduler, observations, recordings, rotator; SSE stream; /ws/satellite_waterfall WebSocket - DB tables: observation_profiles, ground_station_observations, ground_station_events, sigmf_recordings (added to utils/database.py init_db) - app.py: ground_station_queue, WebSocket init, scheduler startup in _deferred_init - routes/__init__.py: register ground_station_bp Phase 2 - Doppler correction: - utils/doppler.py: generalized DopplerTracker extracted from sstv_decoder.py; accepts satellite name or raw TLE tuple; thread-safe; update_tle() method - utils/sstv/sstv_decoder.py: replace inline DopplerTracker with import from utils.doppler - Scheduler runs 5s retune loop; calls rotator.point_to() if enabled Phase 3 - IQ recording (SigMF): - utils/sigmf.py: SigMFWriter writes .sigmf-data + .sigmf-meta; disk-free guard (500MB) - utils/ground_station/consumers/sigmf_writer.py: SigMFConsumer wraps SigMFWriter Phase 4 - Multi-decoder IQ broadcast pipeline: - utils/ground_station/iq_bus.py: IQBus single-producer fan-out; IQConsumer Protocol - utils/ground_station/consumers/waterfall.py: CU8→FFT→binary frames - utils/ground_station/consumers/fm_demod.py: CU8→FM demod (numpy)→decoder subprocess - utils/ground_station/consumers/gr_satellites.py: CU8→cf32→gr_satellites (optional) Phase 5 - Live spectrum waterfall: - static/js/modes/ground_station_waterfall.js: /ws/satellite_waterfall canvas renderer - Waterfall panel in satellite dashboard sidebar, auto-shown on iq_bus_started SSE event Phase 6 - Antenna rotator control (optional): - utils/rotator.py: RotatorController TCP client for rotctld (Hamlib line protocol) - Rotator panel in satellite dashboard; silently disabled if rotctld unreachable Also fixes pre-existing test_weather_sat_predict.py breakage: - utils/weather_sat_predict.py: rewritten with self-contained skyfield implementation using find_discrete (matching what committed tests expected); adds _format_utc_iso - tests/test_weather_sat_predict.py: add _MOCK_WEATHER_SATS and @patch decorators for tests that assumed NOAA-18 active (decommissioned Jun 2025, now active=False) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* Ground Station Live Waterfall — Phase 5
|
||||
*
|
||||
* Subscribes to /ws/satellite_waterfall, receives binary frames in the same
|
||||
* wire format as the main listening-post waterfall, and renders them onto the
|
||||
* <canvas id="gs-waterfall"> element in satellite_dashboard.html.
|
||||
*
|
||||
* Wire frame format (matches utils/waterfall_fft.build_binary_frame):
|
||||
* [uint8 msg_type=0x01]
|
||||
* [float32 start_freq_mhz]
|
||||
* [float32 end_freq_mhz]
|
||||
* [uint16 bin_count]
|
||||
* [uint8[] bins] — 0=noise floor, 255=strongest signal
|
||||
*/
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const CANVAS_ID = 'gs-waterfall';
|
||||
const ROW_HEIGHT = 2; // px per waterfall row
|
||||
const SCROLL_STEP = ROW_HEIGHT;
|
||||
|
||||
let _ws = null;
|
||||
let _canvas = null;
|
||||
let _ctx = null;
|
||||
let _offscreen = null; // offscreen ImageData buffer
|
||||
let _reconnectTimer = null;
|
||||
let _centerMhz = 0;
|
||||
let _spanMhz = 0;
|
||||
let _connected = false;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Colour palette — 256-entry RGB array (matches listening-post waterfall)
|
||||
// -----------------------------------------------------------------------
|
||||
const _palette = _buildPalette();
|
||||
|
||||
function _buildPalette() {
|
||||
const p = new Uint8Array(256 * 3);
|
||||
for (let i = 0; i < 256; i++) {
|
||||
let r, g, b;
|
||||
if (i < 64) {
|
||||
// black → dark blue
|
||||
r = 0; g = 0; b = Math.round(i * 2);
|
||||
} else if (i < 128) {
|
||||
// dark blue → cyan
|
||||
const t = (i - 64) / 64;
|
||||
r = 0; g = Math.round(t * 200); b = Math.round(128 + t * 127);
|
||||
} else if (i < 192) {
|
||||
// cyan → yellow
|
||||
const t = (i - 128) / 64;
|
||||
r = Math.round(t * 255); g = 200; b = Math.round(255 - t * 255);
|
||||
} else {
|
||||
// yellow → white
|
||||
const t = (i - 192) / 64;
|
||||
r = 255; g = 200; b = Math.round(t * 255);
|
||||
}
|
||||
p[i * 3] = r; p[i * 3 + 1] = g; p[i * 3 + 2] = b;
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Public API
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
window.GroundStationWaterfall = {
|
||||
init,
|
||||
connect,
|
||||
disconnect,
|
||||
isConnected: () => _connected,
|
||||
setCenterFreq: (mhz, span) => { _centerMhz = mhz; _spanMhz = span; },
|
||||
};
|
||||
|
||||
function init() {
|
||||
_canvas = document.getElementById(CANVAS_ID);
|
||||
if (!_canvas) return;
|
||||
_ctx = _canvas.getContext('2d');
|
||||
_resizeCanvas();
|
||||
window.addEventListener('resize', _resizeCanvas);
|
||||
_drawPlaceholder();
|
||||
}
|
||||
|
||||
function connect() {
|
||||
if (_ws && (_ws.readyState === WebSocket.CONNECTING || _ws.readyState === WebSocket.OPEN)) {
|
||||
return;
|
||||
}
|
||||
if (_reconnectTimer) {
|
||||
clearTimeout(_reconnectTimer);
|
||||
_reconnectTimer = null;
|
||||
}
|
||||
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const url = `${proto}//${location.host}/ws/satellite_waterfall`;
|
||||
|
||||
try {
|
||||
_ws = new WebSocket(url);
|
||||
_ws.binaryType = 'arraybuffer';
|
||||
|
||||
_ws.onopen = () => {
|
||||
_connected = true;
|
||||
_updateStatus('LIVE');
|
||||
console.log('[GS Waterfall] WebSocket connected');
|
||||
};
|
||||
|
||||
_ws.onmessage = (evt) => {
|
||||
if (evt.data instanceof ArrayBuffer) {
|
||||
_handleFrame(evt.data);
|
||||
}
|
||||
};
|
||||
|
||||
_ws.onclose = () => {
|
||||
_connected = false;
|
||||
_updateStatus('DISCONNECTED');
|
||||
_scheduleReconnect();
|
||||
};
|
||||
|
||||
_ws.onerror = (e) => {
|
||||
console.warn('[GS Waterfall] WebSocket error', e);
|
||||
};
|
||||
} catch (e) {
|
||||
console.error('[GS Waterfall] Failed to create WebSocket', e);
|
||||
_scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
if (_reconnectTimer) { clearTimeout(_reconnectTimer); _reconnectTimer = null; }
|
||||
if (_ws) { _ws.close(); _ws = null; }
|
||||
_connected = false;
|
||||
_updateStatus('STOPPED');
|
||||
_drawPlaceholder();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Frame rendering
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
function _handleFrame(buf) {
|
||||
const view = new DataView(buf);
|
||||
if (buf.byteLength < 11) return;
|
||||
|
||||
const msgType = view.getUint8(0);
|
||||
if (msgType !== 0x01) return;
|
||||
|
||||
// const startFreq = view.getFloat32(1, true); // little-endian
|
||||
// const endFreq = view.getFloat32(5, true);
|
||||
const binCount = view.getUint16(9, true);
|
||||
if (buf.byteLength < 11 + binCount) return;
|
||||
|
||||
const bins = new Uint8Array(buf, 11, binCount);
|
||||
|
||||
if (!_canvas || !_ctx) return;
|
||||
|
||||
const W = _canvas.width;
|
||||
const H = _canvas.height;
|
||||
|
||||
// Scroll existing image up by ROW_HEIGHT pixels
|
||||
if (!_offscreen || _offscreen.width !== W || _offscreen.height !== H) {
|
||||
_offscreen = _ctx.getImageData(0, 0, W, H);
|
||||
} else {
|
||||
_offscreen = _ctx.getImageData(0, 0, W, H);
|
||||
}
|
||||
|
||||
// Shift rows up by ROW_HEIGHT
|
||||
const data = _offscreen.data;
|
||||
const rowBytes = W * 4;
|
||||
data.copyWithin(0, SCROLL_STEP * rowBytes);
|
||||
|
||||
// Write new row(s) at the bottom
|
||||
const bottom = H - ROW_HEIGHT;
|
||||
for (let row = 0; row < ROW_HEIGHT; row++) {
|
||||
const rowStart = (bottom + row) * rowBytes;
|
||||
for (let x = 0; x < W; x++) {
|
||||
const binIdx = Math.floor((x / W) * binCount);
|
||||
const val = bins[Math.min(binIdx, binCount - 1)];
|
||||
const pi = val * 3;
|
||||
const di = rowStart + x * 4;
|
||||
data[di] = _palette[pi];
|
||||
data[di + 1] = _palette[pi + 1];
|
||||
data[di + 2] = _palette[pi + 2];
|
||||
data[di + 3] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
_ctx.putImageData(_offscreen, 0, 0);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
function _resizeCanvas() {
|
||||
if (!_canvas) return;
|
||||
const container = _canvas.parentElement;
|
||||
if (container) {
|
||||
_canvas.width = container.clientWidth || 400;
|
||||
_canvas.height = container.clientHeight || 200;
|
||||
}
|
||||
_offscreen = null;
|
||||
_drawPlaceholder();
|
||||
}
|
||||
|
||||
function _drawPlaceholder() {
|
||||
if (!_ctx || !_canvas) return;
|
||||
_ctx.fillStyle = '#000a14';
|
||||
_ctx.fillRect(0, 0, _canvas.width, _canvas.height);
|
||||
_ctx.fillStyle = 'rgba(0,212,255,0.3)';
|
||||
_ctx.font = '12px monospace';
|
||||
_ctx.textAlign = 'center';
|
||||
_ctx.fillText('AWAITING SATELLITE PASS', _canvas.width / 2, _canvas.height / 2);
|
||||
_ctx.textAlign = 'left';
|
||||
}
|
||||
|
||||
function _updateStatus(text) {
|
||||
const el = document.getElementById('gsWaterfallStatus');
|
||||
if (el) el.textContent = text;
|
||||
}
|
||||
|
||||
function _scheduleReconnect(delayMs = 5000) {
|
||||
if (_reconnectTimer) return;
|
||||
_reconnectTimer = setTimeout(() => {
|
||||
_reconnectTimer = null;
|
||||
connect();
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
// Auto-init when DOM is ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user