Skip to main content

mfsk_core/msg/
wspr.rs

1//! WSPR 50-bit message codec.
2//!
3//! Ports `lib/wsprd/wsprd_utils.c` (unpack) and `lib/wsprd/wsprsim_utils.c`
4//! (pack) from WSJT-X. The 50-bit payload carries one of three message
5//! types:
6//!
7//! | Type | Contents                       | n1 (28 bit) | n2 (22 bit) |
8//! |------|--------------------------------|-------------|-------------|
9//! | 1    | 6-char callsign + grid4 + dBm  | packed call | grid+power  |
10//! | 2    | prefix/suffix callsign + dBm   | packed call | prefix+type |
11//! | 3    | hashed call + grid6 + dBm      | packed grid6| hash+type   |
12//!
13//! Type discrimination happens on the decode side: `ntype = (n2 & 127) - 64`.
14//! Valid "power-in-dBm" values (0, 3, 7, 10, …, 60) mark Type 1; other
15//! positive ntype is Type 2; negative ntype is Type 3.
16//!
17//! Currently Type 1 and Type 3 are implemented end-to-end. Type 2 is
18//! detected but reported as a placeholder — the prefix/suffix unpack
19//! logic can be ported verbatim when a test corpus materialises.
20//!
21//! The decoded representation is a `WsprMessage` enum so callers can
22//! distinguish the types; the convenience `to_string()` impl yields the
23//! familiar `"CALL GRID DBM"` tuple layout that WSPRnet expects.
24
25use alloc::format;
26use alloc::string::{String, ToString};
27use alloc::vec::Vec;
28use core::fmt;
29
30const POWERS: &[i32] = &[
31    0, 3, 7, 10, 13, 17, 20, 23, 27, 30, 33, 37, 40, 43, 47, 50, 53, 57, 60,
32];
33
34/// Decoded WSPR message payload.
35#[derive(Clone, Debug, Eq, PartialEq)]
36pub enum WsprMessage {
37    /// Standard Type-1 message: 6-char callsign, 4-char grid, transmit power.
38    Type1 {
39        callsign: String,
40        grid: String,
41        power_dbm: i32,
42    },
43    /// Type-2 prefix/suffix callsign (e.g. `PJ4/K1ABC 37`).
44    Type2 {
45        /// Fully reconstructed callsign with the prefix or suffix baked in
46        /// (`"PJ4/K1ABC"`, `"K1ABC/7"`, etc).
47        callsign: String,
48        power_dbm: i32,
49    },
50    /// Type-3 hashed callsign + 6-char grid. The hash is exposed raw so
51    /// callers with a compatible WSPR hash table can resolve it.
52    Type3 {
53        /// 15-bit callsign hash derived from `nhash(callsign, 146)` at TX.
54        callsign_hash: u32,
55        /// 6-character Maidenhead locator.
56        grid6: String,
57        power_dbm: i32,
58    },
59}
60
61impl fmt::Display for WsprMessage {
62    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
63        match self {
64            WsprMessage::Type1 {
65                callsign,
66                grid,
67                power_dbm,
68            } => write!(f, "{} {} {}", callsign, grid, power_dbm),
69            WsprMessage::Type2 {
70                callsign,
71                power_dbm,
72            } => write!(f, "{} {}", callsign, power_dbm),
73            WsprMessage::Type3 {
74                callsign_hash,
75                grid6,
76                power_dbm,
77            } => write!(f, "<#{:05x}> {} {}", callsign_hash, grid6, power_dbm),
78        }
79    }
80}
81
82// ─────────────────────────────────────────────────────────────────────────
83// Character tables
84// ─────────────────────────────────────────────────────────────────────────
85
86/// 37-entry table used by callsign/grid unpacking — digits, uppercase
87/// letters, and space. Matches `c[]` in `wsprd_utils.c::unpackcall`.
88const CHAR37: &[u8; 37] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ ";
89
90fn callsign_char_code(ch: u8) -> Option<u8> {
91    match ch {
92        b'0'..=b'9' => Some(ch - b'0'),
93        b'A'..=b'Z' => Some(ch - b'A' + 10),
94        b' ' => Some(36),
95        _ => None,
96    }
97}
98
99fn locator_char_code(ch: u8) -> Option<u8> {
100    match ch {
101        b'0'..=b'9' => Some(ch - b'0'),
102        b'A'..=b'R' => Some(ch - b'A'),
103        b' ' => Some(36),
104        _ => None,
105    }
106}
107
108// ─────────────────────────────────────────────────────────────────────────
109// Pack / unpack 50 bits ↔ (n1, n2)
110// ─────────────────────────────────────────────────────────────────────────
111
112/// Pack (n1, n2) into 50 bits laid out across 7 bytes + 1 bit.
113/// Byte layout matches `wsprsim_utils.c`:
114/// ```text
115/// data[0]=n1[27..20]  data[1]=n1[19..12]  data[2]=n1[11..4]
116/// data[3]=n1[3..0]<<4 | n2[21..18]
117/// data[4]=n2[17..10]  data[5]=n2[9..2]    data[6]=n2[1..0]<<6
118/// ```
119pub fn pack50(n1: u32, n2: u32) -> [u8; 7] {
120    [
121        ((n1 >> 20) & 0xff) as u8,
122        ((n1 >> 12) & 0xff) as u8,
123        ((n1 >> 4) & 0xff) as u8,
124        (((n1 & 0x0f) << 4) | ((n2 >> 18) & 0x0f)) as u8,
125        ((n2 >> 10) & 0xff) as u8,
126        ((n2 >> 2) & 0xff) as u8,
127        (((n2 & 0x03) << 6) & 0xff) as u8,
128    ]
129}
130
131/// Inverse of [`pack50`]: 7-byte packed word → (n1, n2).
132/// Tolerates the 7th byte carrying only the top 2 bits.
133pub fn unpack50(data: &[u8; 7]) -> (u32, u32) {
134    let mut n1: u32 = (data[0] as u32) << 20;
135    n1 |= (data[1] as u32) << 12;
136    n1 |= (data[2] as u32) << 4;
137    n1 |= ((data[3] >> 4) & 0x0f) as u32;
138
139    let mut n2: u32 = ((data[3] & 0x0f) as u32) << 18;
140    n2 |= (data[4] as u32) << 10;
141    n2 |= (data[5] as u32) << 2;
142    n2 |= ((data[6] >> 6) & 0x03) as u32;
143
144    (n1, n2)
145}
146
147// ─────────────────────────────────────────────────────────────────────────
148// Callsign + grid packing (Type 1)
149// ─────────────────────────────────────────────────────────────────────────
150
151/// Encode a callsign into a 28-bit integer. Returns `None` if the callsign
152/// doesn't fit the compressed form (must be ≤ 6 chars with a digit in
153/// position 1 or 2, and only A-Z / 0-9 / space).
154pub fn pack_call(callsign: &str) -> Option<u32> {
155    let bytes = callsign.as_bytes();
156    if bytes.len() > 6 || bytes.is_empty() {
157        return None;
158    }
159    let mut call6 = [b' '; 6];
160    // Right-align to the 3rd slot: if char[2] is a digit keep as-is,
161    // else if char[1] is a digit shift one position right.
162    if bytes.len() >= 3 && bytes[2].is_ascii_digit() {
163        for (i, &b) in bytes.iter().enumerate() {
164            call6[i] = b;
165        }
166    } else if bytes.len() >= 2 && bytes[1].is_ascii_digit() {
167        for (i, &b) in bytes.iter().enumerate() {
168            call6[i + 1] = b;
169        }
170    } else {
171        return None;
172    }
173
174    let codes: [u8; 6] = {
175        let mut c = [0u8; 6];
176        for i in 0..6 {
177            c[i] = callsign_char_code(call6[i])?;
178        }
179        c
180    };
181
182    // n = c0*36 + c1 ...       (first two slots: 37-symbol alphabet)
183    // then digit (c2, 0-9), then three letter/space (c3..c5, 27 symbols).
184    let mut n: u32 = codes[0] as u32;
185    n = n * 36 + codes[1] as u32;
186    n = n * 10 + codes[2] as u32;
187    n = n * 27 + (codes[3].wrapping_sub(10)) as u32;
188    n = n * 27 + (codes[4].wrapping_sub(10)) as u32;
189    n = n * 27 + (codes[5].wrapping_sub(10)) as u32;
190    Some(n)
191}
192
193/// Unpack a 28-bit callsign integer. Returns `None` for the "reserved"
194/// range (≥ 262_177_560) that WSJT-X treats as non-Type-1.
195pub fn unpack_call(ncall: u32) -> Option<String> {
196    if ncall >= 262_177_560 {
197        return None;
198    }
199    let mut n = ncall;
200    let mut tmp = [b' '; 6];
201    // Reverse of pack_call: pull digits/letters out LSB-first.
202    let i = (n % 27 + 10) as usize;
203    tmp[5] = CHAR37[i];
204    n /= 27;
205    let i = (n % 27 + 10) as usize;
206    tmp[4] = CHAR37[i];
207    n /= 27;
208    let i = (n % 27 + 10) as usize;
209    tmp[3] = CHAR37[i];
210    n /= 27;
211    let i = (n % 10) as usize;
212    tmp[2] = CHAR37[i];
213    n /= 10;
214    let i = (n % 36) as usize;
215    tmp[1] = CHAR37[i];
216    n /= 36;
217    tmp[0] = CHAR37[n as usize];
218
219    let s = core::str::from_utf8(&tmp).ok()?;
220    Some(s.trim().to_string())
221}
222
223/// Pack a 4-char grid and transmit power into a 22-bit integer.
224pub fn pack_grid4_power(grid: &str, power_dbm: i32) -> Option<u32> {
225    let bytes = grid.as_bytes();
226    if bytes.len() != 4 {
227        return None;
228    }
229    let g0 = locator_char_code(bytes[0])? as u32;
230    let g1 = locator_char_code(bytes[1])? as u32;
231    let g2 = locator_char_code(bytes[2])? as u32;
232    let g3 = locator_char_code(bytes[3])? as u32;
233    let m = (179 - 10 * g0 - g2) * 180 + 10 * g1 + g3;
234    Some(m * 128 + (power_dbm as u32) + 64)
235}
236
237/// Unpack the 22-bit grid+power integer. Returns `(grid, ntype)` where
238/// `ntype = (n2 & 127) - 64` — the caller decides whether `ntype` names a
239/// Type 1 dBm value, a Type 2 suffix count, or a Type 3 negative tag.
240pub fn unpack_grid(ngrid_full: u32) -> Option<(String, i32)> {
241    let ntype = (ngrid_full & 127) as i32 - 64;
242    let ngrid = ngrid_full >> 7;
243    if ngrid >= 32_400 {
244        return None;
245    }
246    let dlat = (ngrid % 180) as i32 - 90;
247    let mut dlong = (ngrid / 180) as i32 * 2 - 180 + 2;
248    if dlong < -180 {
249        dlong += 360;
250    }
251    if dlong > 180 {
252        dlong += 360;
253    }
254    let nlong = (60.0 * (180.0 - dlong as f32) / 5.0) as i32;
255    let ln1 = nlong / 240;
256    let ln2 = (nlong - 240 * ln1) / 24;
257
258    let nlat = (60.0 * (dlat + 90) as f32 / 2.5) as i32;
259    let la1 = nlat / 240;
260    let la2 = (nlat - 240 * la1) / 24;
261
262    let mut grid = [b'0'; 4];
263    grid[0] = CHAR37[(10 + ln1) as usize];
264    grid[2] = CHAR37[ln2 as usize];
265    grid[1] = CHAR37[(10 + la1) as usize];
266    grid[3] = CHAR37[la2 as usize];
267    Some((core::str::from_utf8(&grid).ok()?.to_string(), ntype))
268}
269
270// ─────────────────────────────────────────────────────────────────────────
271// Public encode / decode entry points
272// ─────────────────────────────────────────────────────────────────────────
273
274/// Pack a Type-1 WSPR message (callsign + 4-char grid + power in dBm) into
275/// 50 bits, stored MSB-first across a 50-element `[u8; 50]` of 0/1 values —
276/// the form required by [`crate::fec::ConvFano`]'s encode path.
277pub fn pack_type1(callsign: &str, grid: &str, power_dbm: i32) -> Option<[u8; 50]> {
278    if !POWERS.contains(&power_dbm) {
279        return None;
280    }
281    let n1 = pack_call(callsign)?;
282    let n2 = pack_grid4_power(grid, power_dbm)?;
283    let bytes = pack50(n1, n2);
284    let mut bits = [0u8; 50];
285    for i in 0..50 {
286        let byte = bytes[i / 8];
287        bits[i] = (byte >> (7 - (i % 8))) & 1;
288    }
289    Some(bits)
290}
291
292/// Add a prefix or suffix to a callsign according to the 16-bit
293/// `nprefix` field carried in Type-2 messages. Ports
294/// `wsprd_utils.c::unpackpfx`.
295///
296/// * `nprefix < 60000` → prefix of 1-3 chars, packed base-37
297/// * `60000 ≤ nprefix ≤ 60035` → single-char digit/letter suffix
298/// * `60036 ≤ nprefix ≤ 60125` → two-digit suffix
299fn apply_prefix(nprefix: u32, base_call: &str) -> Option<String> {
300    const A37: &[u8] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ ";
301    if nprefix < 60_000 {
302        // Prefix, 1-3 chars.
303        let mut n = nprefix;
304        let mut pfx = [b' '; 3];
305        for i in (0..3).rev() {
306            let nc = (n % 37) as usize;
307            pfx[i] = A37[nc];
308            n /= 37;
309        }
310        // Strip leading spaces.
311        let start = pfx.iter().position(|&b| b != b' ')?;
312        let pfx_str = core::str::from_utf8(&pfx[start..]).ok()?;
313        Some(format!("{}/{}", pfx_str, base_call))
314    } else {
315        let nc = nprefix - 60_000;
316        if nc <= 9 {
317            Some(format!("{}/{}", base_call, (b'0' + nc as u8) as char))
318        } else if nc <= 35 {
319            Some(format!(
320                "{}/{}",
321                base_call,
322                (b'A' + (nc - 10) as u8) as char
323            ))
324        } else if nc <= 125 {
325            let d1 = (nc - 26) / 10;
326            let d2 = (nc - 26) % 10;
327            Some(format!(
328                "{}/{}{}",
329                base_call,
330                (b'0' + d1 as u8) as char,
331                (b'0' + d2 as u8) as char
332            ))
333        } else {
334            None
335        }
336    }
337}
338
339/// Unpack 50 info bits into a [`WsprMessage`]. Returns `None` for
340/// pathological ntype/ngrid combinations.
341pub fn unpack(bits: &[u8; 50]) -> Option<WsprMessage> {
342    // Pack bit vector back into the 7-byte word format unpack50 expects.
343    let mut data = [0u8; 7];
344    for i in 0..50 {
345        if bits[i] & 1 != 0 {
346            data[i / 8] |= 1 << (7 - (i % 8));
347        }
348    }
349    let (n1, n2) = unpack50(&data);
350
351    let (maybe_grid, ntype) = unpack_grid(n2).unzip();
352
353    // Type 3: negative ntype → hashed callsign + grid6.
354    // The 6-char grid is stored via pack_call with a rotated layout:
355    // grid6[..5] holds the last 5 chars of the grid, grid6[5] holds the
356    // first. We recover the packed string via unpack_call then rotate
357    // the tail char back to the front.
358    if let Some(t) = ntype
359        && t < 0
360    {
361        let power_dbm = -(t + 1);
362        // Reconstruct grid6 from the "callsign-slot" encoding.
363        let pseudo_call = unpack_call(n1).unwrap_or_default();
364        let mut grid6 = String::new();
365        if pseudo_call.len() == 6 {
366            let bytes = pseudo_call.as_bytes();
367            grid6.push(bytes[5] as char); // rotated-back first char
368            grid6.push_str(core::str::from_utf8(&bytes[..5]).ok()?);
369        }
370        // Hash extraction: ihash = (n2 - ntype - 64) / 128. Since
371        // ntype is negative, this is (n2 + (-ntype) - 64) / 128; with
372        // n2 raw, it equals n2 >> 7 exactly.
373        let hash = n2 >> 7;
374        return Some(WsprMessage::Type3 {
375            callsign_hash: hash,
376            grid6,
377            power_dbm,
378        });
379    }
380
381    let ntype_val = ntype?;
382    let grid = maybe_grid?;
383
384    // Type 1 test: nu = ntype % 10 ∈ {0,3,7} AND ntype ≤ 62.
385    if (0..=62).contains(&ntype_val) {
386        let nu = ntype_val % 10;
387        if nu == 0 || nu == 3 || nu == 7 {
388            let callsign = unpack_call(n1)?;
389            return Some(WsprMessage::Type1 {
390                callsign,
391                grid,
392                power_dbm: ntype_val,
393            });
394        }
395        // Type 2: positive ntype but power-digit not in {0,3,7}.
396        // nadd encodes "this is a compound call" — recover by
397        //   n3 = n2 / 128 + 32768 * (nadd - 1)
398        //   actual_dbm = ntype - nadd
399        let nadd = if nu > 7 {
400            nu - 7
401        } else if nu > 3 {
402            nu - 3
403        } else {
404            nu
405        };
406        let n3 = (n2 >> 7) + 32_768 * (nadd as u32 - 1);
407        let base_call = unpack_call(n1)?;
408        let full_call = apply_prefix(n3, &base_call)?;
409        let power_dbm = ntype_val - nadd;
410        // Plausibility: the recovered power digit must land on {0,3,7,10}.
411        let pu = power_dbm.rem_euclid(10);
412        if pu != 0 && pu != 3 && pu != 7 {
413            return None;
414        }
415        return Some(WsprMessage::Type2 {
416            callsign: full_call,
417            power_dbm,
418        });
419    }
420
421    None
422}
423
424// ─────────────────────────────────────────────────────────────────────────
425// MessageCodec trait impl
426// ─────────────────────────────────────────────────────────────────────────
427
428use crate::core::{DecodeContext, MessageCodec, MessageFields};
429
430#[derive(Copy, Clone, Debug, Default)]
431pub struct Wspr50Message;
432
433impl MessageCodec for Wspr50Message {
434    type Unpacked = WsprMessage;
435    const PAYLOAD_BITS: u32 = 50;
436    const CRC_BITS: u32 = 0;
437
438    fn pack(&self, fields: &MessageFields) -> Option<Vec<u8>> {
439        let call = fields.call1.as_deref()?;
440        let grid = fields.grid.as_deref()?;
441        let power = fields.report?; // re-using MessageFields.report for dBm
442        let bits = pack_type1(call, grid, power)?;
443        Some(bits.to_vec())
444    }
445
446    fn unpack(&self, payload: &[u8], _ctx: &DecodeContext) -> Option<Self::Unpacked> {
447        if payload.len() != 50 {
448            return None;
449        }
450        let mut buf = [0u8; 50];
451        buf.copy_from_slice(payload);
452        unpack(&buf)
453    }
454}
455
456#[cfg(test)]
457mod tests {
458    use super::*;
459
460    #[test]
461    fn type1_roundtrip_callsign() {
462        let bits = pack_type1("K1ABC", "FN42", 37).expect("pack");
463        let m = unpack(&bits).expect("unpack");
464        assert_eq!(
465            m,
466            WsprMessage::Type1 {
467                callsign: "K1ABC".into(),
468                grid: "FN42".into(),
469                power_dbm: 37,
470            }
471        );
472    }
473
474    #[test]
475    fn type1_roundtrip_with_digit_in_second_slot() {
476        // Callsigns with digit at position 1 (e.g. "K9AN") shift into the
477        // "right-aligned" form, which is a known WSJT-X pack-call path.
478        let bits = pack_type1("K9AN", "EN50", 33).expect("pack");
479        let m = unpack(&bits).expect("unpack");
480        match m {
481            WsprMessage::Type1 {
482                callsign,
483                grid,
484                power_dbm,
485            } => {
486                assert_eq!(callsign, "K9AN");
487                assert_eq!(grid, "EN50");
488                assert_eq!(power_dbm, 33);
489            }
490            other => panic!("expected Type 1, got {:?}", other),
491        }
492    }
493
494    #[test]
495    fn invalid_power_rejected() {
496        assert!(pack_type1("K1ABC", "FN42", 42).is_none());
497    }
498
499    #[test]
500    fn invalid_grid_rejected() {
501        // Grid chars beyond 'R' are out of WSJT's locator alphabet.
502        assert!(pack_type1("K1ABC", "SS01", 37).is_none());
503    }
504
505    #[test]
506    fn unpack_rejects_reserved_call_range() {
507        // n1 values ≥ 262177560 have no Type-1 interpretation; when ntype
508        // looks like a Type 1 dBm we bail out to None.
509        let bits = {
510            let mut b = [0u8; 50];
511            // Set n1 = all ones = 0x0fff_ffff (28-bit) → well into reserved.
512            let n1 = 0x0fff_ffffu32;
513            let n2 = pack_grid4_power("FN42", 37).unwrap();
514            let bytes = pack50(n1, n2);
515            for i in 0..50 {
516                b[i] = (bytes[i / 8] >> (7 - (i % 8))) & 1;
517            }
518            b
519        };
520        // Should not produce Type 1 — either None or Type 2/3.
521        if let Some(WsprMessage::Type1 { .. }) = unpack(&bits) {
522            panic!("shouldn't be Type 1");
523        }
524    }
525
526    #[test]
527    fn type2_single_char_suffix() {
528        // Port WSJT-X's single-char-suffix encoding for `K1ABC/7` at 37 dBm
529        // and verify our `unpack` reverses it:
530        //   encode:
531        //     base_call = "K1ABC"
532        //     m_local   = 60000 - 32768 + 7 = 27239
533        //     nadd_enc  = 1
534        //     ntype     = power + 1 + nadd_enc = 39
535        //     n2        = 128 * m_local + ntype + 64 = 3_486_695
536        //   decode:
537        //     nu        = 39 % 10 = 9 → nadd_dec = 9 - 7 = 2
538        //     n3        = n2>>7 + 32768*(nadd_dec - 1) = 27239 + 32768 = 60007
539        //     → apply_prefix(60007) → "K1ABC/7", power = 39 - 2 = 37
540        let n1 = pack_call("K1ABC").expect("pack call");
541        let m_local = 60_000 - 32_768 + 7; // 27239
542        let ntype = 37 + 1 + 1; // 39
543        let n2 = 128 * m_local + (ntype + 64);
544        let bytes = pack50(n1, n2);
545        let mut bits = [0u8; 50];
546        for i in 0..50 {
547            bits[i] = (bytes[i / 8] >> (7 - (i % 8))) & 1;
548        }
549        let m = unpack(&bits).expect("unpack");
550        assert_eq!(
551            m,
552            WsprMessage::Type2 {
553                callsign: "K1ABC/7".into(),
554                power_dbm: 37,
555            }
556        );
557    }
558
559    #[test]
560    fn type2_prefix_pj4() {
561        // Port WSJT-X's prefix encoding for `PJ4/K1ABC` at 37 dBm:
562        //   prefix "PJ4" → packed as base-37 digits, length 3
563        //     start m = 0 (3-char prefix base)
564        //     for each char: m = 37*m + nc
565        //       P (25) → 25
566        //       J (19) → 25*37 + 19 = 944
567        //       4  (4) → 944*37 + 4 = 34932
568        //     m > 32768 → m -= 32768 = 2164, nadd_enc = 1
569        //   ntype = power + 1 + nadd_enc = 39
570        //   n2 = 128 * 2164 + ntype + 64 = 277095
571        //   decode:
572        //     nu = 39 % 10 = 9 → nadd_dec = 2
573        //     n3 = 2164 + 32768 = 34932 → < 60000 → prefix path
574        //     "PJ4" recovered
575        let n1 = pack_call("K1ABC").expect("pack call");
576        let m_local = {
577            let mut m: u32 = 0;
578            for &ch in b"PJ4" {
579                let nc = match ch {
580                    b'0'..=b'9' => ch - b'0',
581                    b'A'..=b'Z' => ch - b'A' + 10,
582                    _ => 36,
583                };
584                m = 37 * m + nc as u32;
585            }
586            assert!(m > 32_768, "PJ4 should land above 32768");
587            m - 32_768
588        };
589        let ntype = 37 + 1 + 1;
590        let n2 = 128 * m_local + (ntype + 64);
591        let bytes = pack50(n1, n2);
592        let mut bits = [0u8; 50];
593        for i in 0..50 {
594            bits[i] = (bytes[i / 8] >> (7 - (i % 8))) & 1;
595        }
596        let m = unpack(&bits).expect("unpack");
597        assert_eq!(
598            m,
599            WsprMessage::Type2 {
600                callsign: "PJ4/K1ABC".into(),
601                power_dbm: 37,
602            }
603        );
604    }
605
606    #[test]
607    fn type3_hashed_call_grid6() {
608        // Build a Type-3 message: hash=12345, grid6="FN42LX", power=27.
609        // Encoding:
610        //   grid6_rotated = "N42LXF"   (last-5 + first char)
611        //   n1 = pack_call(grid6_rotated)
612        //   ntype = -(power + 1) = -28
613        //   n2 = 128*hash + ntype + 64  (i.e. (n2 & 127) - 64 == -28)
614        let hash = 12_345u32;
615        let grid6 = "FN42LX";
616        let power = 27i32;
617        let rotated = {
618            let b = grid6.as_bytes();
619            format!(
620                "{}{}",
621                core::str::from_utf8(&b[1..6]).unwrap(),
622                b[0] as char
623            )
624        };
625        assert_eq!(rotated, "N42LXF");
626        // Hmm — "N42LXF" has a digit at position 1 (char '4'), which
627        // pack_call handles (right-aligned digit form not triggered).
628        // Verify pack_call accepts the rotated grid6.
629        let n1 = pack_call(&rotated).expect("pack call(grid6)");
630        let ntype: i32 = -(power + 1); // -28
631        // n2 = 128*hash + (ntype + 64) where ntype + 64 = 36, all positive
632        let n2 = hash * 128 + (ntype + 64) as u32;
633        let bytes = pack50(n1, n2);
634        let mut bits = [0u8; 50];
635        for i in 0..50 {
636            bits[i] = (bytes[i / 8] >> (7 - (i % 8))) & 1;
637        }
638        let m = unpack(&bits).expect("unpack");
639        assert_eq!(
640            m,
641            WsprMessage::Type3 {
642                callsign_hash: hash,
643                grid6: grid6.into(),
644                power_dbm: power,
645            }
646        );
647    }
648
649    #[test]
650    fn pack50_unpack50_all_bits() {
651        let n1 = 0x0deadb3u32;
652        let n2 = 0x001abcdu32 & 0x003f_ffff;
653        let bytes = pack50(n1, n2);
654        let (rn1, rn2) = unpack50(&bytes);
655        assert_eq!(rn1, n1);
656        assert_eq!(rn2, n2);
657    }
658}