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// These imports look unused when *every* protocol feature is off
40// (the `protocol_meta!` invocations that consume them all gate on a
41// feature). Suppress the lint so `--no-default-features` builds stay
42// clean under `-D warnings`.
43#[allow(unused_imports)]
44use crate::{FecCodec, FrameLayout, MessageCodec, ModulationParams, Protocol};
45
46use crate::ProtocolId;
47
48/// Compile-time metadata describing one wired protocol.
49///
50/// Every field is sourced from the trait surface — see the
51/// `protocol_meta!` macro in this module's source for the explicit
52/// mapping. Field order matches a typical "what does this protocol
53/// look like" display: identity → modulation → frame → FEC →
54/// payload.
55#[derive(Clone, Copy, Debug)]
56pub struct ProtocolMeta {
57 /// Family-level protocol id used at the FFI boundary. Multiple
58 /// `ProtocolMeta` entries may share an id (e.g. all six Q65
59 /// sub-modes are `ProtocolId::Q65`).
60 pub id: ProtocolId,
61 /// Human-readable name (e.g. `"FT8"`, `"Q65-60D"`). Stable —
62 /// safe for logs, UI strings, and as a [`by_name`] key.
63 pub name: &'static str,
64 /// Number of FSK tones (`ModulationParams::NTONES`).
65 pub ntones: u32,
66 /// Information bits per modulated symbol.
67 pub bits_per_symbol: u32,
68 /// Samples per symbol at 12 kHz.
69 pub nsps: u32,
70 /// Symbol duration in seconds.
71 pub symbol_dt: f32,
72 /// Tone-to-tone spacing in Hz.
73 pub tone_spacing_hz: f32,
74 /// Gaussian bandwidth-time product (0 = plain FSK).
75 pub gfsk_bt: f32,
76 /// FSK modulation index (h).
77 pub gfsk_hmod: f32,
78 /// Data symbols per frame.
79 pub n_data: u32,
80 /// Sync symbols per frame (interleaved-sync protocols report 0).
81 pub n_sync: u32,
82 /// Total channel symbols per frame (`= n_data + n_sync`).
83 pub n_symbols: u32,
84 /// Nominal slot length in seconds (15 / 7.5 / 30 / 60 / 120).
85 pub t_slot_s: f32,
86 /// FEC info-bit budget — `FecCodec::K`.
87 pub fec_k: usize,
88 /// FEC codeword length in bits — `FecCodec::N`.
89 pub fec_n: usize,
90 /// Message-codec payload width — `MessageCodec::PAYLOAD_BITS`.
91 pub payload_bits: u32,
92}
93
94/// Build a [`ProtocolMeta`] from a `Protocol`-impl ZST `$ty` plus a
95/// stable display name. Used internally to populate [`PROTOCOLS`].
96///
97/// All fields are read out of the trait constants, so any
98/// per-protocol divergence between the macro invocation and the
99/// type's actual constants is impossible by construction.
100#[allow(unused_macros)] // dead under --no-default-features when every
101// protocol-feature gate evaluates to false.
102macro_rules! protocol_meta {
103 ($name:literal, $ty:ty) => {
104 ProtocolMeta {
105 id: <$ty as Protocol>::ID,
106 name: $name,
107 ntones: <$ty as ModulationParams>::NTONES,
108 bits_per_symbol: <$ty as ModulationParams>::BITS_PER_SYMBOL,
109 nsps: <$ty as ModulationParams>::NSPS,
110 symbol_dt: <$ty as ModulationParams>::SYMBOL_DT,
111 tone_spacing_hz: <$ty as ModulationParams>::TONE_SPACING_HZ,
112 gfsk_bt: <$ty as ModulationParams>::GFSK_BT,
113 gfsk_hmod: <$ty as ModulationParams>::GFSK_HMOD,
114 n_data: <$ty as FrameLayout>::N_DATA,
115 n_sync: <$ty as FrameLayout>::N_SYNC,
116 n_symbols: <$ty as FrameLayout>::N_SYMBOLS,
117 t_slot_s: <$ty as FrameLayout>::T_SLOT_S,
118 fec_k: <<$ty as Protocol>::Fec as FecCodec>::K,
119 fec_n: <<$ty as Protocol>::Fec as FecCodec>::N,
120 payload_bits: <<$ty as Protocol>::Msg as MessageCodec>::PAYLOAD_BITS,
121 }
122 };
123}
124
125/// Compile-time list of every `Protocol` impl wired into the
126/// current build. Indexable, iterable, and safe to `static`-borrow.
127///
128/// ```
129/// # use mfsk_core::PROTOCOLS;
130/// // What does this build support?
131/// for p in PROTOCOLS {
132/// println!("{}: {} tones, {} s slot", p.name, p.ntones, p.t_slot_s);
133/// }
134/// ```
135pub static PROTOCOLS: &[ProtocolMeta] = &[
136 #[cfg(feature = "ft8")]
137 protocol_meta!("FT8", crate::Ft8),
138 #[cfg(feature = "ft4")]
139 protocol_meta!("FT4", crate::Ft4),
140 #[cfg(feature = "fst4")]
141 protocol_meta!("FST4-60A", crate::Fst4s60),
142 #[cfg(feature = "wspr")]
143 protocol_meta!("WSPR", crate::Wspr),
144 #[cfg(feature = "jt9")]
145 protocol_meta!("JT9", crate::Jt9),
146 #[cfg(feature = "jt65")]
147 protocol_meta!("JT65", crate::Jt65),
148 #[cfg(feature = "q65")]
149 protocol_meta!("Q65-30A", crate::q65::Q65a30),
150 #[cfg(feature = "q65")]
151 protocol_meta!("Q65-60A", crate::q65::Q65a60),
152 #[cfg(feature = "q65")]
153 protocol_meta!("Q65-60B", crate::q65::Q65b60),
154 #[cfg(feature = "q65")]
155 protocol_meta!("Q65-60C", crate::q65::Q65c60),
156 #[cfg(feature = "q65")]
157 protocol_meta!("Q65-60D", crate::q65::Q65d60),
158 #[cfg(feature = "q65")]
159 protocol_meta!("Q65-60E", crate::q65::Q65e60),
160];
161
162/// Iterator over every registry entry sharing `id`. For most
163/// protocols this yields exactly one entry; Q65 yields six (one per
164/// sub-mode).
165pub fn by_id(id: ProtocolId) -> impl Iterator<Item = &'static ProtocolMeta> {
166 PROTOCOLS.iter().filter(move |p| p.id == id)
167}
168
169/// Look up a single protocol by its display name (case-sensitive).
170/// Returns `None` if no entry matches — useful for parsing CLI flags
171/// or config files.
172pub fn by_name(name: &str) -> Option<&'static ProtocolMeta> {
173 PROTOCOLS.iter().find(|p| p.name == name)
174}
175
176/// Convenience for the common "single-mode-family" lookup: returns
177/// the *first* registry entry with the given `id`, or `None` when
178/// the build was compiled without that protocol's feature. For Q65
179/// this yields the Q65-30A terrestrial entry; use [`by_id`] when
180/// you need every sub-mode.
181pub fn for_protocol_id(id: ProtocolId) -> Option<&'static ProtocolMeta> {
182 by_id(id).next()
183}
184
185#[cfg(test)]
186mod tests {
187 use super::*;
188
189 #[test]
190 fn registry_is_non_empty_in_default_build() {
191 // `cargo test` with default features must wire at least one
192 // protocol; otherwise the registry is meaningless.
193 assert!(!PROTOCOLS.is_empty());
194 }
195
196 #[test]
197 fn names_are_unique() {
198 let mut names: Vec<&str> = PROTOCOLS.iter().map(|p| p.name).collect();
199 names.sort_unstable();
200 let dedup_len = {
201 let mut v = names.clone();
202 v.dedup();
203 v.len()
204 };
205 assert_eq!(
206 dedup_len,
207 names.len(),
208 "duplicate protocol names in registry: {names:?}"
209 );
210 }
211
212 #[test]
213 fn by_name_round_trips() {
214 for p in PROTOCOLS {
215 let q = by_name(p.name).expect("by_name should find every registered name");
216 assert!(
217 std::ptr::eq(p, q),
218 "by_name returned a different entry for {}",
219 p.name
220 );
221 }
222 }
223
224 #[test]
225 fn by_name_returns_none_for_unknown() {
226 assert!(by_name("NotAProtocol-9000").is_none());
227 }
228
229 #[test]
230 fn by_id_yields_at_least_one_entry_for_each_distinct_id() {
231 let mut ids: Vec<ProtocolId> = PROTOCOLS.iter().map(|p| p.id).collect();
232 ids.sort_unstable_by_key(|id| *id as u8);
233 ids.dedup();
234 for id in ids {
235 assert!(
236 by_id(id).next().is_some(),
237 "by_id({id:?}) found no entries despite the id appearing in the registry"
238 );
239 }
240 }
241
242 #[cfg(feature = "q65")]
243 #[test]
244 fn q65_id_yields_all_six_submodes() {
245 let q65_entries: Vec<&ProtocolMeta> = by_id(ProtocolId::Q65).collect();
246 assert_eq!(
247 q65_entries.len(),
248 6,
249 "expected six Q65 sub-modes in the registry, got {}: {:?}",
250 q65_entries.len(),
251 q65_entries.iter().map(|p| p.name).collect::<Vec<_>>()
252 );
253 // Names are the canonical sub-mode labels.
254 let names: Vec<&str> = q65_entries.iter().map(|p| p.name).collect();
255 for expected in &[
256 "Q65-30A", "Q65-60A", "Q65-60B", "Q65-60C", "Q65-60D", "Q65-60E",
257 ] {
258 assert!(
259 names.contains(expected),
260 "Q65 registry missing sub-mode {expected}; have {names:?}"
261 );
262 }
263 }
264}