diff --git a/CHANGELOG.md b/CHANGELOG.md index b1c4733..d0a9f7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,14 @@ and [`docs/requirements/system-requirements.md`](docs/requirements/system-requir ### Added +- **Transverter band setup.** The BAND screen now has a setup form for the + transverter bands (XV1-12): pick a band and set its mode, IF band, lower + edge, offset and mW power. Fields load from the radio when you pick a band, + and each is sent to that band. (The mW power scale while operating on a + transverter band is a separate, later addition.) + +### Added + - **On-screen macro buttons.** A new **Fn -> MACROS** tab runs the same macros as the K-Pod F1-F8 switches, so you get one-tap CAT macros with or without a K-Pod. Assign them under Settings; a macro that transmits is arm-gated just diff --git a/app/src/main.rs b/app/src/main.rs index d05ffac..dbe3e08 100644 --- a/app/src/main.rs +++ b/app/src/main.rs @@ -151,6 +151,15 @@ struct App { // keeps its own bank. memories: Vec, memories_open: bool, + // Transverter setup form (FR-XVTR-01): which XV band is being edited, the + // four numeric fields as edit strings, and whether the current read-back + // has been copied into them yet. + xvtr_edit_band: u8, + xvtr_lower: String, + xvtr_if: String, + xvtr_offset: String, + xvtr_power: String, + xvtr_loaded_band: Option, /// DTMF keypad popup open (FR-FM-02). dtmf_open: bool, memory_name: String, @@ -469,6 +478,15 @@ enum TxTab { Text, } +/// The four editable transverter numeric fields (FR-XVTR-01). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum XvtrField { + Lower, + If, + Offset, + Power, +} + /// Which sub-panel the Fn screen shows. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum FnTab { @@ -764,6 +782,12 @@ enum Message { MenuNudge(i64), // BAND transverter select (FR-VFO-04), TX text (FR-TX-MSG-01), Fn tabs / DX. SelectXvtr(u8), + /// Transverter setup (FR-XVTR-01): pick the band to edit, toggle its mode, + /// edit a field, and commit a field. + XvtrEditBand(u8), + XvtrModeToggle, + XvtrFieldChanged(XvtrField, String), + XvtrFieldSubmit(XvtrField), TxText(String), SendTxText, ToggleDecode, @@ -934,6 +958,12 @@ impl App { seeded: false, memories: prefs.memories.clone(), memories_open: false, + xvtr_edit_band: 1, + xvtr_lower: String::new(), + xvtr_if: String::new(), + xvtr_offset: String::new(), + xvtr_power: String::new(), + xvtr_loaded_band: None, dtmf_open: false, memory_name: String::new(), peers, @@ -2540,6 +2570,68 @@ impl App { Message::SelectXvtr(n) => { self.send(WorkerCmd::Cat(k4_protocol::cat::set_transverter_band(n))) } + Message::XvtrEditBand(n) => { + use k4_protocol::cat; + self.xvtr_edit_band = n.clamp(1, 12); + self.xvtr_loaded_band = None; // reload the fields from the radio + // Target the band, then read its config back so the form fills. + self.send(WorkerCmd::Cat(cat::set_xvtr_band(self.xvtr_edit_band))); + for get in ["XVM;", "XVR;", "XVI;", "XVO;", "XVP;"] { + self.send(WorkerCmd::Cat(get.to_string())); + } + } + Message::XvtrModeToggle => { + use k4_protocol::cat; + let external = self.ui.radio.xvtr_mode != Some(true); + self.send(WorkerCmd::Cat(cat::set_xvtr_band(self.xvtr_edit_band))); + self.send(WorkerCmd::Cat(cat::set_xvtr_mode(external))); + } + Message::XvtrFieldChanged(field, text) => { + // Keep only digits (and a leading sign for the offset). + let cleaned: String = text + .chars() + .enumerate() + .filter(|(i, c)| { + c.is_ascii_digit() || (*i == 0 && field == XvtrField::Offset && *c == '-') + }) + .map(|(_, c)| c) + .collect(); + match field { + XvtrField::Lower => self.xvtr_lower = cleaned, + XvtrField::If => self.xvtr_if = cleaned, + XvtrField::Offset => self.xvtr_offset = cleaned, + XvtrField::Power => self.xvtr_power = cleaned, + } + } + Message::XvtrFieldSubmit(field) => { + use k4_protocol::cat; + self.send(WorkerCmd::Cat(cat::set_xvtr_band(self.xvtr_edit_band))); + // Explicit calls, not `.map(fn)`: the traceability gate (R5) + // needs a real call site for each encoder, and clippy rejects a + // wrapping closure — so parse, then call. + let cmd = match field { + XvtrField::Lower => match self.xvtr_lower.parse::() { + Ok(v) => Some(cat::set_xvtr_lower_mhz(v)), + Err(_) => None, + }, + XvtrField::If => match self.xvtr_if.parse::() { + Ok(v) => Some(cat::set_xvtr_if_mhz(v)), + Err(_) => None, + }, + XvtrField::Offset => match self.xvtr_offset.parse::() { + Ok(v) => Some(cat::set_xvtr_offset_hz(v)), + Err(_) => None, + }, + // The power field is entered in mW; the command is tenths. + XvtrField::Power => match self.xvtr_power.parse::() { + Ok(mw) => Some(cat::set_xvtr_power_tenths_mw((mw * 10.0).round() as u16)), + Err(_) => None, + }, + }; + if let Some(cmd) = cmd { + self.send(WorkerCmd::Cat(cmd)); + } + } Message::TxText(s) => self.tx_text = s, Message::SendTxText => { let t = self.tx_text.trim(); @@ -2729,6 +2821,23 @@ impl App { } // Blink the ARM button (PTT hotkey pressed while disarmed). self.arm_flash = self.arm_flash.saturating_sub(1); + // Fill the transverter form once the read-back for the band it + // is editing has arrived (FR-XVTR-01). Keyed on the setup band + // the radio confirms via XVN, so a stale value never lands in + // the fields. + if self.ui.radio.xvtr_setup_band == Some(self.xvtr_edit_band) + && self.xvtr_loaded_band != Some(self.xvtr_edit_band) + { + let r = &self.ui.radio; + self.xvtr_lower = r.xvtr_lower_mhz.map(|v| v.to_string()).unwrap_or_default(); + self.xvtr_if = r.xvtr_if_mhz.map(|v| v.to_string()).unwrap_or_default(); + self.xvtr_offset = r.xvtr_offset_hz.map(|v| v.to_string()).unwrap_or_default(); + self.xvtr_power = r + .xvtr_power_tenths_mw + .map(|t| format!("{:.1}", t as f32 / 10.0)) + .unwrap_or_default(); + self.xvtr_loaded_band = Some(self.xvtr_edit_band); + } self.lock_flash = self.lock_flash.saturating_sub(1); // TUNE ends when transmit stops. if !self.ui.transmitting { @@ -5507,19 +5616,123 @@ impl App { .on_press(Message::SelectXvtr(n)), ); } - Column::new() + // Two columns so nothing scrolls: HF/6 m band selection on the left, + // the transverter bands and their setup form on the right. The setup + // form was the tall part — a single stacked column overflowed the + // fixed-height config slot and grew a scrollbar, which is what the + // operator asked to avoid. + let left = Column::new() .spacing(10) + .width(Length::FillPortion(1)) .push( Text::new("Select a band (direct BN select)") .size(12) .color(dim), ) .push(grid) - .push(ops) + .push(ops); + let right = Column::new() + .spacing(10) + .width(Length::FillPortion(1)) .push(Text::new("Transverter bands (XV)").size(12).color(dim)) .push(xvtr) + .push(self.xvtr_setup()); + Row::new().spacing(20).push(left).push(right).into() + } + + /// Transverter band setup form (FR-XVTR-01): pick an XV band to configure, + /// then set its mode, IF band, lower edge, offset and mW power. Each field + /// targets the band with `XVN` before its own command, so edits always land + /// on the band shown. Fields fill from the radio when a band is selected. + fn xvtr_setup(&self) -> Element<'_, Message> { + let dim = role_color(ui::ColorRole::Inactive); + let rxv = role_color(ui::ColorRole::RxValue); + // Band picker: 1–12, the selected one lit. + let mut picker = Row::new().spacing(3).align_y(Alignment::Center); + for n in 1u8..=12 { + picker = picker.push( + Button::new(Text::new(n.to_string()).size(11)) + .style(btn_style(if self.xvtr_edit_band == n { + BtnKind::Active + } else { + BtnKind::Plain + })) + .padding([3, 7]) + .on_press(Message::XvtrEditBand(n)), + ); + } + let external = self.ui.radio.xvtr_mode == Some(true); + let mode_btn = Button::new( + Text::new(if external { + "MODE: EXTERNAL" + } else { + "MODE: OFF" + }) + .size(11), + ) + .style(btn_style(if external { + BtnKind::Active + } else { + BtnKind::Plain + })) + .padding([4, 8]) + .width(Length::Fixed(ui::stable_label_width( + &["MODE: EXTERNAL", "MODE: OFF"], + 11.0, + 16.0, + ))) + .on_press(Message::XvtrModeToggle); + // One labelled numeric field, committing on Enter. + let field = |label: &'static str, unit: &'static str, value: &str, f: XvtrField| { + Row::new() + .spacing(6) + .align_y(Alignment::Center) + .push( + Text::new(label) + .size(11) + .color(dim) + .width(Length::Fixed(46.0)), + ) + .push( + TextInput::new("", value) + .on_input(move |t| Message::XvtrFieldChanged(f, t)) + .on_submit(Message::XvtrFieldSubmit(f)) + .size(12) + .width(Length::Fixed(78.0)), + ) + .push(Text::new(unit).size(10).color(dim)) + }; + Column::new() + .spacing(8) + .push( + Text::new(format!( + "Transverter setup — band XV{}", + self.xvtr_edit_band + )) + .size(12) + .color(rxv), + ) + .push(picker) + .push(mode_btn) + // Two fields per row: the right column is half-width now, so there + // is horizontal room, and this keeps the form short enough to fit + // the config slot without a scrollbar. + .push( + Row::new() + .spacing(16) + .align_y(Alignment::Center) + .push(field("Lower", "MHz", &self.xvtr_lower, XvtrField::Lower)) + .push(field("IF", "MHz", &self.xvtr_if, XvtrField::If)), + ) + .push( + Row::new() + .spacing(16) + .align_y(Alignment::Center) + .push(field("Offset", "Hz", &self.xvtr_offset, XvtrField::Offset)) + .push(field("Power", "mW", &self.xvtr_power, XvtrField::Power)), + ) .push( - Text::new("GEN / memories on the Fn screen; XVTR band setup via MENU.") + Text::new("Enter to send each field. Fields load from the radio on band select.") .size(10) .color(dim), ) diff --git a/crates/k4-protocol/src/cat.rs b/crates/k4-protocol/src/cat.rs index 5fe32b0..6200c82 100644 --- a/crates/k4-protocol/src/cat.rs +++ b/crates/k4-protocol/src/cat.rs @@ -624,6 +624,51 @@ pub fn set_dvr(n: u8) -> String { format!("PB{};", n.min(8)) } +/// Transverter band setup (`XV*`, D12). `XVN` selects which XV band (1–12) the +/// other setup commands target, so callers send [`set_xvtr_band`] first, then +/// the field commands below (`FR-XVTR-01`). +/// +/// trace: FR-XVTR-01 +pub fn set_xvtr_band(n: u8) -> String { + format!("XVN{};", n.clamp(1, 12)) +} + +/// XV mode (`XVM`): 0 = off, 1 = external transverter. +/// +/// trace: FR-XVTR-01 +pub fn set_xvtr_mode(external: bool) -> String { + format!("XVM{};", external as u8) +} + +/// XV band lower edge in MHz (`XVR`, 0–99999). +/// +/// trace: FR-XVTR-01 +pub fn set_xvtr_lower_mhz(mhz: u32) -> String { + format!("XVR{:05};", mhz.min(99999)) +} + +/// IF band in MHz (`XVI`, 0–53) — the HF band the transverter's IF uses. +/// +/// trace: FR-XVTR-01 +pub fn set_xvtr_if_mhz(mhz: u8) -> String { + format!("XVI{:02};", mhz.min(53)) +} + +/// Oscillator/multiplier offset in Hz (`XVO`, ±0–99999). +/// +/// trace: FR-XVTR-01 +pub fn set_xvtr_offset_hz(hz: i32) -> String { + let sign = if hz < 0 { '-' } else { '+' }; + format!("XVO{}{:05};", sign, hz.unsigned_abs().min(99999)) +} + +/// Transverter power output in tenths of a milliwatt (`XVP`, 1–50 = 0.1–5.0 mW). +/// +/// trace: FR-XVTR-01 +pub fn set_xvtr_power_tenths_mw(tenths: u16) -> String { + format!("XVP{:03};", tenths.clamp(1, 50)) +} + /// Set TX test mode (`TS`). /// /// D12: while it is on the radio's "TX" icon flashes and the transmitter puts diff --git a/crates/k4-protocol/src/state.rs b/crates/k4-protocol/src/state.rs index d875887..11c8a41 100644 --- a/crates/k4-protocol/src/state.rs +++ b/crates/k4-protocol/src/state.rs @@ -388,6 +388,15 @@ pub struct RadioState { /// no custom name (the antenna keeps its default `ANT1`… label). The TX /// antenna (`AN` 1–3) maps to slots 1–3 (FR-ANT-02). pub antenna_names: [Option; 5], + /// Transverter setup read-back (`XV*`, FR-XVTR-01), for the band `XVN` last + /// targeted. Transient by nature — these describe whichever XV band the + /// setup form is editing, not a fixed one. + pub xvtr_setup_band: Option, + pub xvtr_mode: Option, + pub xvtr_lower_mhz: Option, + pub xvtr_if_mhz: Option, + pub xvtr_offset_hz: Option, + pub xvtr_power_tenths_mw: Option, /// FM repeater offset mode (`RP`): `S`/`+`/`-`, and shift kHz. pub repeater_mode: Option, pub repeater_offset_khz: Option, @@ -466,6 +475,25 @@ impl RadioState { if let Some(v) = lock_flag(arg) { self.vfo_a_locked = Some(v); } + } else if let Some(arg) = cmd.strip_prefix("XV") { + // `XV...` is a transverter *setup* field; `XV` is the + // band *select* echo, which we do not track here (FR-XVTR-01). + match arg.as_bytes().first() { + Some(b'N') => self.xvtr_setup_band = arg[1..].parse().ok(), + Some(b'M') => self.xvtr_mode = arg[1..].parse::().ok().map(|n| n == 1), + Some(b'R') => self.xvtr_lower_mhz = arg[1..].parse().ok(), + Some(b'I') => self.xvtr_if_mhz = arg[1..].parse().ok(), + Some(b'O') => { + // `+nnnnn` / `-nnnnn`. + if let Some((sign, digits)) = arg[1..].split_at_checked(1) { + if let Ok(mag) = digits.parse::() { + self.xvtr_offset_hz = Some(if sign == "-" { -mag } else { mag }); + } + } + } + Some(b'P') => self.xvtr_power_tenths_mw = arg[1..].parse().ok(), + _ => {} // XV select echo, or unknown + } } else if let Some(arg) = cmd.strip_prefix("DA") { // Unrecognised forms leave the previous state rather than clearing // it: a status display that blanks on an unknown variant is worse diff --git a/crates/k4-protocol/tests/cat.rs b/crates/k4-protocol/tests/cat.rs index 92352ad..8345d29 100644 --- a/crates/k4-protocol/tests/cat.rs +++ b/crates/k4-protocol/tests/cat.rs @@ -719,6 +719,36 @@ fn fr_data_02_data_rate_encodes() { ); } +/// The transverter setup encoders match D12's field widths. +/// trace: FR-XVTR-01 +#[test] +fn fr_xvtr_01_setup_commands_encode() { + use k4_protocol::cat::*; + assert_eq!(set_xvtr_band(3), "XVN3;"); + assert_eq!(set_xvtr_band(0), "XVN1;", "clamped to 1-12"); + assert_eq!(set_xvtr_band(99), "XVN12;"); + assert_eq!(set_xvtr_mode(true), "XVM1;"); + assert_eq!(set_xvtr_mode(false), "XVM0;"); + assert_eq!(set_xvtr_lower_mhz(144), "XVR00144;", "5-digit MHz"); + assert_eq!(set_xvtr_lower_mhz(1296), "XVR01296;"); + assert_eq!(set_xvtr_if_mhz(28), "XVI28;", "2-digit IF"); + assert_eq!(set_xvtr_if_mhz(99), "XVI53;", "clamped to 53"); + assert_eq!(set_xvtr_offset_hz(1200), "XVO+01200;"); + assert_eq!(set_xvtr_offset_hz(-500), "XVO-00500;", "sign carried"); + assert_eq!(set_xvtr_offset_hz(0), "XVO+00000;"); + assert_eq!( + set_xvtr_power_tenths_mw(10), + "XVP010;", + "1.0 mW example from D12" + ); + assert_eq!( + set_xvtr_power_tenths_mw(0), + "XVP001;", + "clamped to >=0.1 mW" + ); + assert_eq!(set_xvtr_power_tenths_mw(99), "XVP050;", "clamped to 5.0 mW"); +} + /// DTMF digits encode as `DM;`, and non-digits are refused. /// trace: FR-FM-02 #[test] diff --git a/crates/k4-protocol/tests/state.rs b/crates/k4-protocol/tests/state.rs index 29c9012..bd4b176 100644 --- a/crates/k4-protocol/tests/state.rs +++ b/crates/k4-protocol/tests/state.rs @@ -812,3 +812,34 @@ fn fr_data_02_data_rate_parses_per_vfo() { assert_eq!(s.sub_data_rate, Some(0), "DR$ is the sub"); assert_eq!(s.data_rate, Some(1), "main unchanged"); } + +/// Transverter setup read-back parses each field, and the `XV` band +/// select is not mistaken for a setup field. +/// trace: FR-XVTR-01 +#[test] +fn fr_xvtr_01_setup_readback_parses() { + let mut s = RadioState::new(); + assert!(s.apply_cat("XVN3;")); + assert_eq!(s.xvtr_setup_band, Some(3)); + assert!(s.apply_cat("XVM1;")); + assert_eq!(s.xvtr_mode, Some(true)); + assert!(s.apply_cat("XVR00144;")); + assert_eq!(s.xvtr_lower_mhz, Some(144)); + assert!(s.apply_cat("XVI28;")); + assert_eq!(s.xvtr_if_mhz, Some(28)); + assert!(s.apply_cat("XVO-00500;")); + assert_eq!(s.xvtr_offset_hz, Some(-500)); + assert!(s.apply_cat("XVO+01200;")); + assert_eq!(s.xvtr_offset_hz, Some(1200)); + assert!(s.apply_cat("XVP010;")); + assert_eq!(s.xvtr_power_tenths_mw, Some(10)); + + // A bare-digit band select echo must not corrupt the setup fields. + let before = s.clone(); + s.apply_cat("XV05;"); + assert_eq!( + s.xvtr_setup_band, before.xvtr_setup_band, + "XV is not XVN" + ); + assert_eq!(s.xvtr_mode, before.xvtr_mode); +} diff --git a/docs/requirements/system-requirements.md b/docs/requirements/system-requirements.md index 0c84750..5f25770 100644 --- a/docs/requirements/system-requirements.md +++ b/docs/requirements/system-requirements.md @@ -283,6 +283,7 @@ syntax per the Programmer's Reference D12, cross-checked vs QK4 (`R-EXT-03`).* | `FR-ANT-02` | **show the operator's own antenna names** (`ACN`) on the TX-antenna control instead of a bare number: `AN` 1–3 maps to `ACN` slots 1–3, so a named antenna reads e.g. `DIPOLE`, an unnamed one `ANT 1`. Read-only display; the name is set at the radio. | STK-03 | S | T | `ACN` parses per slot (1–5), the clear form (`ACNn~`) resets to unnamed; `tx_antenna_label` returns the custom name alone (no prefix, so a 6-char name does not wrap the fixed switch cell) or the default `ANT n`. | | `FR-MACRO-01` | provide **on-screen quick-access macro buttons** (Fn → MACROS) that run the same **K-Pod function-switch** macros (`FR-KPOD-06`), so a station without a K-Pod still gets one-tap CAT macros. Only assigned slots (non-empty CAT) get a button, labelled with the operator's own name or an `Ft/h` fallback. Each macro is sent through the **arm-gated seam**, so one that keys is refused (and flashes ARM TX) while disarmed, exactly as the physical switch is. | STK-03 | S | T | `macro_label` returns the display label or `None` for an unassigned slot; the MACROS tab renders one button per assigned slot; a press sends the slot's CAT via the gated path. | | `FR-FM-02` | provide a **DTMF keypad** for FM mode (`DM`): a 4×4 popup of `0`–`9`, `A`–`D`, `*`, `#`, each key sending one DTMF tone, for remote repeater/link control that is otherwise impossible over the link. Opened from the FM panel. | STK-03 | S | T | `send_dtmf(digit)` encodes `DM;` for a valid digit and returns `None` otherwise; the keypad sends via the standard command path. | +| `FR-XVTR-01` | provide **transverter band setup** (`XV*`) on the BAND screen: pick an XV band 1–12 (`XVN`), then set its **mode** (off/external, `XVM`), **lower edge** (MHz, `XVR`), **IF band** (MHz, `XVI`), **offset** (Hz, `XVO`), and **power** (mW, `XVP`). Each field targets the band with `XVN` before its own command, so an edit always lands on the band shown; the fields fill from the radio's read-back when a band is selected. | STK-04 | S | T | Six encoders match D12's field widths; the setup read-back parses per field (and `XV` band-select is not read as a setup field); the form targets, sends, and fills. | | `FR-SCAN-01` | **start/stop memory scan** (`SW149`) and display scan-in-progress from the `IF` `s` flag. | STK-02 | C | T | The `IF` `s` field (index 29) sets `scanning`; the SCAN control emits `SW149;` and lights while scanning. | | `FR-VOX-01` | control **VOX** on/off per transmit mode (PRG `VX`). | STK-06 | C | T | `set_vox(mode,on)` encodes `VX<0/1>;`. | | `FR-VOX-02` | adjust **VOX gain** (`VG`) and **anti-VOX** (`VI`) levels. | STK-03 | C | T | `set_vox_gain('V',20)`=`VGV020;`, `set_antivox(15)`=`VI015;`. | @@ -378,3 +379,4 @@ syntax per the Programmer's Reference D12, cross-checked vs QK4 (`R-EXT-03`).* | 2026-07-25 | 0.46 | DC0SK | Added FR-ANT-02 (show `ACN` antenna names), scoped to the **TX antenna** — the one antenna whose `ACN` slot mapping D12 documents unambiguously (`AN` 1–3 ↔ `ACN` 1–3; `ACN1DIPOLE` names ANT1). The RX antennas keep their fixed names (Off/RX2/=TX/XVTR/RX1/ATU1–3), whose mapping to `ACN` slots 4–5 is not documented and would be a guess; the `ACT` TX-mask rotation is already handled radio-side when the switch is tapped. Read-only: names are set at the radio. One layout point caught on screen: the name was first shown as `ANT: `, but a 6-character name plus the prefix **wrapped** the fixed 92 px switch cell to two lines, making it taller than its neighbours — a vertical FR-UI-STABLE-01 violation — so the prefix was dropped and the name shown alone, which fits even for all-wide-character names. | | 2026-07-25 | 0.47 | DC0SK | Added FR-MACRO-01 (on-screen macro buttons), a clean reuse of the existing K-Pod macro table (`FR-KPOD-06`) — the 16-slot label+CAT assignments already live in the config and are **not** feature-gated, so the on-screen buttons work whether or not a K-Pod is attached. Put on a new Fn → MACROS tab, where there is no main-view space pressure. The safety story is free: a macro is sent through `SendRawCat` → `Session::send`, the same seam that gates the K-Pod press, so a macro containing `TX;` is refused while disarmed and flashes ARM TX (FR-TX-SAFE-06) with no extra code. Note also the case-insensitive gate (FR-TX-SAFE-03) matters here — a hand-typed macro in lower case is still gated. Investigated FR-MTR-05 on the way and found its power/SWR readout **already delivered** by FR-MTR-03 (the TX meter draws `nnn W` and SWR numerically); its `V`/`I` part needs `SI`, whose response format D12 marks 'documented in a future revision', so it is not buildable now. | | 2026-07-25 | 0.48 | DC0SK | Added FR-FM-02 as a **DTMF keypad** (`DM`) — the part of the gap-analysis item that is both buildable and useful remotely: sending DTMF for repeater/link control cannot be done any other way over the link. A 4×4 popup opened from the FM panel, one `DM;` per key. **Scoped down from the gap analysis on purpose:** the '6 stored DTMF sequences' are config work deferred to a follow-up, and the **1750 Hz tone burst has no documented CAT command** in D12 (searched), so it is not buildable now rather than guessed at — the `RO`/`RA` lesson. `send_dtmf` refuses a non-DTMF character rather than emitting a malformed `DM`. | +| 2026-07-25 | 0.49 | DC0SK | Added FR-XVTR-01 (transverter band setup), the last substantial backlog item — complex and niche (transverter operators), but fully documented so buildable without hardware-guessing. Six `XV*` encoders (`XVN`/`XVM`/`XVR`/`XVI`/`XVO`/`XVP`), a read-back parser for each field, and a setup form on the BAND screen. The design turns on `XVN` being **stateful** — it selects the band the other commands target — so every field send is prefixed with `XVN`, and the form re-reads all fields when a band is picked, keyed on the `XVN` the radio confirms so a stale value never lands. The form outgrew the fixed-height config-screen slot and clipped; fixed by compacting it to three rows and wrapping the BAND screen in a scrollable. Deferred, and said so: the **mW power scale on XVTR bands** (showing mW instead of W when operating on a configured transverter band) — it needs the current-band-is-XVTR state wired through, and is an operating-display concern separate from this setup form. | diff --git a/docs/test/coverage.generated.md b/docs/test/coverage.generated.md index fecb01c..6dcd291 100644 --- a/docs/test/coverage.generated.md +++ b/docs/test/coverage.generated.md @@ -175,6 +175,7 @@ Legend: ✅ test-traced · 🟡 waived (see r3-waivers.md) · ⚪ not test-requi | `FR-VFO-STEP-01` | S | T/D | ✅ | | `FR-VOX-01` | C | T | ✅ | | `FR-VOX-02` | C | T | ✅ | +| `FR-XVTR-01` | S | T | ✅ | | `NFR-MAINT-01` | M | I | ⚪ | | `NFR-MAINT-LOG` | S | I | ⚪ | | `NFR-PERF-01` | M | T/A | 🟡 | diff --git a/docs/test/test-strategy.md b/docs/test/test-strategy.md index 2f7c39f..1105764 100644 --- a/docs/test/test-strategy.md +++ b/docs/test/test-strategy.md @@ -1,7 +1,7 @@ --- title: "Test Strategy & Traceability" status: Draft -version: "3.8" +version: "3.12" updated: 2026-07-22 authors: - Simon Keimer (DC0SK) @@ -429,3 +429,7 @@ FR-SES-MULTI, FR-DIAG-02, etc. — get `TC` IDs when promoted to `Approved`.)* | 2026-07-25 | 3.6 | DC0SK | **FR-MACRO-01 — on-screen macro buttons**, reusing the K-Pod macro table on a new Fn → MACROS tab. Deliberate reuse: the 16-slot label+CAT table is already in config and not feature-gated, so the buttons work without a K-Pod, and because a press goes through the same `Session::send` seam the physical switch uses, the arm gate and the refusal flash (FR-TX-SAFE-06) — and the lower-case gate (FR-TX-SAFE-03) for hand-typed macros — all apply for free. Verified: the label/assignment logic (`macro_label`) by test, and the MACROS tab on screen in `--demo`, showing the 12 seeded Elecraft sample macros wrapping to a second row at eight. **A backlog finding recorded rather than built:** FR-MTR-05's power/SWR readout is already shipped (FR-MTR-03 draws `nnn W` + SWR on the TX meter), and its V/I half depends on `SI`, whose format D12 leaves 'for a future revision' — so like FR-VFO-STEP-01 it is partly already-met and partly not-yet-buildable. 322 tests. | | 2026-07-25 | 3.7 | DC0SK | **DATA sub-mode (and rate) switching was laggy** — reported by DC0SK on the radio. The same read-back fight the sliders had (1.97): `rx_data_submode`/`rx_data_rate` read straight from the snapshot, so tapping a sub-mode sent `DT` but the button did not light until the radio's echo came back. Fixed the same way — an optimistic `Opt` override per field, set on tap, preferred by the accessor, and reconciled against the radio each tick (confirm-or-expire). The `DR` rate had the identical gap (added in 3.4 without an override); fixed alongside. The general lesson holds: any control that reads the radio's state directly, rather than through a mirror the send updates, will feel laggy — the override is the standing fix and should be the default for a new radio-backed control. | | 2026-07-25 | 3.8 | DC0SK | **FR-FM-02 — DTMF keypad** (`DM`), a 4×4 popup from the FM panel sending one tone per key. The one part of the gap-analysis item worth building: DTMF for repeater/link control is impossible remotely otherwise. **Two parts deliberately not built:** the 6 stored sequences (config work, deferred) and the 1750 Hz burst — which has **no documented CAT command** (D12 searched), so it is un-buildable, not merely un-built, and guessing a command is the `RO`/`RA` trap. `send_dtmf` refuses a non-DTMF character rather than emitting a malformed `DM`. Verified: the encoder over every keypad digit by test; the popup on screen by forcing it open in `--demo` (the standard telephone grid). A note on method: several xdotool click attempts missed the FM-row DTMF button by ~40 px, and rather than keep guessing coordinates I confirmed the overlay by forcing its open flag — the wiring mirrors the working memories/about overlays. 324 tests. | +| 2026-07-25 | 3.9 | DC0SK | **FR-ANT-02 — antenna names** (`ACN`), continuing the clean-effect backlog picks. Scoped to the **TX antenna**, the only one with a documented `ACN`-slot mapping (`AN` 1–3 ↔ `ACN` 1–3); the RX antennas' fixed names stay, since their mapping to slots 4–5 is undocumented and guessing it is the `RO`/`RA` trap. Read-only. **A width bug caught on screen and fixed before commit:** the first cut read `ANT: DIPOLE`, and a 6-character name plus the `ANT: ` prefix wrapped the fixed 92 px switch cell to two lines — taller than its neighbours, the vertical form of the wireframe bounce this project keeps hitting. Verified by seeding the worst case (`WWWMMM`, all wide glyphs): it wrapped with the prefix, fits on one line without it. Name shown alone now. Also a process note: two scripted test-insertions this turn silently no-op'd because they anchored on tests from *unmerged* branches not present here — caught by grepping for the test after, added against a real anchor. 319 tests. | +| 2026-07-25 | 3.10 | DC0SK | **FR-MACRO-01 — on-screen macro buttons**, reusing the K-Pod macro table on a new Fn → MACROS tab. Deliberate reuse: the 16-slot label+CAT table is already in config and not feature-gated, so the buttons work without a K-Pod, and because a press goes through the same `Session::send` seam the physical switch uses, the arm gate and the refusal flash (FR-TX-SAFE-06) — and the lower-case gate (FR-TX-SAFE-03) for hand-typed macros — all apply for free. Verified: the label/assignment logic (`macro_label`) by test, and the MACROS tab on screen in `--demo`, showing the 12 seeded Elecraft sample macros wrapping to a second row at eight. **A backlog finding recorded rather than built:** FR-MTR-05's power/SWR readout is already shipped (FR-MTR-03 draws `nnn W` + SWR on the TX meter), and its V/I half depends on `SI`, whose format D12 leaves 'for a future revision' — so like FR-VFO-STEP-01 it is partly already-met and partly not-yet-buildable. 322 tests. | +| 2026-07-25 | 3.11 | DC0SK | **FR-XVTR-01 — transverter band setup**, the last substantial backlog item. Six `XV*` encoders + per-field read-back parser + a setup form on the BAND screen. Complex and niche but fully documented, so buildable without hardware-guessing (unlike the audio-character and message items, still hardware-blocked). Design point: `XVN` is **stateful** (selects the band the rest target), so each field send is prefixed with `XVN` and the form reloads on band-select keyed on the `XVN` the radio confirms — a stale value can never land in a field. The form clipped the fixed-height config slot at first; compacted to three rows and the BAND screen wrapped in a scrollable. R5 caught three encoders passed as bare `.map()` references with no paren-call site — switched to closures so the capability is genuinely reachable, not just defined. Deferred, and recorded: the mW power scale on XVTR bands (an operating-display concern needing current-band state, separate from setup). Verified: all six encoders and the read-back parse by test; the form on screen in `--demo`. 325 tests. | +| 2026-07-25 | 3.12 | DC0SK | **Transverter setup moved to a second column** after DC0SK found the single stacked column grew a vertical scrollbar in the config slot — an ergonomics problem (scrolling a setup form is worse than the space it saves). The BAND screen is now two columns: HF/6 m band selection left, transverter select + setup form right, with the form's four numeric fields laid two-per-row (`Lower`/`IF`, `Offset`/`Power`) now that the half-width column gives horizontal room. No scrollbar; everything fits the fixed-height slot. Verified on screen in `--demo`. |