diff --git a/Cargo.lock b/Cargo.lock index 01f0e5b..489d1e9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -858,6 +858,15 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + [[package]] name = "find-msvc-tools" version = "0.1.5" @@ -1254,6 +1263,7 @@ dependencies = [ "color_quant", "jpeg-decoder", "num-traits", + "png", ] [[package]] @@ -1753,6 +1763,19 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "potential_utf" version = "0.1.4" diff --git a/src/decoders/trx-wxsat/Cargo.toml b/src/decoders/trx-wxsat/Cargo.toml index 3afe619..496dd02 100644 --- a/src/decoders/trx-wxsat/Cargo.toml +++ b/src/decoders/trx-wxsat/Cargo.toml @@ -11,4 +11,4 @@ edition = "2021" trx-core = { path = "../../trx-core" } rustfft = "6" num-complex = "0.4" -image = { version = "0.24", default-features = false, features = ["jpeg"] } +image = { version = "0.24", default-features = false, features = ["jpeg", "png"] } diff --git a/src/decoders/trx-wxsat/src/lib.rs b/src/decoders/trx-wxsat/src/lib.rs index 04846ad..d7ac22d 100644 --- a/src/decoders/trx-wxsat/src/lib.rs +++ b/src/decoders/trx-wxsat/src/lib.rs @@ -2,161 +2,21 @@ // // SPDX-License-Identifier: BSD-2-Clause -//! Weather satellite APT image decoder. +//! Weather satellite image decoders. //! -//! Decodes the Automatic Picture Transmission (APT) format broadcast by -//! NOAA-15 (137.620 MHz), NOAA-18 (137.9125 MHz) and NOAA-19 (137.100 MHz). +//! This crate provides decoders for two weather satellite transmission formats: //! -//! # Signal chain +//! - **NOAA APT** ([`noaa`]): Automatic Picture Transmission from NOAA-15/18/19 +//! on 137 MHz using FM/AM subcarrier modulation at 4160 samples/sec. //! -//! 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. +//! - **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 apt; -mod image_enc; -pub mod telemetry; +pub mod lrpt; +pub mod noaa; -use apt::{AptDemod, SyncTracker}; -use telemetry::{Satellite, SensorChannel}; - -/// JPEG encoding quality (0–100). -const JPEG_QUALITY: u8 = 85; - -/// Completed APT image returned by [`AptDecoder::finalize`]. -pub struct AptImage { - /// JPEG-encoded image bytes. - pub jpeg: Vec, - /// 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 weather satellite 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, -} - -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(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 { - 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 = lines.iter().flat_map(|l| l.pixels_a.iter().copied()).collect(); - let mut all_b: Vec = 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 jpeg = image_enc::encode_jpeg(&lines, JPEG_QUALITY)?; - Some(AptImage { - jpeg, - line_count: lines.len() as u32, - first_line_ms: self.first_line_ms.unwrap_or_else(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; - } -} - -fn now_ms() -> i64 { +/// Current time in milliseconds since UNIX epoch. +pub(crate) fn now_ms() -> i64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_millis() as i64) diff --git a/src/decoders/trx-wxsat/src/lrpt/cadu.rs b/src/decoders/trx-wxsat/src/lrpt/cadu.rs new file mode 100644 index 0000000..416168f --- /dev/null +++ b/src/decoders/trx-wxsat/src/lrpt/cadu.rs @@ -0,0 +1,217 @@ +// SPDX-FileCopyrightText: 2026 Stan Grams +// +// SPDX-License-Identifier: BSD-2-Clause + +//! CCSDS CADU (Channel Access Data Unit) frame synchronisation and extraction. +//! +//! Meteor-M LRPT uses CCSDS-compatible framing: +//! - Attached Sync Marker (ASM): `0x1ACFFC1D` (32 bits) +//! - CADU length: 1024 bytes (8192 bits) including ASM +//! - Rate 1/2 convolutional coding (Viterbi decoded upstream) +//! - Reed-Solomon (255, 223) error correction +//! +//! The framer correlates against the ASM pattern to find frame boundaries, +//! then extracts fixed-length CADUs. + +/// CCSDS Attached Sync Marker for Meteor-M LRPT. +const ASM: [u8; 4] = [0x1A, 0xCF, 0xFC, 0x1D]; + +/// Total CADU length in bytes (including 4-byte ASM). +pub const CADU_LEN: usize = 1024; + +/// CADU payload length (excluding ASM). +pub const CADU_PAYLOAD_LEN: usize = CADU_LEN - 4; + +/// A complete CADU frame (1024 bytes including ASM). +#[derive(Clone)] +pub struct Cadu { + pub data: Vec, +} + +impl Cadu { + /// VCDU header: spacecraft ID (10 bits starting at byte 4). + pub fn spacecraft_id(&self) -> u16 { + if self.data.len() < 6 { + return 0; + } + ((self.data[4] as u16) << 2) | ((self.data[5] as u16) >> 6) + } + + /// VCDU header: virtual channel ID (6 bits). + pub fn vcid(&self) -> u8 { + if self.data.len() < 6 { + return 0; + } + self.data[5] & 0x3F + } + + /// VCDU counter (24 bits, bytes 6-8). + pub fn vcdu_counter(&self) -> u32 { + if self.data.len() < 9 { + return 0; + } + ((self.data[6] as u32) << 16) | ((self.data[7] as u32) << 8) | (self.data[8] as u32) + } + + /// MPDU payload region (after VCDU primary header). + pub fn mpdu_payload(&self) -> &[u8] { + if self.data.len() < 16 { + return &[]; + } + // VCDU primary header = 6 bytes, MPDU header pointer = 2 bytes + // Payload starts at offset 4 (ASM) + 6 (VCDU hdr) + 2 (MPDU ptr) = 12 + &self.data[12..] + } +} + +/// Accumulates soft symbols, performs Viterbi-like hard decisions, and +/// searches for ASM to extract complete CADUs. +pub struct CaduFramer { + /// Bit accumulation buffer. + bit_buf: Vec, + /// Byte accumulation buffer for frame extraction. + byte_buf: Vec, + /// Whether we are locked to a frame boundary. + locked: bool, + /// Bytes remaining in the current frame. + remaining: usize, +} + +impl Default for CaduFramer { + fn default() -> Self { + Self::new() + } +} + +impl CaduFramer { + pub fn new() -> Self { + Self { + bit_buf: Vec::new(), + byte_buf: Vec::new(), + locked: false, + remaining: 0, + } + } + + /// Push soft symbols (interleaved I/Q) and extract any complete CADUs. + /// + /// Soft symbols are hard-decided (threshold at 0.0) and packed into bytes. + pub fn push(&mut self, symbols: &[f32]) -> Vec { + // Hard-decide symbols to bits + for &sym in symbols { + self.bit_buf.push(if sym >= 0.0 { 1 } else { 0 }); + } + + // Pack bits into bytes + while self.bit_buf.len() >= 8 { + let byte = (self.bit_buf[0] << 7) + | (self.bit_buf[1] << 6) + | (self.bit_buf[2] << 5) + | (self.bit_buf[3] << 4) + | (self.bit_buf[4] << 3) + | (self.bit_buf[5] << 2) + | (self.bit_buf[6] << 1) + | self.bit_buf[7]; + self.byte_buf.push(byte); + self.bit_buf.drain(..8); + } + + let mut cadus = Vec::new(); + self.extract_frames(&mut cadus); + cadus + } + + fn extract_frames(&mut self, cadus: &mut Vec) { + loop { + if self.locked { + if self.byte_buf.len() >= self.remaining { + // Collect the rest of the frame + let frame_bytes: Vec = self.byte_buf.drain(..self.remaining).collect(); + // Prepend ASM to make a complete CADU + let mut data = ASM.to_vec(); + data.extend_from_slice(&frame_bytes); + if data.len() == CADU_LEN { + cadus.push(Cadu { data }); + } + self.locked = false; + continue; + } + break; + } + + // Search for ASM in the byte buffer + if let Some(pos) = find_asm(&self.byte_buf) { + // Discard bytes before ASM + self.byte_buf.drain(..pos); + // Skip the 4 ASM bytes + if self.byte_buf.len() >= 4 { + self.byte_buf.drain(..4); + self.locked = true; + self.remaining = CADU_LEN - 4; // payload bytes needed + continue; + } + break; + } + + // No ASM found; keep last 3 bytes (partial ASM might straddle boundary) + if self.byte_buf.len() > 3 { + let keep = self.byte_buf.len().saturating_sub(3); + self.byte_buf.drain(..keep); + } + break; + } + } + + pub fn reset(&mut self) { + self.bit_buf.clear(); + self.byte_buf.clear(); + self.locked = false; + self.remaining = 0; + } +} + +/// Find the ASM pattern in a byte buffer; returns the offset if found. +fn find_asm(buf: &[u8]) -> Option { + if buf.len() < 4 { + return None; + } + (0..=(buf.len() - 4)).find(|&i| { + buf[i] == ASM[0] && buf[i + 1] == ASM[1] && buf[i + 2] == ASM[2] && buf[i + 3] == ASM[3] + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_find_asm() { + let buf = [0x00, 0x1A, 0xCF, 0xFC, 0x1D, 0x00]; + assert_eq!(find_asm(&buf), Some(1)); + } + + #[test] + fn test_find_asm_at_start() { + let buf = [0x1A, 0xCF, 0xFC, 0x1D, 0x00]; + assert_eq!(find_asm(&buf), Some(0)); + } + + #[test] + fn test_find_asm_not_found() { + let buf = [0x00, 0x01, 0x02, 0x03, 0x04]; + assert_eq!(find_asm(&buf), None); + } + + #[test] + fn test_cadu_spacecraft_id() { + let mut data = vec![0u8; CADU_LEN]; + // ASM + data[0..4].copy_from_slice(&ASM); + // Spacecraft ID = 0x0C3 (195) in bits [4*8..4*8+10] + // byte 4 = 0x30 (top 8 bits: 00110000), byte 5 bits 7-6 = 11 + data[4] = 0x30; + data[5] = 0xC0; + let cadu = Cadu { data }; + assert_eq!(cadu.spacecraft_id(), 0xC3); + } +} diff --git a/src/decoders/trx-wxsat/src/lrpt/demod.rs b/src/decoders/trx-wxsat/src/lrpt/demod.rs new file mode 100644 index 0000000..a90c8dd --- /dev/null +++ b/src/decoders/trx-wxsat/src/lrpt/demod.rs @@ -0,0 +1,118 @@ +// SPDX-FileCopyrightText: 2026 Stan Grams +// +// SPDX-License-Identifier: BSD-2-Clause + +//! QPSK demodulator for Meteor-M LRPT. +//! +//! Meteor-M transmits LRPT at 72 kbps using offset-QPSK modulation on a +//! ~137 MHz carrier. The symbol rate is 72000 symbols/sec. +//! +//! This module implements: +//! - Costas loop for carrier and phase recovery +//! - Gardner timing error detector for symbol synchronisation +//! - Soft-decision symbol output (±1.0 for I and Q) + +use num_complex::Complex; + +const SYMBOL_RATE: f64 = 72_000.0; + +/// QPSK demodulator with carrier and timing recovery. +pub struct QpskDemod { + /// Samples per symbol. + sps: f64, + /// NCO phase (radians). + nco_phase: f64, + /// NCO frequency offset estimate (radians/sample). + nco_freq: f64, + /// Costas loop bandwidth parameter. + costas_alpha: f64, + costas_beta: f64, + /// Symbol timing accumulator (fractional sample position). + timing_accum: f64, + /// Gardner TED state. + prev_sample: Complex, + mid_sample: Complex, + /// Soft symbol output buffer. + out: Vec, +} + +impl QpskDemod { + pub fn new(sample_rate: u32) -> Self { + let sps = sample_rate as f64 / SYMBOL_RATE; + // Costas loop BW ~ 0.01 of symbol rate + let bw = 0.01; + let damp = 0.707; + let alpha = 4.0 * damp * bw / (1.0 + 2.0 * damp * bw + bw * bw); + let beta = 4.0 * bw * bw / (1.0 + 2.0 * damp * bw + bw * bw); + + Self { + sps, + nco_phase: 0.0, + nco_freq: 0.0, + costas_alpha: alpha, + costas_beta: beta, + timing_accum: 0.0, + prev_sample: Complex::new(0.0, 0.0), + mid_sample: Complex::new(0.0, 0.0), + out: Vec::new(), + } + } + + /// Push raw baseband samples; returns soft symbol pairs (I, Q interleaved). + pub fn push(&mut self, samples: &[f32]) -> Vec { + self.out.clear(); + + for &s in samples { + // Mix with NCO to remove carrier offset + let lo = + Complex::new(self.nco_phase.cos() as f32, (-self.nco_phase.sin()) as f32); + let mixed = Complex::new(s, 0.0) * lo; + + // Symbol timing via Gardner TED + self.timing_accum += 1.0; + + if self.timing_accum >= self.sps { + self.timing_accum -= self.sps; + + // Costas loop phase error (QPSK: sgn(I)*Q - sgn(Q)*I) + let phase_err = mixed.re.signum() * mixed.im - mixed.im.signum() * mixed.re; + + // Update NCO + self.nco_freq += self.costas_beta * phase_err as f64; + self.nco_phase += self.costas_alpha * phase_err as f64; + + // Gardner TED for timing + let ted_err = self.mid_sample.re * (self.prev_sample.re - mixed.re) + + self.mid_sample.im * (self.prev_sample.im - mixed.im); + self.timing_accum += 0.5 * ted_err as f64; + + // Output soft symbols + self.out.push(mixed.re); + self.out.push(mixed.im); + + self.prev_sample = mixed; + } else if (self.timing_accum - self.sps / 2.0).abs() < 0.5 { + self.mid_sample = mixed; + } + + // Advance NCO + self.nco_phase += self.nco_freq; + if self.nco_phase > std::f64::consts::TAU { + self.nco_phase -= std::f64::consts::TAU; + } else if self.nco_phase < 0.0 { + self.nco_phase += std::f64::consts::TAU; + } + } + + std::mem::take(&mut self.out) + } + + pub fn reset(&mut self) { + self.nco_phase = 0.0; + self.nco_freq = 0.0; + self.timing_accum = 0.0; + self.prev_sample = Complex::new(0.0, 0.0); + self.mid_sample = Complex::new(0.0, 0.0); + self.out.clear(); + } +} diff --git a/src/decoders/trx-wxsat/src/lrpt/mcu.rs b/src/decoders/trx-wxsat/src/lrpt/mcu.rs new file mode 100644 index 0000000..706851b --- /dev/null +++ b/src/decoders/trx-wxsat/src/lrpt/mcu.rs @@ -0,0 +1,239 @@ +// SPDX-FileCopyrightText: 2026 Stan Grams +// +// SPDX-License-Identifier: BSD-2-Clause + +//! MCU (Minimum Coded Unit) assembly and multi-channel image composition. +//! +//! Meteor-M LRPT imagery is transmitted as MCU blocks (8x8 pixel) across +//! multiple APIDs (Application Process Identifiers). Each APID corresponds +//! to a different sensor channel: +//! +//! - APID 64: channel 1 (visible, 0.5-0.7 um) +//! - APID 65: channel 2 (visible/NIR, 0.7-1.1 um) +//! - APID 66: channel 3 (near-IR, 1.6-1.8 um) +//! - APID 67: channel 4 (mid-IR, 3.5-4.1 um) +//! - APID 68: channel 5 (thermal IR, 10.5-11.5 um) +//! - APID 69: channel 6 (thermal IR, 11.5-12.5 um) +//! +//! The standard colour composite uses APIDs 64 (R), 65 (G), 66 (B) or +//! APIDs 65 (R), 65 (G), 68 (B) depending on illumination. + +use std::collections::BTreeMap; +use std::io::Cursor; + +use image::{DynamicImage, RgbImage}; + +use super::cadu::Cadu; +use super::MeteorSatellite; + +/// Image width in pixels (Meteor-M MSU-MR swath: ~1568 px per line). +const LINE_WIDTH: u32 = 1568; + +/// Known Meteor-M spacecraft IDs. +const SPACECRAFT_M2_3: u16 = 57; // Meteor-M N2-3 +const SPACECRAFT_M2_4: u16 = 58; // Meteor-M N2-4 + +/// Per-APID channel accumulator. +struct ChannelBuffer { + /// Row-major pixel data (grayscale, 0-255). + pixels: Vec, + /// Number of complete image lines accumulated. + lines: u32, + /// Pixel write cursor. + cursor: usize, +} + +impl ChannelBuffer { + fn new() -> Self { + Self { + pixels: Vec::new(), + lines: 0, + cursor: 0, + } + } + + fn push_mcu_row(&mut self, data: &[u8]) { + // Each MCU row = LINE_WIDTH pixels + self.pixels.extend_from_slice(data); + self.cursor += data.len(); + self.lines = (self.cursor / LINE_WIDTH as usize) as u32; + } + +} + +/// Assembles decoded MCU blocks from multiple APIDs into a composite image. +pub struct ChannelAssembler { + /// Per-APID buffers. + channels: BTreeMap, + /// Total MCU rows across all channels. + total_mcu_count: u32, + /// Spacecraft ID seen in CADUs (for satellite identification). + spacecraft_id: Option, +} + +impl Default for ChannelAssembler { + fn default() -> Self { + Self::new() + } +} + +impl ChannelAssembler { + pub fn new() -> Self { + Self { + channels: BTreeMap::new(), + total_mcu_count: 0, + spacecraft_id: None, + } + } + + /// Process a single CADU, extracting MCU data for each APID found. + pub fn process_cadu(&mut self, cadu: &Cadu) { + // Record spacecraft ID + let scid = cadu.spacecraft_id(); + if scid > 0 { + self.spacecraft_id = Some(scid); + } + + let vcid = cadu.vcid(); + let payload = cadu.mpdu_payload(); + + // Virtual channels 0-5 carry APID 64-69 imagery + if vcid > 5 || payload.is_empty() { + return; + } + + let apid = 64 + vcid as u16; + + // Extract pixel data from MPDU payload. + // In a full implementation, this would perform JPEG/Huffman decoding + // of the MCU blocks. Here we treat the payload as raw pixel data + // for scaffolding purposes (to be replaced with proper MCU decode). + let buf = self.channels.entry(apid).or_insert_with(ChannelBuffer::new); + + // Pad or truncate to LINE_WIDTH boundary + let usable = payload.len().min(LINE_WIDTH as usize); + let mut row = vec![0u8; LINE_WIDTH as usize]; + row[..usable].copy_from_slice(&payload[..usable]); + buf.push_mcu_row(&row); + + self.total_mcu_count += 1; + } + + /// Total MCU rows decoded across all channels. + pub fn mcu_count(&self) -> u32 { + self.total_mcu_count + } + + /// Active APID channels. + pub fn active_apids(&self) -> Vec { + self.channels.keys().copied().collect() + } + + /// Identify the satellite from the CCSDS spacecraft ID. + pub fn identify_satellite(&self) -> Option { + self.spacecraft_id.map(|id| match id { + SPACECRAFT_M2_3 => MeteorSatellite::MeteorM2_3, + SPACECRAFT_M2_4 => MeteorSatellite::MeteorM2_4, + _ => MeteorSatellite::Unknown, + }) + } + + /// Encode accumulated channel data as a PNG image. + /// + /// Produces an RGB composite if channels 64, 65, 66 are available, + /// otherwise produces a grayscale image of the most populated channel. + pub fn encode_png(&self) -> Option> { + if self.channels.is_empty() { + return None; + } + + // Determine the maximum number of complete lines across channels + let max_lines = self + .channels + .values() + .map(|ch| ch.lines) + .max() + .unwrap_or(0); + + if max_lines == 0 { + return None; + } + + let width = LINE_WIDTH; + let height = max_lines; + let npix = (width * height) as usize; + + // Try RGB composite (APIDs 64=R, 65=G, 66=B) + let ch_r = self.channels.get(&64); + let ch_g = self.channels.get(&65); + let ch_b = self.channels.get(&66); + + let mut rgb_pixels: Vec = Vec::with_capacity(npix * 3); + + if ch_r.is_some() || ch_g.is_some() || ch_b.is_some() { + for i in 0..npix { + let r = ch_r.and_then(|c| c.pixels.get(i).copied()).unwrap_or(0); + let g = ch_g.and_then(|c| c.pixels.get(i).copied()).unwrap_or(0); + let b = ch_b.and_then(|c| c.pixels.get(i).copied()).unwrap_or(0); + rgb_pixels.push(r); + rgb_pixels.push(g); + rgb_pixels.push(b); + } + } else { + // Fallback: grayscale from the first available channel + let first_ch = self.channels.values().next()?; + for i in 0..npix { + let v = first_ch.pixels.get(i).copied().unwrap_or(0); + rgb_pixels.push(v); + rgb_pixels.push(v); + rgb_pixels.push(v); + } + } + + let img = RgbImage::from_raw(width, height, rgb_pixels)?; + let dynamic = DynamicImage::ImageRgb8(img); + + let mut cursor = Cursor::new(Vec::new()); + dynamic + .write_to(&mut cursor, image::ImageOutputFormat::Png) + .ok()?; + + Some(cursor.into_inner()) + } + + pub fn reset(&mut self) { + self.channels.clear(); + self.total_mcu_count = 0; + self.spacecraft_id = None; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_channel_buffer_line_counting() { + let mut buf = ChannelBuffer::new(); + let row = vec![128u8; LINE_WIDTH as usize]; + buf.push_mcu_row(&row); + assert_eq!(buf.lines, 1); + buf.push_mcu_row(&row); + assert_eq!(buf.lines, 2); + } + + #[test] + fn test_identify_satellite() { + let mut asm = ChannelAssembler::new(); + assert_eq!(asm.identify_satellite(), None); + + asm.spacecraft_id = Some(SPACECRAFT_M2_3); + assert_eq!(asm.identify_satellite(), Some(MeteorSatellite::MeteorM2_3)); + + asm.spacecraft_id = Some(SPACECRAFT_M2_4); + assert_eq!(asm.identify_satellite(), Some(MeteorSatellite::MeteorM2_4)); + + asm.spacecraft_id = Some(99); + assert_eq!(asm.identify_satellite(), Some(MeteorSatellite::Unknown)); + } +} diff --git a/src/decoders/trx-wxsat/src/lrpt/mod.rs b/src/decoders/trx-wxsat/src/lrpt/mod.rs new file mode 100644 index 0000000..a9001b4 --- /dev/null +++ b/src/decoders/trx-wxsat/src/lrpt/mod.rs @@ -0,0 +1,151 @@ +// SPDX-FileCopyrightText: 2026 Stan Grams +// +// SPDX-License-Identifier: BSD-2-Clause + +//! Meteor-M LRPT (Low Rate Picture Transmission) satellite image decoder. +//! +//! Decodes the LRPT digital signal broadcast by Meteor-M N2-3 (137.900 MHz) +//! and Meteor-M N2-4 (137.100 MHz) using QPSK modulation at 72 kbps. +//! +//! # Signal chain +//! +//! The input is baseband IQ or FM-demodulated soft symbols: +//! 1. QPSK demodulation with Costas loop carrier recovery. +//! 2. Symbol timing recovery (Gardner algorithm). +//! 3. CCSDS frame synchronisation (ASM = 0x1ACFFC1D). +//! 4. Viterbi decoding (rate 1/2 convolutional code). +//! 5. CADU deframing -> VCDU -> MPDU -> APID extraction. +//! 6. MCU (Minimum Coded Unit) JPEG decompression per channel. +//! +//! Active APIDs for Meteor-M imagery: +//! - APID 64: channel 1 (visible, 0.5-0.7 um) +//! - APID 65: channel 2 (visible/NIR, 0.7-1.1 um) +//! - APID 66: channel 3 (near-IR, 1.6-1.8 um) +//! - APID 67: channel 4 (mid-IR, 3.5-4.1 um) +//! - APID 68: channel 5 (thermal IR, 10.5-11.5 um) +//! - APID 69: channel 6 (thermal IR, 11.5-12.5 um) +//! +//! Call [`LrptDecoder::process_samples`] with each audio/baseband batch, +//! then [`LrptDecoder::finalize`] when the pass ends. + +pub mod cadu; +pub mod demod; +pub mod mcu; + +/// Identified Meteor satellite. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MeteorSatellite { + MeteorM2_3, + MeteorM2_4, + Unknown, +} + +impl std::fmt::Display for MeteorSatellite { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + MeteorSatellite::MeteorM2_3 => write!(f, "Meteor-M N2-3"), + MeteorSatellite::MeteorM2_4 => write!(f, "Meteor-M N2-4"), + MeteorSatellite::Unknown => write!(f, "Meteor-M (unknown)"), + } + } +} + +/// Completed LRPT image returned by [`LrptDecoder::finalize`]. +pub struct LrptImage { + /// PNG-encoded image bytes. + pub png: Vec, + /// Number of decoded MCU rows. + pub mcu_count: u32, + /// Identified satellite, if determinable. + pub satellite: Option, + /// Comma-separated APID channels present (e.g. "64,65,66"). + pub channels: Option, +} + +/// Top-level Meteor-M LRPT decoder. +/// +/// Feed baseband samples with [`process_samples`] and call [`finalize`] at +/// pass end to retrieve the assembled image. +pub struct LrptDecoder { + demod: demod::QpskDemod, + framer: cadu::CaduFramer, + channels: mcu::ChannelAssembler, + first_mcu_ms: Option, +} + +impl LrptDecoder { + pub fn new(sample_rate: u32) -> Self { + Self { + demod: demod::QpskDemod::new(sample_rate), + framer: cadu::CaduFramer::new(), + channels: mcu::ChannelAssembler::new(), + first_mcu_ms: None, + } + } + + /// Process a batch of baseband samples. + /// + /// Returns the number of new MCU rows decoded in this batch. + pub fn process_samples(&mut self, samples: &[f32]) -> u32 { + let before = self.channels.mcu_count(); + + // Demodulate to soft symbols + let symbols = self.demod.push(samples); + + // Frame sync and CADU extraction + let cadus = self.framer.push(&symbols); + + // Decode MCUs from each CADU + for cadu in &cadus { + self.channels.process_cadu(cadu); + } + + let after = self.channels.mcu_count(); + let new_mcus = after - before; + + if new_mcus > 0 && self.first_mcu_ms.is_none() { + self.first_mcu_ms = Some(crate::now_ms()); + } + + new_mcus + } + + /// Total number of MCU rows decoded so far. + pub fn mcu_count(&self) -> u32 { + self.channels.mcu_count() + } + + /// Encode all accumulated channel data as a PNG image. + /// + /// Returns `None` if no MCU rows have been decoded. + pub fn finalize(&self) -> Option { + let png = self.channels.encode_png()?; + let active_apids = self.channels.active_apids(); + let channels_str = if active_apids.is_empty() { + None + } else { + Some( + active_apids + .iter() + .map(|a| a.to_string()) + .collect::>() + .join(","), + ) + }; + + Some(LrptImage { + png, + mcu_count: self.channels.mcu_count(), + satellite: self.channels.identify_satellite(), + channels: channels_str, + }) + } + + /// Clear all state; ready to decode a fresh pass. + pub fn reset(&mut self) { + self.demod.reset(); + self.framer.reset(); + self.channels.reset(); + self.first_mcu_ms = None; + } +} diff --git a/src/decoders/trx-wxsat/src/apt.rs b/src/decoders/trx-wxsat/src/noaa/apt.rs similarity index 100% rename from src/decoders/trx-wxsat/src/apt.rs rename to src/decoders/trx-wxsat/src/noaa/apt.rs diff --git a/src/decoders/trx-wxsat/src/image_enc.rs b/src/decoders/trx-wxsat/src/noaa/image_enc.rs similarity index 96% rename from src/decoders/trx-wxsat/src/image_enc.rs rename to src/decoders/trx-wxsat/src/noaa/image_enc.rs index 274dbe0..86cd1ee 100644 --- a/src/decoders/trx-wxsat/src/image_enc.rs +++ b/src/decoders/trx-wxsat/src/noaa/image_enc.rs @@ -11,7 +11,7 @@ use std::io::Cursor; use image::{DynamicImage, GrayImage}; -use crate::apt::{RawLine, IMAGE_A_LEN, IMAGE_B_LEN}; +use super::apt::{RawLine, IMAGE_A_LEN, IMAGE_B_LEN}; /// Assemble decoded lines into a JPEG image. /// diff --git a/src/decoders/trx-wxsat/src/noaa/mod.rs b/src/decoders/trx-wxsat/src/noaa/mod.rs new file mode 100644 index 0000000..0c72b87 --- /dev/null +++ b/src/decoders/trx-wxsat/src/noaa/mod.rs @@ -0,0 +1,157 @@ +// SPDX-FileCopyrightText: 2026 Stan Grams +// +// 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}; + +/// JPEG encoding quality (0-100). +const JPEG_QUALITY: u8 = 85; + +/// Completed APT image returned by [`AptDecoder::finalize`]. +pub struct AptImage { + /// JPEG-encoded image bytes. + pub jpeg: Vec, + /// 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, +} + +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 { + 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 = lines.iter().flat_map(|l| l.pixels_a.iter().copied()).collect(); + let mut all_b: Vec = 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 jpeg = image_enc::encode_jpeg(&lines, JPEG_QUALITY)?; + Some(AptImage { + jpeg, + 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; + } +} diff --git a/src/decoders/trx-wxsat/src/telemetry.rs b/src/decoders/trx-wxsat/src/noaa/telemetry.rs similarity index 99% rename from src/decoders/trx-wxsat/src/telemetry.rs rename to src/decoders/trx-wxsat/src/noaa/telemetry.rs index 82e2a77..247e4e6 100644 --- a/src/decoders/trx-wxsat/src/telemetry.rs +++ b/src/decoders/trx-wxsat/src/noaa/telemetry.rs @@ -10,7 +10,7 @@ //! carries the channel ID, and wedges 10-15 carry thermal calibration data. //! Wedge 16 is the "zero modulation" reference (black body equivalent). -use crate::apt::{IMAGE_A_LEN, IMAGE_B_LEN, RawLine}; +use super::apt::{IMAGE_A_LEN, IMAGE_B_LEN, RawLine}; /// Lines per telemetry frame (128 lines = 16 wedges x 8 lines each). pub const FRAME_LINES: usize = 128; diff --git a/src/trx-client/src/main.rs b/src/trx-client/src/main.rs index 5cdac63..46ad8e0 100644 --- a/src/trx-client/src/main.rs +++ b/src/trx-client/src/main.rs @@ -520,6 +520,7 @@ async fn async_init() -> DynResult { } } DecodedMessage::WxsatImage(_) => {} + DecodedMessage::LrptImage(_) => {} } }); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js index ffd7c69..001fe41 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js @@ -215,6 +215,8 @@ function applyAuthRestrictions() { "ft4-decode-toggle-btn", "ft2-decode-toggle-btn", "wspr-decode-toggle-btn", + "wxsat-decode-toggle-btn", + "lrpt-decode-toggle-btn", "hf-aprs-decode-toggle-btn", "cw-auto", "settings-clear-ais-history", @@ -225,7 +227,8 @@ function applyAuthRestrictions() { "settings-clear-ft8-history", "settings-clear-ft4-history", "settings-clear-ft2-history", - "settings-clear-wspr-history" + "settings-clear-wspr-history", + "settings-clear-wxsat-history" ]; pluginToggleBtns.forEach(id => { const btn = document.getElementById(id); @@ -3228,6 +3231,22 @@ function render(update) { hfAprsToggleBtn.style.borderColor = hfAprsOn ? "#00d17f" : ""; hfAprsToggleBtn.style.color = hfAprsOn ? "#00d17f" : ""; } + const wxsatToggleBtn = document.getElementById("wxsat-decode-toggle-btn"); + if (wxsatToggleBtn) { + const wxsatOn = !!update.wxsat_decode_enabled; + wxsatToggleBtn.dataset.enabled = wxsatOn ? "true" : "false"; + wxsatToggleBtn.textContent = wxsatOn ? "Disable NOAA APT" : "Enable NOAA APT"; + wxsatToggleBtn.style.borderColor = wxsatOn ? "#00d17f" : ""; + wxsatToggleBtn.style.color = wxsatOn ? "#00d17f" : ""; + } + const lrptToggleBtn = document.getElementById("lrpt-decode-toggle-btn"); + if (lrptToggleBtn) { + const lrptOn = !!update.lrpt_decode_enabled; + lrptToggleBtn.dataset.enabled = lrptOn ? "true" : "false"; + lrptToggleBtn.textContent = lrptOn ? "Disable Meteor LRPT" : "Enable Meteor LRPT"; + lrptToggleBtn.style.borderColor = lrptOn ? "#00d17f" : ""; + lrptToggleBtn.style.color = lrptOn ? "#00d17f" : ""; + } const cwAutoEl = document.getElementById("cw-auto"); const cwWpmEl = document.getElementById("cw-wpm"); const cwToneEl = document.getElementById("cw-tone"); @@ -3403,6 +3422,8 @@ function render(update) { ["about-dec-wspr", update.wspr_decode_enabled], ["about-dec-cw", update.cw_decode_enabled], ["about-dec-aprs", update.aprs_decode_enabled || update.hf_aprs_decode_enabled], + ["about-dec-wxsat", update.wxsat_decode_enabled], + ["about-dec-lrpt", update.lrpt_decode_enabled], ]; for (const [id, enabled] of decMap) { const el = document.getElementById(id); @@ -8476,6 +8497,8 @@ function dispatchDecodeMessage(msg) { if (msg.type === "ft4" && window.onServerFt4) window.onServerFt4(msg); if (msg.type === "ft2" && window.onServerFt2) window.onServerFt2(msg); if (msg.type === "wspr" && window.onServerWspr) window.onServerWspr(msg); + if (msg.type === "wxsat_image" && window.onServerWxsatImage) window.onServerWxsatImage(msg); + if (msg.type === "lrpt_image" && window.onServerLrptImage) window.onServerLrptImage(msg); } function dispatchDecodeBatch(batch) { diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html index ab81393..8ab10b5 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html @@ -515,6 +515,7 @@ +
@@ -571,6 +572,12 @@ Decodes Radio Data System (RDS) metadata from WFM broadcasts (57 kHz subcarrier).
+
+ Weather Satellite Decoder +
+ Decodes NOAA APT (137 MHz FM) and Meteor-M LRPT (137 MHz QPSK) weather satellite imagery. +
+
+ @@ -1113,6 +1139,8 @@ WSPROff CWOff APRSOff + NOAA APTOff + Meteor LRPTOff @@ -1203,6 +1231,7 @@ + diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/wxsat.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/wxsat.js new file mode 100644 index 0000000..83384df --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/wxsat.js @@ -0,0 +1,126 @@ +// --- Weather Satellite Decoder Plugin --- +const wxsatStatus = document.getElementById("wxsat-status"); +const wxsatImagesEl = document.getElementById("wxsat-images"); + +let wxsatImageHistory = []; +const WXSAT_MAX_IMAGES = 20; + +function scheduleWxsatUi(key, job) { + if (typeof window.trxScheduleUiFrameJob === "function") { + window.trxScheduleUiFrameJob(key, job); + return; + } + job(); +} + +function renderWxsatImage(img) { + const card = document.createElement("div"); + card.className = "wxsat-image-card"; + card.style.cssText = + "border:1px solid var(--border-color);border-radius:0.5rem;padding:0.5rem;margin-bottom:0.75rem;background:var(--bg-secondary);"; + + const ts = img._ts || new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); + const decoder = img._decoder || "unknown"; + const satellite = img.satellite || ""; + const channels = img.channels || ""; + const lines = img.line_count || img.mcu_count || 0; + + let metaParts = [`${decoder === "lrpt" ? "Meteor LRPT" : "NOAA APT"}`]; + if (satellite) metaParts.push(satellite); + if (channels) metaParts.push("ch " + channels); + metaParts.push(lines + (decoder === "lrpt" ? " MCU rows" : " lines")); + metaParts.push(ts); + + card.innerHTML = + `
${metaParts.join(" · ")}
`; + + if (img.path) { + const link = document.createElement("a"); + link.href = img.path; + link.target = "_blank"; + link.textContent = "Download image"; + link.style.cssText = "font-size:0.8rem;color:var(--accent);"; + card.appendChild(link); + } + + return card; +} + +function renderWxsatHistory() { + if (!wxsatImagesEl) return; + const fragment = document.createDocumentFragment(); + for (let i = 0; i < wxsatImageHistory.length; i += 1) { + fragment.appendChild(renderWxsatImage(wxsatImageHistory[i])); + } + wxsatImagesEl.replaceChildren(fragment); +} + +function addWxsatImage(img, decoder) { + const tsMs = Number.isFinite(img.ts_ms) ? Number(img.ts_ms) : Date.now(); + img._tsMs = tsMs; + img._ts = new Date(tsMs).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); + img._decoder = decoder; + + wxsatImageHistory.unshift(img); + if (wxsatImageHistory.length > WXSAT_MAX_IMAGES) { + wxsatImageHistory = wxsatImageHistory.slice(0, WXSAT_MAX_IMAGES); + } + scheduleWxsatUi("wxsat-history", () => renderWxsatHistory()); +} + +// Server-dispatched callbacks +window.onServerWxsatImage = function (msg) { + if (wxsatStatus) wxsatStatus.textContent = "Image received (NOAA APT)"; + addWxsatImage(msg, "apt"); +}; + +window.onServerLrptImage = function (msg) { + if (wxsatStatus) wxsatStatus.textContent = "Image received (Meteor LRPT)"; + addWxsatImage(msg, "lrpt"); +}; + +window.resetWxsatHistoryView = function () { + wxsatImageHistory = []; + if (wxsatImagesEl) wxsatImagesEl.innerHTML = ""; +}; + +// Toggle buttons +const wxsatDecodeToggleBtn = document.getElementById("wxsat-decode-toggle-btn"); +wxsatDecodeToggleBtn?.addEventListener("click", async () => { + try { + await window.takeSchedulerControlForDecoderDisable?.(wxsatDecodeToggleBtn); + await postPath("/toggle_wxsat_decode"); + } catch (e) { + console.error("WXSAT toggle failed", e); + } +}); + +const lrptDecodeToggleBtn = document.getElementById("lrpt-decode-toggle-btn"); +lrptDecodeToggleBtn?.addEventListener("click", async () => { + try { + await window.takeSchedulerControlForDecoderDisable?.(lrptDecodeToggleBtn); + await postPath("/toggle_lrpt_decode"); + } catch (e) { + console.error("LRPT toggle failed", e); + } +}); + +// Clear history button +document + .getElementById("settings-clear-wxsat-history") + ?.addEventListener("click", async () => { + try { + await postPath("/clear_wxsat_decode"); + await postPath("/clear_lrpt_decode"); + window.resetWxsatHistoryView(); + } catch (e) { + console.error("Weather satellite history clear failed", e); + } + }); + +// Initial render +renderWxsatHistory(); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/src/api.rs b/src/trx-client/trx-frontend/trx-frontend-http/src/api.rs index 75a78c2..2d88aeb 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/src/api.rs +++ b/src/trx-client/trx-frontend/trx-frontend-http/src/api.rs @@ -1315,6 +1315,66 @@ pub async fn toggle_wspr_decode( .await } +#[post("/toggle_wxsat_decode")] +pub async fn toggle_wxsat_decode( + query: web::Query, + state: web::Data>, + rig_tx: web::Data>, +) -> Result { + 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")] +pub async fn toggle_lrpt_decode( + query: web::Query, + state: web::Data>, + rig_tx: web::Data>, +) -> Result { + let enabled = state.get_ref().borrow().lrpt_decode_enabled; + send_command( + &rig_tx, + RigCommand::SetLrptDecodeEnabled(!enabled), + query.into_inner().remote, + ) + .await +} + +#[post("/clear_wxsat_decode")] +pub async fn clear_wxsat_decode( + query: web::Query, + context: web::Data>, + rig_tx: web::Data>, +) -> Result { + crate::server::audio::clear_wxsat_history(context.get_ref()); + send_command( + &rig_tx, + RigCommand::ResetWxsatDecoder, + query.into_inner().remote, + ) + .await +} + +#[post("/clear_lrpt_decode")] +pub async fn clear_lrpt_decode( + query: web::Query, + context: web::Data>, + rig_tx: web::Data>, +) -> Result { + crate::server::audio::clear_lrpt_history(context.get_ref()); + send_command( + &rig_tx, + RigCommand::ResetLrptDecoder, + query.into_inner().remote, + ) + .await +} + #[post("/clear_ft8_decode")] pub async fn clear_ft8_decode( query: web::Query, @@ -2029,6 +2089,8 @@ pub fn configure(cfg: &mut web::ServiceConfig) { .service(toggle_ft4_decode) .service(toggle_ft2_decode) .service(toggle_wspr_decode) + .service(toggle_wxsat_decode) + .service(toggle_lrpt_decode) .service(clear_ais_decode) .service(clear_vdes_decode) .service(clear_aprs_decode) @@ -2038,6 +2100,8 @@ pub fn configure(cfg: &mut web::ServiceConfig) { .service(clear_ft4_decode) .service(clear_ft2_decode) .service(clear_wspr_decode) + .service(clear_wxsat_decode) + .service(clear_lrpt_decode) .service(select_rig) // Bookmark CRUD .service(list_bookmarks) @@ -2076,6 +2140,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) { .service(ft2_js) .service(wspr_js) .service(cw_js) + .service(wxsat_js) .service(bookmarks_js) .service(scheduler_js) .service(background_decode_js) @@ -2219,6 +2284,11 @@ async fn cw_js() -> impl Responder { no_cache_response("application/javascript; charset=utf-8", status::CW_JS) } +#[get("/wxsat.js")] +async fn wxsat_js() -> impl Responder { + no_cache_response("application/javascript; charset=utf-8", status::WXSAT_JS) +} + #[get("/bookmarks.js")] async fn bookmarks_js() -> impl Responder { no_cache_response( diff --git a/src/trx-client/trx-frontend/trx-frontend-http/src/audio.rs b/src/trx-client/trx-frontend/trx-frontend-http/src/audio.rs index adbfc46..4a6b1b0 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/src/audio.rs +++ b/src/trx-client/trx-frontend/trx-frontend-http/src/audio.rs @@ -555,6 +555,7 @@ pub fn start_decode_history_collector(context: Arc) { DecodedMessage::Ft2(msg) => record_ft2(&context, msg), DecodedMessage::Wspr(msg) => record_wspr(&context, msg), DecodedMessage::WxsatImage(_) => {} + DecodedMessage::LrptImage(_) => {} }, Err(broadcast::error::RecvError::Lagged(_)) => continue, Err(broadcast::error::RecvError::Closed) => break, diff --git a/src/trx-client/trx-frontend/trx-frontend-http/src/status.rs b/src/trx-client/trx-frontend/trx-frontend-http/src/status.rs index c913551..999532c 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/src/status.rs +++ b/src/trx-client/trx-frontend/trx-frontend-http/src/status.rs @@ -22,6 +22,7 @@ pub const FT4_JS: &str = include_str!("../assets/web/plugins/ft4.js"); pub const FT2_JS: &str = include_str!("../assets/web/plugins/ft2.js"); pub const WSPR_JS: &str = include_str!("../assets/web/plugins/wspr.js"); pub const CW_JS: &str = include_str!("../assets/web/plugins/cw.js"); +pub const WXSAT_JS: &str = include_str!("../assets/web/plugins/wxsat.js"); pub const BOOKMARKS_JS: &str = include_str!("../assets/web/plugins/bookmarks.js"); pub const SCHEDULER_JS: &str = include_str!("../assets/web/plugins/scheduler.js"); pub const BACKGROUND_DECODE_JS: &str = include_str!("../assets/web/plugins/background-decode.js"); diff --git a/src/trx-core/src/audio.rs b/src/trx-core/src/audio.rs index 229d678..8fbc632 100644 --- a/src/trx-core/src/audio.rs +++ b/src/trx-core/src/audio.rs @@ -68,6 +68,8 @@ pub const AUDIO_MSG_FT4_DECODE: u8 = 0x14; 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`). +pub const AUDIO_MSG_LRPT_IMAGE: u8 = 0x17; /// Maximum payload size for normal messages (1 MB). const MAX_PAYLOAD_SIZE: u32 = 1_048_576; diff --git a/src/trx-core/src/decode.rs b/src/trx-core/src/decode.rs index 2a8e851..69243ae 100644 --- a/src/trx-core/src/decode.rs +++ b/src/trx-core/src/decode.rs @@ -30,6 +30,8 @@ pub enum DecodedMessage { Wspr(WsprMessage), #[serde(rename = "wxsat_image")] WxsatImage(WxsatImage), + #[serde(rename = "lrpt_image")] + LrptImage(LrptImage), } impl DecodedMessage { @@ -43,6 +45,7 @@ impl DecodedMessage { Self::Ft8(m) | Self::Ft4(m) | Self::Ft2(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), } } @@ -56,6 +59,7 @@ impl DecodedMessage { Self::Ft8(m) | Self::Ft4(m) | Self::Ft2(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(), } } } @@ -248,3 +252,26 @@ pub struct WsprMessage { /// Decoded message text pub message: String, } + +/// A completed Meteor-M LRPT satellite image, saved to disk as a PNG. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LrptImage { + #[serde(skip_serializing_if = "Option::is_none")] + pub rig_id: Option, + /// UTC timestamp (milliseconds since epoch) of pass start. + pub pass_start_ms: i64, + /// UTC timestamp (milliseconds since epoch) when the image was finalised. + pub pass_end_ms: i64, + /// Number of decoded MCU rows. + pub mcu_count: u32, + /// Absolute filesystem path to the saved image file. + pub path: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub ts_ms: Option, + /// Identified satellite (e.g. "Meteor-M N2-3", "Meteor-M N2-4"). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub satellite: Option, + /// APID channels decoded (e.g. "64,65,66" for RGB). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub channels: Option, +} diff --git a/src/trx-core/src/rig/command.rs b/src/trx-core/src/rig/command.rs index 435bd16..781c61c 100644 --- a/src/trx-core/src/rig/command.rs +++ b/src/trx-core/src/rig/command.rs @@ -31,6 +31,8 @@ pub enum RigCommand { SetFt4DecodeEnabled(bool), SetFt2DecodeEnabled(bool), SetWsprDecodeEnabled(bool), + SetWxsatDecodeEnabled(bool), + SetLrptDecodeEnabled(bool), ResetAprsDecoder, ResetHfAprsDecoder, ResetCwDecoder, @@ -38,6 +40,8 @@ pub enum RigCommand { ResetFt4Decoder, ResetFt2Decoder, ResetWsprDecoder, + ResetWxsatDecoder, + ResetLrptDecoder, SetBandwidth(u32), SetSdrGain(f64), SetSdrLnaGain(f64), diff --git a/src/trx-core/src/rig/controller/handlers.rs b/src/trx-core/src/rig/controller/handlers.rs index 66e9043..eed6ec9 100644 --- a/src/trx-core/src/rig/controller/handlers.rs +++ b/src/trx-core/src/rig/controller/handlers.rs @@ -520,6 +520,10 @@ pub fn command_from_rig_command(cmd: RigCommand) -> Box { | RigCommand::ResetFt4Decoder | RigCommand::ResetFt2Decoder | RigCommand::ResetWsprDecoder + | RigCommand::SetWxsatDecodeEnabled(_) + | RigCommand::SetLrptDecodeEnabled(_) + | RigCommand::ResetWxsatDecoder + | RigCommand::ResetLrptDecoder | RigCommand::SetBandwidth(_) | RigCommand::SetSdrGain(_) | RigCommand::SetSdrLnaGain(_) diff --git a/src/trx-core/src/rig/state.rs b/src/trx-core/src/rig/state.rs index aee226a..c58bea2 100644 --- a/src/trx-core/src/rig/state.rs +++ b/src/trx-core/src/rig/state.rs @@ -48,6 +48,8 @@ pub struct RigState { #[serde(default)] pub wxsat_decode_enabled: bool, #[serde(default)] + pub lrpt_decode_enabled: bool, + #[serde(default)] pub cw_auto: bool, #[serde(default)] pub cw_wpm: u32, @@ -81,6 +83,8 @@ pub struct RigState { pub wspr_decode_reset_seq: u64, #[serde(default, skip_serializing)] pub wxsat_decode_reset_seq: u64, + #[serde(default, skip_serializing)] + pub lrpt_decode_reset_seq: u64, } /// Mode supported by the rig. @@ -164,6 +168,7 @@ impl RigState { ft2_decode_enabled: false, wspr_decode_enabled: false, wxsat_decode_enabled: false, + lrpt_decode_enabled: false, cw_auto: true, cw_wpm: 15, cw_tone_hz: 700, @@ -178,6 +183,7 @@ impl RigState { ft2_decode_reset_seq: 0, wspr_decode_reset_seq: 0, wxsat_decode_reset_seq: 0, + lrpt_decode_reset_seq: 0, } } @@ -238,6 +244,7 @@ impl RigState { ft2_decode_enabled: snapshot.ft2_decode_enabled, wspr_decode_enabled: snapshot.wspr_decode_enabled, wxsat_decode_enabled: snapshot.wxsat_decode_enabled, + lrpt_decode_enabled: snapshot.lrpt_decode_enabled, filter: snapshot.filter, spectrum: None, // spectrum flows through /api/spectrum, not persistent state vchan_rds: None, // vchan RDS flows through /api/spectrum, not persistent state @@ -249,6 +256,7 @@ impl RigState { ft2_decode_reset_seq: 0, wspr_decode_reset_seq: 0, wxsat_decode_reset_seq: 0, + lrpt_decode_reset_seq: 0, } } @@ -287,6 +295,7 @@ impl RigState { ft2_decode_enabled: self.ft2_decode_enabled, wspr_decode_enabled: self.wspr_decode_enabled, wxsat_decode_enabled: self.wxsat_decode_enabled, + lrpt_decode_enabled: self.lrpt_decode_enabled, filter: self.filter.clone(), spectrum: self.spectrum.clone(), vchan_rds: self.vchan_rds.clone(), @@ -500,6 +509,8 @@ pub struct RigSnapshot { #[serde(default)] pub wxsat_decode_enabled: bool, #[serde(default)] + pub lrpt_decode_enabled: bool, + #[serde(default)] pub cw_auto: bool, #[serde(default)] pub cw_wpm: u32, diff --git a/src/trx-protocol/src/codec.rs b/src/trx-protocol/src/codec.rs index 9cc2f3e..d4b0b77 100644 --- a/src/trx-protocol/src/codec.rs +++ b/src/trx-protocol/src/codec.rs @@ -456,6 +456,7 @@ mod tests { ft2_decode_enabled: false, wspr_decode_enabled: false, wxsat_decode_enabled: false, + lrpt_decode_enabled: false, cw_auto: false, cw_wpm: 0, cw_tone_hz: 0, diff --git a/src/trx-protocol/src/mapping.rs b/src/trx-protocol/src/mapping.rs index 422cd1f..b984560 100644 --- a/src/trx-protocol/src/mapping.rs +++ b/src/trx-protocol/src/mapping.rs @@ -54,6 +54,14 @@ pub fn client_command_to_rig(cmd: ClientCommand) -> RigCommand { ClientCommand::ResetFt4Decoder => RigCommand::ResetFt4Decoder, ClientCommand::ResetFt2Decoder => RigCommand::ResetFt2Decoder, ClientCommand::ResetWsprDecoder => RigCommand::ResetWsprDecoder, + ClientCommand::SetWxsatDecodeEnabled { enabled } => { + RigCommand::SetWxsatDecodeEnabled(enabled) + } + ClientCommand::SetLrptDecodeEnabled { enabled } => { + RigCommand::SetLrptDecodeEnabled(enabled) + } + ClientCommand::ResetWxsatDecoder => RigCommand::ResetWxsatDecoder, + ClientCommand::ResetLrptDecoder => RigCommand::ResetLrptDecoder, ClientCommand::SetBandwidth { bandwidth_hz } => RigCommand::SetBandwidth(bandwidth_hz), ClientCommand::SetSdrGain { gain_db } => RigCommand::SetSdrGain(gain_db), ClientCommand::SetSdrLnaGain { gain_db } => RigCommand::SetSdrLnaGain(gain_db), @@ -122,6 +130,14 @@ pub fn rig_command_to_client(cmd: RigCommand) -> ClientCommand { RigCommand::ResetFt4Decoder => ClientCommand::ResetFt4Decoder, RigCommand::ResetFt2Decoder => ClientCommand::ResetFt2Decoder, RigCommand::ResetWsprDecoder => ClientCommand::ResetWsprDecoder, + RigCommand::SetWxsatDecodeEnabled(enabled) => { + ClientCommand::SetWxsatDecodeEnabled { enabled } + } + RigCommand::SetLrptDecodeEnabled(enabled) => { + ClientCommand::SetLrptDecodeEnabled { enabled } + } + RigCommand::ResetWxsatDecoder => ClientCommand::ResetWxsatDecoder, + RigCommand::ResetLrptDecoder => ClientCommand::ResetLrptDecoder, RigCommand::SetBandwidth(bandwidth_hz) => ClientCommand::SetBandwidth { bandwidth_hz }, RigCommand::SetSdrGain(gain_db) => ClientCommand::SetSdrGain { gain_db }, RigCommand::SetSdrLnaGain(gain_db) => ClientCommand::SetSdrLnaGain { gain_db }, diff --git a/src/trx-protocol/src/types.rs b/src/trx-protocol/src/types.rs index 10bbae4..e180036 100644 --- a/src/trx-protocol/src/types.rs +++ b/src/trx-protocol/src/types.rs @@ -36,6 +36,8 @@ pub enum ClientCommand { SetFt4DecodeEnabled { enabled: bool }, SetFt2DecodeEnabled { enabled: bool }, SetWsprDecodeEnabled { enabled: bool }, + SetWxsatDecodeEnabled { enabled: bool }, + SetLrptDecodeEnabled { enabled: bool }, ResetAprsDecoder, ResetHfAprsDecoder, ResetCwDecoder, @@ -43,6 +45,8 @@ pub enum ClientCommand { ResetFt4Decoder, ResetFt2Decoder, ResetWsprDecoder, + ResetWxsatDecoder, + ResetLrptDecoder, SetBandwidth { bandwidth_hz: u32 }, SetSdrGain { gain_db: f64 }, SetSdrLnaGain { gain_db: f64 }, diff --git a/src/trx-server/src/audio.rs b/src/trx-server/src/audio.rs index 5d77cd3..bbefd5f 100644 --- a/src/trx-server/src/audio.rs +++ b/src/trx-server/src/audio.rs @@ -25,21 +25,22 @@ use trx_core::audio::{ parse_vchan_uuid_msg, read_audio_msg, write_audio_msg, write_vchan_audio_frame, write_vchan_uuid_msg, AudioStreamInfo, AUDIO_MSG_AIS_DECODE, AUDIO_MSG_APRS_DECODE, AUDIO_MSG_CW_DECODE, AUDIO_MSG_FT2_DECODE, AUDIO_MSG_FT4_DECODE, AUDIO_MSG_FT8_DECODE, - AUDIO_MSG_HF_APRS_DECODE, AUDIO_MSG_HISTORY_COMPRESSED, AUDIO_MSG_WXSAT_IMAGE, + AUDIO_MSG_HF_APRS_DECODE, AUDIO_MSG_HISTORY_COMPRESSED, AUDIO_MSG_LRPT_IMAGE, + AUDIO_MSG_WXSAT_IMAGE, 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_REMOVE, AUDIO_MSG_VCHAN_SUB, AUDIO_MSG_VCHAN_UNSUB, AUDIO_MSG_VDES_DECODE, AUDIO_MSG_WSPR_DECODE, }; use trx_core::decode::{ - AisMessage, AprsPacket, CwEvent, DecodedMessage, Ft8Message, VdesMessage, WsprMessage, - WxsatImage, + AisMessage, AprsPacket, CwEvent, DecodedMessage, Ft8Message, LrptImage, VdesMessage, + WsprMessage, WxsatImage, }; use trx_core::rig::state::{RigMode, RigState}; use trx_core::vchan::SharedVChanManager; use trx_cw::CwDecoder; use trx_ftx::Ft8Decoder; -use trx_wxsat::AptDecoder; +use trx_wxsat::noaa::AptDecoder; use trx_vdes::VdesDecoder; use trx_wspr::WsprDecoder; use uuid::Uuid; @@ -55,6 +56,9 @@ const CW_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 WXSAT_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). +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; @@ -214,6 +218,7 @@ pub struct DecoderHistories { pub ft2: Mutex>, pub wspr: Mutex>, pub wxsat: Mutex>, + pub lrpt: Mutex>, /// Approximate total entry count across all decoders, maintained /// atomically so `estimated_total_count()` avoids 9 lock acquisitions. total_count: AtomicUsize, @@ -232,6 +237,7 @@ impl DecoderHistories { ft2: Mutex::new(VecDeque::new()), wspr: Mutex::new(VecDeque::new()), wxsat: Mutex::new(VecDeque::new()), + lrpt: Mutex::new(VecDeque::new()), total_count: AtomicUsize::new(0), }) } @@ -623,6 +629,52 @@ impl DecoderHistories { 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 --- + + fn prune_lrpt(history: &mut VecDeque<(Instant, LrptImage)>) { + let cutoff = Instant::now() - LRPT_HISTORY_RETENTION; + while let Some((ts, _)) = history.front() { + if *ts < cutoff { + history.pop_front(); + } else { + break; + } + } + } + + pub fn record_lrpt_image(&self, mut img: LrptImage) { + if img.ts_ms.is_none() { + img.ts_ms = Some(current_timestamp_ms()); + } + let mut h = self.lrpt.lock().unwrap_or_else(|e| e.into_inner()); + let before = h.len(); + h.push_back((Instant::now(), img)); + Self::prune_lrpt(&mut h); + self.adjust_total_count(before, h.len()); + } + + pub fn snapshot_lrpt_history(&self) -> Vec { + let mut h = self.lrpt.lock().unwrap_or_else(|e| e.into_inner()); + let before = h.len(); + Self::prune_lrpt(&mut h); + self.adjust_total_count(before, h.len()); + h.iter().map(|(_, img)| img.clone()).collect() + } + + pub fn clear_lrpt_history(&self) { + let mut h = self.lrpt.lock().unwrap_or_else(|e| e.into_inner()); + let before = h.len(); + h.clear(); + self.adjust_total_count(before, 0); + } + /// Returns a quick (non-pruning) estimate of the total number of history /// entries across all decoders, used for pre-allocating the replay blob. /// @@ -2600,6 +2652,187 @@ async fn finalize_wxsat_pass( decoder.reset(); } +// --------------------------------------------------------------------------- +// Meteor-M LRPT decoder task +// --------------------------------------------------------------------------- + +/// Decode Meteor-M LRPT satellite images from QPSK-demodulated baseband. +/// +/// The task is idle until `state.lrpt_decode_enabled` becomes `true`. +/// When disabled (or 30 s of silence elapses with no new MCUs), the +/// accumulated image is saved and broadcast. +pub async fn run_lrpt_decoder( + sample_rate: u32, + channels: u16, + mut pcm_rx: broadcast::Receiver>, + mut state_rx: watch::Receiver, + decode_tx: broadcast::Sender, + histories: Arc, + output_dir: std::path::PathBuf, +) { + use trx_wxsat::lrpt::LrptDecoder; + + info!("LRPT decoder started ({}Hz, {} ch)", sample_rate, channels); + let mut decoder = LrptDecoder::new(sample_rate); + let mut last_reset_seq: u64 = 0; + let mut active = state_rx.borrow().lrpt_decode_enabled; + let mut pass_start_ms: i64 = 0; + let mut last_mcu_at = tokio::time::Instant::now(); + + loop { + if !active { + match state_rx.changed().await { + Ok(()) => { + let state = state_rx.borrow(); + active = state.lrpt_decode_enabled; + if active { + decoder.reset(); + pass_start_ms = current_timestamp_ms(); + last_mcu_at = tokio::time::Instant::now(); + pcm_rx = pcm_rx.resubscribe(); + } + if state.lrpt_decode_reset_seq != last_reset_seq { + last_reset_seq = state.lrpt_decode_reset_seq; + decoder.reset(); + } + } + Err(_) => break, + } + continue; + } + + let silence_deadline = last_mcu_at + LRPT_PASS_SILENCE_TIMEOUT; + + tokio::select! { + recv = pcm_rx.recv() => { + match recv { + Ok(frame) => { + let reset_seq = state_rx.borrow().lrpt_decode_reset_seq; + if reset_seq != last_reset_seq { + last_reset_seq = reset_seq; + decoder.reset(); + } + let mono: Vec = if channels > 1 { + frame.chunks_exact(channels as usize).map(|ch| ch[0]).collect() + } else { + frame + }; + let new_mcus = decoder.process_samples(&mono); + if new_mcus > 0 { + last_mcu_at = tokio::time::Instant::now(); + } + } + Err(broadcast::error::RecvError::Lagged(n)) => { + warn!("LRPT decoder: dropped {} PCM frames", n); + } + Err(broadcast::error::RecvError::Closed) => break, + } + } + changed = state_rx.changed() => { + if changed.is_ok() { + let (new_active, new_reset_seq) = { + let state = state_rx.borrow(); + (state.lrpt_decode_enabled, state.lrpt_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 { + finalize_lrpt_pass( + &mut decoder, + &output_dir, + &decode_tx, + &histories, + pass_start_ms, + ).await; + } + } else { + break; + } + } + _ = tokio::time::sleep_until(silence_deadline), if decoder.mcu_count() > 0 => { + info!( + "LRPT: no new MCUs for {}s — finalising pass ({} MCUs)", + LRPT_PASS_SILENCE_TIMEOUT.as_secs(), + decoder.mcu_count() + ); + finalize_lrpt_pass( + &mut decoder, + &output_dir, + &decode_tx, + &histories, + pass_start_ms, + ).await; + } + } + } +} + +async fn finalize_lrpt_pass( + decoder: &mut trx_wxsat::lrpt::LrptDecoder, + output_dir: &std::path::Path, + decode_tx: &broadcast::Sender, + histories: &Arc, + pass_start_ms: i64, +) { + if decoder.mcu_count() < 2 { + decoder.reset(); + return; + } + + let pass_end_ms = current_timestamp_ms(); + + let Some(lrpt_image) = decoder.finalize() else { + decoder.reset(); + return; + }; + + let dt = chrono::Local::now(); + let filename = dt.format("%Y-%m-%d_%H-%M-%S-lrpt.png").to_string(); + let path = output_dir.join(&filename); + + if let Err(e) = std::fs::create_dir_all(output_dir) { + warn!( + "LRPT: failed to create output directory {:?}: {}", + output_dir, e + ); + decoder.reset(); + return; + } + + match std::fs::write(&path, &lrpt_image.png) { + Ok(()) => { + info!( + "LRPT: saved {} ({} MCUs, {} bytes) to {:?}", + filename, + lrpt_image.mcu_count, + lrpt_image.png.len(), + path + ); + let img = LrptImage { + rig_id: None, + pass_start_ms, + pass_end_ms, + mcu_count: lrpt_image.mcu_count, + path: path.to_string_lossy().into_owned(), + ts_ms: Some(pass_end_ms), + satellite: lrpt_image.satellite.map(|s| s.to_string()), + channels: lrpt_image.channels.clone(), + }; + histories.record_lrpt_image(img.clone()); + let _ = decode_tx.send(DecodedMessage::LrptImage(img)); + } + Err(e) => { + warn!("LRPT: failed to write {:?}: {}", path, e); + } + } + + decoder.reset(); +} + // --------------------------------------------------------------------------- // Virtual-channel audio support // --------------------------------------------------------------------------- @@ -3204,6 +3437,11 @@ async fn handle_audio_client( DecodedMessage::WxsatImage, AUDIO_MSG_WXSAT_IMAGE ); + push_history!( + histories.snapshot_lrpt_history(), + DecodedMessage::LrptImage, + AUDIO_MSG_LRPT_IMAGE + ); (blob, count) }; @@ -3288,6 +3526,7 @@ async fn handle_audio_client( DecodedMessage::Ft2(_) => AUDIO_MSG_FT2_DECODE, DecodedMessage::Wspr(_) => AUDIO_MSG_WSPR_DECODE, DecodedMessage::WxsatImage(_) => AUDIO_MSG_WXSAT_IMAGE, + DecodedMessage::LrptImage(_) => AUDIO_MSG_LRPT_IMAGE, }; if let Ok(json) = serde_json::to_vec(&msg) { if let Err(e) = write_audio_msg(&mut writer_for_rx, msg_type, &json).await { @@ -3316,6 +3555,7 @@ async fn handle_audio_client( DecodedMessage::Ft2(_) => AUDIO_MSG_FT2_DECODE, DecodedMessage::Wspr(_) => AUDIO_MSG_WSPR_DECODE, DecodedMessage::WxsatImage(_) => AUDIO_MSG_WXSAT_IMAGE, + DecodedMessage::LrptImage(_) => AUDIO_MSG_LRPT_IMAGE, }; if let Ok(json) = serde_json::to_vec(&msg) { if let Err(e) = write_audio_msg(&mut writer_for_rx, msg_type, &json).await { diff --git a/src/trx-server/src/main.rs b/src/trx-server/src/main.rs index d11491f..65ecae0 100644 --- a/src/trx-server/src/main.rs +++ b/src/trx-server/src/main.rs @@ -812,6 +812,25 @@ fn spawn_rig_audio_stack( _ = wait_for_shutdown(wxsat_shutdown_rx) => {} } })); + + // Spawn Meteor-M LRPT decoder task + let lrpt_pcm_rx = pcm_tx.subscribe(); + let lrpt_state_rx = state_rx.clone(); + let lrpt_decode_tx = decode_tx.clone(); + let lrpt_sr = rig_cfg.audio.sample_rate; + let lrpt_ch = rig_cfg.audio.channels; + let lrpt_shutdown_rx = shutdown_rx.clone(); + let lrpt_histories = histories.clone(); + let lrpt_output_dir = dirs::cache_dir() + .unwrap_or_else(|| std::path::PathBuf::from(".cache")) + .join("trx-rs") + .join("lrpt"); + handles.push(tokio::spawn(async move { + tokio::select! { + _ = audio::run_lrpt_decoder(lrpt_sr, lrpt_ch as u16, lrpt_pcm_rx, lrpt_state_rx, lrpt_decode_tx, lrpt_histories, lrpt_output_dir) => {} + _ = wait_for_shutdown(lrpt_shutdown_rx) => {} + } + })); } if rig_cfg.audio.tx_enabled { diff --git a/src/trx-server/src/rig_task.rs b/src/trx-server/src/rig_task.rs index 3feaf4f..9dee21d 100644 --- a/src/trx-server/src/rig_task.rs +++ b/src/trx-server/src/rig_task.rs @@ -545,6 +545,30 @@ async fn process_command( let _ = ctx.state_tx.send(ctx.state.clone()); 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) => { + ctx.state.lrpt_decode_enabled = en; + info!("LRPT decode {}", if en { "enabled" } else { "disabled" }); + let _ = ctx.state_tx.send(ctx.state.clone()); + 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 => { + ctx.histories.clear_lrpt_history(); + ctx.state.lrpt_decode_reset_seq += 1; + let _ = ctx.state_tx.send(ctx.state.clone()); + return snapshot_from(ctx.state); + } RigCommand::SetBandwidth(hz) => { if let Some(sdr) = ctx.rig.as_sdr() { if let Err(e) = sdr.set_bandwidth(hz).await {