[feat](trx-wspr): implement WSPR protocol decoder

Replace the always-None stub with a full decode pipeline:
- Bit-reversal deinterleave of 4-FSK symbols (data_bit = symbol >> 1)
- Fano sequential decoder for K=32, rate-1/2 convolutional code
  (polynomials 0xF2D05351 / 0xE4613C47, 100k-cycle budget)
- Payload unpack: 28-bit callsign (mixed-radix N1), 15-bit Maidenhead
  grid (M1 formula), 7-bit power code (dBm + 64)
- Validity checks on callsign, grid, and power range
- Round-trip unit test for K1JT FN20 37

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
This commit is contained in:
2026-03-17 01:04:38 +01:00
parent 7a144375f2
commit 4c69273584
+274 -5
View File
@@ -8,10 +8,279 @@ pub struct WsprProtocolMessage {
pub message: String,
}
const POLY1: u32 = 0xF2D05351;
const POLY2: u32 = 0xE4613C47;
const NBITS: usize = 81; // 50 payload bits + 31 convolutional flush bits
const NSYMS: usize = 162;
/// Reverse the bits of an 8-bit value.
fn rev8(mut b: u8) -> u8 {
let mut r = 0u8;
for _ in 0..8 {
r = (r << 1) | (b & 1);
b >>= 1;
}
r
}
/// Extract hard data bits from 4-FSK symbols then deinterleave.
///
/// In WSPR: symbol = sync_bit + 2*data_bit, so data_bit = symbol >> 1.
/// The 162 data bits are reordered via bit-reversal of 8-bit indices.
fn deinterleave(symbols: &[u8]) -> [u8; NSYMS] {
let mut out = [0u8; NSYMS];
let mut p = 0usize;
for i in 0u8..=255 {
let j = rev8(i) as usize;
if j < NSYMS {
out[j] = symbols[p] >> 1;
p += 1;
}
}
out
}
/// Fano sequential decoder for the K=32, rate-1/2 convolutional code.
///
/// `coded[2*k]` and `coded[2*k+1]` are the two received bits for input bit k.
/// Returns the 81 decoded bits (first 50 are payload), or None if it cannot
/// converge within the iteration budget.
fn fano_decode(coded: &[u8; NSYMS]) -> Option<[u8; NBITS]> {
const MAX_CYCLES: usize = 100_000;
// Hard-decision branch metric: +1 per matching bit, -1 per mismatch.
let bm = |pos: usize, state: u32, bit: u32| -> i32 {
let ns = (state << 1) | bit;
let c0 = ((ns & POLY1).count_ones() & 1) as u8;
let c1 = ((ns & POLY2).count_ones() & 1) as u8;
let r0 = coded[2 * pos];
let r1 = coded[2 * pos + 1];
(if c0 == r0 { 1 } else { -1 }) + (if c1 == r1 { 1 } else { -1 })
};
let mut bits = [0u8; NBITS];
let mut states = [0u32; NBITS + 1];
let mut metrics = [0i32; NBITS + 1];
// 0 = not yet visited, 1 = tried best branch, 2 = tried both branches
let mut visit = [0u8; NBITS];
let mut pos: usize = 0;
let mut threshold: i32 = 0;
for _ in 0..MAX_CYCLES {
if pos == NBITS {
break;
}
let state = states[pos];
let base = metrics[pos];
let m0 = base + bm(pos, state, 0);
let m1 = base + bm(pos, state, 1);
// b0/mv0 = best branch; b1/mv1 = second-best
let (b0, mv0, b1, mv1) = if m0 >= m1 {
(0u8, m0, 1u8, m1)
} else {
(1u8, m1, 0u8, m0)
};
// Which branch to try at this visit count?
let chosen = match visit[pos] {
0 if mv0 >= threshold => Some((b0, mv0)),
1 if mv1 >= threshold => Some((b1, mv1)),
_ => None,
};
if let Some((b, m)) = chosen {
visit[pos] = if visit[pos] == 0 { 1 } else { 2 };
bits[pos] = b;
states[pos + 1] = (state << 1) | b as u32;
metrics[pos + 1] = m;
pos += 1;
} else {
// Both branches at this position fail the threshold: backtrack.
visit[pos] = 0;
if pos == 0 {
threshold -= 1;
} else {
pos -= 1;
// Parent node (visit[pos] == 1 or 2) will try the next branch
// on the following iteration, or backtrack further.
}
}
}
if pos < NBITS {
return None;
}
Some(bits)
}
/// Unpack 50 payload bits into a formatted WSPR message string.
///
/// Layout (MSB first):
/// bits 0-27 — N1 (28 bits): callsign
/// bits 28-42 — M1 (15 bits): Maidenhead grid
/// bits 43-49 — P ( 7 bits): power code (dBm + 64)
fn unpack_message(bits: &[u8; NBITS]) -> Option<String> {
// Accumulate N1, M1, and power code from the bit array.
let mut n1 = 0u32;
for &b in &bits[..28] {
n1 = (n1 << 1) | b as u32;
}
let mut m1 = 0u32;
for &b in &bits[28..43] {
m1 = (m1 << 1) | b as u32;
}
let mut power_code = 0u32;
for &b in &bits[43..50] {
power_code = (power_code << 1) | b as u32;
}
// power_code = dBm + 64; valid WSPR levels are 060 dBm.
let power_dbm = power_code as i32 - 64;
if !(0..=60).contains(&power_dbm) {
return None;
}
// Decode callsign from N1.
// N1 = ((c0*36 + c1)*10 + c2)*27^3 + c3*27^2 + c4*27 + c5
// c0,c1 ∈ charset37; c2 ∈ '0'-'9'; c3,c4,c5 ∈ charset27
const CS37: &[u8] = b" 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
const CS27: &[u8] = b" ABCDEFGHIJKLMNOPQRSTUVWXYZ";
let mut n = n1;
let i5 = (n % 27) as usize;
n /= 27;
let i4 = (n % 27) as usize;
n /= 27;
let i3 = (n % 27) as usize;
n /= 27;
let i2 = (n % 10) as usize;
n /= 10;
let i1 = (n % 36) as usize;
n /= 36;
let i0 = n as usize;
if i0 >= 37 || i1 >= 37 || i2 >= 10 || i3 >= 27 || i4 >= 27 || i5 >= 27 {
return None;
}
let callsign = format!(
"{}{}{}{}{}{}",
CS37[i0] as char,
CS37[i1] as char,
(b'0' + i2 as u8) as char,
CS27[i3] as char,
CS27[i4] as char,
CS27[i5] as char,
)
.trim_end()
.to_string();
if callsign.len() < 3 || !callsign.chars().any(|c| c.is_alphabetic()) {
return None;
}
// Decode Maidenhead grid from M1.
// M1 = (179 - 10*loc1 - loc3)*180 + 10*loc2 + loc4
// loc1,loc2 ∈ 0-17 (A-R); loc3,loc4 ∈ 0-9
if m1 > 32_399 {
return None;
}
let hi = m1 / 180;
let lo = m1 % 180;
let t = 179u32.checked_sub(hi)?;
let loc1 = t / 10; // longitude letter index
let loc3 = t % 10; // longitude digit
let loc2 = lo / 10; // latitude letter index
let loc4 = lo % 10; // latitude digit
if loc1 > 17 || loc2 > 17 {
return None;
}
let grid = format!(
"{}{}{}{}",
(b'A' + loc1 as u8) as char,
(b'A' + loc2 as u8) as char,
(b'0' + loc3 as u8) as char,
(b'0' + loc4 as u8) as char,
);
Some(format!("{} {} {}", callsign, grid, power_dbm))
}
#[cfg(test)]
mod tests {
use super::*;
/// Encode a WSPR callsign+grid+power into N1/M1/power_code, then round-trip
/// through `unpack_message` to verify the pack/unpack formulas are inverse.
#[test]
fn unpack_known_message() {
// Callsign "K1JT", grid "FN20", power 37 dBm — a well-known WSPR beacon.
// Encode callsign "K1JT " (padded to 6 chars with trailing spaces).
// charset37: ' '=0, '0'=1,..'9'=10, 'A'=11,..'Z'=36
// charset27: ' '=0, 'A'=1,..'Z'=26
let cs37 = b" 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
let cs27 = b" ABCDEFGHIJKLMNOPQRSTUVWXYZ";
let idx37 = |c: u8| cs37.iter().position(|&x| x == c).unwrap() as u32;
let idx27 = |c: u8| cs27.iter().position(|&x| x == c).unwrap() as u32;
// "K1JT " → c0='K'=21, c1='1'=2, c2='J'-no, wait: position 2 must be digit
// Standard WSPR normalises so that position 2 is the digit.
// "K1JT " has digit at position 1, not 2 → needs prefix space: " K1JT "
// " K1JT ": c0=' '=0, c1='K'=21, c2='1', c3='J'=10, c4='T'=20, c5=' '=0
let c0 = idx37(b' ');
let c1 = idx37(b'K');
let c2 = 1u32; // '1'
let c3 = idx27(b'J');
let c4 = idx27(b'T');
let c5 = idx27(b' ');
let n1 = ((c0 * 36 + c1) * 10 + c2) * 27u32.pow(3)
+ c3 * 27u32.pow(2)
+ c4 * 27
+ c5;
// Grid "FN20": loc1='F'=5 (lon), loc2='N'=13 (lat), loc3='2', loc4='0'
let loc1 = (b'F' - b'A') as u32; // 5
let loc2 = (b'N' - b'A') as u32; // 13
let loc3 = 2u32;
let loc4 = 0u32;
let m1 = (179 - 10 * loc1 - loc3) * 180 + 10 * loc2 + loc4;
// Power 37 dBm → power_code = 37 + 64 = 101
let power_code = 37u32 + 64;
// Pack into 50-bit array
let mut bits = [0u8; NBITS];
for i in (0..28).rev() {
bits[27 - i] = ((n1 >> i) & 1) as u8;
}
for i in (0..15).rev() {
bits[42 - i] = ((m1 >> i) & 1) as u8;
}
for i in (0..7).rev() {
bits[49 - i] = ((power_code >> i) & 1) as u8;
}
let msg = unpack_message(&bits).expect("unpack_message should succeed");
// Message should contain callsign, grid, and power
assert!(msg.contains("K1JT"), "callsign not found in '{}'", msg);
assert!(msg.contains("FN20"), "grid not found in '{}'", msg);
assert!(msg.contains("37"), "power not found in '{}'", msg);
}
}
/// Attempt protocol-level decode from 162 4-FSK symbols.
///
/// This boundary keeps DSP and protocol concerns separated while the
/// native Rust decoder is implemented incrementally.
pub fn decode_symbols(_symbols: &[u8]) -> Option<WsprProtocolMessage> {
None
pub fn decode_symbols(symbols: &[u8]) -> Option<WsprProtocolMessage> {
if symbols.len() < NSYMS {
return None;
}
let coded = deinterleave(symbols);
let bits = fano_decode(&coded)?;
let message = unpack_message(&bits)?;
Some(WsprProtocolMessage { message })
}