[feat](trx-rs): remove NOAA APT decoder
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
@@ -4,8 +4,7 @@
|
|||||||
|
|
||||||
//! Shared PNG image encoding for weather satellite decoders.
|
//! Shared PNG image encoding for weather satellite decoders.
|
||||||
//!
|
//!
|
||||||
//! Both NOAA APT and Meteor-M LRPT decoders produce PNG output through
|
//! The Meteor-M LRPT decoder produces PNG output through this module.
|
||||||
//! this common module.
|
|
||||||
|
|
||||||
use std::io::Cursor;
|
use std::io::Cursor;
|
||||||
|
|
||||||
|
|||||||
@@ -4,17 +4,12 @@
|
|||||||
|
|
||||||
//! Weather satellite image decoders.
|
//! Weather satellite image decoders.
|
||||||
//!
|
//!
|
||||||
//! This crate provides decoders for two weather satellite transmission formats:
|
//! This crate provides a decoder for Meteor-M LRPT (Low Rate Picture
|
||||||
//!
|
//! Transmission) from Meteor-M N2-3/N2-4 using QPSK modulation at 72 kbps
|
||||||
//! - **NOAA APT** ([`noaa`]): Automatic Picture Transmission from NOAA-15/18/19
|
//! with CCSDS framing.
|
||||||
//! on 137 MHz using FM/AM subcarrier modulation at 4160 samples/sec.
|
|
||||||
//!
|
|
||||||
//! - **Meteor-M LRPT** ([`lrpt`]): Low Rate Picture Transmission from
|
|
||||||
//! Meteor-M N2-3/N2-4 using QPSK modulation at 72 kbps with CCSDS framing.
|
|
||||||
|
|
||||||
pub mod image_enc;
|
pub mod image_enc;
|
||||||
pub mod lrpt;
|
pub mod lrpt;
|
||||||
pub mod noaa;
|
|
||||||
|
|
||||||
/// Current time in milliseconds since UNIX epoch.
|
/// Current time in milliseconds since UNIX epoch.
|
||||||
pub(crate) fn now_ms() -> i64 {
|
pub(crate) fn now_ms() -> i64 {
|
||||||
|
|||||||
@@ -1,353 +0,0 @@
|
|||||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
||||||
//
|
|
||||||
// SPDX-License-Identifier: BSD-2-Clause
|
|
||||||
|
|
||||||
//! APT (Automatic Picture Transmission) demodulator and line decoder.
|
|
||||||
//!
|
|
||||||
//! Weather satellite APT signal chain:
|
|
||||||
//! FM-demodulated audio → 2400 Hz AM subcarrier → envelope → 4160 Hz image
|
|
||||||
//!
|
|
||||||
//! Frame layout at 4160 Hz (2080 samples = 0.5 s per line, 2 lines/sec):
|
|
||||||
//! [SyncA 39][SpaceA 47][ImageA 909][TelA 45][SyncB 39][SpaceB 47][ImageB 909][TelB 45]
|
|
||||||
|
|
||||||
use num_complex::Complex;
|
|
||||||
use rustfft::FftPlanner;
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
pub const APT_RATE: u32 = 4160;
|
|
||||||
pub const LINE_SAMPLES: usize = 2080;
|
|
||||||
|
|
||||||
// Line layout offsets (samples into a line at APT_RATE Hz)
|
|
||||||
pub const SYNC_A_LEN: usize = 39;
|
|
||||||
const SPACE_A_LEN: usize = 47;
|
|
||||||
pub const IMAGE_A_LEN: usize = 909;
|
|
||||||
const TEL_A_LEN: usize = 45;
|
|
||||||
const SYNC_B_LEN: usize = 39;
|
|
||||||
const SPACE_B_LEN: usize = 47;
|
|
||||||
pub const IMAGE_B_LEN: usize = 909;
|
|
||||||
|
|
||||||
pub const IMAGE_A_OFFSET: usize = SYNC_A_LEN + SPACE_A_LEN; // 86
|
|
||||||
pub const IMAGE_B_OFFSET: usize =
|
|
||||||
IMAGE_A_OFFSET + IMAGE_A_LEN + TEL_A_LEN + SYNC_B_LEN + SPACE_B_LEN; // 1126
|
|
||||||
|
|
||||||
// FFT block size for Hilbert-based AM demodulation
|
|
||||||
const BLOCK_SIZE: usize = 4096;
|
|
||||||
|
|
||||||
// Sync detection parameters
|
|
||||||
const SYNC_THRESHOLD: f32 = 0.15;
|
|
||||||
const SYNC_SEARCH_LOCKED: usize = 12; // ±samples around expected sync position when locked
|
|
||||||
const MAX_BAD_SYNC_LINES: u32 = 8; // unlock after this many low-confidence lines
|
|
||||||
|
|
||||||
/// Telemetry block length (samples per channel).
|
|
||||||
pub const TEL_LEN: usize = 45;
|
|
||||||
|
|
||||||
/// A decoded APT line: raw pixel arrays for both image channels plus telemetry.
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct RawLine {
|
|
||||||
pub pixels_a: Box<[u8; IMAGE_A_LEN]>,
|
|
||||||
pub pixels_b: Box<[u8; IMAGE_B_LEN]>,
|
|
||||||
/// Telemetry block A (45 samples, normalised to 0-255).
|
|
||||||
pub tel_a: Box<[u8; TEL_LEN]>,
|
|
||||||
/// Telemetry block B (45 samples, normalised to 0-255).
|
|
||||||
pub tel_b: Box<[u8; TEL_LEN]>,
|
|
||||||
pub line_no: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sync A reference pattern at APT_RATE Hz.
|
|
||||||
///
|
|
||||||
/// 1040 Hz square wave (period = 4 samples): alternating pairs hi/lo.
|
|
||||||
fn sync_a_ref() -> [f32; SYNC_A_LEN] {
|
|
||||||
let mut p = [0.0f32; SYNC_A_LEN];
|
|
||||||
for (i, v) in p.iter_mut().enumerate() {
|
|
||||||
// 7 cycles of alternating pairs, rest is zero (end-of-sync blank)
|
|
||||||
*v = if i < 28 && (i % 4) < 2 { 1.0 } else { -1.0 };
|
|
||||||
}
|
|
||||||
p
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Compute normalised cross-correlation of `buf[offset..]` with the sync A
|
|
||||||
/// reference pattern. Returns a value approximately in `[-1.0, 1.0]`.
|
|
||||||
fn sync_score(buf: &[f32], offset: usize) -> f32 {
|
|
||||||
if offset + SYNC_A_LEN > buf.len() {
|
|
||||||
return 0.0;
|
|
||||||
}
|
|
||||||
let ref_pat = sync_a_ref();
|
|
||||||
let window = &buf[offset..offset + SYNC_A_LEN];
|
|
||||||
let mean = window.iter().sum::<f32>() / SYNC_A_LEN as f32;
|
|
||||||
let rms =
|
|
||||||
(window.iter().map(|&x| (x - mean) * (x - mean)).sum::<f32>() / SYNC_A_LEN as f32).sqrt();
|
|
||||||
if rms < 1e-7 {
|
|
||||||
return 0.0;
|
|
||||||
}
|
|
||||||
window
|
|
||||||
.iter()
|
|
||||||
.zip(ref_pat.iter())
|
|
||||||
.map(|(&s, &r)| (s - mean) * r)
|
|
||||||
.sum::<f32>()
|
|
||||||
/ (SYNC_A_LEN as f32 * rms)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Find the offset in `buf[0..search_len]` with the highest sync A score.
|
|
||||||
/// Returns `(offset, score)`.
|
|
||||||
fn find_best_sync(buf: &[f32], search_len: usize) -> (usize, f32) {
|
|
||||||
let limit = search_len.min(buf.len().saturating_sub(SYNC_A_LEN));
|
|
||||||
let mut best_off = 0usize;
|
|
||||||
let mut best_score = f32::NEG_INFINITY;
|
|
||||||
for off in 0..=limit {
|
|
||||||
let s = sync_score(buf, off);
|
|
||||||
if s > best_score {
|
|
||||||
best_score = s;
|
|
||||||
best_off = off;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(best_off, best_score)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// AM demodulator (Hilbert-based via rustfft)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Converts PCM at `sample_rate` to APT envelope samples at `APT_RATE` Hz.
|
|
||||||
///
|
|
||||||
/// Uses an FFT-based Hilbert transform to obtain the analytic signal, then
|
|
||||||
/// extracts the AM envelope. Processes input in non-overlapping blocks of
|
|
||||||
/// `BLOCK_SIZE` samples.
|
|
||||||
pub struct AptDemod {
|
|
||||||
fft_fwd: Arc<dyn rustfft::Fft<f32>>,
|
|
||||||
fft_inv: Arc<dyn rustfft::Fft<f32>>,
|
|
||||||
/// Lower bin index of the 2400 Hz ± 1040 Hz bandpass filter.
|
|
||||||
k_lo: usize,
|
|
||||||
/// Upper bin index of the 2400 Hz ± 1040 Hz bandpass filter.
|
|
||||||
k_hi: usize,
|
|
||||||
/// Input sample accumulation buffer.
|
|
||||||
in_buf: Vec<f32>,
|
|
||||||
/// Fractional position into the next input block for the resampler.
|
|
||||||
resamp_phase: f64,
|
|
||||||
/// Input samples consumed per APT_RATE output sample.
|
|
||||||
resamp_step: f64,
|
|
||||||
/// Output envelope buffer at APT_RATE Hz.
|
|
||||||
pub out: Vec<f32>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AptDemod {
|
|
||||||
pub fn new(sample_rate: u32) -> Self {
|
|
||||||
let mut planner = FftPlanner::new();
|
|
||||||
let fft_fwd = planner.plan_fft_forward(BLOCK_SIZE);
|
|
||||||
let fft_inv = planner.plan_fft_inverse(BLOCK_SIZE);
|
|
||||||
|
|
||||||
let fs = sample_rate as f64;
|
|
||||||
// Bandpass around 2400 Hz carrier, ±1040 Hz (APT image bandwidth)
|
|
||||||
let k_lo = ((1360.0 * BLOCK_SIZE as f64 / fs).floor() as usize).max(1);
|
|
||||||
let k_hi = ((3440.0 * BLOCK_SIZE as f64 / fs).ceil() as usize).min(BLOCK_SIZE / 2);
|
|
||||||
|
|
||||||
Self {
|
|
||||||
fft_fwd,
|
|
||||||
fft_inv,
|
|
||||||
k_lo,
|
|
||||||
k_hi,
|
|
||||||
in_buf: Vec::new(),
|
|
||||||
resamp_phase: 0.0,
|
|
||||||
resamp_step: sample_rate as f64 / APT_RATE as f64,
|
|
||||||
out: Vec::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Push raw PCM samples; envelope output accumulates in `self.out`.
|
|
||||||
pub fn push(&mut self, samples: &[f32]) {
|
|
||||||
self.in_buf.extend_from_slice(samples);
|
|
||||||
while self.in_buf.len() >= BLOCK_SIZE {
|
|
||||||
// Drain exactly BLOCK_SIZE samples into a stack array
|
|
||||||
let block: Vec<f32> = self.in_buf.drain(..BLOCK_SIZE).collect();
|
|
||||||
self.process_block(&block);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn process_block(&mut self, block: &[f32]) {
|
|
||||||
// Forward FFT
|
|
||||||
let mut spectrum: Vec<Complex<f32>> = block.iter().map(|&s| Complex::new(s, 0.0)).collect();
|
|
||||||
self.fft_fwd.process(&mut spectrum);
|
|
||||||
|
|
||||||
// Bandpass + analytic signal:
|
|
||||||
// - Keep positive-freq band [k_lo, k_hi], doubled (for single-sideband power).
|
|
||||||
// - Zero all other bins (negative freqs and out-of-band positives).
|
|
||||||
// IFFT of the resulting one-sided spectrum ≈ analytic signal of the
|
|
||||||
// bandpass-filtered input; its magnitude is the AM envelope.
|
|
||||||
for (k, bin) in spectrum.iter_mut().enumerate() {
|
|
||||||
if k >= self.k_lo && k <= self.k_hi {
|
|
||||||
*bin *= 2.0;
|
|
||||||
} else {
|
|
||||||
*bin = Complex::ZERO;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Inverse FFT → complex analytic signal
|
|
||||||
self.fft_inv.process(&mut spectrum);
|
|
||||||
let scale = 1.0_f32 / BLOCK_SIZE as f32;
|
|
||||||
|
|
||||||
// Magnitude = AM envelope; resample to APT_RATE via linear interpolation
|
|
||||||
let n = BLOCK_SIZE as f64;
|
|
||||||
while self.resamp_phase + 1.0 < n {
|
|
||||||
let i = self.resamp_phase as usize;
|
|
||||||
let frac = (self.resamp_phase - i as f64) as f32;
|
|
||||||
let s0 = spectrum[i].norm() * scale;
|
|
||||||
let s1 = spectrum[i + 1].norm() * scale;
|
|
||||||
self.out.push(s0 + frac * (s1 - s0));
|
|
||||||
self.resamp_phase += self.resamp_step;
|
|
||||||
}
|
|
||||||
self.resamp_phase -= n;
|
|
||||||
if self.resamp_phase < 0.0 {
|
|
||||||
self.resamp_phase = 0.0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn reset(&mut self) {
|
|
||||||
self.in_buf.clear();
|
|
||||||
self.out.clear();
|
|
||||||
self.resamp_phase = 0.0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Sync tracker and line assembler
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Consumes resampled APT envelope samples (at `APT_RATE` Hz), detects
|
|
||||||
/// sync A markers, and assembles decoded image lines.
|
|
||||||
pub struct SyncTracker {
|
|
||||||
buf: Vec<f32>,
|
|
||||||
locked: bool,
|
|
||||||
bad_sync_count: u32,
|
|
||||||
line_no: u32,
|
|
||||||
pub lines: Vec<RawLine>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for SyncTracker {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SyncTracker {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
buf: Vec::new(),
|
|
||||||
locked: false,
|
|
||||||
bad_sync_count: 0,
|
|
||||||
line_no: 0,
|
|
||||||
lines: Vec::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Push envelope samples; fully assembled lines accumulate in `self.lines`.
|
|
||||||
pub fn push(&mut self, samples: &[f32]) {
|
|
||||||
self.buf.extend_from_slice(samples);
|
|
||||||
self.drain();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn drain(&mut self) {
|
|
||||||
loop {
|
|
||||||
if !self.locked {
|
|
||||||
// Need 2 × LINE_SAMPLES to have room for a full line after scan
|
|
||||||
if self.buf.len() < 2 * LINE_SAMPLES {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
let (pos, score) = find_best_sync(&self.buf, LINE_SAMPLES);
|
|
||||||
if score >= SYNC_THRESHOLD {
|
|
||||||
self.buf.drain(0..pos);
|
|
||||||
self.locked = true;
|
|
||||||
// Fall through to locked extraction below
|
|
||||||
} else {
|
|
||||||
// No convincing sync yet; discard half a line and retry
|
|
||||||
self.buf.drain(0..LINE_SAMPLES / 2);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Locked mode: need LINE_SAMPLES + search window to be available
|
|
||||||
if self.buf.len() < LINE_SAMPLES + SYNC_SEARCH_LOCKED {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Refine within a small window around the expected line start
|
|
||||||
let (drift, score) = find_best_sync(&self.buf, SYNC_SEARCH_LOCKED);
|
|
||||||
if score >= SYNC_THRESHOLD * 0.5 {
|
|
||||||
// Accept refined position
|
|
||||||
if drift > 0 {
|
|
||||||
self.buf.drain(0..drift);
|
|
||||||
}
|
|
||||||
self.bad_sync_count = 0;
|
|
||||||
} else {
|
|
||||||
self.bad_sync_count += 1;
|
|
||||||
if self.bad_sync_count >= MAX_BAD_SYNC_LINES {
|
|
||||||
self.locked = false;
|
|
||||||
self.bad_sync_count = 0;
|
|
||||||
// Discard the current suspect region and restart search
|
|
||||||
self.buf.drain(0..LINE_SAMPLES / 2);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// Keep going at the expected position (don't drift on bad sync)
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.buf.len() < LINE_SAMPLES {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
self.extract_line();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn extract_line(&mut self) {
|
|
||||||
let samples: Vec<f32> = self.buf.drain(0..LINE_SAMPLES).collect();
|
|
||||||
|
|
||||||
// Per-line normalisation: scale to [0, 255] using the 2nd–98th percentile
|
|
||||||
// of this line's values to clip noise and hot pixels.
|
|
||||||
let mut sorted: Vec<f32> = samples.clone();
|
|
||||||
sorted.sort_unstable_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
|
||||||
let p_lo = sorted[(sorted.len() * 2 / 100).max(0)];
|
|
||||||
let p_hi = sorted[(sorted.len() * 98 / 100).min(sorted.len() - 1)];
|
|
||||||
let range = (p_hi - p_lo).max(1e-6);
|
|
||||||
|
|
||||||
let norm = |v: f32| -> u8 { ((v - p_lo) / range * 255.0).round().clamp(0.0, 255.0) as u8 };
|
|
||||||
|
|
||||||
let mut pixels_a = Box::new([0u8; IMAGE_A_LEN]);
|
|
||||||
for (i, p) in pixels_a.iter_mut().enumerate() {
|
|
||||||
*p = norm(samples[IMAGE_A_OFFSET + i]);
|
|
||||||
}
|
|
||||||
let mut pixels_b = Box::new([0u8; IMAGE_B_LEN]);
|
|
||||||
for (i, p) in pixels_b.iter_mut().enumerate() {
|
|
||||||
*p = norm(samples[IMAGE_B_OFFSET + i]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract telemetry blocks (adjacent to image data)
|
|
||||||
let tel_a_offset = IMAGE_A_OFFSET + IMAGE_A_LEN; // right after image A
|
|
||||||
let tel_b_offset = IMAGE_B_OFFSET + IMAGE_B_LEN; // right after image B
|
|
||||||
let mut tel_a = Box::new([0u8; TEL_LEN]);
|
|
||||||
for (i, p) in tel_a.iter_mut().enumerate() {
|
|
||||||
if tel_a_offset + i < LINE_SAMPLES {
|
|
||||||
*p = norm(samples[tel_a_offset + i]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let mut tel_b = Box::new([0u8; TEL_LEN]);
|
|
||||||
for (i, p) in tel_b.iter_mut().enumerate() {
|
|
||||||
if tel_b_offset + i < LINE_SAMPLES {
|
|
||||||
*p = norm(samples[tel_b_offset + i]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
self.lines.push(RawLine {
|
|
||||||
pixels_a,
|
|
||||||
pixels_b,
|
|
||||||
tel_a,
|
|
||||||
tel_b,
|
|
||||||
line_no: self.line_no,
|
|
||||||
});
|
|
||||||
self.line_no += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn reset(&mut self) {
|
|
||||||
self.buf.clear();
|
|
||||||
self.locked = false;
|
|
||||||
self.bad_sync_count = 0;
|
|
||||||
self.line_no = 0;
|
|
||||||
self.lines.clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
||||||
//
|
|
||||||
// SPDX-License-Identifier: BSD-2-Clause
|
|
||||||
|
|
||||||
//! APT image assembly.
|
|
||||||
//!
|
|
||||||
//! Standard output layout: channel A (visible / IR-A) on the left half and
|
|
||||||
//! channel B (IR-B / IR) on the right half, stacked vertically by line number.
|
|
||||||
|
|
||||||
use super::apt::{RawLine, IMAGE_A_LEN, IMAGE_B_LEN};
|
|
||||||
|
|
||||||
/// Assemble decoded APT lines into a PNG image.
|
|
||||||
///
|
|
||||||
/// Returns the PNG bytes, or `None` if `lines` is empty or encoding fails.
|
|
||||||
/// Width = `IMAGE_A_LEN + IMAGE_B_LEN` (1818 px), height = number of lines.
|
|
||||||
pub fn encode_png(lines: &[RawLine]) -> Option<Vec<u8>> {
|
|
||||||
if lines.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let width = (IMAGE_A_LEN + IMAGE_B_LEN) as u32;
|
|
||||||
let height = lines.len() as u32;
|
|
||||||
let mut pixels: Vec<u8> = Vec::with_capacity((width * height) as usize);
|
|
||||||
|
|
||||||
for line in lines {
|
|
||||||
pixels.extend_from_slice(line.pixels_a.as_ref());
|
|
||||||
pixels.extend_from_slice(line.pixels_b.as_ref());
|
|
||||||
}
|
|
||||||
|
|
||||||
crate::image_enc::encode_grayscale_png(width, height, pixels)
|
|
||||||
}
|
|
||||||
@@ -1,166 +0,0 @@
|
|||||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
||||||
//
|
|
||||||
// SPDX-License-Identifier: BSD-2-Clause
|
|
||||||
|
|
||||||
//! NOAA APT (Automatic Picture Transmission) weather satellite image decoder.
|
|
||||||
//!
|
|
||||||
//! Decodes the APT format broadcast by NOAA-15 (137.620 MHz),
|
|
||||||
//! NOAA-18 (137.9125 MHz) and NOAA-19 (137.100 MHz).
|
|
||||||
//!
|
|
||||||
//! # Signal chain
|
|
||||||
//!
|
|
||||||
//! The input is FM-demodulated audio containing a 2400 Hz AM subcarrier.
|
|
||||||
//! The decoder:
|
|
||||||
//! 1. Extracts the AM envelope via a FFT-based Hilbert transform (rustfft).
|
|
||||||
//! 2. Resamples to 4160 Hz (the APT image sample rate).
|
|
||||||
//! 3. Detects line sync markers (1040 Hz alternating pattern).
|
|
||||||
//! 4. Assembles image lines (2080 samples each) and extracts both channels.
|
|
||||||
//!
|
|
||||||
//! Call [`AptDecoder::process_samples`] with each audio batch, then
|
|
||||||
//! [`AptDecoder::finalize`] when the pass ends to obtain JPEG bytes.
|
|
||||||
|
|
||||||
pub mod apt;
|
|
||||||
mod image_enc;
|
|
||||||
pub mod telemetry;
|
|
||||||
|
|
||||||
use apt::{AptDemod, SyncTracker};
|
|
||||||
use telemetry::{Satellite, SensorChannel};
|
|
||||||
|
|
||||||
/// Completed APT image returned by [`AptDecoder::finalize`].
|
|
||||||
pub struct AptImage {
|
|
||||||
/// PNG-encoded image bytes.
|
|
||||||
pub png: Vec<u8>,
|
|
||||||
/// Number of decoded image lines.
|
|
||||||
pub line_count: u32,
|
|
||||||
/// Millisecond timestamp when the first line was decoded.
|
|
||||||
pub first_line_ms: i64,
|
|
||||||
/// Identified satellite, if telemetry was decodable.
|
|
||||||
pub satellite: Satellite,
|
|
||||||
/// Detected sensor channel for sub-channel A.
|
|
||||||
pub sensor_a: SensorChannel,
|
|
||||||
/// Detected sensor channel for sub-channel B.
|
|
||||||
pub sensor_b: SensorChannel,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Top-level NOAA APT decoder.
|
|
||||||
///
|
|
||||||
/// Feed audio samples with [`process_samples`] and call [`finalize`] at
|
|
||||||
/// pass end to retrieve the assembled JPEG.
|
|
||||||
pub struct AptDecoder {
|
|
||||||
demod: AptDemod,
|
|
||||||
sync: SyncTracker,
|
|
||||||
first_line_ms: Option<i64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AptDecoder {
|
|
||||||
pub fn new(sample_rate: u32) -> Self {
|
|
||||||
Self {
|
|
||||||
demod: AptDemod::new(sample_rate),
|
|
||||||
sync: SyncTracker::new(),
|
|
||||||
first_line_ms: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Process a batch of PCM samples (float32, mono or will be treated as-is).
|
|
||||||
///
|
|
||||||
/// Returns the number of new lines decoded in this batch.
|
|
||||||
pub fn process_samples(&mut self, samples: &[f32]) -> u32 {
|
|
||||||
self.demod.push(samples);
|
|
||||||
|
|
||||||
let before = self.sync.lines.len() as u32;
|
|
||||||
|
|
||||||
// Move accumulated envelope output into the sync tracker
|
|
||||||
if !self.demod.out.is_empty() {
|
|
||||||
let envelope = std::mem::take(&mut self.demod.out);
|
|
||||||
self.sync.push(&envelope);
|
|
||||||
}
|
|
||||||
|
|
||||||
let after = self.sync.lines.len() as u32;
|
|
||||||
let new_lines = after - before;
|
|
||||||
|
|
||||||
if new_lines > 0 && self.first_line_ms.is_none() {
|
|
||||||
self.first_line_ms = Some(crate::now_ms());
|
|
||||||
}
|
|
||||||
|
|
||||||
new_lines
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Total number of lines decoded so far.
|
|
||||||
pub fn line_count(&self) -> u32 {
|
|
||||||
self.sync.lines.len() as u32
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Encode all accumulated lines as a JPEG image and return the result.
|
|
||||||
///
|
|
||||||
/// Performs telemetry extraction, radiometric calibration (when enough
|
|
||||||
/// lines are available for a full 128-line telemetry frame), and
|
|
||||||
/// histogram equalisation before JPEG encoding.
|
|
||||||
///
|
|
||||||
/// Returns `None` if no lines have been decoded yet.
|
|
||||||
/// Does **not** reset the decoder; call [`reset`] afterwards if needed.
|
|
||||||
pub fn finalize(&self) -> Option<AptImage> {
|
|
||||||
if self.sync.lines.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract telemetry for calibration and satellite identification
|
|
||||||
let tel = telemetry::extract_telemetry(&self.sync.lines);
|
|
||||||
|
|
||||||
// Clone lines so we can apply calibration without mutating decoder state
|
|
||||||
let mut lines = self.sync.lines.clone();
|
|
||||||
|
|
||||||
let (satellite, sensor_a, sensor_b) = if let Some(ref tf) = tel {
|
|
||||||
// Apply radiometric calibration using telemetry wedge LUTs
|
|
||||||
for line in &mut lines {
|
|
||||||
telemetry::calibrate_line_a(&mut line.pixels_a, &tf.cal_lut_a);
|
|
||||||
telemetry::calibrate_line_b(&mut line.pixels_b, &tf.cal_lut_b);
|
|
||||||
}
|
|
||||||
(tf.satellite, tf.sensor_a, tf.sensor_b)
|
|
||||||
} else {
|
|
||||||
(
|
|
||||||
Satellite::Unknown,
|
|
||||||
SensorChannel::Unknown,
|
|
||||||
SensorChannel::Unknown,
|
|
||||||
)
|
|
||||||
};
|
|
||||||
|
|
||||||
// Apply histogram equalisation per-channel for contrast enhancement
|
|
||||||
let mut all_a: Vec<u8> = lines
|
|
||||||
.iter()
|
|
||||||
.flat_map(|l| l.pixels_a.iter().copied())
|
|
||||||
.collect();
|
|
||||||
let mut all_b: Vec<u8> = lines
|
|
||||||
.iter()
|
|
||||||
.flat_map(|l| l.pixels_b.iter().copied())
|
|
||||||
.collect();
|
|
||||||
telemetry::histogram_equalize(&mut all_a);
|
|
||||||
telemetry::histogram_equalize(&mut all_b);
|
|
||||||
|
|
||||||
// Write equalised pixels back
|
|
||||||
let width_a = apt::IMAGE_A_LEN;
|
|
||||||
let width_b = apt::IMAGE_B_LEN;
|
|
||||||
for (i, line) in lines.iter_mut().enumerate() {
|
|
||||||
line.pixels_a
|
|
||||||
.copy_from_slice(&all_a[i * width_a..(i + 1) * width_a]);
|
|
||||||
line.pixels_b
|
|
||||||
.copy_from_slice(&all_b[i * width_b..(i + 1) * width_b]);
|
|
||||||
}
|
|
||||||
|
|
||||||
let png = image_enc::encode_png(&lines)?;
|
|
||||||
Some(AptImage {
|
|
||||||
png,
|
|
||||||
line_count: lines.len() as u32,
|
|
||||||
first_line_ms: self.first_line_ms.unwrap_or_else(crate::now_ms),
|
|
||||||
satellite,
|
|
||||||
sensor_a,
|
|
||||||
sensor_b,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clear all state; ready to decode a fresh pass.
|
|
||||||
pub fn reset(&mut self) {
|
|
||||||
self.demod.reset();
|
|
||||||
self.sync.reset();
|
|
||||||
self.first_line_ms = None;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,396 +0,0 @@
|
|||||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
||||||
//
|
|
||||||
// SPDX-License-Identifier: BSD-2-Clause
|
|
||||||
|
|
||||||
//! APT telemetry frame parsing, satellite identification, and channel detection.
|
|
||||||
//!
|
|
||||||
//! Each APT line contains two 45-sample telemetry blocks (one per channel).
|
|
||||||
//! The telemetry frame repeats every 128 lines and contains 16 wedges of
|
|
||||||
//! 8 lines each. Wedges 1-8 carry calibration reference levels, wedge 9
|
|
||||||
//! carries the channel ID, and wedges 10-15 carry thermal calibration data.
|
|
||||||
//! Wedge 16 is the "zero modulation" reference (black body equivalent).
|
|
||||||
|
|
||||||
use super::apt::{RawLine, IMAGE_A_LEN, IMAGE_B_LEN};
|
|
||||||
|
|
||||||
/// Lines per telemetry frame (128 lines = 16 wedges x 8 lines each).
|
|
||||||
pub const FRAME_LINES: usize = 128;
|
|
||||||
|
|
||||||
/// Lines per wedge.
|
|
||||||
pub const WEDGE_LINES: usize = 8;
|
|
||||||
|
|
||||||
/// Number of wedges in a telemetry frame.
|
|
||||||
pub const NUM_WEDGES: usize = 16;
|
|
||||||
|
|
||||||
/// The 8 calibration step values defined by the APT spec (wedges 1-8).
|
|
||||||
/// These represent known modulation levels from 1/8 to 8/8 of full scale.
|
|
||||||
pub const WEDGE_STEPS: [f32; 8] = [0.125, 0.25, 0.375, 0.5, 0.625, 0.75, 0.875, 1.0];
|
|
||||||
|
|
||||||
/// NOAA AVHRR sensor channel assignments.
|
|
||||||
///
|
|
||||||
/// The NOAA APT format transmits two channels simultaneously. Which sensors
|
|
||||||
/// are mapped to channel A and B depends on the satellite and illumination.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum SensorChannel {
|
|
||||||
/// Channel 1: Visible (0.58 - 0.68 um)
|
|
||||||
Visible1,
|
|
||||||
/// Channel 2: Near-IR (0.725 - 1.0 um)
|
|
||||||
NearIr2,
|
|
||||||
/// Channel 3A: Near-IR (1.58 - 1.64 um) — daytime only on NOAA-15/18/19
|
|
||||||
NearIr3A,
|
|
||||||
/// Channel 3B: Mid-IR thermal (3.55 - 3.93 um)
|
|
||||||
MidIr3B,
|
|
||||||
/// Channel 4: Thermal IR (10.30 - 11.30 um)
|
|
||||||
ThermalIr4,
|
|
||||||
/// Channel 5: Thermal IR (11.50 - 12.50 um) — not on NOAA-15 APT
|
|
||||||
ThermalIr5,
|
|
||||||
/// Unknown / could not be determined.
|
|
||||||
Unknown,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::fmt::Display for SensorChannel {
|
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
match self {
|
|
||||||
SensorChannel::Visible1 => write!(f, "1-VIS"),
|
|
||||||
SensorChannel::NearIr2 => write!(f, "2-NIR"),
|
|
||||||
SensorChannel::NearIr3A => write!(f, "3A-NIR"),
|
|
||||||
SensorChannel::MidIr3B => write!(f, "3B-MIR"),
|
|
||||||
SensorChannel::ThermalIr4 => write!(f, "4-TIR"),
|
|
||||||
SensorChannel::ThermalIr5 => write!(f, "5-TIR"),
|
|
||||||
SensorChannel::Unknown => write!(f, "unknown"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Identified NOAA satellite.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum Satellite {
|
|
||||||
Noaa15,
|
|
||||||
Noaa18,
|
|
||||||
Noaa19,
|
|
||||||
Unknown,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::fmt::Display for Satellite {
|
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
match self {
|
|
||||||
Satellite::Noaa15 => write!(f, "NOAA-15"),
|
|
||||||
Satellite::Noaa18 => write!(f, "NOAA-18"),
|
|
||||||
Satellite::Noaa19 => write!(f, "NOAA-19"),
|
|
||||||
Satellite::Unknown => write!(f, "Unknown"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Wedge 9 channel-ID values for each satellite.
|
|
||||||
///
|
|
||||||
/// The channel ID wedge has a distinctive grey level that encodes which
|
|
||||||
/// AVHRR sensor channel is being transmitted on that APT sub-channel.
|
|
||||||
/// Values are approximate normalised levels (0.0 - 1.0).
|
|
||||||
///
|
|
||||||
/// Reference: NOAA KLM User's Guide, Section 4.2 (APT format).
|
|
||||||
///
|
|
||||||
/// Channel A mapping:
|
|
||||||
/// Wedge 9 ≈ step 1 (1/8) → channel 1 (VIS)
|
|
||||||
/// Wedge 9 ≈ step 2 (2/8) → channel 2 (NIR)
|
|
||||||
/// Wedge 9 ≈ step 3 (3/8) → channel 3A (NIR, daytime)
|
|
||||||
///
|
|
||||||
/// Channel B mapping:
|
|
||||||
/// Wedge 9 ≈ step 4 (4/8) → channel 3B (MIR)
|
|
||||||
/// Wedge 9 ≈ step 5 (5/8) → channel 4 (TIR)
|
|
||||||
/// Wedge 9 ≈ step 6 (6/8) → channel 5 (TIR)
|
|
||||||
fn wedge9_to_sensor(normalised: f32) -> SensorChannel {
|
|
||||||
// Map to nearest step (1/8 increments)
|
|
||||||
let step = (normalised * 8.0).round() as u8;
|
|
||||||
match step {
|
|
||||||
1 => SensorChannel::Visible1,
|
|
||||||
2 => SensorChannel::NearIr2,
|
|
||||||
3 => SensorChannel::NearIr3A,
|
|
||||||
4 => SensorChannel::MidIr3B,
|
|
||||||
5 => SensorChannel::ThermalIr4,
|
|
||||||
6 => SensorChannel::ThermalIr5,
|
|
||||||
_ => SensorChannel::Unknown,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extracted telemetry data from one complete 128-line frame.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct TelemetryFrame {
|
|
||||||
/// Mean pixel value for each of the 16 wedges (normalised 0.0 - 1.0).
|
|
||||||
pub wedge_means_a: [f32; NUM_WEDGES],
|
|
||||||
pub wedge_means_b: [f32; NUM_WEDGES],
|
|
||||||
/// Detected sensor channel for sub-channel A.
|
|
||||||
pub sensor_a: SensorChannel,
|
|
||||||
/// Detected sensor channel for sub-channel B.
|
|
||||||
pub sensor_b: SensorChannel,
|
|
||||||
/// Calibration mapping: maps raw pixel [0,255] → calibrated [0.0, 1.0]
|
|
||||||
/// using wedges 1-8 as known reference levels.
|
|
||||||
pub cal_lut_a: [u8; 256],
|
|
||||||
pub cal_lut_b: [u8; 256],
|
|
||||||
/// Identified satellite (from channel pairing heuristics).
|
|
||||||
pub satellite: Satellite,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extract telemetry from raw lines, requiring at least one full 128-line frame.
|
|
||||||
///
|
|
||||||
/// Picks the best complete frame (highest overall signal quality) and parses
|
|
||||||
/// wedge values from the telemetry blocks.
|
|
||||||
pub fn extract_telemetry(lines: &[RawLine]) -> Option<TelemetryFrame> {
|
|
||||||
if lines.len() < FRAME_LINES {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use the middle complete frame for best quality (avoids pass start/end noise)
|
|
||||||
let num_frames = lines.len() / FRAME_LINES;
|
|
||||||
let frame_idx = num_frames / 2;
|
|
||||||
let frame_start = frame_idx * FRAME_LINES;
|
|
||||||
let frame = &lines[frame_start..frame_start + FRAME_LINES];
|
|
||||||
|
|
||||||
// Extract wedge means from telemetry blocks.
|
|
||||||
// Each wedge spans 8 lines; we average the telemetry samples across those lines.
|
|
||||||
let mut wedge_means_a = [0.0f32; NUM_WEDGES];
|
|
||||||
let mut wedge_means_b = [0.0f32; NUM_WEDGES];
|
|
||||||
|
|
||||||
for wedge_idx in 0..NUM_WEDGES {
|
|
||||||
let line_start = wedge_idx * WEDGE_LINES;
|
|
||||||
let mut sum_a = 0.0f32;
|
|
||||||
let mut sum_b = 0.0f32;
|
|
||||||
let mut count = 0u32;
|
|
||||||
|
|
||||||
for line_offset in 0..WEDGE_LINES {
|
|
||||||
let line = &frame[line_start + line_offset];
|
|
||||||
for &v in line.tel_a.as_ref() {
|
|
||||||
sum_a += v as f32;
|
|
||||||
count += 1;
|
|
||||||
}
|
|
||||||
for &v in line.tel_b.as_ref() {
|
|
||||||
sum_b += v as f32;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if count > 0 {
|
|
||||||
wedge_means_a[wedge_idx] = sum_a / count as f32 / 255.0;
|
|
||||||
wedge_means_b[wedge_idx] = sum_b / count as f32 / 255.0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Detect sensor channels from wedge 9 (index 8)
|
|
||||||
let sensor_a = wedge9_to_sensor(wedge_means_a[8]);
|
|
||||||
let sensor_b = wedge9_to_sensor(wedge_means_b[8]);
|
|
||||||
|
|
||||||
// Build calibration LUTs from wedges 1-8
|
|
||||||
let cal_lut_a = build_calibration_lut(&wedge_means_a);
|
|
||||||
let cal_lut_b = build_calibration_lut(&wedge_means_b);
|
|
||||||
|
|
||||||
// Identify satellite from channel pairing
|
|
||||||
let satellite = identify_satellite(sensor_a, sensor_b);
|
|
||||||
|
|
||||||
Some(TelemetryFrame {
|
|
||||||
wedge_means_a,
|
|
||||||
wedge_means_b,
|
|
||||||
sensor_a,
|
|
||||||
sensor_b,
|
|
||||||
cal_lut_a,
|
|
||||||
cal_lut_b,
|
|
||||||
satellite,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build a 256-entry calibration look-up table from wedge means.
|
|
||||||
///
|
|
||||||
/// Wedges 1-8 (indices 0-7) represent known reference levels at 1/8 to 8/8.
|
|
||||||
/// We fit a piecewise linear mapping from observed pixel values to calibrated
|
|
||||||
/// output levels, producing a corrected 0-255 output.
|
|
||||||
fn build_calibration_lut(wedge_means: &[f32; NUM_WEDGES]) -> [u8; 256] {
|
|
||||||
let mut lut = [0u8; 256];
|
|
||||||
|
|
||||||
// Collect (observed_pixel_value, target_normalised) pairs from wedges 1-8
|
|
||||||
let mut pairs: Vec<(f32, f32)> = Vec::with_capacity(8);
|
|
||||||
for i in 0..8 {
|
|
||||||
let observed = wedge_means[i] * 255.0;
|
|
||||||
let target = WEDGE_STEPS[i];
|
|
||||||
pairs.push((observed, target));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort by observed value
|
|
||||||
pairs.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
|
|
||||||
|
|
||||||
// Deduplicate (if two wedges map to nearly the same observed value)
|
|
||||||
pairs.dedup_by(|a, b| (a.0 - b.0).abs() < 0.5);
|
|
||||||
|
|
||||||
if pairs.len() < 2 {
|
|
||||||
// Not enough calibration data — return identity
|
|
||||||
for (i, v) in lut.iter_mut().enumerate() {
|
|
||||||
*v = i as u8;
|
|
||||||
}
|
|
||||||
return lut;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Piecewise linear interpolation
|
|
||||||
for (i, entry) in lut.iter_mut().enumerate() {
|
|
||||||
let x = i as f32;
|
|
||||||
let calibrated = if x <= pairs[0].0 {
|
|
||||||
pairs[0].1
|
|
||||||
} else if x >= pairs[pairs.len() - 1].0 {
|
|
||||||
pairs[pairs.len() - 1].1
|
|
||||||
} else {
|
|
||||||
let mut cal = pairs[0].1;
|
|
||||||
for w in pairs.windows(2) {
|
|
||||||
if x >= w[0].0 && x <= w[1].0 {
|
|
||||||
let t = (x - w[0].0) / (w[1].0 - w[0].0).max(1e-6);
|
|
||||||
cal = w[0].1 + t * (w[1].1 - w[0].1);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cal
|
|
||||||
};
|
|
||||||
*entry = (calibrated * 255.0).round().clamp(0.0, 255.0) as u8;
|
|
||||||
}
|
|
||||||
|
|
||||||
lut
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Identify the satellite based on channel pairing heuristics.
|
|
||||||
///
|
|
||||||
/// Typical APT channel pairings:
|
|
||||||
/// - NOAA-15: Ch A = 2 (NIR), Ch B = 4 (TIR) daytime;
|
|
||||||
/// Ch A = 3A (NIR), Ch B = 4 (TIR) alternate daytime
|
|
||||||
/// - NOAA-18: Ch A = 1 (VIS), Ch B = 4 (TIR) daytime;
|
|
||||||
/// Ch A = 3A (NIR), Ch B = 4 (TIR) alternate
|
|
||||||
/// - NOAA-19: Ch A = 2 (NIR), Ch B = 4 (TIR) daytime
|
|
||||||
///
|
|
||||||
/// Night passes typically transmit Ch 3B or Ch 4 on channel A.
|
|
||||||
fn identify_satellite(sensor_a: SensorChannel, sensor_b: SensorChannel) -> Satellite {
|
|
||||||
match (sensor_a, sensor_b) {
|
|
||||||
// NOAA-18 typically sends VIS ch1 on A
|
|
||||||
(SensorChannel::Visible1, SensorChannel::ThermalIr4) => Satellite::Noaa18,
|
|
||||||
// NOAA-15 and NOAA-19 both send NIR ch2 on A; distinguish by B channel
|
|
||||||
(SensorChannel::NearIr2, SensorChannel::ThermalIr4) => {
|
|
||||||
// Both NOAA-15 and NOAA-19 use this pairing; cannot easily distinguish
|
|
||||||
// without orbital data. Default to NOAA-19 (most common active).
|
|
||||||
Satellite::Noaa19
|
|
||||||
}
|
|
||||||
(SensorChannel::NearIr3A, SensorChannel::ThermalIr4) => Satellite::Noaa15,
|
|
||||||
(SensorChannel::NearIr2, SensorChannel::ThermalIr5) => Satellite::Noaa19,
|
|
||||||
_ => Satellite::Unknown,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Apply calibration LUT to a line's pixel data (in-place).
|
|
||||||
pub fn calibrate_line_a(pixels: &mut [u8; IMAGE_A_LEN], lut: &[u8; 256]) {
|
|
||||||
for p in pixels.iter_mut() {
|
|
||||||
*p = lut[*p as usize];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Apply calibration LUT to a line's pixel data (in-place).
|
|
||||||
pub fn calibrate_line_b(pixels: &mut [u8; IMAGE_B_LEN], lut: &[u8; 256]) {
|
|
||||||
for p in pixels.iter_mut() {
|
|
||||||
*p = lut[*p as usize];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Apply histogram equalisation to an image channel for contrast enhancement.
|
|
||||||
pub fn histogram_equalize(pixels: &mut [u8]) {
|
|
||||||
if pixels.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build histogram
|
|
||||||
let mut hist = [0u32; 256];
|
|
||||||
for &p in pixels.iter() {
|
|
||||||
hist[p as usize] += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compute CDF
|
|
||||||
let mut cdf = [0u32; 256];
|
|
||||||
cdf[0] = hist[0];
|
|
||||||
for i in 1..256 {
|
|
||||||
cdf[i] = cdf[i - 1] + hist[i];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find minimum non-zero CDF value
|
|
||||||
let cdf_min = cdf.iter().copied().find(|&v| v > 0).unwrap_or(0);
|
|
||||||
let total = pixels.len() as u32;
|
|
||||||
let denom = (total - cdf_min).max(1);
|
|
||||||
|
|
||||||
// Build equalisation LUT
|
|
||||||
let mut lut = [0u8; 256];
|
|
||||||
for i in 0..256 {
|
|
||||||
lut[i] = ((cdf[i].saturating_sub(cdf_min) as f64 / denom as f64) * 255.0).round() as u8;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply
|
|
||||||
for p in pixels.iter_mut() {
|
|
||||||
*p = lut[*p as usize];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_wedge9_to_sensor() {
|
|
||||||
assert_eq!(wedge9_to_sensor(0.125), SensorChannel::Visible1);
|
|
||||||
assert_eq!(wedge9_to_sensor(0.25), SensorChannel::NearIr2);
|
|
||||||
assert_eq!(wedge9_to_sensor(0.375), SensorChannel::NearIr3A);
|
|
||||||
assert_eq!(wedge9_to_sensor(0.5), SensorChannel::MidIr3B);
|
|
||||||
assert_eq!(wedge9_to_sensor(0.625), SensorChannel::ThermalIr4);
|
|
||||||
assert_eq!(wedge9_to_sensor(0.75), SensorChannel::ThermalIr5);
|
|
||||||
assert_eq!(wedge9_to_sensor(0.0), SensorChannel::Unknown);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_identify_satellite() {
|
|
||||||
assert_eq!(
|
|
||||||
identify_satellite(SensorChannel::Visible1, SensorChannel::ThermalIr4),
|
|
||||||
Satellite::Noaa18
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
identify_satellite(SensorChannel::NearIr2, SensorChannel::ThermalIr4),
|
|
||||||
Satellite::Noaa19
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
identify_satellite(SensorChannel::NearIr3A, SensorChannel::ThermalIr4),
|
|
||||||
Satellite::Noaa15
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_calibration_lut_identity_on_insufficient_data() {
|
|
||||||
let mut means = [0.0f32; NUM_WEDGES];
|
|
||||||
// All zeros → insufficient data → identity LUT
|
|
||||||
let lut = build_calibration_lut(&means);
|
|
||||||
for i in 0..256 {
|
|
||||||
assert_eq!(lut[i], i as u8);
|
|
||||||
}
|
|
||||||
|
|
||||||
// One non-zero wedge still insufficient (need ≥ 2 distinct)
|
|
||||||
means[0] = 0.5;
|
|
||||||
let lut = build_calibration_lut(&means);
|
|
||||||
// Still degenerate
|
|
||||||
assert!(lut[0] == lut[0]); // trivially true, but confirms no panic
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_histogram_equalize_uniform() {
|
|
||||||
// Uniform distribution should remain roughly unchanged
|
|
||||||
let mut pixels: Vec<u8> = (0..=255).collect();
|
|
||||||
histogram_equalize(&mut pixels);
|
|
||||||
// After equalization, values should span full range
|
|
||||||
assert_eq!(*pixels.first().unwrap(), 0);
|
|
||||||
assert_eq!(*pixels.last().unwrap(), 255);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_sensor_channel_display() {
|
|
||||||
assert_eq!(format!("{}", SensorChannel::Visible1), "1-VIS");
|
|
||||||
assert_eq!(format!("{}", SensorChannel::ThermalIr4), "4-TIR");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_satellite_display() {
|
|
||||||
assert_eq!(format!("{}", Satellite::Noaa15), "NOAA-15");
|
|
||||||
assert_eq!(format!("{}", Satellite::Noaa19), "NOAA-19");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -520,7 +520,6 @@ async fn async_init() -> DynResult<AppState> {
|
|||||||
history.push_back((now, None, message));
|
history.push_back((now, None, message));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
DecodedMessage::WxsatImage(_) => {}
|
|
||||||
DecodedMessage::LrptImage(_) => {}
|
DecodedMessage::LrptImage(_) => {}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1235,7 +1235,6 @@ mod tests {
|
|||||||
cw_auto: true,
|
cw_auto: true,
|
||||||
cw_wpm: 15,
|
cw_wpm: 15,
|
||||||
cw_tone_hz: 700,
|
cw_tone_hz: 700,
|
||||||
wxsat_decode_enabled: false,
|
|
||||||
lrpt_decode_enabled: false,
|
lrpt_decode_enabled: false,
|
||||||
filter: None,
|
filter: None,
|
||||||
spectrum: None,
|
spectrum: None,
|
||||||
|
|||||||
@@ -420,6 +420,7 @@ mod tests {
|
|||||||
ft4_decode_enabled: false,
|
ft4_decode_enabled: false,
|
||||||
ft2_decode_enabled: false,
|
ft2_decode_enabled: false,
|
||||||
wspr_decode_enabled: false,
|
wspr_decode_enabled: false,
|
||||||
|
lrpt_decode_enabled: false,
|
||||||
cw_auto: true,
|
cw_auto: true,
|
||||||
cw_wpm: 15,
|
cw_wpm: 15,
|
||||||
cw_tone_hz: 700,
|
cw_tone_hz: 700,
|
||||||
|
|||||||
@@ -215,7 +215,6 @@ function applyAuthRestrictions() {
|
|||||||
"ft4-decode-toggle-btn",
|
"ft4-decode-toggle-btn",
|
||||||
"ft2-decode-toggle-btn",
|
"ft2-decode-toggle-btn",
|
||||||
"wspr-decode-toggle-btn",
|
"wspr-decode-toggle-btn",
|
||||||
"sat-decode-toggle-btn",
|
|
||||||
"lrpt-decode-toggle-btn",
|
"lrpt-decode-toggle-btn",
|
||||||
"hf-aprs-decode-toggle-btn",
|
"hf-aprs-decode-toggle-btn",
|
||||||
"cw-auto",
|
"cw-auto",
|
||||||
@@ -371,7 +370,6 @@ const _decoderToggles = {
|
|||||||
ft2: { el: document.getElementById("ft2-decode-toggle-btn"), last: null },
|
ft2: { el: document.getElementById("ft2-decode-toggle-btn"), last: null },
|
||||||
wspr: { el: document.getElementById("wspr-decode-toggle-btn"), last: null },
|
wspr: { el: document.getElementById("wspr-decode-toggle-btn"), last: null },
|
||||||
hfAprs: { el: document.getElementById("hf-aprs-decode-toggle-btn"), last: null },
|
hfAprs: { el: document.getElementById("hf-aprs-decode-toggle-btn"), last: null },
|
||||||
sat: { el: document.getElementById("sat-decode-toggle-btn"), last: null },
|
|
||||||
lrpt: { el: document.getElementById("lrpt-decode-toggle-btn"), last: null },
|
lrpt: { el: document.getElementById("lrpt-decode-toggle-btn"), last: null },
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -387,7 +385,7 @@ function syncDecoderToggle(entry, enabled, label) {
|
|||||||
// Cached About-tab decoder status elements — avoids 8× getElementById per render().
|
// Cached About-tab decoder status elements — avoids 8× getElementById per render().
|
||||||
const _aboutDecEls = [
|
const _aboutDecEls = [
|
||||||
"about-dec-ft8", "about-dec-ft4", "about-dec-ft2", "about-dec-wspr",
|
"about-dec-ft8", "about-dec-ft4", "about-dec-ft2", "about-dec-wspr",
|
||||||
"about-dec-cw", "about-dec-aprs", "about-dec-sat", "about-dec-lrpt",
|
"about-dec-cw", "about-dec-aprs", "about-dec-lrpt",
|
||||||
].map((id) => ({ el: document.getElementById(id), last: null }));
|
].map((id) => ({ el: document.getElementById(id), last: null }));
|
||||||
|
|
||||||
function syncAboutDecoder(idx, enabled) {
|
function syncAboutDecoder(idx, enabled) {
|
||||||
@@ -3270,7 +3268,6 @@ function render(update) {
|
|||||||
syncDecoderToggle(_decoderToggles.ft2, !!update.ft2_decode_enabled, "FT2");
|
syncDecoderToggle(_decoderToggles.ft2, !!update.ft2_decode_enabled, "FT2");
|
||||||
syncDecoderToggle(_decoderToggles.wspr, !!update.wspr_decode_enabled, "WSPR");
|
syncDecoderToggle(_decoderToggles.wspr, !!update.wspr_decode_enabled, "WSPR");
|
||||||
syncDecoderToggle(_decoderToggles.hfAprs, !!update.hf_aprs_decode_enabled, "HF APRS");
|
syncDecoderToggle(_decoderToggles.hfAprs, !!update.hf_aprs_decode_enabled, "HF APRS");
|
||||||
syncDecoderToggle(_decoderToggles.sat, !!update.wxsat_decode_enabled, "NOAA APT");
|
|
||||||
syncDecoderToggle(_decoderToggles.lrpt, !!update.lrpt_decode_enabled, "Meteor LRPT");
|
syncDecoderToggle(_decoderToggles.lrpt, !!update.lrpt_decode_enabled, "Meteor LRPT");
|
||||||
if (window.updateSatLiveState) window.updateSatLiveState(update);
|
if (window.updateSatLiveState) window.updateSatLiveState(update);
|
||||||
const cwAutoEl = document.getElementById("cw-auto");
|
const cwAutoEl = document.getElementById("cw-auto");
|
||||||
@@ -3447,8 +3444,7 @@ function render(update) {
|
|||||||
syncAboutDecoder(3, !!update.wspr_decode_enabled);
|
syncAboutDecoder(3, !!update.wspr_decode_enabled);
|
||||||
syncAboutDecoder(4, !!update.cw_decode_enabled);
|
syncAboutDecoder(4, !!update.cw_decode_enabled);
|
||||||
syncAboutDecoder(5, !!(update.aprs_decode_enabled || update.hf_aprs_decode_enabled));
|
syncAboutDecoder(5, !!(update.aprs_decode_enabled || update.hf_aprs_decode_enabled));
|
||||||
syncAboutDecoder(6, !!update.wxsat_decode_enabled);
|
syncAboutDecoder(6, !!update.lrpt_decode_enabled);
|
||||||
syncAboutDecoder(7, !!update.lrpt_decode_enabled);
|
|
||||||
|
|
||||||
// About — Integrations card
|
// About — Integrations card
|
||||||
if (update.pskreporter_status) {
|
if (update.pskreporter_status) {
|
||||||
@@ -5717,7 +5713,7 @@ window.addSatMapOverlay = function(msg) {
|
|||||||
mapMarkers.add(overlay);
|
mapMarkers.add(overlay);
|
||||||
|
|
||||||
// Build a popup for the overlay
|
// Build a popup for the overlay
|
||||||
const decoder = msg.mcu_count != null ? "Meteor LRPT" : "NOAA APT";
|
const decoder = "Meteor LRPT";
|
||||||
const satellite = msg.satellite || "Unknown";
|
const satellite = msg.satellite || "Unknown";
|
||||||
const ts = msg.ts_ms ? new Date(msg.ts_ms).toLocaleString() : "";
|
const ts = msg.ts_ms ? new Date(msg.ts_ms).toLocaleString() : "";
|
||||||
overlay.bindPopup(
|
overlay.bindPopup(
|
||||||
@@ -8632,7 +8628,6 @@ function dispatchDecodeMessage(msg) {
|
|||||||
if (msg.type === "ft4" && window.onServerFt4) window.onServerFt4(msg);
|
if (msg.type === "ft4" && window.onServerFt4) window.onServerFt4(msg);
|
||||||
if (msg.type === "ft2" && window.onServerFt2) window.onServerFt2(msg);
|
if (msg.type === "ft2" && window.onServerFt2) window.onServerFt2(msg);
|
||||||
if (msg.type === "wspr" && window.onServerWspr) window.onServerWspr(msg);
|
if (msg.type === "wspr" && window.onServerWspr) window.onServerWspr(msg);
|
||||||
if (msg.type === "wxsat_image" && window.onServerSatImage) window.onServerSatImage(msg);
|
|
||||||
if (msg.type === "lrpt_image" && window.onServerLrptImage) window.onServerLrptImage(msg);
|
if (msg.type === "lrpt_image" && window.onServerLrptImage) window.onServerLrptImage(msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -577,7 +577,7 @@
|
|||||||
<div class="plugin-item">
|
<div class="plugin-item">
|
||||||
<strong>Weather Satellite Decoder</strong>
|
<strong>Weather Satellite Decoder</strong>
|
||||||
<div style="color:var(--text-muted); font-size:0.85rem; margin-top:0.2rem;">
|
<div style="color:var(--text-muted); font-size:0.85rem; margin-top:0.2rem;">
|
||||||
Decodes NOAA APT (137 MHz FM) and Meteor-M LRPT (137 MHz QPSK) weather satellite imagery.
|
Decodes Meteor-M LRPT (137 MHz QPSK) weather satellite imagery.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -805,7 +805,6 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="subtab-sat" class="sub-tab-panel" style="display:none;">
|
<div id="subtab-sat" class="sub-tab-panel" style="display:none;">
|
||||||
<div class="ft8-controls">
|
<div class="ft8-controls">
|
||||||
<button id="sat-decode-toggle-btn" type="button">Enable NOAA APT</button>
|
|
||||||
<button id="lrpt-decode-toggle-btn" type="button">Enable Meteor LRPT</button>
|
<button id="lrpt-decode-toggle-btn" type="button">Enable Meteor LRPT</button>
|
||||||
<small id="sat-status" style="color:var(--text-muted);">Waiting for satellite pass</small>
|
<small id="sat-status" style="color:var(--text-muted);">Waiting for satellite pass</small>
|
||||||
</div>
|
</div>
|
||||||
@@ -818,10 +817,6 @@
|
|||||||
<!-- Live view -->
|
<!-- Live view -->
|
||||||
<div id="sat-live-view">
|
<div id="sat-live-view">
|
||||||
<div class="sat-live-grid">
|
<div class="sat-live-grid">
|
||||||
<div class="sat-live-card">
|
|
||||||
<span class="sat-live-label">NOAA APT</span>
|
|
||||||
<span id="sat-apt-state" class="sat-live-value sat-state-idle">Idle</span>
|
|
||||||
</div>
|
|
||||||
<div class="sat-live-card">
|
<div class="sat-live-card">
|
||||||
<span class="sat-live-label">Meteor LRPT</span>
|
<span class="sat-live-label">Meteor LRPT</span>
|
||||||
<span id="sat-lrpt-state" class="sat-live-value sat-state-idle">Idle</span>
|
<span id="sat-lrpt-state" class="sat-live-value sat-state-idle">Idle</span>
|
||||||
@@ -829,10 +824,6 @@
|
|||||||
</div>
|
</div>
|
||||||
<div style="margin:0.5rem 0;">
|
<div style="margin:0.5rem 0;">
|
||||||
<div style="color:var(--text-muted); font-size:0.82rem; line-height:1.5;">
|
<div style="color:var(--text-muted); font-size:0.82rem; line-height:1.5;">
|
||||||
<strong>NOAA APT</strong> — Automatic Picture Transmission from NOAA-15/18/19 (137 MHz FM).
|
|
||||||
Dual-channel visible + infrared imagery at 4160 samples/sec with telemetry-based radiometric calibration.
|
|
||||||
</div>
|
|
||||||
<div style="color:var(--text-muted); font-size:0.82rem; line-height:1.5; margin-top:0.3rem;">
|
|
||||||
<strong>Meteor-M LRPT</strong> — Low Rate Picture Transmission from Meteor-M N2-3/N2-4 (137 MHz QPSK at 72 kbps).
|
<strong>Meteor-M LRPT</strong> — Low Rate Picture Transmission from Meteor-M N2-3/N2-4 (137 MHz QPSK at 72 kbps).
|
||||||
Multi-channel CCSDS-framed imagery (APIDs 64–69) with RGB composite output.
|
Multi-channel CCSDS-framed imagery (APIDs 64–69) with RGB composite output.
|
||||||
</div>
|
</div>
|
||||||
@@ -842,14 +833,13 @@
|
|||||||
<!-- History view -->
|
<!-- History view -->
|
||||||
<div id="sat-history-view" style="display:none;">
|
<div id="sat-history-view" style="display:none;">
|
||||||
<div class="sat-history-controls">
|
<div class="sat-history-controls">
|
||||||
<input id="sat-filter" class="ft8-filter" type="text" placeholder="Filter (e.g. NOAA-18, Meteor, APT)" />
|
<input id="sat-filter" class="ft8-filter" type="text" placeholder="Filter (e.g. Meteor, LRPT)" />
|
||||||
<select id="sat-sort" class="sat-sort-select">
|
<select id="sat-sort" class="sat-sort-select">
|
||||||
<option value="newest">Newest first</option>
|
<option value="newest">Newest first</option>
|
||||||
<option value="oldest">Oldest first</option>
|
<option value="oldest">Oldest first</option>
|
||||||
</select>
|
</select>
|
||||||
<select id="sat-type-filter" class="sat-sort-select">
|
<select id="sat-type-filter" class="sat-sort-select">
|
||||||
<option value="all">All types</option>
|
<option value="all">All types</option>
|
||||||
<option value="apt">NOAA APT only</option>
|
|
||||||
<option value="lrpt">Meteor LRPT only</option>
|
<option value="lrpt">Meteor LRPT only</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -1091,9 +1081,6 @@
|
|||||||
<label class="bm-label">Satellite preset
|
<label class="bm-label">Satellite preset
|
||||||
<select id="scheduler-sat-preset" class="status-input" aria-label="Satellite preset">
|
<select id="scheduler-sat-preset" class="status-input" aria-label="Satellite preset">
|
||||||
<option value="">— custom —</option>
|
<option value="">— custom —</option>
|
||||||
<option value="NOAA 15|25338">NOAA 15 (137.620 MHz APT)</option>
|
|
||||||
<option value="NOAA 18|28654">NOAA 18 (137.9125 MHz APT)</option>
|
|
||||||
<option value="NOAA 19|33591">NOAA 19 (137.100 MHz APT)</option>
|
|
||||||
<option value="METEOR-M2 3|57166">Meteor-M2 3 (137.900 MHz LRPT)</option>
|
<option value="METEOR-M2 3|57166">Meteor-M2 3 (137.900 MHz LRPT)</option>
|
||||||
<option value="METEOR-M2-4|59051">Meteor-M2-4 (137.900 MHz LRPT)</option>
|
<option value="METEOR-M2-4|59051">Meteor-M2-4 (137.900 MHz LRPT)</option>
|
||||||
</select>
|
</select>
|
||||||
@@ -1299,7 +1286,6 @@
|
|||||||
<tr><td>WSPR</td><td id="about-dec-wspr" class="about-status-off">Off</td></tr>
|
<tr><td>WSPR</td><td id="about-dec-wspr" class="about-status-off">Off</td></tr>
|
||||||
<tr><td>CW</td><td id="about-dec-cw" class="about-status-off">Off</td></tr>
|
<tr><td>CW</td><td id="about-dec-cw" class="about-status-off">Off</td></tr>
|
||||||
<tr><td>APRS</td><td id="about-dec-aprs" class="about-status-off">Off</td></tr>
|
<tr><td>APRS</td><td id="about-dec-aprs" class="about-status-off">Off</td></tr>
|
||||||
<tr><td>NOAA APT</td><td id="about-dec-sat" class="about-status-off">Off</td></tr>
|
|
||||||
<tr><td>Meteor LRPT</td><td id="about-dec-lrpt" class="about-status-off">Off</td></tr>
|
<tr><td>Meteor LRPT</td><td id="about-dec-lrpt" class="about-status-off">Off</td></tr>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ const satDom = {
|
|||||||
filterInput: document.getElementById("sat-filter"),
|
filterInput: document.getElementById("sat-filter"),
|
||||||
sortSelect: document.getElementById("sat-sort"),
|
sortSelect: document.getElementById("sat-sort"),
|
||||||
typeFilter: document.getElementById("sat-type-filter"),
|
typeFilter: document.getElementById("sat-type-filter"),
|
||||||
aptState: document.getElementById("sat-apt-state"),
|
|
||||||
lrptState: document.getElementById("sat-lrpt-state"),
|
lrptState: document.getElementById("sat-lrpt-state"),
|
||||||
viewLiveBtn: document.getElementById("sat-view-live"),
|
viewLiveBtn: document.getElementById("sat-view-live"),
|
||||||
viewHistoryBtn: document.getElementById("sat-view-history"),
|
viewHistoryBtn: document.getElementById("sat-view-history"),
|
||||||
@@ -84,16 +83,10 @@ satDom.viewHistoryBtn?.addEventListener("click", () => switchSatView("history"))
|
|||||||
satDom.viewPredBtn?.addEventListener("click", () => switchSatView("predictions"));
|
satDom.viewPredBtn?.addEventListener("click", () => switchSatView("predictions"));
|
||||||
|
|
||||||
// ── Live view: decoder state ────────────────────────────────────────
|
// ── Live view: decoder state ────────────────────────────────────────
|
||||||
let _lastSatAptOn = null, _lastSatLrptOn = null;
|
let _lastSatLrptOn = null;
|
||||||
window.updateSatLiveState = function (update) {
|
window.updateSatLiveState = function (update) {
|
||||||
if (!satDom.aptState || !satDom.lrptState) return;
|
if (!satDom.lrptState) return;
|
||||||
const aptOn = !!update.wxsat_decode_enabled;
|
|
||||||
const lrptOn = !!update.lrpt_decode_enabled;
|
const lrptOn = !!update.lrpt_decode_enabled;
|
||||||
if (aptOn !== _lastSatAptOn) {
|
|
||||||
_lastSatAptOn = aptOn;
|
|
||||||
satDom.aptState.textContent = aptOn ? "Listening" : "Idle";
|
|
||||||
satDom.aptState.className = "sat-live-value " + (aptOn ? "sat-state-listening" : "sat-state-idle");
|
|
||||||
}
|
|
||||||
if (lrptOn !== _lastSatLrptOn) {
|
if (lrptOn !== _lastSatLrptOn) {
|
||||||
_lastSatLrptOn = lrptOn;
|
_lastSatLrptOn = lrptOn;
|
||||||
satDom.lrptState.textContent = lrptOn ? "Listening" : "Idle";
|
satDom.lrptState.textContent = lrptOn ? "Listening" : "Idle";
|
||||||
@@ -111,11 +104,11 @@ function renderSatLatestCard() {
|
|||||||
|
|
||||||
const img = satImageHistory[0];
|
const img = satImageHistory[0];
|
||||||
const decoder = img._decoder || "unknown";
|
const decoder = img._decoder || "unknown";
|
||||||
const typeName = decoder === "lrpt" ? "Meteor LRPT" : "NOAA APT";
|
const typeName = "Meteor LRPT";
|
||||||
const satellite = img.satellite || "";
|
const satellite = img.satellite || "";
|
||||||
const channels = img.channels || img.channel_a || "";
|
const channels = img.channels || img.channel_a || "";
|
||||||
const lines = img.line_count || img.mcu_count || 0;
|
const lines = img.mcu_count || img.line_count || 0;
|
||||||
const unit = decoder === "lrpt" ? "MCU rows" : "lines";
|
const unit = "MCU rows";
|
||||||
const ts = img._ts || "--";
|
const ts = img._ts || "--";
|
||||||
const date = img._tsMs ? new Date(img._tsMs).toLocaleDateString() : "";
|
const date = img._tsMs ? new Date(img._tsMs).toLocaleDateString() : "";
|
||||||
|
|
||||||
@@ -143,13 +136,12 @@ function getSatFilteredHistory() {
|
|||||||
let items = satImageHistory;
|
let items = satImageHistory;
|
||||||
|
|
||||||
const typeVal = satDom.typeFilter ? satDom.typeFilter.value : "all";
|
const typeVal = satDom.typeFilter ? satDom.typeFilter.value : "all";
|
||||||
if (typeVal === "apt") items = items.filter((i) => i._decoder === "apt");
|
if (typeVal === "lrpt") items = items.filter((i) => i._decoder === "lrpt");
|
||||||
else if (typeVal === "lrpt") items = items.filter((i) => i._decoder === "lrpt");
|
|
||||||
|
|
||||||
if (satFilterText) {
|
if (satFilterText) {
|
||||||
items = items.filter((i) => {
|
items = items.filter((i) => {
|
||||||
const haystack = [
|
const haystack = [
|
||||||
i._decoder === "lrpt" ? "meteor lrpt" : "noaa apt",
|
"meteor lrpt",
|
||||||
i.satellite || "",
|
i.satellite || "",
|
||||||
i.channels || "",
|
i.channels || "",
|
||||||
i.channel_a || "",
|
i.channel_a || "",
|
||||||
@@ -170,14 +162,14 @@ function renderSatHistoryRow(img) {
|
|||||||
row.className = "sat-history-row";
|
row.className = "sat-history-row";
|
||||||
|
|
||||||
const decoder = img._decoder || "unknown";
|
const decoder = img._decoder || "unknown";
|
||||||
const typeName = decoder === "lrpt" ? "Meteor LRPT" : "NOAA APT";
|
const typeName = "Meteor LRPT";
|
||||||
const typeClass = decoder === "lrpt" ? "sat-type-lrpt" : "sat-type-apt";
|
const typeClass = "sat-type-lrpt";
|
||||||
const ts = img._ts || "--";
|
const ts = img._ts || "--";
|
||||||
const date = img._tsMs ? new Date(img._tsMs).toLocaleDateString([], { month: "short", day: "numeric" }) : "";
|
const date = img._tsMs ? new Date(img._tsMs).toLocaleDateString([], { month: "short", day: "numeric" }) : "";
|
||||||
const satellite = img.satellite || "--";
|
const satellite = img.satellite || "--";
|
||||||
const channels = decoder === "lrpt" ? (img.channels || "--") : (img.channel_a && img.channel_b ? `A:${img.channel_a} B:${img.channel_b}` : img.channel_a || "--");
|
const channels = img.channels || "--";
|
||||||
const lines = img.line_count || img.mcu_count || 0;
|
const lines = img.mcu_count || img.line_count || 0;
|
||||||
const unit = decoder === "lrpt" ? "MCU" : "ln";
|
const unit = "MCU";
|
||||||
let link = img.path
|
let link = img.path
|
||||||
? `<a href="${img.path}" target="_blank" style="color:var(--accent);">PNG</a>`
|
? `<a href="${img.path}" target="_blank" style="color:var(--accent);">PNG</a>`
|
||||||
: "--";
|
: "--";
|
||||||
@@ -241,14 +233,6 @@ function addSatImage(img, decoder) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Server callbacks ────────────────────────────────────────────────
|
// ── Server callbacks ────────────────────────────────────────────────
|
||||||
window.onServerSatImage = function (msg) {
|
|
||||||
if (satDom.status) satDom.status.textContent = "Image received (NOAA APT)";
|
|
||||||
addSatImage(msg, "apt");
|
|
||||||
if (msg.geo_bounds && msg.path && window.addSatMapOverlay) {
|
|
||||||
window.addSatMapOverlay(msg);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.onServerLrptImage = function (msg) {
|
window.onServerLrptImage = function (msg) {
|
||||||
if (satDom.status) satDom.status.textContent = "Image received (Meteor LRPT)";
|
if (satDom.status) satDom.status.textContent = "Image received (Meteor LRPT)";
|
||||||
addSatImage(msg, "lrpt");
|
addSatImage(msg, "lrpt");
|
||||||
@@ -271,16 +255,6 @@ window.pruneSatHistoryView = function () {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ── Toggle buttons ──────────────────────────────────────────────────
|
// ── Toggle buttons ──────────────────────────────────────────────────
|
||||||
const satDecodeToggleBtn = document.getElementById("sat-decode-toggle-btn");
|
|
||||||
satDecodeToggleBtn?.addEventListener("click", async () => {
|
|
||||||
try {
|
|
||||||
await window.takeSchedulerControlForDecoderDisable?.(satDecodeToggleBtn);
|
|
||||||
await postPath("/toggle_wxsat_decode");
|
|
||||||
} catch (e) {
|
|
||||||
console.error("SAT toggle failed", e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const lrptDecodeToggleBtn = document.getElementById("lrpt-decode-toggle-btn");
|
const lrptDecodeToggleBtn = document.getElementById("lrpt-decode-toggle-btn");
|
||||||
lrptDecodeToggleBtn?.addEventListener("click", async () => {
|
lrptDecodeToggleBtn?.addEventListener("click", async () => {
|
||||||
try {
|
try {
|
||||||
@@ -305,7 +279,6 @@ document
|
|||||||
.getElementById("settings-clear-sat-history")
|
.getElementById("settings-clear-sat-history")
|
||||||
?.addEventListener("click", async () => {
|
?.addEventListener("click", async () => {
|
||||||
try {
|
try {
|
||||||
await postPath("/clear_wxsat_decode");
|
|
||||||
await postPath("/clear_lrpt_decode");
|
await postPath("/clear_lrpt_decode");
|
||||||
window.resetSatHistoryView();
|
window.resetSatHistoryView();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -4606,7 +4606,6 @@ button:focus-visible, input:focus-visible, select:focus-visible {
|
|||||||
.sat-history-row { display: grid; grid-template-columns: 7rem 5.5rem 9rem 6rem 4.5rem 1fr; gap: 0.25rem; padding: 0.35rem 0.4rem; font-size: 0.82rem; border-bottom: 1px solid var(--border-faint, rgba(255,255,255,0.04)); }
|
.sat-history-row { display: grid; grid-template-columns: 7rem 5.5rem 9rem 6rem 4.5rem 1fr; gap: 0.25rem; padding: 0.35rem 0.4rem; font-size: 0.82rem; border-bottom: 1px solid var(--border-faint, rgba(255,255,255,0.04)); }
|
||||||
.sat-history-row:hover { background: var(--bg-hover, rgba(255,255,255,0.02)); }
|
.sat-history-row:hover { background: var(--bg-hover, rgba(255,255,255,0.02)); }
|
||||||
.sat-col-type { font-weight: 500; }
|
.sat-col-type { font-weight: 500; }
|
||||||
.sat-type-apt { color: #6ec6ff; }
|
|
||||||
.sat-type-lrpt { color: #b39ddb; }
|
.sat-type-lrpt { color: #b39ddb; }
|
||||||
.sat-latest-card { background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 0.4rem; padding: 0.6rem 0.75rem; }
|
.sat-latest-card { background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 0.4rem; padding: 0.6rem 0.75rem; }
|
||||||
.sat-latest-card .sat-latest-title { font-size: 0.82rem; font-weight: 600; margin-bottom: 0.25rem; }
|
.sat-latest-card .sat-latest-title { font-size: 0.82rem; font-weight: 600; margin-bottom: 0.25rem; }
|
||||||
|
|||||||
@@ -1305,21 +1305,6 @@ pub async fn toggle_wspr_decode(
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[post("/toggle_wxsat_decode")]
|
|
||||||
pub async fn toggle_wxsat_decode(
|
|
||||||
query: web::Query<RemoteQuery>,
|
|
||||||
state: web::Data<watch::Receiver<RigState>>,
|
|
||||||
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
|
||||||
) -> Result<HttpResponse, Error> {
|
|
||||||
let enabled = state.get_ref().borrow().wxsat_decode_enabled;
|
|
||||||
send_command(
|
|
||||||
&rig_tx,
|
|
||||||
RigCommand::SetWxsatDecodeEnabled(!enabled),
|
|
||||||
query.into_inner().remote,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[post("/toggle_lrpt_decode")]
|
#[post("/toggle_lrpt_decode")]
|
||||||
pub async fn toggle_lrpt_decode(
|
pub async fn toggle_lrpt_decode(
|
||||||
query: web::Query<RemoteQuery>,
|
query: web::Query<RemoteQuery>,
|
||||||
@@ -1335,19 +1320,6 @@ pub async fn toggle_lrpt_decode(
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[post("/clear_wxsat_decode")]
|
|
||||||
pub async fn clear_wxsat_decode(
|
|
||||||
query: web::Query<RemoteQuery>,
|
|
||||||
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
|
||||||
) -> Result<HttpResponse, Error> {
|
|
||||||
send_command(
|
|
||||||
&rig_tx,
|
|
||||||
RigCommand::ResetWxsatDecoder,
|
|
||||||
query.into_inner().remote,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[post("/clear_lrpt_decode")]
|
#[post("/clear_lrpt_decode")]
|
||||||
pub async fn clear_lrpt_decode(
|
pub async fn clear_lrpt_decode(
|
||||||
query: web::Query<RemoteQuery>,
|
query: web::Query<RemoteQuery>,
|
||||||
@@ -2195,7 +2167,6 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
|
|||||||
.service(toggle_ft4_decode)
|
.service(toggle_ft4_decode)
|
||||||
.service(toggle_ft2_decode)
|
.service(toggle_ft2_decode)
|
||||||
.service(toggle_wspr_decode)
|
.service(toggle_wspr_decode)
|
||||||
.service(toggle_wxsat_decode)
|
|
||||||
.service(toggle_lrpt_decode)
|
.service(toggle_lrpt_decode)
|
||||||
.service(sat_passes)
|
.service(sat_passes)
|
||||||
.service(clear_ais_decode)
|
.service(clear_ais_decode)
|
||||||
@@ -2207,7 +2178,6 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
|
|||||||
.service(clear_ft4_decode)
|
.service(clear_ft4_decode)
|
||||||
.service(clear_ft2_decode)
|
.service(clear_ft2_decode)
|
||||||
.service(clear_wspr_decode)
|
.service(clear_wspr_decode)
|
||||||
.service(clear_wxsat_decode)
|
|
||||||
.service(clear_lrpt_decode)
|
.service(clear_lrpt_decode)
|
||||||
.service(select_rig)
|
.service(select_rig)
|
||||||
// Bookmark CRUD
|
// Bookmark CRUD
|
||||||
@@ -2523,14 +2493,13 @@ async fn send_command_to_rig(
|
|||||||
|
|
||||||
fn bookmark_decoder_state(
|
fn bookmark_decoder_state(
|
||||||
bookmark: &crate::server::bookmarks::Bookmark,
|
bookmark: &crate::server::bookmarks::Bookmark,
|
||||||
) -> (bool, bool, bool, bool, bool, bool, bool, bool) {
|
) -> (bool, bool, bool, bool, bool, bool, bool) {
|
||||||
let mut want_aprs = bookmark.mode.trim().eq_ignore_ascii_case("PKT");
|
let mut want_aprs = bookmark.mode.trim().eq_ignore_ascii_case("PKT");
|
||||||
let mut want_hf_aprs = false;
|
let mut want_hf_aprs = false;
|
||||||
let mut want_ft8 = false;
|
let mut want_ft8 = false;
|
||||||
let mut want_ft4 = false;
|
let mut want_ft4 = false;
|
||||||
let mut want_ft2 = false;
|
let mut want_ft2 = false;
|
||||||
let mut want_wspr = false;
|
let mut want_wspr = false;
|
||||||
let mut want_wxsat = false;
|
|
||||||
let mut want_lrpt = false;
|
let mut want_lrpt = false;
|
||||||
|
|
||||||
for decoder in bookmark
|
for decoder in bookmark
|
||||||
@@ -2545,7 +2514,6 @@ fn bookmark_decoder_state(
|
|||||||
"ft4" => want_ft4 = true,
|
"ft4" => want_ft4 = true,
|
||||||
"ft2" => want_ft2 = true,
|
"ft2" => want_ft2 = true,
|
||||||
"wspr" => want_wspr = true,
|
"wspr" => want_wspr = true,
|
||||||
"wxsat" => want_wxsat = true,
|
|
||||||
"lrpt" => want_lrpt = true,
|
"lrpt" => want_lrpt = true,
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
@@ -2558,7 +2526,6 @@ fn bookmark_decoder_state(
|
|||||||
want_ft4,
|
want_ft4,
|
||||||
want_ft2,
|
want_ft2,
|
||||||
want_wspr,
|
want_wspr,
|
||||||
want_wxsat,
|
|
||||||
want_lrpt,
|
want_lrpt,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -2627,7 +2594,7 @@ async fn apply_selected_channel(
|
|||||||
let Some(bookmark) = bookmark_store_map.get_for_rig(remote, bookmark_id) else {
|
let Some(bookmark) = bookmark_store_map.get_for_rig(remote, bookmark_id) else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
let (want_aprs, want_hf_aprs, want_ft8, want_ft4, want_ft2, want_wspr, want_wxsat, want_lrpt) =
|
let (want_aprs, want_hf_aprs, want_ft8, want_ft4, want_ft2, want_wspr, want_lrpt) =
|
||||||
bookmark_decoder_state(&bookmark);
|
bookmark_decoder_state(&bookmark);
|
||||||
let desired = [
|
let desired = [
|
||||||
RigCommand::SetAprsDecodeEnabled(want_aprs),
|
RigCommand::SetAprsDecodeEnabled(want_aprs),
|
||||||
@@ -2636,7 +2603,6 @@ async fn apply_selected_channel(
|
|||||||
RigCommand::SetFt4DecodeEnabled(want_ft4),
|
RigCommand::SetFt4DecodeEnabled(want_ft4),
|
||||||
RigCommand::SetFt2DecodeEnabled(want_ft2),
|
RigCommand::SetFt2DecodeEnabled(want_ft2),
|
||||||
RigCommand::SetWsprDecodeEnabled(want_wspr),
|
RigCommand::SetWsprDecodeEnabled(want_wspr),
|
||||||
RigCommand::SetWxsatDecodeEnabled(want_wxsat),
|
|
||||||
RigCommand::SetLrptDecodeEnabled(want_lrpt),
|
RigCommand::SetLrptDecodeEnabled(want_lrpt),
|
||||||
];
|
];
|
||||||
for cmd in desired {
|
for cmd in desired {
|
||||||
@@ -2688,7 +2654,6 @@ async fn wait_for_view(mut rx: watch::Receiver<RigState>) -> Result<RigSnapshot,
|
|||||||
ft4_decode_enabled: state.ft4_decode_enabled,
|
ft4_decode_enabled: state.ft4_decode_enabled,
|
||||||
ft2_decode_enabled: state.ft2_decode_enabled,
|
ft2_decode_enabled: state.ft2_decode_enabled,
|
||||||
wspr_decode_enabled: state.wspr_decode_enabled,
|
wspr_decode_enabled: state.wspr_decode_enabled,
|
||||||
wxsat_decode_enabled: state.wxsat_decode_enabled,
|
|
||||||
lrpt_decode_enabled: state.lrpt_decode_enabled,
|
lrpt_decode_enabled: state.lrpt_decode_enabled,
|
||||||
filter: state.filter.clone(),
|
filter: state.filter.clone(),
|
||||||
spectrum: None,
|
spectrum: None,
|
||||||
|
|||||||
@@ -554,7 +554,6 @@ pub fn start_decode_history_collector(context: Arc<FrontendRuntimeContext>) {
|
|||||||
DecodedMessage::Ft4(msg) => record_ft4(&context, msg),
|
DecodedMessage::Ft4(msg) => record_ft4(&context, msg),
|
||||||
DecodedMessage::Ft2(msg) => record_ft2(&context, msg),
|
DecodedMessage::Ft2(msg) => record_ft2(&context, msg),
|
||||||
DecodedMessage::Wspr(msg) => record_wspr(&context, msg),
|
DecodedMessage::Wspr(msg) => record_wspr(&context, msg),
|
||||||
DecodedMessage::WxsatImage(_) => {}
|
|
||||||
DecodedMessage::LrptImage(_) => {}
|
DecodedMessage::LrptImage(_) => {}
|
||||||
},
|
},
|
||||||
Err(broadcast::error::RecvError::Lagged(_)) => continue,
|
Err(broadcast::error::RecvError::Lagged(_)) => continue,
|
||||||
|
|||||||
@@ -803,7 +803,6 @@ async fn apply_scheduler_decoders(
|
|||||||
let mut want_ft4 = false;
|
let mut want_ft4 = false;
|
||||||
let mut want_ft2 = false;
|
let mut want_ft2 = false;
|
||||||
let mut want_wspr = false;
|
let mut want_wspr = false;
|
||||||
let mut want_wxsat = false;
|
|
||||||
let mut want_lrpt = false;
|
let mut want_lrpt = false;
|
||||||
|
|
||||||
let mut update_from = |bm: &crate::server::bookmarks::Bookmark| {
|
let mut update_from = |bm: &crate::server::bookmarks::Bookmark| {
|
||||||
@@ -819,7 +818,6 @@ async fn apply_scheduler_decoders(
|
|||||||
"ft4" => want_ft4 = true,
|
"ft4" => want_ft4 = true,
|
||||||
"ft2" => want_ft2 = true,
|
"ft2" => want_ft2 = true,
|
||||||
"wspr" => want_wspr = true,
|
"wspr" => want_wspr = true,
|
||||||
"wxsat" => want_wxsat = true,
|
|
||||||
"lrpt" => want_lrpt = true,
|
"lrpt" => want_lrpt = true,
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
@@ -838,7 +836,6 @@ async fn apply_scheduler_decoders(
|
|||||||
("FT4", RigCommand::SetFt4DecodeEnabled(want_ft4)),
|
("FT4", RigCommand::SetFt4DecodeEnabled(want_ft4)),
|
||||||
("FT2", RigCommand::SetFt2DecodeEnabled(want_ft2)),
|
("FT2", RigCommand::SetFt2DecodeEnabled(want_ft2)),
|
||||||
("WSPR", RigCommand::SetWsprDecodeEnabled(want_wspr)),
|
("WSPR", RigCommand::SetWsprDecodeEnabled(want_wspr)),
|
||||||
("WXSAT", RigCommand::SetWxsatDecodeEnabled(want_wxsat)),
|
|
||||||
("LRPT", RigCommand::SetLrptDecodeEnabled(want_lrpt)),
|
("LRPT", RigCommand::SetLrptDecodeEnabled(want_lrpt)),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -663,6 +663,7 @@ mod tests {
|
|||||||
ft4_decode_enabled: false,
|
ft4_decode_enabled: false,
|
||||||
ft2_decode_enabled: false,
|
ft2_decode_enabled: false,
|
||||||
wspr_decode_enabled: false,
|
wspr_decode_enabled: false,
|
||||||
|
lrpt_decode_enabled: false,
|
||||||
cw_auto: false,
|
cw_auto: false,
|
||||||
cw_wpm: 0,
|
cw_wpm: 0,
|
||||||
cw_tone_hz: 0,
|
cw_tone_hz: 0,
|
||||||
|
|||||||
@@ -66,8 +66,6 @@ pub const AUDIO_MSG_VCHAN_BW: u8 = 0x13;
|
|||||||
pub const AUDIO_MSG_FT4_DECODE: u8 = 0x14;
|
pub const AUDIO_MSG_FT4_DECODE: u8 = 0x14;
|
||||||
/// Server → client: FT2 decoded message (JSON `DecodedMessage::Ft2`).
|
/// Server → client: FT2 decoded message (JSON `DecodedMessage::Ft2`).
|
||||||
pub const AUDIO_MSG_FT2_DECODE: u8 = 0x15;
|
pub const AUDIO_MSG_FT2_DECODE: u8 = 0x15;
|
||||||
/// Server → client: weather satellite APT image complete (JSON `DecodedMessage::WxsatImage`).
|
|
||||||
pub const AUDIO_MSG_WXSAT_IMAGE: u8 = 0x16;
|
|
||||||
/// Server → client: Meteor-M LRPT image complete (JSON `DecodedMessage::LrptImage`).
|
/// Server → client: Meteor-M LRPT image complete (JSON `DecodedMessage::LrptImage`).
|
||||||
pub const AUDIO_MSG_LRPT_IMAGE: u8 = 0x17;
|
pub const AUDIO_MSG_LRPT_IMAGE: u8 = 0x17;
|
||||||
|
|
||||||
|
|||||||
@@ -28,8 +28,6 @@ pub enum DecodedMessage {
|
|||||||
Ft2(Ft8Message),
|
Ft2(Ft8Message),
|
||||||
#[serde(rename = "wspr")]
|
#[serde(rename = "wspr")]
|
||||||
Wspr(WsprMessage),
|
Wspr(WsprMessage),
|
||||||
#[serde(rename = "wxsat_image")]
|
|
||||||
WxsatImage(WxsatImage),
|
|
||||||
#[serde(rename = "lrpt_image")]
|
#[serde(rename = "lrpt_image")]
|
||||||
LrptImage(LrptImage),
|
LrptImage(LrptImage),
|
||||||
}
|
}
|
||||||
@@ -44,7 +42,6 @@ impl DecodedMessage {
|
|||||||
Self::Cw(m) => m.rig_id = Some(id),
|
Self::Cw(m) => m.rig_id = Some(id),
|
||||||
Self::Ft8(m) | Self::Ft4(m) | Self::Ft2(m) => m.rig_id = Some(id),
|
Self::Ft8(m) | Self::Ft4(m) | Self::Ft2(m) => m.rig_id = Some(id),
|
||||||
Self::Wspr(m) => m.rig_id = Some(id),
|
Self::Wspr(m) => m.rig_id = Some(id),
|
||||||
Self::WxsatImage(m) => m.rig_id = Some(id),
|
|
||||||
Self::LrptImage(m) => m.rig_id = Some(id),
|
Self::LrptImage(m) => m.rig_id = Some(id),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -58,7 +55,6 @@ impl DecodedMessage {
|
|||||||
Self::Cw(m) => m.rig_id.as_deref(),
|
Self::Cw(m) => m.rig_id.as_deref(),
|
||||||
Self::Ft8(m) | Self::Ft4(m) | Self::Ft2(m) => m.rig_id.as_deref(),
|
Self::Ft8(m) | Self::Ft4(m) | Self::Ft2(m) => m.rig_id.as_deref(),
|
||||||
Self::Wspr(m) => m.rig_id.as_deref(),
|
Self::Wspr(m) => m.rig_id.as_deref(),
|
||||||
Self::WxsatImage(m) => m.rig_id.as_deref(),
|
|
||||||
Self::LrptImage(m) => m.rig_id.as_deref(),
|
Self::LrptImage(m) => m.rig_id.as_deref(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -211,38 +207,6 @@ pub struct Ft8Message {
|
|||||||
pub message: String,
|
pub message: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A completed weather satellite APT image, saved to disk as a PNG.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct WxsatImage {
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub rig_id: Option<String>,
|
|
||||||
/// UTC timestamp (milliseconds since epoch) of pass start (first decoded line).
|
|
||||||
pub pass_start_ms: i64,
|
|
||||||
/// UTC timestamp (milliseconds since epoch) when the image was finalised.
|
|
||||||
pub pass_end_ms: i64,
|
|
||||||
/// Number of decoded image lines.
|
|
||||||
pub line_count: u32,
|
|
||||||
/// Absolute filesystem path to the saved PNG file.
|
|
||||||
pub path: String,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub ts_ms: Option<i64>,
|
|
||||||
/// Identified satellite (e.g. "NOAA-15", "NOAA-18", "NOAA-19").
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub satellite: Option<String>,
|
|
||||||
/// Sensor channel name for sub-channel A (e.g. "1-VIS", "2-NIR", "4-TIR").
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub channel_a: Option<String>,
|
|
||||||
/// Sensor channel name for sub-channel B.
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub channel_b: Option<String>,
|
|
||||||
/// Geographic bounds `[south, west, north, east]` for map overlay.
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub geo_bounds: Option<[f64; 4]>,
|
|
||||||
/// Ground track points `[[lat, lon], ...]` from SGP4 propagation.
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub ground_track: Option<Vec<[f64; 2]>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct WsprMessage {
|
pub struct WsprMessage {
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ pub enum TleSource {
|
|||||||
Unavailable,
|
Unavailable,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Half-swath width in km for NOAA APT / Meteor LRPT imagery.
|
/// Half-swath width in km for Meteor LRPT imagery.
|
||||||
const SWATH_HALF_WIDTH_KM: f64 = 1400.0;
|
const SWATH_HALF_WIDTH_KM: f64 = 1400.0;
|
||||||
|
|
||||||
/// Earth radius in km (WGS84 mean).
|
/// Earth radius in km (WGS84 mean).
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ pub enum RigCommand {
|
|||||||
SetFt4DecodeEnabled(bool),
|
SetFt4DecodeEnabled(bool),
|
||||||
SetFt2DecodeEnabled(bool),
|
SetFt2DecodeEnabled(bool),
|
||||||
SetWsprDecodeEnabled(bool),
|
SetWsprDecodeEnabled(bool),
|
||||||
SetWxsatDecodeEnabled(bool),
|
|
||||||
SetLrptDecodeEnabled(bool),
|
SetLrptDecodeEnabled(bool),
|
||||||
ResetAprsDecoder,
|
ResetAprsDecoder,
|
||||||
ResetHfAprsDecoder,
|
ResetHfAprsDecoder,
|
||||||
@@ -40,7 +39,6 @@ pub enum RigCommand {
|
|||||||
ResetFt4Decoder,
|
ResetFt4Decoder,
|
||||||
ResetFt2Decoder,
|
ResetFt2Decoder,
|
||||||
ResetWsprDecoder,
|
ResetWsprDecoder,
|
||||||
ResetWxsatDecoder,
|
|
||||||
ResetLrptDecoder,
|
ResetLrptDecoder,
|
||||||
SetBandwidth(u32),
|
SetBandwidth(u32),
|
||||||
SetSdrGain(f64),
|
SetSdrGain(f64),
|
||||||
|
|||||||
@@ -520,9 +520,7 @@ pub fn command_from_rig_command(cmd: RigCommand) -> Box<dyn RigCommandHandler> {
|
|||||||
| RigCommand::ResetFt4Decoder
|
| RigCommand::ResetFt4Decoder
|
||||||
| RigCommand::ResetFt2Decoder
|
| RigCommand::ResetFt2Decoder
|
||||||
| RigCommand::ResetWsprDecoder
|
| RigCommand::ResetWsprDecoder
|
||||||
| RigCommand::SetWxsatDecodeEnabled(_)
|
|
||||||
| RigCommand::SetLrptDecodeEnabled(_)
|
| RigCommand::SetLrptDecodeEnabled(_)
|
||||||
| RigCommand::ResetWxsatDecoder
|
|
||||||
| RigCommand::ResetLrptDecoder
|
| RigCommand::ResetLrptDecoder
|
||||||
| RigCommand::SetBandwidth(_)
|
| RigCommand::SetBandwidth(_)
|
||||||
| RigCommand::SetSdrGain(_)
|
| RigCommand::SetSdrGain(_)
|
||||||
|
|||||||
@@ -46,8 +46,6 @@ pub struct RigState {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub wspr_decode_enabled: bool,
|
pub wspr_decode_enabled: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub wxsat_decode_enabled: bool,
|
|
||||||
#[serde(default)]
|
|
||||||
pub lrpt_decode_enabled: bool,
|
pub lrpt_decode_enabled: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub cw_auto: bool,
|
pub cw_auto: bool,
|
||||||
@@ -82,8 +80,6 @@ pub struct RigState {
|
|||||||
#[serde(default, skip_serializing)]
|
#[serde(default, skip_serializing)]
|
||||||
pub wspr_decode_reset_seq: u64,
|
pub wspr_decode_reset_seq: u64,
|
||||||
#[serde(default, skip_serializing)]
|
#[serde(default, skip_serializing)]
|
||||||
pub wxsat_decode_reset_seq: u64,
|
|
||||||
#[serde(default, skip_serializing)]
|
|
||||||
pub lrpt_decode_reset_seq: u64,
|
pub lrpt_decode_reset_seq: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,7 +163,7 @@ impl RigState {
|
|||||||
ft4_decode_enabled: false,
|
ft4_decode_enabled: false,
|
||||||
ft2_decode_enabled: false,
|
ft2_decode_enabled: false,
|
||||||
wspr_decode_enabled: false,
|
wspr_decode_enabled: false,
|
||||||
wxsat_decode_enabled: false,
|
|
||||||
lrpt_decode_enabled: false,
|
lrpt_decode_enabled: false,
|
||||||
cw_auto: true,
|
cw_auto: true,
|
||||||
cw_wpm: 15,
|
cw_wpm: 15,
|
||||||
@@ -182,7 +178,7 @@ impl RigState {
|
|||||||
ft4_decode_reset_seq: 0,
|
ft4_decode_reset_seq: 0,
|
||||||
ft2_decode_reset_seq: 0,
|
ft2_decode_reset_seq: 0,
|
||||||
wspr_decode_reset_seq: 0,
|
wspr_decode_reset_seq: 0,
|
||||||
wxsat_decode_reset_seq: 0,
|
|
||||||
lrpt_decode_reset_seq: 0,
|
lrpt_decode_reset_seq: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -243,7 +239,6 @@ impl RigState {
|
|||||||
ft4_decode_enabled: snapshot.ft4_decode_enabled,
|
ft4_decode_enabled: snapshot.ft4_decode_enabled,
|
||||||
ft2_decode_enabled: snapshot.ft2_decode_enabled,
|
ft2_decode_enabled: snapshot.ft2_decode_enabled,
|
||||||
wspr_decode_enabled: snapshot.wspr_decode_enabled,
|
wspr_decode_enabled: snapshot.wspr_decode_enabled,
|
||||||
wxsat_decode_enabled: snapshot.wxsat_decode_enabled,
|
|
||||||
lrpt_decode_enabled: snapshot.lrpt_decode_enabled,
|
lrpt_decode_enabled: snapshot.lrpt_decode_enabled,
|
||||||
filter: snapshot.filter,
|
filter: snapshot.filter,
|
||||||
spectrum: None, // spectrum flows through /api/spectrum, not persistent state
|
spectrum: None, // spectrum flows through /api/spectrum, not persistent state
|
||||||
@@ -255,7 +250,7 @@ impl RigState {
|
|||||||
ft4_decode_reset_seq: 0,
|
ft4_decode_reset_seq: 0,
|
||||||
ft2_decode_reset_seq: 0,
|
ft2_decode_reset_seq: 0,
|
||||||
wspr_decode_reset_seq: 0,
|
wspr_decode_reset_seq: 0,
|
||||||
wxsat_decode_reset_seq: 0,
|
|
||||||
lrpt_decode_reset_seq: 0,
|
lrpt_decode_reset_seq: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -294,7 +289,6 @@ impl RigState {
|
|||||||
ft4_decode_enabled: self.ft4_decode_enabled,
|
ft4_decode_enabled: self.ft4_decode_enabled,
|
||||||
ft2_decode_enabled: self.ft2_decode_enabled,
|
ft2_decode_enabled: self.ft2_decode_enabled,
|
||||||
wspr_decode_enabled: self.wspr_decode_enabled,
|
wspr_decode_enabled: self.wspr_decode_enabled,
|
||||||
wxsat_decode_enabled: self.wxsat_decode_enabled,
|
|
||||||
lrpt_decode_enabled: self.lrpt_decode_enabled,
|
lrpt_decode_enabled: self.lrpt_decode_enabled,
|
||||||
filter: self.filter.clone(),
|
filter: self.filter.clone(),
|
||||||
spectrum: self.spectrum.clone(),
|
spectrum: self.spectrum.clone(),
|
||||||
@@ -507,8 +501,6 @@ pub struct RigSnapshot {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub wspr_decode_enabled: bool,
|
pub wspr_decode_enabled: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub wxsat_decode_enabled: bool,
|
|
||||||
#[serde(default)]
|
|
||||||
pub lrpt_decode_enabled: bool,
|
pub lrpt_decode_enabled: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub cw_auto: bool,
|
pub cw_auto: bool,
|
||||||
|
|||||||
@@ -458,7 +458,6 @@ mod tests {
|
|||||||
ft4_decode_enabled: false,
|
ft4_decode_enabled: false,
|
||||||
ft2_decode_enabled: false,
|
ft2_decode_enabled: false,
|
||||||
wspr_decode_enabled: false,
|
wspr_decode_enabled: false,
|
||||||
wxsat_decode_enabled: false,
|
|
||||||
lrpt_decode_enabled: false,
|
lrpt_decode_enabled: false,
|
||||||
cw_auto: false,
|
cw_auto: false,
|
||||||
cw_wpm: 0,
|
cw_wpm: 0,
|
||||||
|
|||||||
@@ -57,13 +57,9 @@ pub fn client_command_to_rig(cmd: ClientCommand) -> RigCommand {
|
|||||||
ClientCommand::ResetFt4Decoder => RigCommand::ResetFt4Decoder,
|
ClientCommand::ResetFt4Decoder => RigCommand::ResetFt4Decoder,
|
||||||
ClientCommand::ResetFt2Decoder => RigCommand::ResetFt2Decoder,
|
ClientCommand::ResetFt2Decoder => RigCommand::ResetFt2Decoder,
|
||||||
ClientCommand::ResetWsprDecoder => RigCommand::ResetWsprDecoder,
|
ClientCommand::ResetWsprDecoder => RigCommand::ResetWsprDecoder,
|
||||||
ClientCommand::SetWxsatDecodeEnabled { enabled } => {
|
|
||||||
RigCommand::SetWxsatDecodeEnabled(enabled)
|
|
||||||
}
|
|
||||||
ClientCommand::SetLrptDecodeEnabled { enabled } => {
|
ClientCommand::SetLrptDecodeEnabled { enabled } => {
|
||||||
RigCommand::SetLrptDecodeEnabled(enabled)
|
RigCommand::SetLrptDecodeEnabled(enabled)
|
||||||
}
|
}
|
||||||
ClientCommand::ResetWxsatDecoder => RigCommand::ResetWxsatDecoder,
|
|
||||||
ClientCommand::ResetLrptDecoder => RigCommand::ResetLrptDecoder,
|
ClientCommand::ResetLrptDecoder => RigCommand::ResetLrptDecoder,
|
||||||
ClientCommand::SetBandwidth { bandwidth_hz } => RigCommand::SetBandwidth(bandwidth_hz),
|
ClientCommand::SetBandwidth { bandwidth_hz } => RigCommand::SetBandwidth(bandwidth_hz),
|
||||||
ClientCommand::SetSdrGain { gain_db } => RigCommand::SetSdrGain(gain_db),
|
ClientCommand::SetSdrGain { gain_db } => RigCommand::SetSdrGain(gain_db),
|
||||||
@@ -133,13 +129,9 @@ pub fn rig_command_to_client(cmd: RigCommand) -> ClientCommand {
|
|||||||
RigCommand::ResetFt4Decoder => ClientCommand::ResetFt4Decoder,
|
RigCommand::ResetFt4Decoder => ClientCommand::ResetFt4Decoder,
|
||||||
RigCommand::ResetFt2Decoder => ClientCommand::ResetFt2Decoder,
|
RigCommand::ResetFt2Decoder => ClientCommand::ResetFt2Decoder,
|
||||||
RigCommand::ResetWsprDecoder => ClientCommand::ResetWsprDecoder,
|
RigCommand::ResetWsprDecoder => ClientCommand::ResetWsprDecoder,
|
||||||
RigCommand::SetWxsatDecodeEnabled(enabled) => {
|
|
||||||
ClientCommand::SetWxsatDecodeEnabled { enabled }
|
|
||||||
}
|
|
||||||
RigCommand::SetLrptDecodeEnabled(enabled) => {
|
RigCommand::SetLrptDecodeEnabled(enabled) => {
|
||||||
ClientCommand::SetLrptDecodeEnabled { enabled }
|
ClientCommand::SetLrptDecodeEnabled { enabled }
|
||||||
}
|
}
|
||||||
RigCommand::ResetWxsatDecoder => ClientCommand::ResetWxsatDecoder,
|
|
||||||
RigCommand::ResetLrptDecoder => ClientCommand::ResetLrptDecoder,
|
RigCommand::ResetLrptDecoder => ClientCommand::ResetLrptDecoder,
|
||||||
RigCommand::SetBandwidth(bandwidth_hz) => ClientCommand::SetBandwidth { bandwidth_hz },
|
RigCommand::SetBandwidth(bandwidth_hz) => ClientCommand::SetBandwidth { bandwidth_hz },
|
||||||
RigCommand::SetSdrGain(gain_db) => ClientCommand::SetSdrGain { gain_db },
|
RigCommand::SetSdrGain(gain_db) => ClientCommand::SetSdrGain { gain_db },
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ pub enum ClientCommand {
|
|||||||
SetFt4DecodeEnabled { enabled: bool },
|
SetFt4DecodeEnabled { enabled: bool },
|
||||||
SetFt2DecodeEnabled { enabled: bool },
|
SetFt2DecodeEnabled { enabled: bool },
|
||||||
SetWsprDecodeEnabled { enabled: bool },
|
SetWsprDecodeEnabled { enabled: bool },
|
||||||
SetWxsatDecodeEnabled { enabled: bool },
|
|
||||||
SetLrptDecodeEnabled { enabled: bool },
|
SetLrptDecodeEnabled { enabled: bool },
|
||||||
ResetAprsDecoder,
|
ResetAprsDecoder,
|
||||||
ResetHfAprsDecoder,
|
ResetHfAprsDecoder,
|
||||||
@@ -46,7 +45,6 @@ pub enum ClientCommand {
|
|||||||
ResetFt4Decoder,
|
ResetFt4Decoder,
|
||||||
ResetFt2Decoder,
|
ResetFt2Decoder,
|
||||||
ResetWsprDecoder,
|
ResetWsprDecoder,
|
||||||
ResetWxsatDecoder,
|
|
||||||
ResetLrptDecoder,
|
ResetLrptDecoder,
|
||||||
SetBandwidth { bandwidth_hz: u32 },
|
SetBandwidth { bandwidth_hz: u32 },
|
||||||
SetSdrGain { gain_db: f64 },
|
SetSdrGain { gain_db: f64 },
|
||||||
|
|||||||
+4
-298
@@ -29,11 +29,11 @@ use trx_core::audio::{
|
|||||||
AUDIO_MSG_RX_FRAME, AUDIO_MSG_STREAM_INFO, AUDIO_MSG_TX_FRAME, AUDIO_MSG_VCHAN_ALLOCATED,
|
AUDIO_MSG_RX_FRAME, AUDIO_MSG_STREAM_INFO, AUDIO_MSG_TX_FRAME, AUDIO_MSG_VCHAN_ALLOCATED,
|
||||||
AUDIO_MSG_VCHAN_BW, AUDIO_MSG_VCHAN_DESTROYED, AUDIO_MSG_VCHAN_FREQ, AUDIO_MSG_VCHAN_MODE,
|
AUDIO_MSG_VCHAN_BW, AUDIO_MSG_VCHAN_DESTROYED, AUDIO_MSG_VCHAN_FREQ, AUDIO_MSG_VCHAN_MODE,
|
||||||
AUDIO_MSG_VCHAN_REMOVE, AUDIO_MSG_VCHAN_SUB, AUDIO_MSG_VCHAN_UNSUB, AUDIO_MSG_VDES_DECODE,
|
AUDIO_MSG_VCHAN_REMOVE, AUDIO_MSG_VCHAN_SUB, AUDIO_MSG_VCHAN_UNSUB, AUDIO_MSG_VDES_DECODE,
|
||||||
AUDIO_MSG_WSPR_DECODE, AUDIO_MSG_WXSAT_IMAGE,
|
AUDIO_MSG_WSPR_DECODE,
|
||||||
};
|
};
|
||||||
use trx_core::decode::{
|
use trx_core::decode::{
|
||||||
AisMessage, AprsPacket, CwEvent, DecodedMessage, Ft8Message, LrptImage, VdesMessage,
|
AisMessage, AprsPacket, CwEvent, DecodedMessage, Ft8Message, LrptImage, VdesMessage,
|
||||||
WsprMessage, WxsatImage,
|
WsprMessage,
|
||||||
};
|
};
|
||||||
use trx_core::rig::state::{RigMode, RigState};
|
use trx_core::rig::state::{RigMode, RigState};
|
||||||
use trx_core::vchan::SharedVChanManager;
|
use trx_core::vchan::SharedVChanManager;
|
||||||
@@ -41,7 +41,6 @@ use trx_cw::CwDecoder;
|
|||||||
use trx_ftx::Ft8Decoder;
|
use trx_ftx::Ft8Decoder;
|
||||||
use trx_vdes::VdesDecoder;
|
use trx_vdes::VdesDecoder;
|
||||||
use trx_wspr::WsprDecoder;
|
use trx_wspr::WsprDecoder;
|
||||||
use trx_wxsat::noaa::AptDecoder;
|
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::config::AudioConfig;
|
use crate::config::AudioConfig;
|
||||||
@@ -54,12 +53,9 @@ const VDES_HISTORY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
|
|||||||
const CW_HISTORY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
|
const CW_HISTORY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
|
||||||
const FT8_HISTORY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
|
const FT8_HISTORY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
|
||||||
const WSPR_HISTORY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
|
const WSPR_HISTORY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
|
||||||
const WXSAT_HISTORY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
|
|
||||||
const LRPT_HISTORY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
|
const LRPT_HISTORY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
|
||||||
/// Silence timeout before auto-finalising an LRPT pass (30 s without new MCUs).
|
/// Silence timeout before auto-finalising an LRPT pass (30 s without new MCUs).
|
||||||
const LRPT_PASS_SILENCE_TIMEOUT: Duration = Duration::from_secs(30);
|
const LRPT_PASS_SILENCE_TIMEOUT: Duration = Duration::from_secs(30);
|
||||||
/// Silence timeout before auto-finalising a wxsat pass (30 s without new lines).
|
|
||||||
const WXSAT_PASS_SILENCE_TIMEOUT: Duration = Duration::from_secs(30);
|
|
||||||
const FT8_SAMPLE_RATE: u32 = 12_000;
|
const FT8_SAMPLE_RATE: u32 = 12_000;
|
||||||
const FT2_ASYNC_BUFFER_SAMPLES: usize = 45_000;
|
const FT2_ASYNC_BUFFER_SAMPLES: usize = 45_000;
|
||||||
const FT2_ASYNC_TRIGGER_SAMPLES: usize = 9_000;
|
const FT2_ASYNC_TRIGGER_SAMPLES: usize = 9_000;
|
||||||
@@ -216,7 +212,6 @@ pub struct DecoderHistories {
|
|||||||
pub ft4: Mutex<VecDeque<(Instant, Ft8Message)>>,
|
pub ft4: Mutex<VecDeque<(Instant, Ft8Message)>>,
|
||||||
pub ft2: Mutex<VecDeque<(Instant, Ft8Message)>>,
|
pub ft2: Mutex<VecDeque<(Instant, Ft8Message)>>,
|
||||||
pub wspr: Mutex<VecDeque<(Instant, WsprMessage)>>,
|
pub wspr: Mutex<VecDeque<(Instant, WsprMessage)>>,
|
||||||
pub wxsat: Mutex<VecDeque<(Instant, WxsatImage)>>,
|
|
||||||
pub lrpt: Mutex<VecDeque<(Instant, LrptImage)>>,
|
pub lrpt: Mutex<VecDeque<(Instant, LrptImage)>>,
|
||||||
/// Approximate total entry count across all decoders, maintained
|
/// Approximate total entry count across all decoders, maintained
|
||||||
/// atomically so `estimated_total_count()` avoids 9 lock acquisitions.
|
/// atomically so `estimated_total_count()` avoids 9 lock acquisitions.
|
||||||
@@ -235,7 +230,6 @@ impl DecoderHistories {
|
|||||||
ft4: Mutex::new(VecDeque::new()),
|
ft4: Mutex::new(VecDeque::new()),
|
||||||
ft2: Mutex::new(VecDeque::new()),
|
ft2: Mutex::new(VecDeque::new()),
|
||||||
wspr: Mutex::new(VecDeque::new()),
|
wspr: Mutex::new(VecDeque::new()),
|
||||||
wxsat: Mutex::new(VecDeque::new()),
|
|
||||||
lrpt: Mutex::new(VecDeque::new()),
|
lrpt: Mutex::new(VecDeque::new()),
|
||||||
total_count: AtomicUsize::new(0),
|
total_count: AtomicUsize::new(0),
|
||||||
})
|
})
|
||||||
@@ -596,45 +590,6 @@ impl DecoderHistories {
|
|||||||
self.adjust_total_count(before, 0);
|
self.adjust_total_count(before, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- WXSAT ---
|
|
||||||
|
|
||||||
fn prune_wxsat(history: &mut VecDeque<(Instant, WxsatImage)>) {
|
|
||||||
let cutoff = Instant::now() - WXSAT_HISTORY_RETENTION;
|
|
||||||
while let Some((ts, _)) = history.front() {
|
|
||||||
if *ts < cutoff {
|
|
||||||
history.pop_front();
|
|
||||||
} else {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn record_wxsat_image(&self, mut img: WxsatImage) {
|
|
||||||
if img.ts_ms.is_none() {
|
|
||||||
img.ts_ms = Some(current_timestamp_ms());
|
|
||||||
}
|
|
||||||
let mut h = self.wxsat.lock().unwrap_or_else(|e| e.into_inner());
|
|
||||||
let before = h.len();
|
|
||||||
h.push_back((Instant::now(), img));
|
|
||||||
Self::prune_wxsat(&mut h);
|
|
||||||
self.adjust_total_count(before, h.len());
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn snapshot_wxsat_history(&self) -> Vec<WxsatImage> {
|
|
||||||
let mut h = self.wxsat.lock().unwrap_or_else(|e| e.into_inner());
|
|
||||||
let before = h.len();
|
|
||||||
Self::prune_wxsat(&mut h);
|
|
||||||
self.adjust_total_count(before, h.len());
|
|
||||||
h.iter().map(|(_, img)| img.clone()).collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn clear_wxsat_history(&self) {
|
|
||||||
let mut h = self.wxsat.lock().unwrap_or_else(|e| e.into_inner());
|
|
||||||
let before = h.len();
|
|
||||||
h.clear();
|
|
||||||
self.adjust_total_count(before, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- LRPT ---
|
// --- LRPT ---
|
||||||
|
|
||||||
fn prune_lrpt(history: &mut VecDeque<(Instant, LrptImage)>) {
|
fn prune_lrpt(history: &mut VecDeque<(Instant, LrptImage)>) {
|
||||||
@@ -2444,250 +2399,6 @@ pub async fn run_wspr_decoder(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Weather satellite APT decoder task
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Decode weather satellite APT images from FM-demodulated audio.
|
|
||||||
///
|
|
||||||
/// The task is idle until `state.wxsat_decode_enabled` becomes `true`.
|
|
||||||
/// When the user disables the decoder (or 30 s of silence elapses with no
|
|
||||||
/// new decoded lines), the accumulated image is encoded as PNG and saved to
|
|
||||||
/// `output_dir/<YYYY-MM-DD_HH-MM-SS>.png`.
|
|
||||||
pub async fn run_wxsat_decoder(
|
|
||||||
sample_rate: u32,
|
|
||||||
channels: u16,
|
|
||||||
mut pcm_rx: broadcast::Receiver<Vec<f32>>,
|
|
||||||
mut state_rx: watch::Receiver<RigState>,
|
|
||||||
decode_tx: broadcast::Sender<DecodedMessage>,
|
|
||||||
histories: Arc<DecoderHistories>,
|
|
||||||
output_dir: std::path::PathBuf,
|
|
||||||
) {
|
|
||||||
info!("wxsat decoder started ({}Hz, {} ch)", sample_rate, channels);
|
|
||||||
let mut decoder = AptDecoder::new(sample_rate);
|
|
||||||
let mut last_reset_seq: u64 = 0;
|
|
||||||
let mut active = state_rx.borrow().wxsat_decode_enabled;
|
|
||||||
// Instant of the last time new lines were decoded (for auto-finalise)
|
|
||||||
let mut last_line_at = tokio::time::Instant::now();
|
|
||||||
|
|
||||||
if active {
|
|
||||||
pcm_rx = pcm_rx.resubscribe();
|
|
||||||
}
|
|
||||||
|
|
||||||
loop {
|
|
||||||
if !active {
|
|
||||||
match state_rx.changed().await {
|
|
||||||
Ok(()) => {
|
|
||||||
let state = state_rx.borrow();
|
|
||||||
active = state.wxsat_decode_enabled;
|
|
||||||
if active {
|
|
||||||
decoder.reset();
|
|
||||||
last_line_at = tokio::time::Instant::now();
|
|
||||||
pcm_rx = pcm_rx.resubscribe();
|
|
||||||
}
|
|
||||||
if state.wxsat_decode_reset_seq != last_reset_seq {
|
|
||||||
last_reset_seq = state.wxsat_decode_reset_seq;
|
|
||||||
decoder.reset();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(_) => break,
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let silence_deadline = last_line_at + WXSAT_PASS_SILENCE_TIMEOUT;
|
|
||||||
|
|
||||||
tokio::select! {
|
|
||||||
recv = pcm_rx.recv() => {
|
|
||||||
match recv {
|
|
||||||
Ok(frame) => {
|
|
||||||
let reset_seq = state_rx.borrow().wxsat_decode_reset_seq;
|
|
||||||
if reset_seq != last_reset_seq {
|
|
||||||
last_reset_seq = reset_seq;
|
|
||||||
decoder.reset();
|
|
||||||
pcm_rx = pcm_rx.resubscribe();
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mono = downmix_mono(frame, channels);
|
|
||||||
let new_lines = tokio::task::block_in_place(|| decoder.process_samples(&mono));
|
|
||||||
if new_lines > 0 {
|
|
||||||
last_line_at = tokio::time::Instant::now();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(broadcast::error::RecvError::Lagged(n)) => {
|
|
||||||
warn!("wxsat decoder: dropped {} PCM frames", n);
|
|
||||||
}
|
|
||||||
Err(broadcast::error::RecvError::Closed) => break,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
changed = state_rx.changed() => {
|
|
||||||
match changed {
|
|
||||||
Ok(()) => {
|
|
||||||
// Extract fields before any await so the Ref is dropped.
|
|
||||||
let (new_active, new_reset_seq) = {
|
|
||||||
let state = state_rx.borrow();
|
|
||||||
(state.wxsat_decode_enabled, state.wxsat_decode_reset_seq)
|
|
||||||
};
|
|
||||||
let was_active = active;
|
|
||||||
active = new_active;
|
|
||||||
if new_reset_seq != last_reset_seq {
|
|
||||||
last_reset_seq = new_reset_seq;
|
|
||||||
decoder.reset();
|
|
||||||
}
|
|
||||||
if was_active && !active {
|
|
||||||
// User disabled — finalise whatever we have
|
|
||||||
let (slat, slon) = {
|
|
||||||
let s = state_rx.borrow();
|
|
||||||
(s.server_latitude, s.server_longitude)
|
|
||||||
};
|
|
||||||
finalize_wxsat_pass(
|
|
||||||
&mut decoder,
|
|
||||||
&output_dir,
|
|
||||||
&decode_tx,
|
|
||||||
&histories,
|
|
||||||
slat,
|
|
||||||
slon,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
} else if !was_active && active {
|
|
||||||
decoder.reset();
|
|
||||||
last_line_at = tokio::time::Instant::now();
|
|
||||||
pcm_rx = pcm_rx.resubscribe();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(_) => break,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Auto-finalise after sustained silence (satellite pass ended)
|
|
||||||
_ = tokio::time::sleep_until(silence_deadline), if decoder.line_count() > 0 => {
|
|
||||||
info!(
|
|
||||||
"wxsat: no new lines for {}s — finalising pass ({} lines)",
|
|
||||||
WXSAT_PASS_SILENCE_TIMEOUT.as_secs(),
|
|
||||||
decoder.line_count()
|
|
||||||
);
|
|
||||||
let (slat, slon) = {
|
|
||||||
let s = state_rx.borrow();
|
|
||||||
(s.server_latitude, s.server_longitude)
|
|
||||||
};
|
|
||||||
finalize_wxsat_pass(
|
|
||||||
&mut decoder,
|
|
||||||
&output_dir,
|
|
||||||
&decode_tx,
|
|
||||||
&histories,
|
|
||||||
slat,
|
|
||||||
slon,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
// Remain active; ready for the next pass
|
|
||||||
last_line_at = tokio::time::Instant::now();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Encode all accumulated lines as PNG, write to disk, and broadcast the
|
|
||||||
/// `DecodedMessage::WxsatImage` event. No-ops if fewer than 2 lines decoded.
|
|
||||||
async fn finalize_wxsat_pass(
|
|
||||||
decoder: &mut AptDecoder,
|
|
||||||
output_dir: &std::path::Path,
|
|
||||||
decode_tx: &broadcast::Sender<DecodedMessage>,
|
|
||||||
histories: &Arc<DecoderHistories>,
|
|
||||||
station_lat: Option<f64>,
|
|
||||||
station_lon: Option<f64>,
|
|
||||||
) {
|
|
||||||
if decoder.line_count() < 2 {
|
|
||||||
decoder.reset();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let pass_end_ms = current_timestamp_ms();
|
|
||||||
|
|
||||||
let Some(apt_image) = decoder.finalize() else {
|
|
||||||
decoder.reset();
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Build output path: <output_dir>/<YYYY-MM-DD_HH-MM-SS>.png
|
|
||||||
let dt = chrono::Local::now();
|
|
||||||
let filename = dt.format("%Y-%m-%d_%H-%M-%S.png").to_string();
|
|
||||||
let path = output_dir.join(&filename);
|
|
||||||
|
|
||||||
if let Err(e) = std::fs::create_dir_all(output_dir) {
|
|
||||||
warn!(
|
|
||||||
"wxsat: failed to create output directory {:?}: {}",
|
|
||||||
output_dir, e
|
|
||||||
);
|
|
||||||
decoder.reset();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
match std::fs::write(&path, &apt_image.png) {
|
|
||||||
Ok(()) => {
|
|
||||||
let sat_str = format!("{}", apt_image.satellite);
|
|
||||||
let ch_a_str = format!("{}", apt_image.sensor_a);
|
|
||||||
let ch_b_str = format!("{}", apt_image.sensor_b);
|
|
||||||
info!(
|
|
||||||
"wxsat: saved {} ({} lines, {} bytes, {}, A={}, B={}) to {:?}",
|
|
||||||
filename,
|
|
||||||
apt_image.line_count,
|
|
||||||
apt_image.png.len(),
|
|
||||||
sat_str,
|
|
||||||
ch_a_str,
|
|
||||||
ch_b_str,
|
|
||||||
path
|
|
||||||
);
|
|
||||||
// Compute geographic bounds from SGP4 propagation
|
|
||||||
let pass_geo = trx_core::geo::compute_pass_geo(
|
|
||||||
&sat_str,
|
|
||||||
apt_image.first_line_ms,
|
|
||||||
pass_end_ms,
|
|
||||||
station_lat,
|
|
||||||
station_lon,
|
|
||||||
)
|
|
||||||
.or_else(|| {
|
|
||||||
// Fallback: use station location if available
|
|
||||||
match (station_lat, station_lon) {
|
|
||||||
(Some(lat), Some(lon)) => Some(trx_core::geo::estimate_pass_geo_from_station(
|
|
||||||
apt_image.first_line_ms,
|
|
||||||
pass_end_ms,
|
|
||||||
lat,
|
|
||||||
lon,
|
|
||||||
)),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
let (geo_bounds, ground_track) = match pass_geo {
|
|
||||||
Some(geo) => (Some(geo.bounds), Some(geo.ground_track)),
|
|
||||||
None => (None, None),
|
|
||||||
};
|
|
||||||
let img = WxsatImage {
|
|
||||||
rig_id: None,
|
|
||||||
pass_start_ms: apt_image.first_line_ms,
|
|
||||||
pass_end_ms,
|
|
||||||
line_count: apt_image.line_count,
|
|
||||||
path: path.to_string_lossy().into_owned(),
|
|
||||||
ts_ms: Some(pass_end_ms),
|
|
||||||
satellite: Some(sat_str.clone()),
|
|
||||||
channel_a: Some(ch_a_str),
|
|
||||||
channel_b: Some(ch_b_str),
|
|
||||||
geo_bounds,
|
|
||||||
ground_track,
|
|
||||||
};
|
|
||||||
if geo_bounds.is_some() {
|
|
||||||
info!("wxsat: geo-referenced {} image overlay", sat_str);
|
|
||||||
}
|
|
||||||
histories.record_wxsat_image(img.clone());
|
|
||||||
let _ = decode_tx.send(DecodedMessage::WxsatImage(img));
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
warn!("wxsat: failed to write {:?}: {}", path, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
decoder.reset();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Meteor-M LRPT decoder task
|
// Meteor-M LRPT decoder task
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -3516,11 +3227,6 @@ async fn handle_audio_client(
|
|||||||
DecodedMessage::Cw,
|
DecodedMessage::Cw,
|
||||||
AUDIO_MSG_CW_DECODE
|
AUDIO_MSG_CW_DECODE
|
||||||
);
|
);
|
||||||
push_history!(
|
|
||||||
histories.snapshot_wxsat_history(),
|
|
||||||
DecodedMessage::WxsatImage,
|
|
||||||
AUDIO_MSG_WXSAT_IMAGE
|
|
||||||
);
|
|
||||||
push_history!(
|
push_history!(
|
||||||
histories.snapshot_lrpt_history(),
|
histories.snapshot_lrpt_history(),
|
||||||
DecodedMessage::LrptImage,
|
DecodedMessage::LrptImage,
|
||||||
@@ -3609,7 +3315,7 @@ async fn handle_audio_client(
|
|||||||
DecodedMessage::Ft4(_) => AUDIO_MSG_FT4_DECODE,
|
DecodedMessage::Ft4(_) => AUDIO_MSG_FT4_DECODE,
|
||||||
DecodedMessage::Ft2(_) => AUDIO_MSG_FT2_DECODE,
|
DecodedMessage::Ft2(_) => AUDIO_MSG_FT2_DECODE,
|
||||||
DecodedMessage::Wspr(_) => AUDIO_MSG_WSPR_DECODE,
|
DecodedMessage::Wspr(_) => AUDIO_MSG_WSPR_DECODE,
|
||||||
DecodedMessage::WxsatImage(_) => AUDIO_MSG_WXSAT_IMAGE,
|
|
||||||
DecodedMessage::LrptImage(_) => AUDIO_MSG_LRPT_IMAGE,
|
DecodedMessage::LrptImage(_) => AUDIO_MSG_LRPT_IMAGE,
|
||||||
};
|
};
|
||||||
if let Ok(json) = serde_json::to_vec(&msg) {
|
if let Ok(json) = serde_json::to_vec(&msg) {
|
||||||
@@ -3638,7 +3344,7 @@ async fn handle_audio_client(
|
|||||||
DecodedMessage::Ft4(_) => AUDIO_MSG_FT4_DECODE,
|
DecodedMessage::Ft4(_) => AUDIO_MSG_FT4_DECODE,
|
||||||
DecodedMessage::Ft2(_) => AUDIO_MSG_FT2_DECODE,
|
DecodedMessage::Ft2(_) => AUDIO_MSG_FT2_DECODE,
|
||||||
DecodedMessage::Wspr(_) => AUDIO_MSG_WSPR_DECODE,
|
DecodedMessage::Wspr(_) => AUDIO_MSG_WSPR_DECODE,
|
||||||
DecodedMessage::WxsatImage(_) => AUDIO_MSG_WXSAT_IMAGE,
|
|
||||||
DecodedMessage::LrptImage(_) => AUDIO_MSG_LRPT_IMAGE,
|
DecodedMessage::LrptImage(_) => AUDIO_MSG_LRPT_IMAGE,
|
||||||
};
|
};
|
||||||
if let Ok(json) = serde_json::to_vec(&msg) {
|
if let Ok(json) = serde_json::to_vec(&msg) {
|
||||||
|
|||||||
@@ -794,25 +794,6 @@ fn spawn_rig_audio_stack(
|
|||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Spawn weather satellite APT decoder task
|
|
||||||
let wxsat_pcm_rx = pcm_tx.subscribe();
|
|
||||||
let wxsat_state_rx = state_rx.clone();
|
|
||||||
let wxsat_decode_tx = decode_tx.clone();
|
|
||||||
let wxsat_sr = rig_cfg.audio.sample_rate;
|
|
||||||
let wxsat_ch = rig_cfg.audio.channels;
|
|
||||||
let wxsat_shutdown_rx = shutdown_rx.clone();
|
|
||||||
let wxsat_histories = histories.clone();
|
|
||||||
let wxsat_output_dir = dirs::cache_dir()
|
|
||||||
.unwrap_or_else(|| std::path::PathBuf::from(".cache"))
|
|
||||||
.join("trx-rs")
|
|
||||||
.join("wxsat");
|
|
||||||
handles.push(tokio::spawn(async move {
|
|
||||||
tokio::select! {
|
|
||||||
_ = audio::run_wxsat_decoder(wxsat_sr, wxsat_ch as u16, wxsat_pcm_rx, wxsat_state_rx, wxsat_decode_tx, wxsat_histories, wxsat_output_dir) => {}
|
|
||||||
_ = wait_for_shutdown(wxsat_shutdown_rx) => {}
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Spawn Meteor-M LRPT decoder task
|
// Spawn Meteor-M LRPT decoder task
|
||||||
let lrpt_pcm_rx = pcm_tx.subscribe();
|
let lrpt_pcm_rx = pcm_tx.subscribe();
|
||||||
let lrpt_state_rx = state_rx.clone();
|
let lrpt_state_rx = state_rx.clone();
|
||||||
|
|||||||
@@ -545,24 +545,12 @@ async fn process_command(
|
|||||||
let _ = ctx.state_tx.send(ctx.state.clone());
|
let _ = ctx.state_tx.send(ctx.state.clone());
|
||||||
return snapshot_from(ctx.state);
|
return snapshot_from(ctx.state);
|
||||||
}
|
}
|
||||||
RigCommand::SetWxsatDecodeEnabled(en) => {
|
|
||||||
ctx.state.wxsat_decode_enabled = en;
|
|
||||||
info!("wxsat decode {}", if en { "enabled" } else { "disabled" });
|
|
||||||
let _ = ctx.state_tx.send(ctx.state.clone());
|
|
||||||
return snapshot_from(ctx.state);
|
|
||||||
}
|
|
||||||
RigCommand::SetLrptDecodeEnabled(en) => {
|
RigCommand::SetLrptDecodeEnabled(en) => {
|
||||||
ctx.state.lrpt_decode_enabled = en;
|
ctx.state.lrpt_decode_enabled = en;
|
||||||
info!("LRPT decode {}", if en { "enabled" } else { "disabled" });
|
info!("LRPT decode {}", if en { "enabled" } else { "disabled" });
|
||||||
let _ = ctx.state_tx.send(ctx.state.clone());
|
let _ = ctx.state_tx.send(ctx.state.clone());
|
||||||
return snapshot_from(ctx.state);
|
return snapshot_from(ctx.state);
|
||||||
}
|
}
|
||||||
RigCommand::ResetWxsatDecoder => {
|
|
||||||
ctx.histories.clear_wxsat_history();
|
|
||||||
ctx.state.wxsat_decode_reset_seq += 1;
|
|
||||||
let _ = ctx.state_tx.send(ctx.state.clone());
|
|
||||||
return snapshot_from(ctx.state);
|
|
||||||
}
|
|
||||||
RigCommand::ResetLrptDecoder => {
|
RigCommand::ResetLrptDecoder => {
|
||||||
ctx.histories.clear_lrpt_history();
|
ctx.histories.clear_lrpt_history();
|
||||||
ctx.state.lrpt_decode_reset_seq += 1;
|
ctx.state.lrpt_decode_reset_seq += 1;
|
||||||
|
|||||||
Reference in New Issue
Block a user