Sync docs to Wiki / wiki (push) Has been cancelled
Signed-off-by: Stan Grams <sjg@haxx.space>
249 lines
7.5 KiB
Rust
249 lines
7.5 KiB
Rust
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
//
|
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
|
|
//! 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;
|
|
|
|
/// 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 {
|
|
pub data: Vec<u8>,
|
|
}
|
|
|
|
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<u8>,
|
|
/// Byte accumulation buffer for frame extraction.
|
|
byte_buf: Vec<u8>,
|
|
/// 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<Cadu> {
|
|
// 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<Cadu>) {
|
|
loop {
|
|
if self.locked {
|
|
if self.byte_buf.len() >= self.remaining {
|
|
// Collect the rest of the frame
|
|
let frame_bytes: Vec<u8> = 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 {
|
|
// Derandomize payload (everything after 4-byte ASM)
|
|
ccsds_derandomize(&mut data[4..]);
|
|
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<usize> {
|
|
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_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];
|
|
// 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);
|
|
}
|
|
}
|