From 332462c53bd0be0c696ca38f592f330c95c11b10 Mon Sep 17 00:00:00 2001 From: DC0SK Date: Sat, 25 Jul 2026 15:55:51 +0200 Subject: [PATCH] feat(fm): DTMF keypad for FM mode (DM) FR-FM-02. A DTMF button on the FM panel opens a 4x4 keypad (0-9, A-D, *, #), each key sending one DM tone. This is the part of the gap-analysis item worth building: sending DTMF for repeater and link control is impossible over a remote link any other way. Scoped down deliberately. The 6 stored DTMF sequences are config work, deferred to a follow-up. The 1750 Hz tone burst has no documented CAT command in D12 (searched), so it is un-buildable rather than un-built - guessing a command is the RO/RA wire-format 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 renders). Several xdotool clicks missed the FM-row button by ~40 px, so rather than keep guessing coordinates I confirmed the overlay via its open flag - the wiring mirrors the working memories and about overlays. 324 tests. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01KiAgfnGv746wwVBSfFkoRY --- CHANGELOG.md | 6 ++ app/src/main.rs | 81 ++++++++++++++++++++++++ crates/k4-protocol/src/cat.rs | 15 +++++ crates/k4-protocol/tests/cat.rs | 19 ++++++ docs/requirements/system-requirements.md | 4 +- docs/test/coverage.generated.md | 1 + docs/test/test-strategy.md | 3 +- 7 files changed, 127 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d019a20..2658f07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,12 @@ and [`docs/requirements/system-requirements.md`](docs/requirements/system-requir ### Added +- **DTMF keypad for FM.** A **DTMF** button on the FM panel opens a keypad + (0-9, A-D, *, #) that sends tones to the radio - for repeater and link + control that is otherwise out of reach over a remote link. + +### 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 385ce22..bc5a080 100644 --- a/app/src/main.rs +++ b/app/src/main.rs @@ -151,6 +151,8 @@ struct App { // keeps its own bank. memories: Vec, memories_open: bool, + /// DTMF keypad popup open (FR-FM-02). + dtmf_open: bool, memory_name: String, // Peer cache + settings dialog (FR-CFG-04, FR-UI-23). peers: k4_config::PeerCache, @@ -612,6 +614,9 @@ enum Message { /// Run an assigned macro from the on-screen MACROS tab (FR-MACRO-01): send /// its CAT string, gated by the TX arm exactly as the K-Pod press is. RunMacro(usize), + /// DTMF keypad (FR-FM-02): open/close, and send one digit. + ToggleDtmf, + DtmfDigit(char), /// Edit a K-Pod slot's free-form CAT macro string (slot index, text). KpodButtonCatChanged(usize, String), /// Apply a preset (by label) to a K-Pod slot, filling its label + CAT. @@ -925,6 +930,7 @@ impl App { seeded: false, memories: prefs.memories.clone(), memories_open: false, + dtmf_open: false, memory_name: String::new(), peers, settings_open: false, @@ -2013,6 +2019,14 @@ impl App { self.kpod_buttons = k4_config::default_kpod_buttons(); self.push_kpod_buttons(); } + Message::ToggleDtmf => self.dtmf_open = !self.dtmf_open, + Message::DtmfDigit(d) => { + // FM-only, SET-only. The encoder refuses a non-DTMF char, so a + // stray key sends nothing. + if let Some(cmd) = k4_protocol::cat::send_dtmf(d) { + self.send(WorkerCmd::Cat(cmd)); + } + } Message::RunMacro(idx) => { // Same path a K-Pod press takes: the raw CAT string through the // arm-gated seam, so a macro that keys is refused (and flashes @@ -2076,6 +2090,10 @@ impl App { self.memories_open = false; return Task::none(); } + if self.dtmf_open { + self.dtmf_open = false; + return Task::none(); + } if self.settings_open { self.settings_open = false; return Task::none(); @@ -3829,6 +3847,20 @@ impl App { .color(rxv), ) .push(step("+", 1)) + // DTMF keypad (FR-FM-02): a compact button opening a 4×4 keypad + // popup, so the keys do not crowd this row. FM only, which this + // panel already is. + .push(Space::with_width(Length::Fixed(10.0))) + .push( + Button::new(Text::new("DTMF").size(11)) + .style(btn_style(if self.dtmf_open { + BtnKind::Active + } else { + BtnKind::Plain + })) + .padding([4, 8]) + .on_press(Message::ToggleDtmf), + ) .into() } @@ -6955,6 +6987,8 @@ impl App { stack![content, self.rx_popup_overlay(p)].into() } else if self.memories_open { stack![content, self.memories_overlay()].into() + } else if self.dtmf_open { + stack![content, self.dtmf_overlay()].into() } else if self.settings_open { stack![content, settings_card].into() } else if self.about_open { @@ -7722,6 +7756,53 @@ impl App { /// Client-side, because the radio's own memory-channel command (`MC`) is /// "[Pending] TBD" in the Programmer's Reference — there is no documented /// way to reach the K4's memories over CAT. + /// The DTMF keypad popup (FR-FM-02): a 4×4 grid of the standard telephone + /// layout plus A–D and `*`/`#`, each key sending one `DM` digit. FM only. + /// Sending, not storing — the 6 stored-sequence memories the gap analysis + /// mentions are a later addition, and the 1750 Hz burst has no documented + /// CAT command, so neither is here. + fn dtmf_overlay(&self) -> Element<'_, Message> { + let dim = role_color(ui::ColorRole::Inactive); + let key = |d: char| -> Element { + Button::new(Text::new(d.to_string()).size(16).center()) + .style(btn_style(BtnKind::Plain)) + .padding([8, 0]) + .width(Length::Fixed(52.0)) + .on_press(Message::DtmfDigit(d)) + .into() + }; + let mut grid = Column::new().spacing(6); + for r in 0..4 { + let mut row = Row::new().spacing(6); + for c in 0..4 { + row = row.push(key(k4_protocol::cat::DTMF_DIGITS[r * 4 + c])); + } + grid = grid.push(row); + } + let card = Container::new( + Column::new() + .spacing(10) + .push( + Row::new() + .spacing(12) + .align_y(Alignment::Center) + .push(Text::new("DTMF").size(12).color(dim)) + .push(horizontal_space()) + .push(small_btn("Close", Message::ToggleDtmf)), + ) + .push( + Text::new("Each key sends a DTMF tone (FM only).") + .size(11) + .color(dim), + ) + .push(grid), + ) + .style(panel_style) + .padding(18) + .width(Length::Fixed(260.0)); + modal_scrim(card.into()) + } + fn memories_overlay(&self) -> Element<'_, Message> { let dim = role_color(ui::ColorRole::Inactive); let mut list = Column::new().spacing(4); diff --git a/crates/k4-protocol/src/cat.rs b/crates/k4-protocol/src/cat.rs index ff856f7..5fe32b0 100644 --- a/crates/k4-protocol/src/cat.rs +++ b/crates/k4-protocol/src/cat.rs @@ -602,6 +602,21 @@ pub fn set_pl_tone(index: u8, on: bool) -> String { format!("PL{:02}{};", index.clamp(1, 50), on as u8) } +/// The valid DTMF digits (`DM`), in keypad order (`FR-FM-02`). +pub const DTMF_DIGITS: [char; 16] = [ + '1', '2', '3', 'A', '4', '5', '6', 'B', '7', '8', '9', 'C', '*', '0', '#', 'D', +]; + +/// Send one DTMF digit (`DM`, FM mode only): `0`–`9`, `A`–`D`, `*`, `#`. +/// +/// Returns `None` for anything not a DTMF digit rather than sending a malformed +/// command. SET-only on the radio (no read-back). +/// +/// trace: FR-FM-02 +pub fn send_dtmf(digit: char) -> Option { + DTMF_DIGITS.contains(&digit).then(|| format!("DM{digit};")) +} + /// DVR voice-message playback (`PB`): message 1–8, or 0 to cancel play/record. /// /// trace: FR-DVR-01 diff --git a/crates/k4-protocol/tests/cat.rs b/crates/k4-protocol/tests/cat.rs index a00bd71..92352ad 100644 --- a/crates/k4-protocol/tests/cat.rs +++ b/crates/k4-protocol/tests/cat.rs @@ -718,3 +718,22 @@ fn fr_data_02_data_rate_encodes() { "clamped to the 1-bit field" ); } + +/// DTMF digits encode as `DM;`, and non-digits are refused. +/// trace: FR-FM-02 +#[test] +fn fr_fm_02_dtmf_encodes_valid_digits_only() { + use k4_protocol::cat::{send_dtmf, DTMF_DIGITS}; + assert_eq!(send_dtmf('5'), Some("DM5;".to_string())); + assert_eq!(send_dtmf('A'), Some("DMA;".to_string())); + assert_eq!(send_dtmf('*'), Some("DM*;".to_string())); + assert_eq!(send_dtmf('#'), Some("DM#;".to_string())); + // Every keypad digit encodes. + for d in DTMF_DIGITS { + assert!(send_dtmf(d).is_some(), "{d} must be a valid DTMF digit"); + } + // Non-DTMF characters are refused, not sent malformed. + assert_eq!(send_dtmf('E'), None); + assert_eq!(send_dtmf(';'), None); + assert_eq!(send_dtmf(' '), None); +} diff --git a/docs/requirements/system-requirements.md b/docs/requirements/system-requirements.md index 61c888b..0c84750 100644 --- a/docs/requirements/system-requirements.md +++ b/docs/requirements/system-requirements.md @@ -1,7 +1,7 @@ --- title: "System Requirements Specification" status: Draft -version: "0.47" +version: "0.48" updated: 2026-07-22 version: "0.40" updated: 2026-07-21 @@ -282,6 +282,7 @@ syntax per the Programmer's Reference D12, cross-checked vs QK4 (`R-EXT-03`).* | `FR-DATA-02` | **select the DATA rate** for the active receiver (`DR`/`DR$`) beside the DATA sub-mode strip: 45 / 75 baud in AFSK-A and FSK-D, BPSK31 / BPSK63 in PSK-D. The rate has no meaning in DATA-A, so the control is hidden there; the label follows the sub-mode, since the single `DR` bit means different rates in different sub-modes. | STK-03 | S | T | `set_data_rate(sub, r)` encodes `DR[$]r` clamped to one bit; `DR`/`DR$` parse per receiver; `data_rate_labels` returns the sub-mode's pair or `None` for DATA-A. | | `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-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;`. | @@ -376,3 +377,4 @@ syntax per the Programmer's Reference D12, cross-checked vs QK4 (`R-EXT-03`).* | 2026-07-25 | 0.45 | DC0SK | Added FR-DATA-02 (DATA rate select `DR`/`DR$`), a Med backlog item and a clean radio-side control — unlike the audio-character item (`MX`/`BL`/`FX`/`AL`) next to it in the gap analysis, which was **left unbuilt** because it entangles with the unresolved remote-stream question (the `AG` lesson: the K4 streams RX audio at a fixed level regardless of the radio's own AF/mix, so radio-side audio routing may not reach the remote listener). `DR` has no such ambiguity: it sets the demodulator rate. The one subtlety is that the single rate *bit* means different things per sub-mode — 45/75 baud in AFSK-A/FSK-D, BPSK31/63 in PSK-D, nothing in DATA-A — so the label is a pure function of the sub-mode (`data_rate_labels`), and the control is hidden where it does not apply. Mirrors the existing `DT`/`DT$` sub-mode plumbing. | | 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`. | diff --git a/docs/test/coverage.generated.md b/docs/test/coverage.generated.md index 397dfbc..fecb01c 100644 --- a/docs/test/coverage.generated.md +++ b/docs/test/coverage.generated.md @@ -57,6 +57,7 @@ Legend: ✅ test-traced · 🟡 waived (see r3-waivers.md) · ⚪ not test-requi | `FR-FIL-02` | C | T | ✅ | | `FR-FIL-03` | C | D | ⚪ | | `FR-FM-01` | C | T | ✅ | +| `FR-FM-02` | S | T | ✅ | | `FR-KEY-01` | S | T | ✅ | | `FR-KEY-02` | C | T | ✅ | | `FR-KPOD-01` | S | T | ✅ | diff --git a/docs/test/test-strategy.md b/docs/test/test-strategy.md index 33aa25f..308f60b 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.7" +version: "3.8" updated: 2026-07-22 authors: - Simon Keimer (DC0SK) @@ -427,3 +427,4 @@ FR-SES-MULTI, FR-DIAG-02, etc. — get `TC` IDs when promoted to `Approved`.)* | 2026-07-25 | 3.4 | DC0SK | **FR-DATA-02 — DATA rate select** (`DR`/`DR$`), continuing the backlog. Picked over the higher-listed audio-character item deliberately: `MX`/`BL`/`FX`/`AL` all touch the **remote-stream question** this session already got burned on with `AG` — whether a radio-side audio setting reaches the remote listener at all — so building them blind would repeat that mistake, and they are left hardware-blocked. `DR` is unambiguous: it sets the demodulator rate. The design point worth keeping is that one wire *bit* carries different meanings per sub-mode (45/75 baud vs BPSK31/63), so the label is computed from the sub-mode (`data_rate_labels`) rather than the bit, and the control is hidden in DATA-A where there is no rate. Verified: encoder and per-receiver parse by test, the label-per-sub-mode logic by test, and the buttons on screen by seeding FSK-D in `--demo` (`45 Bd`/`75 Bd`, `75 Bd` lit). 320 tests. | | 2026-07-25 | 3.6 | 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.7 | 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.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. |