mirror of
https://github.com/smittix/intercept.git
synced 2026-04-25 07:10:00 -07:00
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>
234 lines
7.5 KiB
JavaScript
234 lines
7.5 KiB
JavaScript
/**
|
|
* 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();
|
|
}
|
|
})();
|