diff --git a/crates/syntonia/Cargo.toml b/crates/syntonia/Cargo.toml index e2dd6a5..a61f90e 100644 --- a/crates/syntonia/Cargo.toml +++ b/crates/syntonia/Cargo.toml @@ -1,5 +1,6 @@ [package] name = "syntonia" +description = "Radio-agnostic channel and frequency plan data model" version.workspace = true edition.workspace = true rust-version.workspace = true diff --git a/crates/syntonia/src/baofeng/codec.rs b/crates/syntonia/src/baofeng/codec.rs new file mode 100644 index 0000000..e24d5c2 --- /dev/null +++ b/crates/syntonia/src/baofeng/codec.rs @@ -0,0 +1,405 @@ +//! Variant-aware EEPROM channel codec for the UV-5R family. +//! +//! Decodes and encodes 16-byte channel records from/to the radio-agnostic +//! [`Channel`] type, using the variant's [`VariantConfig`] for power mapping. + +use koinon::Frequency; +use snafu::Snafu; + +use crate::channel::Channel; +use crate::tone::ToneMode; +use crate::types::{Bandwidth, FrequencyOffset, PowerLevel, ScanMode}; + +use super::variant::VariantConfig; + +/// Size of a single channel record in the EEPROM image. +pub const CHANNEL_RECORD_SIZE: usize = 16; + +/// Byte offset of the power/bandwidth/scan flags byte within a channel record. +const FLAGS_OFFSET: usize = 14; + +/// Bit mask for the 2-bit power field within the flags byte (bits 1:0). +const POWER_MASK: u8 = 0x03; + +/// Bit position of the bandwidth flag within the flags byte. +const BANDWIDTH_BIT: u8 = 2; + +/// Bit position of the scan-skip flag within the flags byte. +const SCAN_SKIP_BIT: u8 = 3; + +/// Bit position of the busy-lock flag within the flags byte. +const BUSY_LOCK_BIT: u8 = 4; + +// ── Errors ─────────────────────────────────────────────────────────────────── + +/// Errors from channel codec operations. +#[derive(Debug, Snafu)] +#[snafu(visibility(pub(crate)))] +#[non_exhaustive] +pub enum CodecError { + /// The channel record is too short to decode. + #[snafu(display( + "channel record too short: expected {CHANNEL_RECORD_SIZE} bytes, got {actual}" + ))] + RecordTooShort { + /// Actual size of the record. + actual: usize, + }, + + /// The power bits in the EEPROM do not map to a known power level. + #[snafu(display("unknown power bits {bits:#04X} for variant {variant}"))] + UnknownPowerBits { + /// The raw 2-bit value. + bits: u8, + /// Variant name for context. + variant: String, + }, + + /// The power level is not supported by this variant. + #[snafu(display("power level {level:?} not supported by variant {variant}"))] + UnsupportedPowerLevel { + /// The requested power level. + level: PowerLevel, + /// Variant name for context. + variant: String, + }, +} + +// ── Decode ─────────────────────────────────────────────────────────────────── + +/// Decode a single channel from a 16-byte EEPROM record. +/// +/// Uses the variant config to interpret the 2-bit power field correctly. +/// +/// # EEPROM channel record layout (16 bytes) +/// +/// | Offset | Size | Field | +/// |--------|------|-------| +/// | 0–3 | 4 | RX frequency (BCD, MHz × 10) | +/// | 4–7 | 4 | TX offset (BCD, MHz × 10) | +/// | 8 | 1 | RX tone index | +/// | 9 | 1 | TX tone index | +/// | 10 | 1 | Signal / scramble | +/// | 11–13 | 3 | Reserved | +/// | 14 | 1 | Flags: power(1:0), bandwidth(2), scan(3), busy-lock(4) | +/// | 15 | 1 | Step / pad | +/// +/// # Errors +/// +/// Returns [`CodecError::RecordTooShort`] if the slice is too small, or +/// [`CodecError::UnknownPowerBits`] if the power field doesn't map to a +/// known level. +pub fn decode_channel( + index: u16, + record: &[u8], + config: &VariantConfig, +) -> Result { + if record.len() < CHANNEL_RECORD_SIZE { + return RecordTooShortSnafu { + actual: record.len(), + } + .fail(); + } + + let rx_bytes: [u8; 4] = record + .get(..4) + .and_then(|s| <[u8; 4]>::try_from(s).ok()) + .ok_or(CodecError::RecordTooShort { + actual: record.len(), + })?; + let tx_bytes: [u8; 4] = record + .get(4..8) + .and_then(|s| <[u8; 4]>::try_from(s).ok()) + .ok_or(CodecError::RecordTooShort { + actual: record.len(), + })?; + let rx_freq = decode_bcd_freq(rx_bytes); + let tx_offset = decode_bcd_freq(tx_bytes); + + let flags = *record.get(FLAGS_OFFSET).ok_or(CodecError::RecordTooShort { + actual: record.len(), + })?; + let power_bits = flags & POWER_MASK; + let power = config + .power_from_bits(power_bits) + .ok_or_else(|| CodecError::UnknownPowerBits { + bits: power_bits, + variant: config.variant.to_string(), + })?; + + let bandwidth = if flags & (1 << BANDWIDTH_BIT) != 0 { + Bandwidth::Narrow + } else { + Bandwidth::Wide + }; + + let scan = if flags & (1 << SCAN_SKIP_BIT) != 0 { + ScanMode::Skip + } else { + ScanMode::Include + }; + + let busy_lock = flags & (1 << BUSY_LOCK_BIT) != 0; + + let (offset, tx_freq) = if tx_offset.as_hz() == 0 { + (FrequencyOffset::None, None) + } else { + // WHY: TX offset is stored as an absolute value — direction determined + // by a separate direction bit, but for simplicity we store as Plus. + (FrequencyOffset::Plus(tx_offset), Some(rx_freq + tx_offset)) + }; + + Ok(Channel { + index, + name: String::new(), + rx_freq, + tx_freq, + offset, + tone: ToneMode::None, + power, + bandwidth, + scan, + busy_lock, + }) +} + +/// Encode a channel into a 16-byte EEPROM record. +/// +/// # Errors +/// +/// Returns [`CodecError::UnsupportedPowerLevel`] if the channel's power level +/// cannot be represented by this variant. +pub fn encode_channel( + channel: &Channel, + config: &VariantConfig, +) -> Result<[u8; CHANNEL_RECORD_SIZE], CodecError> { + let mut record = [0u8; CHANNEL_RECORD_SIZE]; + + let mut rx_buf = [0u8; 4]; + encode_bcd_freq(channel.rx_freq, &mut rx_buf); + record[..4].copy_from_slice(&rx_buf); + + let tx_offset = match channel.offset { + FrequencyOffset::None => Frequency::hz(0), + FrequencyOffset::Plus(f) | FrequencyOffset::Minus(f) | FrequencyOffset::Split(f) => f, + }; + let mut tx_buf = [0u8; 4]; + encode_bcd_freq(tx_offset, &mut tx_buf); + record[4..8].copy_from_slice(&tx_buf); + + let power_bits = + config + .bits_from_power(channel.power) + .ok_or_else(|| CodecError::UnsupportedPowerLevel { + level: channel.power, + variant: config.variant.to_string(), + })?; + + let mut flags = power_bits & POWER_MASK; + if channel.bandwidth == Bandwidth::Narrow { + flags |= 1 << BANDWIDTH_BIT; + } + if channel.scan == ScanMode::Skip { + flags |= 1 << SCAN_SKIP_BIT; + } + if channel.busy_lock { + flags |= 1 << BUSY_LOCK_BIT; + } + record[FLAGS_OFFSET] = flags; + + Ok(record) +} + +// ── BCD frequency helpers ──────────────────────────────────────────────────── + +/// Decode a 4-byte BCD-encoded frequency (units of 10 Hz). +fn decode_bcd_freq(bytes: [u8; 4]) -> Frequency { + let mut val: u64 = 0; + for b in bytes { + val = val * 100 + u64::from(b >> 4) * 10 + u64::from(b & 0x0F); + } + // BCD value is in units of 10 Hz + Frequency::hz(val * 10) +} + +/// Encode a frequency into 4 bytes of BCD (units of 10 Hz). +fn encode_bcd_freq(freq: Frequency, out: &mut [u8; 4]) { + let mut val = freq.as_hz() / 10; + for byte in out.iter_mut().rev() { + let lo = (val % 10) as u8; + val /= 10; + let hi = (val % 10) as u8; + val /= 10; + *byte = (hi << 4) | lo; + } +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +#[cfg(test)] +#[allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::indexing_slicing, + clippy::missing_docs_in_private_items +)] +mod tests { + use super::*; + use crate::baofeng::variant::{bf_f8hp_config, uv5r_config}; + + fn make_record(rx_freq: Frequency, power_bits: u8) -> [u8; CHANNEL_RECORD_SIZE] { + let mut record = [0u8; CHANNEL_RECORD_SIZE]; + let rx_out: &mut [u8; 4] = (&mut record[..4]).try_into().unwrap(); + encode_bcd_freq(rx_freq, rx_out); + record[FLAGS_OFFSET] = power_bits & POWER_MASK; + record + } + + #[test] + fn decode_uv5r_high_power() { + let config = uv5r_config(); + let record = make_record(Frequency::hz(146_520_000), 0); + let ch = decode_channel(0, &record, &config).unwrap(); + assert_eq!(ch.power, PowerLevel::High); + assert_eq!(ch.rx_freq, Frequency::hz(146_520_000)); + } + + #[test] + fn decode_uv5r_low_power() { + let config = uv5r_config(); + let record = make_record(Frequency::hz(446_000_000), 1); + let ch = decode_channel(0, &record, &config).unwrap(); + assert_eq!(ch.power, PowerLevel::Low); + } + + #[test] + fn decode_uv5r_mid_bits_treated_as_high() { + let config = uv5r_config(); + let record = make_record(Frequency::hz(146_520_000), 2); + let ch = decode_channel(0, &record, &config).unwrap(); + assert_eq!(ch.power, PowerLevel::High); + } + + #[test] + fn decode_f8hp_high_power() { + let config = bf_f8hp_config(); + let record = make_record(Frequency::hz(146_520_000), 0); + let ch = decode_channel(0, &record, &config).unwrap(); + assert_eq!(ch.power, PowerLevel::High); + } + + #[test] + fn decode_f8hp_mid_power() { + let config = bf_f8hp_config(); + let record = make_record(Frequency::hz(146_520_000), 2); + let ch = decode_channel(0, &record, &config).unwrap(); + assert_eq!(ch.power, PowerLevel::Mid); + } + + #[test] + fn decode_f8hp_low_power() { + let config = bf_f8hp_config(); + let record = make_record(Frequency::hz(146_520_000), 1); + let ch = decode_channel(0, &record, &config).unwrap(); + assert_eq!(ch.power, PowerLevel::Low); + } + + #[test] + fn encode_decode_roundtrip_uv5r() { + let config = uv5r_config(); + for level in [PowerLevel::High, PowerLevel::Low] { + let ch = Channel { + index: 5, + name: String::new(), + rx_freq: Frequency::hz(146_520_000), + tx_freq: None, + offset: FrequencyOffset::None, + tone: ToneMode::None, + power: level, + bandwidth: Bandwidth::Wide, + scan: ScanMode::Include, + busy_lock: false, + }; + let record = encode_channel(&ch, &config).unwrap(); + let decoded = decode_channel(5, &record, &config).unwrap(); + assert_eq!(decoded.power, level); + assert_eq!(decoded.rx_freq, ch.rx_freq); + assert_eq!(decoded.bandwidth, ch.bandwidth); + assert_eq!(decoded.scan, ch.scan); + assert_eq!(decoded.busy_lock, ch.busy_lock); + } + } + + #[test] + fn encode_decode_roundtrip_f8hp() { + let config = bf_f8hp_config(); + for level in [PowerLevel::High, PowerLevel::Mid, PowerLevel::Low] { + let ch = Channel { + index: 10, + name: String::new(), + rx_freq: Frequency::hz(446_000_000), + tx_freq: None, + offset: FrequencyOffset::None, + tone: ToneMode::None, + power: level, + bandwidth: Bandwidth::Narrow, + scan: ScanMode::Skip, + busy_lock: true, + }; + let record = encode_channel(&ch, &config).unwrap(); + let decoded = decode_channel(10, &record, &config).unwrap(); + assert_eq!(decoded.power, level); + assert_eq!(decoded.rx_freq, ch.rx_freq); + assert_eq!(decoded.bandwidth, Bandwidth::Narrow); + assert_eq!(decoded.scan, ScanMode::Skip); + assert!(decoded.busy_lock); + } + } + + #[test] + fn record_too_short_returns_error() { + let config = uv5r_config(); + let short = [0u8; 8]; + let err = decode_channel(0, &short, &config); + assert!(matches!(err, Err(CodecError::RecordTooShort { actual: 8 }))); + } + + #[test] + fn bcd_frequency_roundtrip() { + let freqs = [ + Frequency::hz(146_520_000), + Frequency::hz(446_000_000), + Frequency::hz(136_000_000), + Frequency::hz(520_000_000), + ]; + for freq in freqs { + let mut buf = [0u8; 4]; + encode_bcd_freq(freq, &mut buf); + let decoded = decode_bcd_freq(buf); + assert_eq!(decoded, freq, "BCD roundtrip failed for {freq}"); + } + } + + #[test] + fn flags_byte_encodes_all_fields() { + let config = bf_f8hp_config(); + let ch = Channel { + index: 0, + name: String::new(), + rx_freq: Frequency::hz(146_520_000), + tx_freq: None, + offset: FrequencyOffset::None, + tone: ToneMode::None, + power: PowerLevel::Mid, + bandwidth: Bandwidth::Narrow, + scan: ScanMode::Skip, + busy_lock: true, + }; + let record = encode_channel(&ch, &config).unwrap(); + let flags = record[FLAGS_OFFSET]; + assert_eq!(flags & POWER_MASK, 2); // Mid + assert_ne!(flags & (1 << BANDWIDTH_BIT), 0); // Narrow + assert_ne!(flags & (1 << SCAN_SKIP_BIT), 0); // Skip + assert_ne!(flags & (1 << BUSY_LOCK_BIT), 0); // Busy lock + } +} diff --git a/crates/syntonia/src/baofeng/detect.rs b/crates/syntonia/src/baofeng/detect.rs new file mode 100644 index 0000000..4185669 --- /dev/null +++ b/crates/syntonia/src/baofeng/detect.rs @@ -0,0 +1,299 @@ +//! Auto-detection of Baofeng UV-5R family radio variants. +//! +//! Tries multiple magic byte sequences to identify the connected radio, +//! returning the appropriate [`VariantConfig`] on success. Hardware +//! serial interaction is abstracted behind the [`SerialPort`] trait +//! for testability. + +use snafu::Snafu; + +use super::variant::{MAGIC_SETS, RadioIdent, VariantConfig, VariantError, identify_variant}; + +// ── SerialPort trait ───────────────────────────────────────────────────────── + +/// Minimal serial port abstraction for radio communication. +/// +/// Implementations must handle baud rate, timeout, and framing. +/// This trait exists to enable mock-based testing of the detection flow +/// without requiring actual hardware. +pub trait SerialPort { + /// Write bytes to the serial port. + /// + /// # Errors + /// + /// Returns an error if the write fails. + fn write_all(&mut self, buf: &[u8]) -> Result<(), DetectError>; + + /// Read exactly `len` bytes from the serial port. + /// + /// # Errors + /// + /// Returns [`DetectError::Timeout`] if the read times out, or + /// [`DetectError::SerialIo`] on other I/O errors. + fn read_exact(&mut self, buf: &mut [u8]) -> Result<(), DetectError>; + + /// Flush transmit buffer. + /// + /// # Errors + /// + /// Returns an error if the flush fails. + fn flush(&mut self) -> Result<(), DetectError>; +} + +// ── Errors ─────────────────────────────────────────────────────────────────── + +/// Errors from the auto-detection flow. +#[derive(Debug, Snafu)] +#[snafu(visibility(pub(crate)))] +#[non_exhaustive] +pub enum DetectError { + /// Serial I/O failure. + #[snafu(display("serial I/O error: {message}"))] + SerialIo { + /// Description of the failure. + message: String, + }, + + /// Read timed out waiting for radio response. + #[snafu(display("timeout waiting for radio response"))] + Timeout, + + /// All magic byte sequences failed — no radio detected. + #[snafu(display( + "no compatible radio detected after trying all magic sequences. \ + Troubleshooting: (1) check cable connection, (2) ensure radio is powered on, \ + (3) if UV-5RM Plus: this radio may use UV17Pro protocol (115200 baud) — \ + investigation needed" + ))] + NoRadioDetected, + + /// The radio responded but its firmware ident is unrecognized. + #[snafu(display("variant identification failed: {source}"))] + VariantIdentification { + /// The underlying variant error. + source: VariantError, + }, +} + +// ── Detection flow ─────────────────────────────────────────────────────────── + +/// Try to detect and identify a Baofeng UV-5R family radio. +/// +/// Iterates through [`MAGIC_SETS`] in priority order, attempting to enter +/// programming mode with each set. On success, reads the firmware ident +/// and returns the matched [`VariantConfig`]. +/// +/// # Errors +/// +/// - [`DetectError::NoRadioDetected`] if all magic sets fail (includes +/// troubleshooting hints for UV-5RM Plus / `UV17Pro`) +/// - [`DetectError::VariantIdentification`] if the radio responds but has +/// an unrecognized firmware ident +/// - [`DetectError::SerialIo`] on I/O errors +pub fn auto_detect(port: &mut dyn SerialPort) -> Result<(RadioIdent, VariantConfig), DetectError> { + for &magic in MAGIC_SETS { + match try_magic(port, magic) { + Ok((ident, config)) => return Ok((ident, config)), + Err(DetectError::Timeout) => {} + Err(other) => return Err(other), + } + } + + NoRadioDetectedSnafu.fail() +} + +/// Attempt a single magic byte sequence handshake. +fn try_magic( + port: &mut dyn SerialPort, + magic: [u8; 7], +) -> Result<(RadioIdent, VariantConfig), DetectError> { + // Send magic bytes + port.write_all(&magic)?; + port.flush()?; + + // Read ACK (single byte: 0x06) + let mut ack = [0u8; 1]; + port.read_exact(&mut ack)?; + if ack[0] != 0x06 { + return TimeoutSnafu.fail(); + } + + // Send identify command (0x02) + port.write_all(&[0x02])?; + port.flush()?; + + // Read ident response: length byte + ident data + let mut len_buf = [0u8; 1]; + port.read_exact(&mut len_buf)?; + let ident_len = len_buf[0] as usize; + + let mut ident_data = vec![0u8; ident_len]; + port.read_exact(&mut ident_data)?; + + let ident = RadioIdent { raw: ident_data }; + + let config = + identify_variant(&ident).map_err(|e| DetectError::VariantIdentification { source: e })?; + + Ok((ident, config)) +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +#[cfg(test)] +#[allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::indexing_slicing, + clippy::missing_docs_in_private_items +)] +mod tests { + use std::collections::VecDeque; + + use super::*; + use crate::baofeng::variant::RadioVariant; + + /// Mock serial port that replays pre-recorded responses. + struct MockSerial { + /// Queued responses, popped from front on each read. + responses: VecDeque, + /// Bytes written by the caller (for verification). + written: Vec, + } + + enum MockResponse { + Data(Vec), + Timeout, + } + + impl MockSerial { + fn new() -> Self { + Self { + responses: VecDeque::new(), + written: Vec::new(), + } + } + + fn queue_data(&mut self, data: &[u8]) { + self.responses.push_back(MockResponse::Data(data.to_vec())); + } + + fn queue_timeout(&mut self) { + self.responses.push_back(MockResponse::Timeout); + } + } + + impl SerialPort for MockSerial { + fn write_all(&mut self, buf: &[u8]) -> Result<(), DetectError> { + self.written.extend_from_slice(buf); + Ok(()) + } + + fn read_exact(&mut self, buf: &mut [u8]) -> Result<(), DetectError> { + match self.responses.pop_front() { + Some(MockResponse::Data(data)) => { + let len = buf.len().min(data.len()); + buf[..len].copy_from_slice(&data[..len]); + Ok(()) + } + Some(MockResponse::Timeout) | None => TimeoutSnafu.fail(), + } + } + + fn flush(&mut self) -> Result<(), DetectError> { + Ok(()) + } + } + + /// Queue a successful handshake for a UV-5R with given firmware prefix. + fn queue_uv5r_handshake(mock: &mut MockSerial, firmware: &[u8]) { + // ACK + mock.queue_data(&[0x06]); + // Ident response: length + data + let mut response = vec![firmware.len() as u8]; + response.extend_from_slice(firmware); + // Split into length byte and ident data as separate reads + mock.queue_data(&response[..1]); + mock.queue_data(firmware); + } + + #[test] + fn detect_uv5r_on_first_magic() { + let mut mock = MockSerial::new(); + queue_uv5r_handshake(&mut mock, b"BFB297"); + let (ident, config) = auto_detect(&mut mock).unwrap(); + assert_eq!(config.variant, RadioVariant::Uv5r); + assert!(ident.firmware_prefix().starts_with("BFB")); + } + + #[test] + fn detect_f8hp_after_first_magic_fails() { + let mut mock = MockSerial::new(); + // First magic: timeout on ACK + mock.queue_timeout(); + // Second magic (BF-F8HP): success + queue_uv5r_handshake(&mut mock, b"BFP3V3 F"); + let (_, config) = auto_detect(&mut mock).unwrap(); + assert_eq!(config.variant, RadioVariant::BfF8hp); + } + + #[test] + fn all_magics_fail_returns_no_radio_detected() { + let mut mock = MockSerial::new(); + // All three magic attempts timeout + mock.queue_timeout(); + mock.queue_timeout(); + mock.queue_timeout(); + let err = auto_detect(&mut mock).unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("no compatible radio"), + "expected 'no compatible radio', got: {msg}" + ); + assert!( + msg.contains("UV17Pro"), + "error should mention UV17Pro protocol, got: {msg}" + ); + } + + #[test] + fn detect_with_bad_ack_tries_next_magic() { + let mut mock = MockSerial::new(); + // First magic: bad ACK (not 0x06) + mock.queue_data(&[0xFF]); + // Second magic: timeout + mock.queue_timeout(); + // Third magic: success (original UV-5R) + queue_uv5r_handshake(&mut mock, b"BFB100"); + let (_, config) = auto_detect(&mut mock).unwrap(); + assert_eq!(config.variant, RadioVariant::Uv5r); + } + + #[test] + fn unknown_ident_returns_variant_error() { + let mut mock = MockSerial::new(); + // ACK + unknown ident + mock.queue_data(&[0x06]); + mock.queue_data(&[4]); // length = 4 + mock.queue_data(&[0xDE, 0xAD, 0xBE, 0xEF]); + let err = auto_detect(&mut mock).unwrap_err(); + assert!( + matches!(err, DetectError::VariantIdentification { .. }), + "expected VariantIdentification error, got: {err:?}" + ); + } + + #[test] + fn no_radio_detected_includes_troubleshooting() { + let mut mock = MockSerial::new(); + mock.queue_timeout(); + mock.queue_timeout(); + mock.queue_timeout(); + let err = auto_detect(&mut mock).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("check cable")); + assert!(msg.contains("powered on")); + assert!(msg.contains("UV17Pro")); + assert!(msg.contains("115200")); + } +} diff --git a/crates/syntonia/src/baofeng/mod.rs b/crates/syntonia/src/baofeng/mod.rs new file mode 100644 index 0000000..f8108cc --- /dev/null +++ b/crates/syntonia/src/baofeng/mod.rs @@ -0,0 +1,14 @@ +//! Baofeng UV-5R family radio driver — variant identification, codec, and protocol. +//! +//! Supports the UV-5R, BF-F8HP, and UV-5RM Plus radios. All share the same +//! EEPROM clone protocol at 9600 baud but differ in magic bytes, power levels, +//! and auxiliary block handling. + +pub mod codec; +pub mod detect; +pub mod protocol; +pub mod variant; + +pub use codec::{decode_channel, encode_channel}; +pub use detect::auto_detect; +pub use variant::{RadioVariant, VariantConfig, identify_variant}; diff --git a/crates/syntonia/src/baofeng/protocol.rs b/crates/syntonia/src/baofeng/protocol.rs new file mode 100644 index 0000000..cf93f2a --- /dev/null +++ b/crates/syntonia/src/baofeng/protocol.rs @@ -0,0 +1,323 @@ +//! Variant-aware EEPROM read/write protocol for the UV-5R family. +//! +//! Handles the differences between UV-5R (no aux block) and BF-F8HP +//! (aux block with warm-up read, dropped-byte workaround at 0x1FCF). + +use snafu::Snafu; + +use super::variant::VariantConfig; + +/// Standard EEPROM block size for reads/writes. +pub const BLOCK_SIZE: usize = 16; + +/// Start of the main channel memory region. +pub const MAIN_START: u16 = 0x0000; + +/// End of the main memory region (exclusive) for UV-5R (no aux). +pub const MAIN_END: u16 = 0x1800; + +/// Start of the auxiliary EEPROM block (BF-F8HP and variants). +pub const AUX_START: u16 = 0x1E80; + +/// End of the auxiliary EEPROM block (exclusive). +pub const AUX_END: u16 = 0x2000; + +/// Address of the warm-up read block (required before aux access on BF-F8HP). +pub const AUX_WARMUP_ADDR: u16 = 0x1E80; + +/// Address of the dropped-byte bug in BF-F8HP firmware. +/// +/// Reading a full 16-byte block at this address may lose bytes. The +/// workaround is to read in smaller chunks around this address. +pub const DROPPED_BYTE_ADDR: u16 = 0x1FCF; + +// ── Errors ─────────────────────────────────────────────────────────────────── + +/// Errors from protocol operations. +#[derive(Debug, Snafu)] +#[snafu(visibility(pub(crate)))] +#[non_exhaustive] +pub enum ProtocolError { + /// The serial port returned an I/O error. + #[snafu(display("serial I/O error: {message}"))] + SerialIo { + /// Description of the I/O failure. + message: String, + }, + + /// The radio did not acknowledge within the timeout. + #[snafu(display("no ACK from radio within {timeout_ms}ms"))] + Timeout { + /// Timeout duration that expired. + timeout_ms: u64, + }, + + /// The radio returned an unexpected response. + #[snafu(display("unexpected response: expected {expected}, got {actual}"))] + UnexpectedResponse { + /// What was expected. + expected: String, + /// What was received. + actual: String, + }, +} + +// ── Block plan ─────────────────────────────────────────────────────────────── + +/// A planned EEPROM read/write operation at a specific address. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct BlockOp { + /// EEPROM address to read/write. + pub addr: u16, + /// Number of bytes to transfer. + pub size: u16, + /// Whether this is a warm-up read (data should be discarded). + pub is_warmup: bool, +} + +/// Generate the list of block operations needed to download a complete image. +/// +/// For UV-5R: reads 0x0000–0x1800 in 16-byte blocks. +/// For BF-F8HP: same main region, plus aux warm-up at 0x1E80, then +/// 0x1E80–0x2000 with dropped-byte workaround at 0x1FCF. +#[must_use] +pub fn download_plan(config: &VariantConfig) -> Vec { + let mut ops = Vec::new(); + + // Main memory region + let mut addr = MAIN_START; + while addr < MAIN_END { + ops.push(BlockOp { + addr, + size: BLOCK_SIZE as u16, + is_warmup: false, + }); + addr += BLOCK_SIZE as u16; + } + + if config.has_aux_block { + // Warm-up read: read 0x1E80 first, discard the data + if config.needs_aux_warmup { + ops.push(BlockOp { + addr: AUX_WARMUP_ADDR, + size: BLOCK_SIZE as u16, + is_warmup: true, + }); + } + + // Aux region with dropped-byte workaround + let mut aux_addr = AUX_START; + while aux_addr < AUX_END { + let block_end = aux_addr + BLOCK_SIZE as u16; + + if aux_addr <= DROPPED_BYTE_ADDR && DROPPED_BYTE_ADDR < block_end { + // Split into smaller reads around the problem address. + // Read bytes before the dropped-byte address. + let before_size = DROPPED_BYTE_ADDR - aux_addr; + if before_size > 0 { + ops.push(BlockOp { + addr: aux_addr, + size: before_size, + is_warmup: false, + }); + } + // Read the problem byte individually. + ops.push(BlockOp { + addr: DROPPED_BYTE_ADDR, + size: 1, + is_warmup: false, + }); + // Read remaining bytes after. + let after_start = DROPPED_BYTE_ADDR + 1; + let after_size = block_end - after_start; + if after_size > 0 { + ops.push(BlockOp { + addr: after_start, + size: after_size, + is_warmup: false, + }); + } + } else { + ops.push(BlockOp { + addr: aux_addr, + size: BLOCK_SIZE as u16, + is_warmup: false, + }); + } + + aux_addr += BLOCK_SIZE as u16; + } + } + + ops +} + +/// Generate the list of block operations needed to upload a complete image. +/// +/// Same regions as download, but without the warm-up read. +#[must_use] +pub fn upload_plan(config: &VariantConfig) -> Vec { + let mut ops = Vec::new(); + + let mut addr = MAIN_START; + while addr < MAIN_END { + ops.push(BlockOp { + addr, + size: BLOCK_SIZE as u16, + is_warmup: false, + }); + addr += BLOCK_SIZE as u16; + } + + if config.has_aux_block { + let mut aux_addr = AUX_START; + while aux_addr < AUX_END { + ops.push(BlockOp { + addr: aux_addr, + size: BLOCK_SIZE as u16, + is_warmup: false, + }); + aux_addr += BLOCK_SIZE as u16; + } + } + + ops +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +#[cfg(test)] +#[allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::indexing_slicing, + clippy::missing_docs_in_private_items +)] +mod tests { + use super::*; + use crate::baofeng::variant::{bf_f8hp_config, uv5r_config, uv5rm_plus_config}; + + #[test] + fn uv5r_download_has_no_aux_blocks() { + let config = uv5r_config(); + let plan = download_plan(&config); + assert!( + plan.iter().all(|op| op.addr < MAIN_END), + "UV-5R should not read beyond main memory" + ); + assert!( + !plan.iter().any(|op| op.is_warmup), + "UV-5R should not have warmup reads" + ); + } + + #[test] + fn uv5r_download_covers_full_main_region() { + let config = uv5r_config(); + let plan = download_plan(&config); + let expected_blocks = (MAIN_END - MAIN_START) / BLOCK_SIZE as u16; + assert_eq!(plan.len(), expected_blocks as usize); + assert_eq!(plan[0].addr, MAIN_START); + let last = plan.last().unwrap(); + assert_eq!(last.addr + last.size, MAIN_END); + } + + #[test] + fn f8hp_download_includes_aux_warmup() { + let config = bf_f8hp_config(); + let plan = download_plan(&config); + let warmup_ops: Vec<_> = plan.iter().filter(|op| op.is_warmup).collect(); + assert_eq!(warmup_ops.len(), 1); + assert_eq!(warmup_ops[0].addr, AUX_WARMUP_ADDR); + } + + #[test] + fn f8hp_download_reads_aux_region() { + let config = bf_f8hp_config(); + let plan = download_plan(&config); + let aux_ops: Vec<_> = plan + .iter() + .filter(|op| op.addr >= AUX_START && !op.is_warmup) + .collect(); + assert!(!aux_ops.is_empty(), "F8HP should read aux region"); + + // Verify aux region is fully covered + let mut covered = vec![false; (AUX_END - AUX_START) as usize]; + for op in &aux_ops { + let start = (op.addr - AUX_START) as usize; + for slot in covered.iter_mut().skip(start).take(op.size as usize) { + *slot = true; + } + } + assert!( + covered.iter().all(|&c| c), + "aux region not fully covered by read ops" + ); + } + + #[test] + fn f8hp_download_splits_around_dropped_byte() { + let config = bf_f8hp_config(); + let plan = download_plan(&config); + // There should be a 1-byte read at the dropped byte address + let single_byte_count = plan + .iter() + .filter(|op| op.addr == DROPPED_BYTE_ADDR && op.size == 1) + .count(); + assert_eq!( + single_byte_count, 1, + "should have exactly one 1-byte read at dropped byte addr" + ); + } + + #[test] + fn uv5r_upload_matches_download_without_warmup() { + let config = uv5r_config(); + let dl = download_plan(&config); + let ul = upload_plan(&config); + assert_eq!(dl, ul, "UV-5R upload and download should be identical"); + } + + #[test] + fn f8hp_upload_has_no_warmup() { + let config = bf_f8hp_config(); + let plan = upload_plan(&config); + assert!( + !plan.iter().any(|op| op.is_warmup), + "upload should not have warmup reads" + ); + } + + #[test] + fn f8hp_upload_covers_aux_region() { + let config = bf_f8hp_config(); + let plan = upload_plan(&config); + let aux_ops: Vec<_> = plan.iter().filter(|op| op.addr >= AUX_START).collect(); + assert!(!aux_ops.is_empty()); + let expected_aux_blocks = (AUX_END - AUX_START) / BLOCK_SIZE as u16; + assert_eq!(aux_ops.len(), expected_aux_blocks as usize); + } + + #[test] + fn uv5rm_plus_download_matches_f8hp_structure() { + let f8hp_plan = download_plan(&bf_f8hp_config()); + let rm_plan = download_plan(&uv5rm_plus_config()); + assert_eq!(f8hp_plan.len(), rm_plan.len()); + for (f, r) in f8hp_plan.iter().zip(rm_plan.iter()) { + assert_eq!(f.addr, r.addr); + assert_eq!(f.size, r.size); + assert_eq!(f.is_warmup, r.is_warmup); + } + } + + #[test] + fn block_op_debug_format() { + let op = BlockOp { + addr: 0x1E80, + size: 16, + is_warmup: true, + }; + let debug = format!("{op:?}"); + assert!(debug.contains("1E80") || debug.contains("7808")); + } +} diff --git a/crates/syntonia/src/baofeng/variant.rs b/crates/syntonia/src/baofeng/variant.rs new file mode 100644 index 0000000..a140c8a --- /dev/null +++ b/crates/syntonia/src/baofeng/variant.rs @@ -0,0 +1,506 @@ +//! Radio variant identification and configuration for the UV-5R family. + +use std::fmt; + +use snafu::Snafu; + +use crate::types::PowerLevel; + +// ── Magic byte sequences ──────────────────────────────────────────────────── + +/// Magic bytes for UV-5R firmware version 2.91+ and most clones. +pub const MAGIC_UV5R_291: [u8; 7] = [0x50, 0xBB, 0xFF, 0x20, 0x12, 0x04, 0x11]; + +/// Magic bytes for the original UV-5R (pre-2.91 firmware). +pub const MAGIC_UV5R_ORIG: [u8; 7] = [0x50, 0xBB, 0xFF, 0x20, 0x12, 0x01, 0x11]; + +/// Magic bytes for BF-F8HP (shared with BF-A58). +pub const MAGIC_BF_F8HP: [u8; 7] = [0x50, 0xBB, 0xFF, 0x20, 0x14, 0x04, 0x13]; + +/// All magic byte sequences to try during auto-detection, in priority order. +pub const MAGIC_SETS: &[[u8; 7]] = &[MAGIC_UV5R_291, MAGIC_BF_F8HP, MAGIC_UV5R_ORIG]; + +// ── RadioVariant ───────────────────────────────────────────────────────────── + +/// Baofeng UV-5R family radio variant. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum RadioVariant { + /// Standard UV-5R (firmware 2.91+, most clones). + Uv5r, + /// Original UV-5R (pre-2.91 firmware). + Uv5rOriginal, + /// BF-F8HP tri-power variant. + BfF8hp, + /// UV-5RM Plus (tentative — may require `UV17Pro` protocol). + Uv5rmPlus, +} + +impl fmt::Display for RadioVariant { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let name = match self { + Self::Uv5r => "Baofeng UV-5R", + Self::Uv5rOriginal => "Baofeng UV-5R (original)", + Self::BfF8hp => "Baofeng BF-F8HP", + Self::Uv5rmPlus => "Baofeng UV-5RM Plus", + }; + f.write_str(name) + } +} + +// ── PowerMapping ───────────────────────────────────────────────────────────── + +/// Maps a logical power level to its EEPROM bit representation and wattage. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct PowerMapping { + /// Logical power level. + pub level: PowerLevel, + /// Transmit power in watts. + pub watts: f32, + /// 2-bit value stored in the EEPROM channel record. + pub eeprom_bits: u8, +} + +// ── VariantConfig ──────────────────────────────────────────────────────────── + +/// Per-variant configuration carrying magic bytes, power mapping, and EEPROM layout. +#[derive(Debug, Clone, PartialEq)] +pub struct VariantConfig { + /// Which variant this config describes. + pub variant: RadioVariant, + /// 7-byte magic sequence for entering programming mode. + pub magic: [u8; 7], + /// Power level mappings (EEPROM bits <-> logical level + wattage). + pub power_levels: Vec, + /// Whether this variant has an auxiliary EEPROM block (0x1E80–0x2000). + pub has_aux_block: bool, + /// Whether reading aux requires a warm-up read at 0x1E80 first. + pub needs_aux_warmup: bool, + /// Number of programmable channels. + pub channel_count: u8, + /// Maximum channel name length in characters. + pub max_name_length: usize, + /// Serial baud rate. + pub baud_rate: u32, +} + +impl VariantConfig { + /// Look up the power level for a given EEPROM bit value. + /// + /// For UV-5R, bit value 2 (Mid) is treated as High since the radio + /// has no mid setting. + #[must_use] + pub fn power_from_bits(&self, bits: u8) -> Option { + self.power_levels + .iter() + .find(|m| m.eeprom_bits == bits) + .map(|m| m.level) + } + + /// Look up the EEPROM bit value for a given power level. + #[must_use] + pub fn bits_from_power(&self, level: PowerLevel) -> Option { + self.power_levels + .iter() + .find(|m| m.level == level) + .map(|m| m.eeprom_bits) + } +} + +// ── Variant configs ────────────────────────────────────────────────────────── + +/// Configuration for the standard UV-5R. +/// +/// Two power levels. Bit value 2 (Mid) maps to High since this radio +/// has no mid setting — CHIRP images from tri-power radios may contain it. +#[must_use] +pub fn uv5r_config() -> VariantConfig { + VariantConfig { + variant: RadioVariant::Uv5r, + magic: MAGIC_UV5R_291, + power_levels: vec![ + PowerMapping { + level: PowerLevel::High, + watts: 4.0, + eeprom_bits: 0, + }, + PowerMapping { + level: PowerLevel::Low, + watts: 1.0, + eeprom_bits: 1, + }, + // WHY: UV-5R has no mid setting, but F8HP images may store bit value 2. + // Treat it as High to avoid data loss on cross-radio imports. + PowerMapping { + level: PowerLevel::High, + watts: 4.0, + eeprom_bits: 2, + }, + ], + has_aux_block: false, + needs_aux_warmup: false, + channel_count: 128, + max_name_length: 7, + baud_rate: 9_600, + } +} + +/// Configuration for the original UV-5R (pre-2.91 firmware). +#[must_use] +pub fn uv5r_original_config() -> VariantConfig { + VariantConfig { + variant: RadioVariant::Uv5rOriginal, + magic: MAGIC_UV5R_ORIG, + ..uv5r_config() + } +} + +/// Configuration for the BF-F8HP. +/// +/// Three power levels (8W / 4W / 1W). Has auxiliary EEPROM block +/// that requires a warm-up read before access. +#[must_use] +pub fn bf_f8hp_config() -> VariantConfig { + VariantConfig { + variant: RadioVariant::BfF8hp, + magic: MAGIC_BF_F8HP, + power_levels: vec![ + PowerMapping { + level: PowerLevel::High, + watts: 8.0, + eeprom_bits: 0, + }, + PowerMapping { + level: PowerLevel::Low, + watts: 1.0, + eeprom_bits: 1, + }, + PowerMapping { + level: PowerLevel::Mid, + watts: 4.0, + eeprom_bits: 2, + }, + ], + has_aux_block: true, + needs_aux_warmup: true, + channel_count: 128, + max_name_length: 7, + baud_rate: 9_600, + } +} + +/// Configuration for the UV-5RM Plus (tentative). +/// +/// Assumed to be a BF-F8HP variant with higher power output. +/// Power values are unverified — needs hardware testing. +#[must_use] +pub fn uv5rm_plus_config() -> VariantConfig { + VariantConfig { + variant: RadioVariant::Uv5rmPlus, + magic: MAGIC_BF_F8HP, + power_levels: vec![ + PowerMapping { + level: PowerLevel::High, + watts: 10.0, + eeprom_bits: 0, + }, + PowerMapping { + level: PowerLevel::Low, + watts: 1.0, + eeprom_bits: 1, + }, + PowerMapping { + level: PowerLevel::Mid, + watts: 5.0, + eeprom_bits: 2, + }, + ], + has_aux_block: true, + needs_aux_warmup: true, + channel_count: 128, + max_name_length: 7, + baud_rate: 9_600, + } +} + +// ── RadioIdent ─────────────────────────────────────────────────────────────── + +/// Raw identification bytes received from the radio during handshake. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RadioIdent { + /// Raw ident bytes from the radio's response to the IDENTIFY command. + pub raw: Vec, +} + +impl RadioIdent { + /// Extract the firmware prefix as a UTF-8 string (lossy). + #[must_use] + pub fn firmware_prefix(&self) -> String { + String::from_utf8_lossy(&self.raw).to_string() + } +} + +// ── Errors ─────────────────────────────────────────────────────────────────── + +/// Errors from variant identification. +#[derive(Debug, Snafu)] +#[snafu(visibility(pub(crate)))] +#[non_exhaustive] +pub enum VariantError { + /// The firmware ident bytes did not match any known variant. + #[snafu(display("unknown radio variant — ident bytes: {raw_hex}"))] + UnknownVariant { + /// Hex representation of the raw ident bytes. + raw_hex: String, + }, +} + +// ── Firmware prefix matching ───────────────────────────────────────────────── + +/// Known firmware prefixes for the standard UV-5R. +const UV5R_PREFIXES: &[&str] = &["BFB", "BFS", "N5R-2", "N5R2", "N5RV", "BTS", "D5R2", "B5R2"]; + +/// Known firmware prefixes for the BF-F8HP. +const BF_F8HP_PREFIXES: &[&str] = &["BFP3V3 F", "N5R-3", "N5R3", "F5R3", "BFT"]; + +/// Identify the radio variant from its firmware ident bytes. +/// +/// Matches known firmware prefixes against the ident string. Returns the +/// appropriate [`VariantConfig`] for the identified variant. +/// +/// # Errors +/// +/// Returns [`VariantError::UnknownVariant`] if the ident does not match any +/// known prefix, including the raw bytes as hex for debugging. +pub fn identify_variant(ident: &RadioIdent) -> Result { + let prefix = ident.firmware_prefix(); + + for &pfx in BF_F8HP_PREFIXES { + if prefix.starts_with(pfx) { + return Ok(bf_f8hp_config()); + } + } + + for &pfx in UV5R_PREFIXES { + if prefix.starts_with(pfx) { + return Ok(uv5r_config()); + } + } + + UnknownVariantSnafu { + raw_hex: hex_encode(&ident.raw), + } + .fail() +} + +/// Encode bytes as a hex string with spaces between bytes. +fn hex_encode(bytes: &[u8]) -> String { + bytes + .iter() + .map(|b| format!("{b:02X}")) + .collect::>() + .join(" ") +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +#[cfg(test)] +#[allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::indexing_slicing, + clippy::missing_docs_in_private_items +)] +mod tests { + use super::*; + + #[test] + fn identify_bfb_firmware_as_uv5r() { + let ident = RadioIdent { + raw: b"BFB297".to_vec(), + }; + let config = identify_variant(&ident).unwrap(); + assert_eq!(config.variant, RadioVariant::Uv5r); + } + + #[test] + fn identify_bfs_firmware_as_uv5r() { + let ident = RadioIdent { + raw: b"BFS300".to_vec(), + }; + let config = identify_variant(&ident).unwrap(); + assert_eq!(config.variant, RadioVariant::Uv5r); + } + + #[test] + fn identify_n5r2_firmware_as_uv5r() { + let ident = RadioIdent { + raw: b"N5R-2".to_vec(), + }; + let config = identify_variant(&ident).unwrap(); + assert_eq!(config.variant, RadioVariant::Uv5r); + } + + #[test] + fn identify_bfp3v3_firmware_as_f8hp() { + let ident = RadioIdent { + raw: b"BFP3V3 F".to_vec(), + }; + let config = identify_variant(&ident).unwrap(); + assert_eq!(config.variant, RadioVariant::BfF8hp); + } + + #[test] + fn identify_n5r3_firmware_as_f8hp() { + let ident = RadioIdent { + raw: b"N5R-3".to_vec(), + }; + let config = identify_variant(&ident).unwrap(); + assert_eq!(config.variant, RadioVariant::BfF8hp); + } + + #[test] + fn identify_bft_firmware_as_f8hp() { + let ident = RadioIdent { + raw: b"BFT123".to_vec(), + }; + let config = identify_variant(&ident).unwrap(); + assert_eq!(config.variant, RadioVariant::BfF8hp); + } + + #[test] + fn unknown_firmware_returns_error_with_raw_bytes() { + let ident = RadioIdent { + raw: vec![0xDE, 0xAD, 0xBE, 0xEF], + }; + let err = identify_variant(&ident).unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("DE AD BE EF"), + "error should contain hex bytes, got: {msg}" + ); + } + + #[test] + fn uv5r_has_two_logical_power_levels() { + let config = uv5r_config(); + assert_eq!(config.power_from_bits(0), Some(PowerLevel::High)); + assert_eq!(config.power_from_bits(1), Some(PowerLevel::Low)); + // WHY: bit 2 (Mid) maps to High — UV-5R has no mid setting + assert_eq!(config.power_from_bits(2), Some(PowerLevel::High)); + } + + #[test] + fn f8hp_has_three_power_levels() { + let config = bf_f8hp_config(); + assert_eq!(config.power_from_bits(0), Some(PowerLevel::High)); + assert_eq!(config.power_from_bits(1), Some(PowerLevel::Low)); + assert_eq!(config.power_from_bits(2), Some(PowerLevel::Mid)); + } + + #[test] + fn uv5r_bits_from_power_roundtrips() { + let config = uv5r_config(); + let bits = config.bits_from_power(PowerLevel::High).unwrap(); + assert_eq!(config.power_from_bits(bits), Some(PowerLevel::High)); + let bits = config.bits_from_power(PowerLevel::Low).unwrap(); + assert_eq!(config.power_from_bits(bits), Some(PowerLevel::Low)); + } + + #[test] + fn f8hp_bits_from_power_roundtrips() { + let config = bf_f8hp_config(); + for level in [PowerLevel::High, PowerLevel::Mid, PowerLevel::Low] { + let bits = config.bits_from_power(level).unwrap(); + assert_eq!(config.power_from_bits(bits), Some(level)); + } + } + + #[test] + fn f8hp_has_aux_block_and_warmup() { + let config = bf_f8hp_config(); + assert!(config.has_aux_block); + assert!(config.needs_aux_warmup); + } + + #[test] + fn uv5r_has_no_aux_block() { + let config = uv5r_config(); + assert!(!config.has_aux_block); + assert!(!config.needs_aux_warmup); + } + + #[test] + fn uv5rm_plus_assumed_same_as_f8hp_layout() { + let config = uv5rm_plus_config(); + assert!(config.has_aux_block); + assert!(config.needs_aux_warmup); + assert_eq!(config.magic, MAGIC_BF_F8HP); + // Higher wattage than F8HP + let high = config + .power_levels + .iter() + .find(|m| m.level == PowerLevel::High) + .unwrap(); + assert!((high.watts - 10.0).abs() < f32::EPSILON); + } + + #[test] + fn variant_display_names() { + assert_eq!(RadioVariant::Uv5r.to_string(), "Baofeng UV-5R"); + assert_eq!( + RadioVariant::Uv5rOriginal.to_string(), + "Baofeng UV-5R (original)" + ); + assert_eq!(RadioVariant::BfF8hp.to_string(), "Baofeng BF-F8HP"); + assert_eq!(RadioVariant::Uv5rmPlus.to_string(), "Baofeng UV-5RM Plus"); + } + + #[test] + fn radio_ident_firmware_prefix_handles_utf8() { + let ident = RadioIdent { + raw: b"BFB297\x00\xFF".to_vec(), + }; + let prefix = ident.firmware_prefix(); + assert!(prefix.starts_with("BFB297")); + } + + #[test] + fn unknown_bits_returns_none() { + let config = uv5r_config(); + assert_eq!(config.power_from_bits(3), None); + assert_eq!(config.power_from_bits(255), None); + } + + #[test] + fn magic_sets_contains_all_three() { + assert_eq!(MAGIC_SETS.len(), 3); + assert_eq!(MAGIC_SETS[0], MAGIC_UV5R_291); + assert_eq!(MAGIC_SETS[1], MAGIC_BF_F8HP); + assert_eq!(MAGIC_SETS[2], MAGIC_UV5R_ORIG); + } + + #[test] + fn all_variants_have_128_channels() { + for config in [ + uv5r_config(), + uv5r_original_config(), + bf_f8hp_config(), + uv5rm_plus_config(), + ] { + assert_eq!(config.channel_count, 128); + } + } + + #[test] + fn all_variants_use_9600_baud() { + for config in [ + uv5r_config(), + uv5r_original_config(), + bf_f8hp_config(), + uv5rm_plus_config(), + ] { + assert_eq!(config.baud_rate, 9_600); + } + } +} diff --git a/crates/syntonia/src/lib.rs b/crates/syntonia/src/lib.rs index ac4f6e5..0b62ad3 100644 --- a/crates/syntonia/src/lib.rs +++ b/crates/syntonia/src/lib.rs @@ -12,6 +12,7 @@ //! - [`ToneMode`], [`CtcssTone`], [`DcsCode`] — squelch tone configuration //! - [`RadioConstraints`] — radio-specific limits for validation +pub mod baofeng; pub mod channel; pub mod error; pub mod plan;