Skip to main content

radio_utils_protocol/
types.rs

1use std::fmt;
2use std::net::SocketAddr;
3
4use num_complex::Complex;
5
6// ---------------------------------------------------------------------------
7// Error type
8// ---------------------------------------------------------------------------
9
10#[derive(Debug, thiserror::Error)]
11pub enum ProtocolError {
12    #[error("IO error: {0}")]
13    Io(#[from] std::io::Error),
14    #[error("no devices found")]
15    NoDevicesFound,
16    #[error("not connected")]
17    NotConnected,
18    #[error("invalid packet: {0}")]
19    InvalidPacket(String),
20    #[error("timeout")]
21    Timeout,
22    #[error("connection lost")]
23    ConnectionLost,
24}
25
26pub type Result<T> = std::result::Result<T, ProtocolError>;
27
28// ---------------------------------------------------------------------------
29// HpsdrHw enum
30// ---------------------------------------------------------------------------
31
32/// Hardware identity of the radio. The crate targets two boards: the
33/// original Hermes and the Hermes Lite 2. Filter, attenuator, and PA
34/// wiring differ; the enum dispatches the variant-specific control bytes.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum HpsdrHw {
37    Hermes,
38    HermesLite,
39}
40
41impl HpsdrHw {
42    /// Protocol 1 device-type code (byte 10 of the discovery response).
43    pub fn p1_code(self) -> u8 {
44        match self {
45            Self::Hermes => 1,
46            Self::HermesLite => 6,
47        }
48    }
49
50    /// Decode a Protocol 1 device-type code. Returns `None` for codes that
51    /// don't map to a supported board.
52    pub fn from_p1_code(code: u8) -> Option<Self> {
53        match code {
54            1 => Some(Self::Hermes),
55            6 => Some(Self::HermesLite),
56            _ => None,
57        }
58    }
59
60    /// Decode a CLI / config name like "hermes" or "hermeslite"
61    /// (case-insensitive). `hermeslite2` is accepted as a synonym for
62    /// `HermesLite`.
63    pub fn from_name(name: &str) -> Option<Self> {
64        match name.to_lowercase().as_str() {
65            "hermes" => Some(Self::Hermes),
66            "hermeslite" | "hermeslite2" => Some(Self::HermesLite),
67            _ => None,
68        }
69    }
70
71    /// Canonical name list for CLI/help output.
72    pub fn all_names() -> &'static [&'static str] {
73        &["hermes", "hermeslite"]
74    }
75
76    /// Maximum number of DDC (digital down-converter) channels this board
77    /// supports.
78    pub fn max_ddcs(self) -> u8 {
79        match self {
80            Self::Hermes => 4,
81            Self::HermesLite => 2,
82        }
83    }
84
85    /// Default RX meter calibration offset: dBm = dBFS + offset. Accounts
86    /// for the ADC full-scale reference and typical front-end gain. Based
87    /// on Thetis/deskHPSDR calibration values.
88    pub fn rx_meter_cal_offset(self) -> f64 {
89        match self {
90            Self::Hermes => -20.0,
91            Self::HermesLite => -19.0,
92        }
93    }
94}
95
96impl fmt::Display for HpsdrHw {
97    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
98        match self {
99            Self::Hermes => write!(f, "Hermes"),
100            Self::HermesLite => write!(f, "HermesLite"),
101        }
102    }
103}
104
105// ---------------------------------------------------------------------------
106// Sample rate tables
107// ---------------------------------------------------------------------------
108
109pub const SAMPLE_RATES_P1: &[(u32, u8)] = &[(48_000, 0), (96_000, 1), (192_000, 2), (384_000, 3)];
110
111pub fn sample_rate_to_p1_code(rate: u32) -> u8 {
112    for &(r, c) in SAMPLE_RATES_P1 {
113        if r == rate {
114            return c;
115        }
116    }
117    0
118}
119
120pub fn p1_code_to_sample_rate(code: u8) -> Option<u32> {
121    for &(r, c) in SAMPLE_RATES_P1 {
122        if c == code {
123            return Some(r);
124        }
125    }
126    None
127}
128
129// ---------------------------------------------------------------------------
130// Frequency-to-filter helpers (Hermes with Alex board)
131// ---------------------------------------------------------------------------
132
133/// Alex TX LPF bit for C0=0x12 C4. Returns a single-bit value matching the
134/// Thetis `_rbpfilter` mapping.
135pub fn alex_tx_lpf_for_freq(freq_hz: u32) -> u8 {
136    if freq_hz <= 2_500_000 {
137        0x08 // 160m
138    } else if freq_hz <= 5_000_000 {
139        0x04 // 80m
140    } else if freq_hz <= 8_000_000 {
141        0x02 // 60/40m
142    } else if freq_hz <= 16_500_000 {
143        0x01 // 30/20m
144    } else if freq_hz <= 24_000_000 {
145        0x40 // 17/15m
146    } else if freq_hz <= 35_600_000 {
147        0x20 // 12/10m
148    } else {
149        0x10 // 6m
150    }
151}
152
153/// Alex RX HPF bits for C0=0x12 C3. Returns the HPF selector bits matching
154/// the Thetis mapping.
155pub fn alex_rx_hpf_for_freq(freq_hz: u32) -> u8 {
156    if freq_hz < 1_500_000 {
157        0x20 // bypass
158    } else if freq_hz < 6_500_000 {
159        0x10 // 1.5 MHz HPF
160    } else if freq_hz < 9_500_000 {
161        0x08 // 6.5 MHz HPF
162    } else if freq_hz < 13_000_000 {
163        0x04 // 9.5 MHz HPF
164    } else if freq_hz < 20_000_000 {
165        0x01 // 13 MHz HPF
166    } else if freq_hz < 50_000_000 {
167        0x02 // 20 MHz HPF
168    } else {
169        0x42 // 20 MHz HPF + 6m preamp
170    }
171}
172
173/// N2ADR OC output value for HL2 (C0=0x00 C2, shifted left by 1 by caller).
174///
175/// Bit layout (before caller shift): bits [6:1] select LPF/HPF relay
176/// combinations on the N2ADR filter board. Bit 0 selects the 160m bypass
177/// (no HPF). Higher bits select progressively higher LPF and HPF cutoffs.
178/// Mapping per the N2ADR filter-board documentation.
179pub fn n2adr_oc_for_freq(freq_hz: u32) -> u8 {
180    if freq_hz <= 2_000_000 {
181        1 // 160m LPF (no HPF)
182    } else if freq_hz <= 4_000_000 {
183        66 // 80m LPF + 3 MHz HPF
184    } else if freq_hz <= 8_000_000 {
185        68 // 60/40m LPF + HPF
186    } else if freq_hz <= 15_000_000 {
187        72 // 30/20m LPF + HPF
188    } else if freq_hz <= 22_000_000 {
189        80 // 17/15m LPF + HPF
190    } else if freq_hz <= 30_000_000 {
191        96 // 12/10m LPF + HPF
192    } else {
193        64 // HPF only (6m)
194    }
195}
196
197// ---------------------------------------------------------------------------
198// Discovered device
199// ---------------------------------------------------------------------------
200
201#[derive(Debug, Clone)]
202pub struct DiscoveredDevice {
203    pub addr: SocketAddr,
204    pub mac: [u8; 6],
205    pub hw_type: HpsdrHw,
206    pub firmware_version: u8,
207    pub num_rxs: u8,
208    pub status: u8,
209}
210
211impl fmt::Display for DiscoveredDevice {
212    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
213        write!(
214            f,
215            "{} at {} (MAC={}, FW={}, RXs={})",
216            self.hw_type,
217            self.addr,
218            mac_to_string(&self.mac),
219            self.firmware_version,
220            self.num_rxs,
221        )
222    }
223}
224
225pub fn mac_to_string(mac: &[u8; 6]) -> String {
226    mac.iter()
227        .map(|b| format!("{:02x}", b))
228        .collect::<Vec<_>>()
229        .join(":")
230}
231
232// ---------------------------------------------------------------------------
233// Radio status
234// ---------------------------------------------------------------------------
235
236#[derive(Debug, Clone, Default)]
237pub struct RadioStatus {
238    pub ptt: bool,
239    pub adc_overflow: u8,
240    pub forward_power: u16,
241    pub reverse_power: u16,
242    pub exciter_power: u16,
243    pub supply_voltage: u16,
244    pub pa_current: u16,
245}
246
247// ---------------------------------------------------------------------------
248// IQ packing / unpacking
249// ---------------------------------------------------------------------------
250
251/// Unpack a 24-bit signed big-endian IQ sample from 6 bytes into Complex<f64>.
252///
253/// Q is negated to convert from the HPSDR wire convention (where the DDC
254/// mixer `e^{-jωt}` places USB at negative baseband frequency) to the
255/// standard math convention (positive baseband frequency = USB).
256#[inline]
257pub fn unpack_iq_24bit(buf: &[u8], offset: usize) -> Complex<f64> {
258    let i_raw = ((buf[offset] as i32) << 24)
259        | ((buf[offset + 1] as i32) << 16)
260        | ((buf[offset + 2] as i32) << 8);
261    let q_raw = ((buf[offset + 3] as i32) << 24)
262        | ((buf[offset + 4] as i32) << 16)
263        | ((buf[offset + 5] as i32) << 8);
264    Complex::new(
265        i_raw as f64 / 2_147_483_648.0,
266        -(q_raw as f64 / 2_147_483_648.0),
267    )
268}
269
270/// Pack a Complex<f64> IQ sample into 6 bytes (24-bit signed big-endian).
271///
272/// Q is written as-is (no negation). Used on the radio/emulator side where
273/// samples are already in wire format.
274#[inline]
275pub fn pack_iq_24bit_into(buf: &mut [u8], offset: usize, sample: Complex<f64>) -> usize {
276    pack_iq_24bit_into_ex(buf, offset, sample, false)
277}
278
279/// Pack a Complex<f64> IQ sample into 6 bytes (24-bit signed big-endian).
280///
281/// Q is negated to match HPSDR wire convention (host→radio direction).
282#[inline]
283pub fn pack_iq_24bit_into_negate_q(buf: &mut [u8], offset: usize, sample: Complex<f64>) -> usize {
284    pack_iq_24bit_into_ex(buf, offset, sample, true)
285}
286
287/// Pack a Complex<f64> IQ sample into 6 bytes (24-bit signed big-endian)
288/// with configurable Q-sign convention.
289///
290/// - `negate_q = true`: host-side (negates Q for the wire, matching protocol convention).
291/// - `negate_q = false`: radio/emulator-side (Q is already in wire format).
292#[inline]
293pub fn pack_iq_24bit_into_ex(
294    buf: &mut [u8],
295    offset: usize,
296    sample: Complex<f64>,
297    negate_q: bool,
298) -> usize {
299    let max_val: f64 = 8_388_607.0;
300    let iv = (sample.re.clamp(-1.0, 1.0) * max_val) as i32;
301    let q = if negate_q { -sample.im } else { sample.im };
302    let qv = (q.clamp(-1.0, 1.0) * max_val) as i32;
303    let iu = iv as u32 & 0xFF_FFFF;
304    let qu = qv as u32 & 0xFF_FFFF;
305    buf[offset] = ((iu >> 16) & 0xFF) as u8;
306    buf[offset + 1] = ((iu >> 8) & 0xFF) as u8;
307    buf[offset + 2] = (iu & 0xFF) as u8;
308    buf[offset + 3] = ((qu >> 16) & 0xFF) as u8;
309    buf[offset + 4] = ((qu >> 8) & 0xFF) as u8;
310    buf[offset + 5] = (qu & 0xFF) as u8;
311    offset + 6
312}
313
314/// Unpack 16-bit signed big-endian TX IQ from Protocol 1 sub-frame.
315/// Each 8-byte block: [L(2B) R(2B) I(2B) Q(2B)].
316pub fn unpack_tx_iq_16bit(data: &[u8]) -> Vec<Complex<f64>> {
317    let n_blocks = data.len() / 8;
318    let mut samples = Vec::with_capacity(n_blocks);
319    for k in 0..n_blocks {
320        let off = k * 8;
321        let i_val = i16::from_be_bytes([data[off + 4], data[off + 5]]);
322        let q_val = i16::from_be_bytes([data[off + 6], data[off + 7]]);
323        samples.push(Complex::new(i_val as f64 / 32768.0, q_val as f64 / 32768.0));
324    }
325    samples
326}
327
328/// Pack TX IQ samples into Protocol 1 format.
329/// Each 8-byte block: [L(2B) R(2B) I(2B) Q(2B)], L/R set to 0.
330pub fn pack_tx_iq_16bit(samples: &[Complex<f64>]) -> Vec<u8> {
331    let mut buf = vec![0u8; samples.len() * 8];
332    for (k, s) in samples.iter().enumerate() {
333        let off = k * 8;
334        // L/R audio = 0 (bytes 0-3)
335        let i_val = (s.re.clamp(-1.0, 1.0) * 32767.0) as i16;
336        let q_val = (s.im.clamp(-1.0, 1.0) * 32767.0) as i16;
337        let i_bytes = i_val.to_be_bytes();
338        let q_bytes = q_val.to_be_bytes();
339        buf[off + 4] = i_bytes[0];
340        buf[off + 5] = i_bytes[1];
341        buf[off + 6] = q_bytes[0];
342        buf[off + 7] = q_bytes[1];
343    }
344    buf
345}
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350
351    // ---- IQ 24-bit pack/unpack roundtrips ----
352    // These tests use pack_iq_24bit_into_negate_q paired with unpack_iq_24bit,
353    // both of which apply Q-negation, so they form a correct roundtrip.
354
355    #[test]
356    fn iq_24bit_roundtrip_zero() {
357        let sample = Complex::new(0.0, 0.0);
358        let mut buf = [0u8; 6];
359        pack_iq_24bit_into_negate_q(&mut buf, 0, sample);
360        let out = unpack_iq_24bit(&buf, 0);
361        assert!(out.re.abs() < 1e-6);
362        assert!(out.im.abs() < 1e-6);
363    }
364
365    #[test]
366    fn iq_24bit_roundtrip_positive_one() {
367        let sample = Complex::new(1.0, 1.0);
368        let mut buf = [0u8; 6];
369        pack_iq_24bit_into_negate_q(&mut buf, 0, sample);
370        let out = unpack_iq_24bit(&buf, 0);
371        // Should be close to 1.0 within 24-bit quantization error
372        assert!((out.re - 1.0).abs() < 2e-7 + 1.0 / 8_388_607.0);
373        assert!((out.im - 1.0).abs() < 2e-7 + 1.0 / 8_388_607.0);
374    }
375
376    #[test]
377    fn iq_24bit_roundtrip_negative_one() {
378        let sample = Complex::new(-1.0, -1.0);
379        let mut buf = [0u8; 6];
380        pack_iq_24bit_into_negate_q(&mut buf, 0, sample);
381        let out = unpack_iq_24bit(&buf, 0);
382        assert!((out.re - (-1.0)).abs() < 2e-7 + 1.0 / 8_388_607.0);
383        assert!((out.im - (-1.0)).abs() < 2e-7 + 1.0 / 8_388_607.0);
384    }
385
386    #[test]
387    fn iq_24bit_roundtrip_half() {
388        let sample = Complex::new(0.5, -0.5);
389        let mut buf = [0u8; 6];
390        pack_iq_24bit_into_negate_q(&mut buf, 0, sample);
391        let out = unpack_iq_24bit(&buf, 0);
392        assert!((out.re - 0.5).abs() < 2e-7);
393        assert!((out.im - (-0.5)).abs() < 2e-7);
394    }
395
396    #[test]
397    fn iq_24bit_known_bit_pattern() {
398        // Pack a known value and verify bytes
399        let sample = Complex::new(0.0, 0.0);
400        let mut buf = [0u8; 6];
401        pack_iq_24bit_into(&mut buf, 0, sample);
402        assert_eq!(buf, [0, 0, 0, 0, 0, 0]);
403    }
404
405    #[test]
406    fn iq_24bit_clamps_out_of_range() {
407        // Values > 1.0 should be clamped
408        let sample = Complex::new(2.0, -2.0);
409        let mut buf = [0u8; 6];
410        pack_iq_24bit_into_negate_q(&mut buf, 0, sample);
411        let out = unpack_iq_24bit(&buf, 0);
412        assert!((out.re - 1.0).abs() < 2e-7 + 1.0 / 8_388_607.0);
413        assert!((out.im - (-1.0)).abs() < 2e-7 + 1.0 / 8_388_607.0);
414    }
415
416    #[test]
417    fn iq_24bit_offset_packing() {
418        // Test with non-zero offset
419        let sample = Complex::new(0.25, 0.75);
420        let mut buf = [0u8; 12];
421        pack_iq_24bit_into_negate_q(&mut buf, 6, sample);
422        let out = unpack_iq_24bit(&buf, 6);
423        assert!((out.re - 0.25).abs() < 2e-7);
424        assert!((out.im - 0.75).abs() < 2e-7);
425    }
426
427    // ---- IQ 16-bit roundtrip ----
428
429    #[test]
430    fn tx_iq_16bit_roundtrip() {
431        let samples = vec![
432            Complex::new(0.5, -0.5),
433            Complex::new(0.0, 1.0),
434            Complex::new(-1.0, 0.0),
435        ];
436        let packed = pack_tx_iq_16bit(&samples);
437        assert_eq!(packed.len(), 24); // 3 * 8 bytes
438        let unpacked = unpack_tx_iq_16bit(&packed);
439        assert_eq!(unpacked.len(), 3);
440        for (orig, recovered) in samples.iter().zip(unpacked.iter()) {
441            // 16-bit has ~3e-5 quantization error
442            assert!((orig.re - recovered.re).abs() < 5e-5);
443            assert!((orig.im - recovered.im).abs() < 5e-5);
444        }
445    }
446
447    #[test]
448    fn tx_iq_16bit_lr_bytes_zero() {
449        // L/R audio bytes should always be zero
450        let samples = vec![Complex::new(0.5, 0.5)];
451        let packed = pack_tx_iq_16bit(&samples);
452        assert_eq!(packed[0], 0); // L high
453        assert_eq!(packed[1], 0); // L low
454        assert_eq!(packed[2], 0); // R high
455        assert_eq!(packed[3], 0); // R low
456    }
457
458    // ---- HpsdrHw P1 code roundtrip ----
459
460    #[test]
461    fn hpsdr_hw_p1_roundtrip() {
462        for hw in [HpsdrHw::Hermes, HpsdrHw::HermesLite] {
463            let code = hw.p1_code();
464            assert_eq!(
465                HpsdrHw::from_p1_code(code),
466                Some(hw),
467                "P1 roundtrip failed for {:?}",
468                hw
469            );
470        }
471    }
472
473    #[test]
474    fn hpsdr_hw_unknown_code_returns_none() {
475        assert_eq!(HpsdrHw::from_p1_code(99), None);
476    }
477
478    #[test]
479    fn from_name_accepts_canonical_and_alias() {
480        assert_eq!(HpsdrHw::from_name("hermes"), Some(HpsdrHw::Hermes));
481        assert_eq!(HpsdrHw::from_name("HERMES"), Some(HpsdrHw::Hermes));
482        assert_eq!(HpsdrHw::from_name("hermeslite"), Some(HpsdrHw::HermesLite));
483        // `hermeslite2` is the user-facing model name; map it to HermesLite.
484        assert_eq!(HpsdrHw::from_name("hermeslite2"), Some(HpsdrHw::HermesLite));
485        assert_eq!(HpsdrHw::from_name("nope"), None);
486    }
487
488    // ---- max_ddcs bound ----
489
490    #[test]
491    fn max_ddcs_positive() {
492        for hw in [HpsdrHw::Hermes, HpsdrHw::HermesLite] {
493            assert!(hw.max_ddcs() >= 1);
494        }
495    }
496
497    // ---- Sample rate code roundtrips ----
498
499    #[test]
500    fn sample_rate_code_roundtrip() {
501        for &(rate, code) in SAMPLE_RATES_P1 {
502            assert_eq!(sample_rate_to_p1_code(rate), code);
503            assert_eq!(p1_code_to_sample_rate(code), Some(rate));
504        }
505    }
506
507    #[test]
508    fn sample_rate_unknown_returns_zero() {
509        assert_eq!(sample_rate_to_p1_code(12345), 0);
510    }
511
512    #[test]
513    fn p1_code_unknown_returns_none() {
514        assert_eq!(p1_code_to_sample_rate(99), None);
515    }
516
517    // ---- mac_to_string format ----
518
519    #[test]
520    fn mac_to_string_format() {
521        let mac = [0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02];
522        let s = mac_to_string(&mac);
523        assert_eq!(s, "de:ad:be:ef:01:02");
524    }
525
526    #[test]
527    fn mac_to_string_zeros() {
528        let mac = [0x00; 6];
529        assert_eq!(mac_to_string(&mac), "00:00:00:00:00:00");
530    }
531
532    // ---- RadioStatus defaults ----
533
534    #[test]
535    fn radio_status_defaults() {
536        let status = RadioStatus::default();
537        assert!(!status.ptt);
538        assert_eq!(status.adc_overflow, 0);
539        assert_eq!(status.forward_power, 0);
540        assert_eq!(status.reverse_power, 0);
541        assert_eq!(status.exciter_power, 0);
542    }
543}