Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,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
Expand Down
81 changes: 81 additions & 0 deletions app/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,8 @@ struct App {
// keeps its own bank.
memories: Vec<k4_config::Memory>,
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,
Expand Down Expand Up @@ -616,6 +618,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.
Expand Down Expand Up @@ -929,6 +934,7 @@ impl App {
seeded: false,
memories: prefs.memories.clone(),
memories_open: false,
dtmf_open: false,
memory_name: String::new(),
peers,
settings_open: false,
Expand Down Expand Up @@ -2021,6 +2027,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
Expand Down Expand Up @@ -2084,6 +2098,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();
Expand Down Expand Up @@ -3844,6 +3862,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()
}

Expand Down Expand Up @@ -6972,6 +7004,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 {
Expand Down Expand Up @@ -7739,6 +7773,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<Message> {
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);
Expand Down
15 changes: 15 additions & 0 deletions crates/k4-protocol/src/cat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
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
Expand Down
19 changes: 19 additions & 0 deletions crates/k4-protocol/tests/cat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -718,3 +718,22 @@ fn fr_data_02_data_rate_encodes() {
"clamped to the 1-bit field"
);
}

/// DTMF digits encode as `DM<digit>;`, 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);
}
4 changes: 3 additions & 1 deletion docs/requirements/system-requirements.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 `F<n>t/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<digit>;` 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<mode><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;`. |
Expand Down Expand Up @@ -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: <name>`, 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<digit>;` 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`. |
1 change: 1 addition & 0 deletions docs/test/coverage.generated.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 | ✅ |
Expand Down
Loading
Loading