mfsk-core 0.11.0

Pure-Rust WSJT-family decoders + synthesisers (FT8 FT4 FST4 WSPR JT9 JT65 Q65) behind a zero-cost Protocol trait. Host (rustfft) or no_std embedded (ESP32-S3, RP2350, Cortex-M) via a pluggable FFT backend; fixed-point hot path for FPU-less MCUs. Ships with embedded-poc/m5stack-s3-app, a working M5StickS3 FT8 controller (LCD UI, BLE CI-V to IC-705, acoustic mic, QSO FSM) decoding real on-air signals in ~1.2 s post-SlotEnd on Xtensa LX7.
//! # `msg` — message-layer codecs and callsign hash table
//!
//! Message-layer codecs for WSJT-family digital modes.
//!
//! | Module       | Payload bits | Used by                   |
//! |--------------|--------------|---------------------------|
//! | [`wsjt77`]   | 77           | FT8, FT4, FT2, FST4       |
//! | [`wspr`]     | 50           | WSPR                      |
//! | [`jt72`]     | 72           | JT65, JT9                 |
//!
//! [`hash_table::CallsignHashTable`] tracks hashed callsigns across decodes;
//! typically a single instance lives in the decoder's side-channel state and
//! is shared by every message unpack invocation.

pub mod ap;
pub mod callsign28;
/// Unified owned decode row for host UIs — see [`decoded::Decoded`].
pub mod decoded;
// `DecodeRequest`/`SniperRequest` builder (issue #191). Needs at least
// one `FrameDecodable` implementor (`ft8`/`ft4`/`fst4`) or its generic
// structs have zero concrete instantiations anywhere in the crate,
// making every field dead code under `-D warnings` (e.g. a `jt65`-only
// build: `fft-rustfft` is on via `jt65`'s own feature dependency, but
// no protocol implements `FrameDecodable`).
#[cfg(all(
    any(feature = "fft-rustfft", feature = "fft-extern"),
    any(feature = "ft8", feature = "ft4", feature = "fst4")
))]
pub mod decode_request;
pub mod hash_table;
pub mod jt72;
#[cfg(feature = "packet-bytes")]
pub mod packet_bytes;
// AP hypothesis generation for the protocols whose decoders take an
// `ApHint`. Gated on the FFT meta-feature so embedded-rx (alloc +
// microfft) gets it, and on `ft4`/`fst4` because they are its only
// callers: FT8 builds its own pass list inline in
// `ft8::decode_block::process_candidates`, so an `ft8`-only or
// `jt9`-only build would compile these as dead code under
// `-D warnings`. (It used to carry a whole parallel AP decode engine,
// which is what made it reachable from everywhere; that was deleted
// once AP became a rung on `engine::pipeline`'s own ladder.)
#[cfg(all(
    any(feature = "fft-rustfft", feature = "fft-extern"),
    any(feature = "ft4", feature = "fst4")
))]
pub mod pipeline_ap;
#[cfg(feature = "q65")]
pub mod q65;
pub mod wsjt77;
pub mod wspr;

pub use ap::ApHint;
pub use decoded::Decoded;
pub use hash_table::CallsignHashTable;
pub use jt72::{Jt72Codec, Jt72Message};
#[cfg(feature = "packet-bytes")]
pub use packet_bytes::PacketBytesMessage;
#[cfg(feature = "q65")]
pub use q65::Q65Message;
pub use wspr::{Wspr50Message, WsprMessage};

use alloc::format;
use alloc::vec::Vec;

use crate::engine::{DecodeContext, MessageCodec, MessageFields};

/// WSJT 77-bit message codec used by FT8, FT4, FT2 and FST4.
///
/// Pure wrapper around the free functions in [`wsjt77`], implementing the
/// generic [`crate::MessageCodec`] trait so pipeline code can
/// consume messages without knowing which concrete protocol produced them.
#[derive(Copy, Clone, Debug, Default)]
pub struct Wsjt77Message;

impl MessageCodec for Wsjt77Message {
    /// The decoded *fields*, not the string they render to — see
    /// [`wsjt77::Wsjt77Fields`]. `unpack77`/`unpack77_with_hash` are
    /// still there for callers that only want the rendering.
    type Unpacked = wsjt77::Wsjt77Fields;
    const PAYLOAD_BITS: u32 = 77;
    const CRC_BITS: u32 = 14;

    fn pack(&self, fields: &MessageFields) -> Option<Vec<u8>> {
        // Free text wins if set; otherwise fall back to the standard three-
        // field call/call/report packing used by the overwhelming majority of
        // FT8/FT4 QSOs.
        if let Some(txt) = &fields.free_text {
            return wsjt77::pack77_free_text(txt).map(|a| a.to_vec());
        }
        let call1 = fields.call1.as_deref()?;
        let call2 = fields.call2.as_deref()?;
        // Prefer grid; if the caller supplied a numeric report, format it
        // WSJT-X-style (sign-padded two-digit dB string).
        let report = if let Some(g) = &fields.grid {
            g.clone()
        } else {
            let r = fields.report?;
            if r >= 0 {
                format!("+{:02}", r)
            } else {
                format!("{:03}", r)
            }
        };
        wsjt77::pack77(call1, call2, &report).map(|a| a.to_vec())
    }

    fn unpack(&self, payload: &[u8], ctx: &DecodeContext) -> Option<Self::Unpacked> {
        if payload.len() != 77 {
            return None;
        }
        let mut buf = [0u8; 77];
        buf.copy_from_slice(payload);

        // Prefer the hash-aware path when the caller threaded a table through
        // `DecodeContext`; fall back to the placeholder-emitting variant.
        if let Some(any) = ctx.callsign_hash_table.as_ref()
            && let Some(ht) = any.downcast_ref::<CallsignHashTable>()
        {
            return wsjt77::unpack77_fields(&buf, ht);
        }
        wsjt77::unpack77_fields(&buf, &CallsignHashTable::new())
    }

    /// Wsjt77 reserves the trailing K-77 info bits for a CRC. Two
    /// flavours coexist in the WSJT-X family: FT8 / FT4 / FT2 use
    /// LDPC(174, 91) with a 14-bit CRC at bits 77..91, while FST4
    /// uses LDPC(240, 101) with a 24-bit CRC at bits 77..101.
    /// Both share the same Wsjt77 77-bit message field; only the
    /// CRC width differs by FEC pairing. We length-dispatch on the
    /// `info` slice the FEC layer passes through here:
    ///
    /// - 91 → [`crate::fec::ldpc::check_crc14`]
    /// - 101 → [`crate::fec::ldpc240_101::check_crc24`]
    /// - other → reject (no Wsjt77-compatible CRC for that K)
    fn verify_info(info: &[u8]) -> bool {
        match info.len() {
            91 => crate::fec::ldpc::check_crc14(info),
            101 => crate::fec::ldpc240_101::check_crc24(info),
            _ => false,
        }
    }

    /// [`wsjt77::Wsjt77Fields::is_plausible`] — the ITU-prefix
    /// allowlist over the callsign *fields*, with free text and
    /// telemetry exempt (nothing in them to check) and the EU VHF
    /// contest requiring a resolved hash (nothing else in it to check).
    fn is_plausible(message: &Self::Unpacked) -> bool {
        message.is_plausible()
    }
}