Skip to main content

mfsk_core/
registry.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2//! Compile-time registry of every protocol mfsk-core builds with.
3//!
4//! [`PROTOCOLS`] is a `&'static [ProtocolMeta]` populated by an
5//! internal macro from each protocol's
6//! [`crate::ModulationParams`] / [`crate::FrameLayout`] /
7//! [`crate::Protocol`] associated constants. It exists so that
8//! consumers (UI layers, FFI bridges, autodetect probes) can
9//! enumerate the supported protocols without hardcoding a list of
10//! their own.
11//!
12//! Entries are gated on Cargo features — disabling `q65` removes the
13//! six Q65 entries from the registry, etc. The order is stable but
14//! not load-bearing; consume [`PROTOCOLS`] as a set or filter via
15//! [`by_id`] / [`by_name`] / [`for_protocol_id`].
16//!
17//! ## Adding a new protocol
18//!
19//! After implementing the [`crate::Protocol`] super-trait for your
20//! ZST, add one line to the [`PROTOCOLS`] slice using the
21//! `protocol_meta!` macro:
22//!
23//! ```text
24//! protocol_meta!("Pretty-Name", MyProtocolZst),
25//! ```
26//!
27//! `tests/protocol_invariants.rs` cross-checks every registry entry
28//! against its ZST's trait constants — drift between the macro
29//! invocation and the actual trait values trips that test.
30//!
31//! ## Q65 sub-modes
32//!
33//! All six wired Q65 sub-modes (Q65-30A, Q65-60A‥E) appear as
34//! distinct registry entries because their `NSPS` / `TONE_SPACING_HZ`
35//! / `T_SLOT_S` differ; they share `ProtocolId::Q65` because the FFI
36//! protocol tag is family-level. [`by_id`] returns *all* entries
37//! sharing a given id, so a Q65 lookup yields six metadata records.
38//!
39//! ## FST4 sub-modes
40//!
41//! Same story as Q65: all five wired FST4 T/R-period sub-modes
42//! (FST4-15, -30, -60A, -120, -300) share `ProtocolId::Fst4`.
43//! FST4-60A is listed first (ahead of FST4-15, even though 15 < 60)
44//! so [`for_protocol_id`]`(ProtocolId::Fst4)` keeps returning the
45//! dominant terrestrial sub-mode as the default — this is one of the
46//! rare spots where entry order *is* load-bearing.
47
48// These imports look unused when *every* protocol feature is off
49// (the `protocol_meta!` invocations that consume them all gate on a
50// feature). Suppress the lint so `--no-default-features` builds stay
51// clean under `-D warnings`.
52#[allow(unused_imports)]
53use crate::{FecCodec, FrameLayout, MessageCodec, ModulationParams, Protocol};
54
55use crate::ProtocolId;
56
57/// Compile-time metadata describing one wired protocol.
58///
59/// Every field is sourced from the trait surface — see the
60/// `protocol_meta!` macro in this module's source for the explicit
61/// mapping. Field order matches a typical "what does this protocol
62/// look like" display: identity → modulation → frame → FEC →
63/// payload.
64#[derive(Clone, Copy, Debug)]
65pub struct ProtocolMeta {
66    /// Family-level protocol id used at the FFI boundary. Multiple
67    /// `ProtocolMeta` entries may share an id (e.g. all six Q65
68    /// sub-modes are `ProtocolId::Q65`).
69    pub id: ProtocolId,
70    /// Human-readable name (e.g. `"FT8"`, `"Q65-60D"`). Stable —
71    /// safe for logs, UI strings, and as a [`by_name`] key.
72    pub name: &'static str,
73    /// Number of FSK tones (`ModulationParams::NTONES`).
74    pub ntones: u32,
75    /// Information bits per modulated symbol.
76    pub bits_per_symbol: u32,
77    /// Samples per symbol at 12 kHz.
78    pub nsps: u32,
79    /// Symbol duration in seconds.
80    pub symbol_dt: f32,
81    /// Tone-to-tone spacing in Hz.
82    pub tone_spacing_hz: f32,
83    /// Gaussian bandwidth-time product (0 = plain FSK).
84    pub gfsk_bt: f32,
85    /// FSK modulation index (h).
86    pub gfsk_hmod: f32,
87    /// Data symbols per frame.
88    pub n_data: u32,
89    /// Sync symbols per frame (interleaved-sync protocols report 0).
90    pub n_sync: u32,
91    /// Total channel symbols per frame (`= n_data + n_sync`).
92    pub n_symbols: u32,
93    /// Nominal slot length in seconds (15 / 7.5 / 30 / 60 / 120).
94    pub t_slot_s: f32,
95    /// FEC info-bit budget — `FecCodec::K`.
96    pub fec_k: usize,
97    /// FEC codeword length in bits — `FecCodec::N`.
98    pub fec_n: usize,
99    /// Message-codec payload width — `MessageCodec::PAYLOAD_BITS`.
100    pub payload_bits: u32,
101}
102
103/// Build a [`ProtocolMeta`] from a `Protocol`-impl ZST `$ty` plus a
104/// stable display name. Used internally to populate [`PROTOCOLS`].
105///
106/// All fields are read out of the trait constants, so any
107/// per-protocol divergence between the macro invocation and the
108/// type's actual constants is impossible by construction.
109#[allow(unused_macros)] // dead under --no-default-features when every
110// protocol-feature gate evaluates to false.
111macro_rules! protocol_meta {
112    ($name:literal, $ty:ty) => {
113        ProtocolMeta {
114            id: <$ty as Protocol>::ID,
115            name: $name,
116            ntones: <$ty as ModulationParams>::NTONES,
117            bits_per_symbol: <$ty as ModulationParams>::BITS_PER_SYMBOL,
118            nsps: <$ty as ModulationParams>::NSPS,
119            symbol_dt: <$ty as ModulationParams>::SYMBOL_DT,
120            tone_spacing_hz: <$ty as ModulationParams>::TONE_SPACING_HZ,
121            gfsk_bt: <$ty as ModulationParams>::GFSK_BT,
122            gfsk_hmod: <$ty as ModulationParams>::GFSK_HMOD,
123            n_data: <$ty as FrameLayout>::N_DATA,
124            n_sync: <$ty as FrameLayout>::N_SYNC,
125            n_symbols: <$ty as FrameLayout>::N_SYMBOLS,
126            t_slot_s: <$ty as FrameLayout>::T_SLOT_S,
127            fec_k: <<$ty as Protocol>::Fec as FecCodec>::K,
128            fec_n: <<$ty as Protocol>::Fec as FecCodec>::N,
129            payload_bits: <<$ty as Protocol>::Msg as MessageCodec>::PAYLOAD_BITS,
130        }
131    };
132}
133
134/// Compile-time list of every `Protocol` impl wired into the
135/// current build. Indexable, iterable, and safe to `static`-borrow.
136///
137/// ```
138/// # use mfsk_core::PROTOCOLS;
139/// // What does this build support?
140/// for p in PROTOCOLS {
141///     println!("{}: {} tones, {} s slot", p.name, p.ntones, p.t_slot_s);
142/// }
143/// ```
144pub static PROTOCOLS: &[ProtocolMeta] = &[
145    #[cfg(feature = "ft8")]
146    protocol_meta!("FT8", crate::Ft8),
147    #[cfg(feature = "ft4")]
148    protocol_meta!("FT4", crate::Ft4),
149    // FST4-60A stays first among the FST4 entries — it's the dominant
150    // terrestrial sub-mode and `by_id`/`for_protocol_id` return the
151    // *first* matching entry, so this preserves the pre-existing
152    // "FST4-60A is the default FST4" behaviour for callers that don't
153    // care about sub-mode.
154    #[cfg(feature = "fst4")]
155    protocol_meta!("FST4-60A", crate::Fst4s60),
156    #[cfg(feature = "fst4")]
157    protocol_meta!("FST4-15", crate::fst4::Fst4s15),
158    #[cfg(feature = "fst4")]
159    protocol_meta!("FST4-30", crate::fst4::Fst4s30),
160    #[cfg(feature = "fst4")]
161    protocol_meta!("FST4-120", crate::fst4::Fst4s120),
162    #[cfg(feature = "fst4")]
163    protocol_meta!("FST4-300", crate::fst4::Fst4s300),
164    #[cfg(feature = "wspr")]
165    protocol_meta!("WSPR", crate::Wspr),
166    #[cfg(feature = "jt9")]
167    protocol_meta!("JT9", crate::Jt9),
168    #[cfg(feature = "jt65")]
169    protocol_meta!("JT65", crate::Jt65),
170    #[cfg(feature = "q65")]
171    protocol_meta!("Q65-30A", crate::q65::Q65a30),
172    #[cfg(feature = "q65")]
173    protocol_meta!("Q65-60A", crate::q65::Q65a60),
174    #[cfg(feature = "q65")]
175    protocol_meta!("Q65-60B", crate::q65::Q65b60),
176    #[cfg(feature = "q65")]
177    protocol_meta!("Q65-60C", crate::q65::Q65c60),
178    #[cfg(feature = "q65")]
179    protocol_meta!("Q65-60D", crate::q65::Q65d60),
180    #[cfg(feature = "q65")]
181    protocol_meta!("Q65-60E", crate::q65::Q65e60),
182    #[cfg(feature = "uvpacket")]
183    protocol_meta!("UvRobust", crate::UvRobust),
184    #[cfg(feature = "uvpacket")]
185    protocol_meta!("UvStandard", crate::UvStandard),
186    #[cfg(feature = "uvpacket")]
187    protocol_meta!("UvUltraRobust", crate::UvUltraRobust),
188    #[cfg(feature = "uvpacket")]
189    protocol_meta!("UvExpress", crate::UvExpress),
190];
191
192/// Iterator over every registry entry sharing `id`. For most
193/// protocols this yields exactly one entry; Q65 yields six (one per
194/// sub-mode).
195pub fn by_id(id: ProtocolId) -> impl Iterator<Item = &'static ProtocolMeta> {
196    PROTOCOLS.iter().filter(move |p| p.id == id)
197}
198
199/// Look up a single protocol by its display name (case-sensitive).
200/// Returns `None` if no entry matches — useful for parsing CLI flags
201/// or config files.
202pub fn by_name(name: &str) -> Option<&'static ProtocolMeta> {
203    PROTOCOLS.iter().find(|p| p.name == name)
204}
205
206/// Convenience for the common "single-mode-family" lookup: returns
207/// the *first* registry entry with the given `id`, or `None` when
208/// the build was compiled without that protocol's feature. For Q65
209/// this yields the Q65-30A terrestrial entry; use [`by_id`] when
210/// you need every sub-mode.
211pub fn for_protocol_id(id: ProtocolId) -> Option<&'static ProtocolMeta> {
212    by_id(id).next()
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218
219    #[test]
220    fn registry_is_non_empty_in_default_build() {
221        // `cargo test` with default features must wire at least one
222        // protocol; otherwise the registry is meaningless.
223        assert!(!PROTOCOLS.is_empty());
224    }
225
226    #[test]
227    fn names_are_unique() {
228        let mut names: Vec<&str> = PROTOCOLS.iter().map(|p| p.name).collect();
229        names.sort_unstable();
230        let dedup_len = {
231            let mut v = names.clone();
232            v.dedup();
233            v.len()
234        };
235        assert_eq!(
236            dedup_len,
237            names.len(),
238            "duplicate protocol names in registry: {names:?}"
239        );
240    }
241
242    #[test]
243    fn by_name_round_trips() {
244        for p in PROTOCOLS {
245            let q = by_name(p.name).expect("by_name should find every registered name");
246            assert!(
247                std::ptr::eq(p, q),
248                "by_name returned a different entry for {}",
249                p.name
250            );
251        }
252    }
253
254    #[test]
255    fn by_name_returns_none_for_unknown() {
256        assert!(by_name("NotAProtocol-9000").is_none());
257    }
258
259    #[test]
260    fn by_id_yields_at_least_one_entry_for_each_distinct_id() {
261        let mut ids: Vec<ProtocolId> = PROTOCOLS.iter().map(|p| p.id).collect();
262        ids.sort_unstable_by_key(|id| *id as u8);
263        ids.dedup();
264        for id in ids {
265            assert!(
266                by_id(id).next().is_some(),
267                "by_id({id:?}) found no entries despite the id appearing in the registry"
268            );
269        }
270    }
271
272    #[cfg(feature = "q65")]
273    #[test]
274    fn q65_id_yields_all_six_submodes() {
275        let q65_entries: Vec<&ProtocolMeta> = by_id(ProtocolId::Q65).collect();
276        assert_eq!(
277            q65_entries.len(),
278            6,
279            "expected six Q65 sub-modes in the registry, got {}: {:?}",
280            q65_entries.len(),
281            q65_entries.iter().map(|p| p.name).collect::<Vec<_>>()
282        );
283        // Names are the canonical sub-mode labels.
284        let names: Vec<&str> = q65_entries.iter().map(|p| p.name).collect();
285        for expected in &[
286            "Q65-30A", "Q65-60A", "Q65-60B", "Q65-60C", "Q65-60D", "Q65-60E",
287        ] {
288            assert!(
289                names.contains(expected),
290                "Q65 registry missing sub-mode {expected}; have {names:?}"
291            );
292        }
293    }
294
295    #[cfg(feature = "fst4")]
296    #[test]
297    fn fst4_id_yields_all_five_submodes() {
298        let fst4_entries: Vec<&ProtocolMeta> = by_id(ProtocolId::Fst4).collect();
299        assert_eq!(
300            fst4_entries.len(),
301            5,
302            "expected five FST4 sub-modes in the registry, got {}: {:?}",
303            fst4_entries.len(),
304            fst4_entries.iter().map(|p| p.name).collect::<Vec<_>>()
305        );
306        let names: Vec<&str> = fst4_entries.iter().map(|p| p.name).collect();
307        for expected in &["FST4-15", "FST4-30", "FST4-60A", "FST4-120", "FST4-300"] {
308            assert!(
309                names.contains(expected),
310                "FST4 registry missing sub-mode {expected}; have {names:?}"
311            );
312        }
313    }
314
315    #[cfg(feature = "fst4")]
316    #[test]
317    fn for_protocol_id_defaults_to_fst4_60a() {
318        // FST4-60A must stay the default entry (order-dependent — see
319        // the "FST4 sub-modes" module doc section) since it's the
320        // dominant terrestrial sub-mode and pre-existing callers rely
321        // on `for_protocol_id(Fst4)` resolving to it.
322        let meta = for_protocol_id(ProtocolId::Fst4).expect("fst4 feature is on");
323        assert_eq!(meta.name, "FST4-60A");
324    }
325}