diff --git a/src/decoders/trx-wxsat/src/lrpt/cadu.rs b/src/decoders/trx-wxsat/src/lrpt/cadu.rs index 416168f..c5eaf3d 100644 --- a/src/decoders/trx-wxsat/src/lrpt/cadu.rs +++ b/src/decoders/trx-wxsat/src/lrpt/cadu.rs @@ -22,6 +22,22 @@ pub const CADU_LEN: usize = 1024; /// CADU payload length (excluding ASM). pub const CADU_PAYLOAD_LEN: usize = CADU_LEN - 4; +/// Generate the CCSDS pseudo-random derandomization sequence. +/// +/// Polynomial: x^8 + x^7 + x^5 + x^3 + 1, initial state 0xFF. +/// The sequence is XOR'd with CADU bytes after the ASM to undo the +/// on-board randomization applied before transmission. +fn ccsds_derandomize(data: &mut [u8]) { + let mut sr: u8 = 0xFF; + for byte in data.iter_mut() { + *byte ^= sr; + for _ in 0..8 { + let feedback = ((sr >> 7) ^ (sr >> 5) ^ (sr >> 3) ^ sr) & 1; + sr = (sr << 1) | feedback; + } + } +} + /// A complete CADU frame (1024 bytes including ASM). #[derive(Clone)] pub struct Cadu { @@ -131,6 +147,8 @@ impl CaduFramer { let mut data = ASM.to_vec(); data.extend_from_slice(&frame_bytes); if data.len() == CADU_LEN { + // Derandomize payload (everything after 4-byte ASM) + ccsds_derandomize(&mut data[4..]); cadus.push(Cadu { data }); } self.locked = false; @@ -202,6 +220,19 @@ mod tests { assert_eq!(find_asm(&buf), None); } + #[test] + fn test_derandomize_roundtrip() { + let original = vec![0xAB; CADU_PAYLOAD_LEN]; + let mut data = original.clone(); + // Randomize + ccsds_derandomize(&mut data); + // Should differ from original + assert_ne!(data, original); + // Derandomize again (same sequence) should restore + ccsds_derandomize(&mut data); + assert_eq!(data, original); + } + #[test] fn test_cadu_spacecraft_id() { let mut data = vec![0u8; CADU_LEN]; diff --git a/src/decoders/trx-wxsat/src/lrpt/mcu.rs b/src/decoders/trx-wxsat/src/lrpt/mcu.rs index eb58cd6..49a99b2 100644 --- a/src/decoders/trx-wxsat/src/lrpt/mcu.rs +++ b/src/decoders/trx-wxsat/src/lrpt/mcu.rs @@ -17,27 +17,358 @@ //! //! The standard colour composite uses APIDs 64 (R), 65 (G), 66 (B) or //! APIDs 65 (R), 65 (G), 68 (B) depending on illumination. +//! +//! Each CCSDS packet carries compressed MCU data using a JPEG-like scheme: +//! Huffman-coded DCT coefficients with fixed quantization and Huffman tables. use std::collections::BTreeMap; use super::cadu::Cadu; use super::MeteorSatellite; -/// Image width in pixels (Meteor-M MSU-MR swath: ~1568 px per line). +/// Image width in pixels (Meteor-M MSU-MR swath: 196 MCU blocks * 8 px). const LINE_WIDTH: u32 = 1568; +/// Number of 8x8 MCU blocks per image line. +const MCUS_PER_LINE: usize = (LINE_WIDTH / 8) as usize; + /// 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 +// ============================================================================ +// Meteor-M LRPT JPEG quantization table +// ============================================================================ + +/// Standard quantization table for Meteor-M LRPT imagery. +/// Applied in zigzag order to dequantize DCT coefficients. +#[rustfmt::skip] +const QUANT_TABLE: [i32; 64] = [ + 16, 11, 10, 16, 24, 40, 51, 61, + 12, 12, 14, 19, 26, 58, 60, 55, + 14, 13, 16, 24, 40, 57, 69, 56, + 14, 17, 22, 29, 51, 87, 80, 62, + 18, 22, 37, 56, 68, 109, 103, 77, + 24, 35, 55, 64, 81, 104, 113, 92, + 49, 64, 78, 87, 103, 121, 120, 101, + 72, 92, 95, 98, 112, 100, 103, 99, +]; + +/// JPEG zigzag scan order (maps zigzag index → row-major 8x8 index). +#[rustfmt::skip] +const ZIGZAG: [usize; 64] = [ + 0, 1, 8, 16, 9, 2, 3, 10, + 17, 24, 32, 25, 18, 11, 4, 5, + 12, 19, 26, 33, 40, 48, 41, 34, + 27, 20, 13, 6, 7, 14, 21, 28, + 35, 42, 49, 56, 57, 50, 43, 36, + 29, 22, 15, 23, 30, 37, 44, 51, + 58, 59, 52, 45, 38, 31, 39, 46, + 53, 60, 61, 54, 47, 55, 62, 63, +]; + +// ============================================================================ +// Huffman tables for Meteor-M LRPT (standard JPEG baseline tables) +// ============================================================================ + +/// DC Huffman table: (code_length, code_value) → category. +/// Standard JPEG luminance DC table. +struct HuffTable { + /// For each bit length (1..=16), the codes and their symbol values. + entries: Vec<(u8, u16, u8)>, // (bits, code, symbol) +} + +impl HuffTable { + fn dc_table() -> Self { + // Standard JPEG luminance DC Huffman table + // Category 0-11, code lengths from JPEG spec + #[rustfmt::skip] + let symbols_by_length: &[(u8, &[u8])] = &[ + (2, &[0, 1, 2, 3, 4, 5]), + (3, &[6]), + (4, &[7]), + (5, &[8]), + (6, &[9]), + (7, &[10]), + (8, &[11]), + ]; + + Self::build(symbols_by_length) + } + + fn ac_table() -> Self { + // Standard JPEG luminance AC Huffman table + // Each symbol is (run_length << 4 | category) + #[rustfmt::skip] + let symbols_by_length: &[(u8, &[u8])] = &[ + (2, &[0x01, 0x02]), + (3, &[0x03]), + (4, &[0x00, 0x04, 0x11]), + (5, &[0x05, 0x12, 0x21]), + (6, &[0x31, 0x41]), + (7, &[0x06, 0x13, 0x51, 0x61]), + (8, &[0x07, 0x22, 0x71]), + (9, &[0x14, 0x32, 0x81, 0x91, 0xA1]), + (10, &[0x08, 0x23, 0x42, 0xB1, 0xC1]), + (11, &[0x15, 0x52, 0xD1, 0xF0]), + (12, &[0x24, 0x33, 0x62, 0x72]), + (15, &[0x82]), + (16, &[0x09, 0x0A, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x25, + 0x26, 0x27, 0x28, 0x29, 0x2A, 0x34, 0x35, 0x36, + 0x37, 0x38, 0x39, 0x3A, 0x43, 0x44, 0x45, 0x46, + 0x47, 0x48, 0x49, 0x4A, 0x53, 0x54, 0x55, 0x56, + 0x57, 0x58, 0x59, 0x5A, 0x63, 0x64, 0x65, 0x66, + 0x67, 0x68, 0x69, 0x6A, 0x73, 0x74, 0x75, 0x76, + 0x77, 0x78, 0x79, 0x7A, 0x83, 0x84, 0x85, 0x86, + 0x87, 0x88, 0x89, 0x8A, 0x92, 0x93, 0x94, 0x95, + 0x96, 0x97, 0x98, 0x99, 0x9A, 0xA2, 0xA3, 0xA4, + 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xB2, 0xB3, + 0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA, 0xC2, + 0xC3, 0xC4, 0xC5, 0xC6, 0xC7, 0xC8, 0xC9, 0xCA, + 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, 0xD9, + 0xDA, 0xE1, 0xE2, 0xE3, 0xE4, 0xE5, 0xE6, 0xE7, + 0xE8, 0xE9, 0xEA, 0xF1, 0xF2, 0xF3, 0xF4, 0xF5, + 0xF6, 0xF7, 0xF8, 0xF9, 0xFA]), + ]; + + Self::build(symbols_by_length) + } + + fn build(symbols_by_length: &[(u8, &[u8])]) -> Self { + let mut entries = Vec::new(); + let mut code: u16 = 0; + + // Sort by bit length to generate canonical Huffman codes + let mut all: Vec<(u8, u8)> = Vec::new(); + for &(bits, syms) in symbols_by_length { + for &sym in syms { + all.push((bits, sym)); + } + } + all.sort_by_key(|&(bits, _)| bits); + + let mut prev_bits = 0u8; + for &(bits, sym) in &all { + if prev_bits > 0 { + code = (code + 1) << (bits - prev_bits); + } + entries.push((bits, code, sym)); + prev_bits = bits; + } + + Self { entries } + } +} + +// ============================================================================ +// Bitstream reader +// ============================================================================ + +struct BitReader<'a> { + data: &'a [u8], + byte_pos: usize, + bit_pos: u8, // 0-7, MSB first +} + +impl<'a> BitReader<'a> { + fn new(data: &'a [u8]) -> Self { + Self { + data, + byte_pos: 0, + bit_pos: 0, + } + } + + fn read_bit(&mut self) -> Option { + if self.byte_pos >= self.data.len() { + return None; + } + let bit = (self.data[self.byte_pos] >> (7 - self.bit_pos)) & 1; + self.bit_pos += 1; + if self.bit_pos >= 8 { + self.bit_pos = 0; + self.byte_pos += 1; + } + Some(bit) + } + + fn read_bits(&mut self, count: u8) -> Option { + let mut val: i32 = 0; + for _ in 0..count { + val = (val << 1) | self.read_bit()? as i32; + } + Some(val) + } + + fn decode_huffman(&mut self, table: &HuffTable) -> Option { + let mut code: u16 = 0; + let mut bits_read: u8 = 0; + + loop { + let bit = self.read_bit()?; + code = (code << 1) | bit as u16; + bits_read += 1; + + for &(entry_bits, entry_code, symbol) in &table.entries { + if entry_bits == bits_read && entry_code == code { + return Some(symbol); + } + } + + if bits_read >= 16 { + return None; + } + } + } + + fn has_remaining(&self) -> bool { + self.byte_pos < self.data.len() + } +} + +/// Decode a signed value from category bits (JPEG magnitude encoding). +fn decode_magnitude(category: u8, bits: i32) -> i32 { + if category == 0 { + return 0; + } + // If MSB is 0, value is negative + if bits < (1 << (category - 1)) { + bits - (1 << category) + 1 + } else { + bits + } +} + +// ============================================================================ +// Inverse DCT (8x8) +// ============================================================================ + +/// Perform 8x8 inverse discrete cosine transform on dequantized coefficients. +fn idct_8x8(coeffs: &[i32; 64], output: &mut [u8; 64]) { + // Use the standard IDCT formula with precomputed cosine values. + // cos(pi * (2*x + 1) * u / 16) for x,u in 0..8 + let mut workspace = [0.0f64; 64]; + + for y in 0..8 { + for x in 0..8 { + let mut sum = 0.0f64; + for v in 0..8 { + for u in 0..8 { + let cu = if u == 0 { + std::f64::consts::FRAC_1_SQRT_2 + } else { + 1.0 + }; + let cv = if v == 0 { + std::f64::consts::FRAC_1_SQRT_2 + } else { + 1.0 + }; + let coeff = coeffs[v * 8 + u] as f64; + let cos_x = + (std::f64::consts::PI * (2 * x + 1) as f64 * u as f64 / 16.0).cos(); + let cos_y = + (std::f64::consts::PI * (2 * y + 1) as f64 * v as f64 / 16.0).cos(); + sum += cu * cv * coeff * cos_x * cos_y; + } + } + workspace[y * 8 + x] = sum / 4.0; + } + } + + // Level shift (+128) and clamp to [0, 255] + for i in 0..64 { + let val = (workspace[i] + 128.0).round(); + output[i] = val.clamp(0.0, 255.0) as u8; + } +} + +// ============================================================================ +// MCU block decoder +// ============================================================================ + +/// Decode a single 8x8 MCU block from a bitstream. +/// +/// Returns the decoded 64-pixel block and the updated DC prediction value. +fn decode_mcu_block( + reader: &mut BitReader, + dc_table: &HuffTable, + ac_table: &HuffTable, + prev_dc: i32, +) -> Option<([u8; 64], i32)> { + let mut coeffs = [0i32; 64]; + + // DC coefficient + let dc_category = reader.decode_huffman(dc_table)?; + let dc_bits = if dc_category > 0 { + reader.read_bits(dc_category)? + } else { + 0 + }; + let dc_diff = decode_magnitude(dc_category, dc_bits); + let dc_val = prev_dc + dc_diff; + coeffs[0] = dc_val; + + // AC coefficients (zigzag positions 1-63) + let mut idx = 1; + while idx < 64 { + let symbol = reader.decode_huffman(ac_table)?; + if symbol == 0x00 { + // EOB — remaining coefficients are zero + break; + } + let run = (symbol >> 4) as usize; + let category = symbol & 0x0F; + + if symbol == 0xF0 { + // ZRL — skip 16 zeros + idx += 16; + continue; + } + + idx += run; + if idx >= 64 { + break; + } + + let ac_bits = if category > 0 { + reader.read_bits(category)? + } else { + 0 + }; + coeffs[idx] = decode_magnitude(category, ac_bits); + idx += 1; + } + + // De-zigzag and dequantize + let mut dequant = [0i32; 64]; + for i in 0..64 { + dequant[ZIGZAG[i]] = coeffs[i] * QUANT_TABLE[i]; + } + + // Inverse DCT + let mut pixels = [0u8; 64]; + idct_8x8(&dequant, &mut pixels); + + Some((pixels, dc_val)) +} + +// ============================================================================ +// Channel buffer and assembler +// ============================================================================ + /// 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, + /// Current MCU column position within the current MCU row. + mcu_col: usize, + /// Row buffer for the current MCU row (8 lines * LINE_WIDTH pixels). + row_buf: Vec, + /// DC prediction value for differential coding. + prev_dc: i32, } impl ChannelBuffer { @@ -45,15 +376,51 @@ impl ChannelBuffer { Self { pixels: Vec::new(), lines: 0, - cursor: 0, + mcu_col: 0, + row_buf: vec![0u8; 8 * LINE_WIDTH as usize], + prev_dc: 0, } } - fn push_mcu_row(&mut self, data: &[u8]) { - // Each MCU row = LINE_WIDTH pixels + /// Write an 8x8 MCU block at the current column position. + fn push_mcu_block(&mut self, block: &[u8; 64]) { + let col = self.mcu_col; + if col >= MCUS_PER_LINE { + // Flush the current MCU row to pixels, start a new one + self.flush_mcu_row(); + } + + let x_off = self.mcu_col * 8; + for row in 0..8 { + let dst_start = row * LINE_WIDTH as usize + x_off; + let src_start = row * 8; + if dst_start + 8 <= self.row_buf.len() { + self.row_buf[dst_start..dst_start + 8] + .copy_from_slice(&block[src_start..src_start + 8]); + } + } + self.mcu_col += 1; + + // If we've filled a complete MCU row, flush it + if self.mcu_col >= MCUS_PER_LINE { + self.flush_mcu_row(); + } + } + + fn flush_mcu_row(&mut self) { + if self.mcu_col == 0 { + return; + } + self.pixels.extend_from_slice(&self.row_buf); + self.lines += 8; + self.row_buf.fill(0); + self.mcu_col = 0; + } + + /// Push raw pixel data as a fallback (one LINE_WIDTH row at a time). + fn push_raw_row(&mut self, data: &[u8]) { self.pixels.extend_from_slice(data); - self.cursor += data.len(); - self.lines = (self.cursor / LINE_WIDTH as usize) as u32; + self.lines = (self.pixels.len() / LINE_WIDTH as usize) as u32; } } @@ -65,6 +432,11 @@ pub struct ChannelAssembler { total_mcu_count: u32, /// Spacecraft ID seen in CADUs (for satellite identification). spacecraft_id: Option, + /// Huffman tables (built once). + dc_table: HuffTable, + ac_table: HuffTable, + /// Partial CCSDS packet reassembly buffer, keyed by APID. + packet_buf: BTreeMap>, } impl Default for ChannelAssembler { @@ -79,6 +451,9 @@ impl ChannelAssembler { channels: BTreeMap::new(), total_mcu_count: 0, spacecraft_id: None, + dc_table: HuffTable::dc_table(), + ac_table: HuffTable::ac_table(), + packet_buf: BTreeMap::new(), } } @@ -100,19 +475,83 @@ impl ChannelAssembler { 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). + // Parse first header pointer from MPDU header. + // The 2 bytes before the payload in the CADU (at offset 10-11 after ASM) + // contain the first header pointer. If 0x07FF, no packet starts here. + let fhp = if cadu.data.len() >= 12 { + ((cadu.data[10] as u16 & 0x07) << 8) | cadu.data[11] as u16 + } else { + 0x07FF + }; + + if fhp == 0x07FF { + // No new packet starts in this MPDU — append to ongoing packet + self.packet_buf + .entry(apid) + .or_default() + .extend_from_slice(payload); + } else { + let fhp = fhp as usize; + + // Complete the previous packet with data before the pointer + if fhp > 0 && fhp <= payload.len() { + if let Some(buf) = self.packet_buf.get_mut(&apid) { + buf.extend_from_slice(&payload[..fhp]); + let packet_data = std::mem::take(buf); + self.decode_packet(apid, &packet_data); + } + } + + // Start new packet from the first header pointer + if fhp < payload.len() { + let buf = self.packet_buf.entry(apid).or_default(); + buf.clear(); + buf.extend_from_slice(&payload[fhp..]); + } + } + } + + /// Attempt to decode MCU blocks from a reassembled CCSDS packet. + fn decode_packet(&mut self, apid: u16, data: &[u8]) { + // CCSDS source packet: 6-byte primary header + data zone + if data.len() < 10 { + return; + } + + // Skip 6-byte CCSDS primary header + 4 bytes of secondary header + // to reach the compressed MCU data + let mcu_data = &data[10..]; + if mcu_data.is_empty() { + return; + } + 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); + // Try JPEG MCU decompression + let mut reader = BitReader::new(mcu_data); + let mut blocks_decoded = 0u32; - self.total_mcu_count += 1; + while reader.has_remaining() { + match decode_mcu_block(&mut reader, &self.dc_table, &self.ac_table, buf.prev_dc) { + Some((block, new_dc)) => { + buf.prev_dc = new_dc; + buf.push_mcu_block(&block); + blocks_decoded += 1; + } + None => break, + } + } + + if blocks_decoded > 0 { + self.total_mcu_count += blocks_decoded; + } else if mcu_data.len() >= LINE_WIDTH as usize { + // Fallback: if JPEG decode fails entirely, try as raw data + let usable = mcu_data.len().min(LINE_WIDTH as usize); + let mut row = vec![0u8; LINE_WIDTH as usize]; + row[..usable].copy_from_slice(&mcu_data[..usable]); + buf.push_raw_row(&row); + self.total_mcu_count += 1; + } } /// Total MCU rows decoded across all channels. @@ -143,8 +582,16 @@ impl ChannelAssembler { 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); + // Flush any partial MCU rows by computing effective heights + let max_lines = self + .channels + .values() + .map(|ch| { + let extra = if ch.mcu_col > 0 { 8 } else { 0 }; + ch.lines + extra + }) + .max() + .unwrap_or(0); if max_lines == 0 { return None; @@ -154,17 +601,30 @@ impl ChannelAssembler { let height = max_lines; let npix = (width * height) as usize; + // Helper to get pixel data including unflushed MCU row + let get_pixels = |ch: &ChannelBuffer| -> Vec { + let mut px = ch.pixels.clone(); + if ch.mcu_col > 0 { + px.extend_from_slice(&ch.row_buf); + } + px + }; + // 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); if ch_r.is_some() || ch_g.is_some() || ch_b.is_some() { + let px_r = ch_r.map(get_pixels); + let px_g = ch_g.map(get_pixels); + let px_b = ch_b.map(get_pixels); + let mut rgb_pixels: Vec = Vec::with_capacity(npix * 3); 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); + let r = px_r.as_ref().and_then(|p| p.get(i).copied()).unwrap_or(0); + let g = px_g.as_ref().and_then(|p| p.get(i).copied()).unwrap_or(0); + let b = px_b.as_ref().and_then(|p| p.get(i).copied()).unwrap_or(0); rgb_pixels.push(r); rgb_pixels.push(g); rgb_pixels.push(b); @@ -173,9 +633,10 @@ impl ChannelAssembler { } else { // Fallback: grayscale from the first available channel let first_ch = self.channels.values().next()?; + let px = get_pixels(first_ch); let mut gray_pixels: Vec = Vec::with_capacity(npix); for i in 0..npix { - gray_pixels.push(first_ch.pixels.get(i).copied().unwrap_or(0)); + gray_pixels.push(px.get(i).copied().unwrap_or(0)); } crate::image_enc::encode_grayscale_png(width, height, gray_pixels) } @@ -185,6 +646,7 @@ impl ChannelAssembler { self.channels.clear(); self.total_mcu_count = 0; self.spacecraft_id = None; + self.packet_buf.clear(); } } @@ -196,24 +658,109 @@ mod tests { fn test_channel_buffer_line_counting() { let mut buf = ChannelBuffer::new(); let row = vec![128u8; LINE_WIDTH as usize]; - buf.push_mcu_row(&row); + buf.push_raw_row(&row); assert_eq!(buf.lines, 1); - buf.push_mcu_row(&row); + buf.push_raw_row(&row); assert_eq!(buf.lines, 2); } + #[test] + fn test_mcu_block_placement() { + let mut buf = ChannelBuffer::new(); + let block = [200u8; 64]; + + // Push one MCU block + buf.push_mcu_block(&block); + assert_eq!(buf.mcu_col, 1); + assert_eq!(buf.lines, 0); // Not yet a full MCU row + + // The first 8 pixels of row 0 in row_buf should be 200 + assert_eq!(buf.row_buf[0], 200); + assert_eq!(buf.row_buf[7], 200); + // Pixel at column 8 should still be 0 + assert_eq!(buf.row_buf[8], 0); + } + + #[test] + fn test_mcu_row_flush() { + let mut buf = ChannelBuffer::new(); + let block = [128u8; 64]; + + // Fill a complete MCU row (196 blocks) + for _ in 0..MCUS_PER_LINE { + buf.push_mcu_block(&block); + } + + // Should have flushed: 8 lines of LINE_WIDTH pixels + assert_eq!(buf.lines, 8); + assert_eq!(buf.pixels.len(), 8 * LINE_WIDTH as usize); + assert_eq!(buf.mcu_col, 0); + } + #[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)); + 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)); + assert_eq!( + asm.identify_satellite(), + Some(MeteorSatellite::MeteorM2_4) + ); asm.spacecraft_id = Some(99); assert_eq!(asm.identify_satellite(), Some(MeteorSatellite::Unknown)); } + + #[test] + fn test_decode_magnitude() { + assert_eq!(decode_magnitude(0, 0), 0); + assert_eq!(decode_magnitude(1, 1), 1); + assert_eq!(decode_magnitude(1, 0), -1); + assert_eq!(decode_magnitude(2, 3), 3); + assert_eq!(decode_magnitude(2, 2), 2); + assert_eq!(decode_magnitude(2, 1), -2); + assert_eq!(decode_magnitude(2, 0), -3); + } + + #[test] + fn test_idct_dc_only() { + // A block with only a DC coefficient should produce a uniform block + let mut coeffs = [0i32; 64]; + coeffs[0] = 100; + let mut output = [0u8; 64]; + idct_8x8(&coeffs, &mut output); + + // All pixels should be close to 128 + 100/4 = 153 (DC is scaled by 1/4) + // Actually DC: C(0)*C(0) * coeff * cos(0)*cos(0) / 4 + // = (1/√2)*(1/√2) * 100 * 1 * 1 / 4 = 100/8 = 12.5, + 128 = 140.5 + let expected = (100.0_f64 * 0.5 / 4.0 + 128.0).round() as u8; + for &px in &output { + assert!( + (px as i32 - expected as i32).unsigned_abs() <= 1, + "pixel {} != expected {}", + px, + expected + ); + } + } + + #[test] + fn test_bitreader_basics() { + let data = [0b10110100, 0b01100000]; + let mut reader = BitReader::new(&data); + + assert_eq!(reader.read_bit(), Some(1)); + assert_eq!(reader.read_bit(), Some(0)); + assert_eq!(reader.read_bit(), Some(1)); + assert_eq!(reader.read_bit(), Some(1)); + assert_eq!(reader.read_bits(4), Some(0b0100)); + assert_eq!(reader.read_bits(3), Some(0b011)); + } } diff --git a/src/trx-protocol/src/decoders.rs b/src/trx-protocol/src/decoders.rs index 6b3cffa..4d93b8f 100644 --- a/src/trx-protocol/src/decoders.rs +++ b/src/trx-protocol/src/decoders.rs @@ -125,7 +125,7 @@ pub const DECODER_REGISTRY: &[DecoderDescriptor] = &[ id: "lrpt", label: "Meteor LRPT", activation: DecoderActivation::Toggle, - active_modes: &["DIG", "USB"], + active_modes: &["FM"], background_decode: false, bookmark_selectable: true, }, diff --git a/src/trx-server/src/audio.rs b/src/trx-server/src/audio.rs index a852fb2..3ed3f6b 100644 --- a/src/trx-server/src/audio.rs +++ b/src/trx-server/src/audio.rs @@ -2395,7 +2395,8 @@ pub async fn run_lrpt_decoder( 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().decoders.lrpt_decode_enabled; + let mut active = state_rx.borrow().decoders.lrpt_decode_enabled + && matches!(state_rx.borrow().status.mode, RigMode::FM); let mut pass_start_ms: i64 = 0; let mut last_mcu_at = tokio::time::Instant::now(); @@ -2404,7 +2405,8 @@ pub async fn run_lrpt_decoder( match state_rx.changed().await { Ok(()) => { let state = state_rx.borrow(); - active = state.decoders.lrpt_decode_enabled; + active = state.decoders.lrpt_decode_enabled + && matches!(state.status.mode, RigMode::FM); if active { decoder.reset(); pass_start_ms = current_timestamp_ms(); @@ -2455,7 +2457,9 @@ pub async fn run_lrpt_decoder( if changed.is_ok() { let (new_active, new_reset_seq) = { let state = state_rx.borrow(); - (state.decoders.lrpt_decode_enabled, state.reset_seqs.lrpt_decode_reset_seq) + (state.decoders.lrpt_decode_enabled + && matches!(state.status.mode, RigMode::FM), + state.reset_seqs.lrpt_decode_reset_seq) }; let was_active = active; active = new_active;