From 5880d1fd0e394914758bcc15630cd43fda2081c5 Mon Sep 17 00:00:00 2001 From: DC0SK Date: Thu, 6 Aug 2026 16:15:59 +0200 Subject: [PATCH] =?UTF-8?q?feat(kpa1500):=20live=20amp=20client=20?= =?UTF-8?q?=E2=80=94=20poll,=20telemetry=20panel,=20controls=20(FR-AMP-03)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the k4-kpa codec to a real connection. A background worker (app/src/kpa.rs) mirrors the K4 worker's shared-snapshot model: it owns the amplifier's TCP socket (its own Ethernet command server, port 1500), polls on the configured interval, parses replies via k4_kpa::apply, and publishes a KpaState + connection status the UI copies on tick. The connection is reconciled off the tick against `enabled && K4-connected && host-set`, so it follows the K4 link, the enable toggle and host edits with no per-event wiring, and drops cleanly otherwise; transport errors surface in the amp window rather than going silent. UI: a top-bar amp indicator (shown only when enabled; click opens the amp window) whose colour and wording follow the telemetry — fault red beats mode, Operate green with live power/SWR, Caution on SWR >= 2.0, Standby amber, down-link dim. The KPA1500 window gains live telemetry (mode, power, SWR, temp, band, antenna, ATU, fan, fault, firmware) and controls (Operate/Standby, ATU in/bypass, antenna). Deferred: a full ATU tune (needs the K4 keyed — couples to the arm-gated TX path). Verified end to end against a mock KPA1500 TCP server: the worker connects, the panel fills with live telemetry, the top-bar indicator reads "AMP OPER 1200 W SWR 1.3", and STANDBY round-trips (command reaches the amp, poll reflects it back, UI updates). Indicator precedence is unit-tested (fr_amp_03_amp_indicator_precedence_and_wording). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01KiAgfnGv746wwVBSfFkoRY --- CHANGELOG.md | 13 +- Cargo.lock | 1 + app/Cargo.toml | 1 + app/src/kpa.rs | 241 +++++++++++++++++++++++ app/src/main.rs | 213 +++++++++++++++++++- app/src/ui.rs | 96 +++++++++ docs/requirements/system-requirements.md | 4 +- docs/test/coverage.generated.md | 1 + docs/test/test-strategy.md | 3 +- 9 files changed, 563 insertions(+), 10 deletions(-) create mode 100644 app/src/kpa.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 5053204..43f00b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,11 +17,14 @@ and [`docs/requirements/system-requirements.md`](docs/requirements/system-requir - **KPA1500 amplifier support (opt-in).** A new **KPA1500** section in Settings with an enable toggle and a **Configuration…** button that opens a separate window for the amplifier's connection — host, port (default 1500) and poll - interval. Off by default; your settings are saved. This first step wires the - connection to the amp's own Ethernet control server (a second link alongside - the K4); live amplifier metering and control follow in a later release. Only - the KPA1500 is supported — the KPA500 and KAT500 have no network interface of - their own. + interval. Off by default; your settings are saved. The app connects to the + amp's own Ethernet control server (a second link alongside the K4) whenever + the K4 is connected, and shows it **live**: a top-bar amp indicator + (Operate/Standby, forward power, SWR, faults) and, in the KPA1500 window, + full telemetry plus Operate/Standby, ATU in/bypass and antenna controls. + Only the KPA1500 is supported — the KPA500 and KAT500 have no network + interface of their own. (A one-touch ATU tune, which needs the K4 keyed, + comes in a later release.) ## [0.9.0] — 2026-07-27 diff --git a/Cargo.lock b/Cargo.lock index 354c861..9e13c72 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2222,6 +2222,7 @@ dependencies = [ "k4-audio", "k4-config", "k4-diag", + "k4-kpa", "k4-kpod", "k4-protocol", "k4-session", diff --git a/app/Cargo.toml b/app/Cargo.toml index 047aaa5..16630c8 100644 --- a/app/Cargo.toml +++ b/app/Cargo.toml @@ -76,6 +76,7 @@ k4-stream = { path = "../crates/k4-stream" } k4-audio = { path = "../crates/k4-audio" } k4-config = { path = "../crates/k4-config" } k4-diag = { path = "../crates/k4-diag" } +k4-kpa = { path = "../crates/k4-kpa" } iced = { version = "0.13", features = ["tokio", "canvas", "image"] } ureq = { version = "3.3.0", default-features = false, features = ["rustls"] } tokio = { version = "1.53.0", default-features = false, features = ["rt"] } diff --git a/app/src/kpa.rs b/app/src/kpa.rs new file mode 100644 index 0000000..765a650 --- /dev/null +++ b/app/src/kpa.rs @@ -0,0 +1,241 @@ +//! KPA1500 amplifier client worker (FR-AMP-03). +//! +//! A background thread that owns the TCP socket to the amplifier's own remote +//! command server (`k4_kpa::TCP_PORT`), polls it on an interval with +//! [`k4_kpa::POLL`], parses the replies into a [`k4_kpa::KpaState`], and +//! publishes the result plus a connection status into a shared snapshot the UI +//! reads on its tick — mirroring the K4 worker's `Arc>` model. +//! +//! It is deliberately independent of the K4 link at the socket level; the app +//! decides *when* to connect (only while the K4 is up and the operator has +//! enabled support — FR-AMP-01) by sending [`Cmd`]s. + +use std::io::{Read, Write}; +use std::net::TcpStream; +use std::sync::mpsc::{Receiver, TryRecvError}; +use std::sync::{Arc, Mutex}; +use std::thread::{self, JoinHandle}; +use std::time::{Duration, Instant}; + +use k4_kpa::KpaState; + +/// Connection lifecycle, shown by the amp indicator. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Conn { + /// No target set, or intentionally disconnected. + #[default] + Disconnected, + /// A target is set and the socket is being (re)established. + Connecting, + /// Connected and polling. + Connected, +} + +/// The snapshot the UI reads: connection status plus the latest telemetry. +#[derive(Debug, Clone, Default)] +pub struct Shared { + pub conn: Conn, + pub state: KpaState, + /// Last transport error, for the diagnostics/status line. + pub error: Option, +} + +/// Commands from the app to the worker. +pub enum Cmd { + /// (Re)connect to `host:port`, polling every `interval_ms`. A repeat of the + /// current target while connected is ignored. + Connect { + host: String, + port: u16, + interval_ms: u64, + }, + /// Drop the socket and stop polling. + Disconnect, + /// Send a raw control command (already `^…;`-framed) to the amplifier. + Send(String), +} + +/// How long to wait for the initial TCP connect before reporting failure. +const CONNECT_TIMEOUT: Duration = Duration::from_secs(4); +/// Read timeout per service slice — short so commands stay responsive. +const READ_SLICE: Duration = Duration::from_millis(100); +/// Minimum gap between reconnect attempts after a failure. +const RECONNECT_BACKOFF: Duration = Duration::from_secs(3); + +/// Spawn the worker thread. +pub fn spawn(rx: Receiver, shared: Arc>) -> JoinHandle<()> { + thread::spawn(move || run(&rx, &shared)) +} + +struct Target { + host: String, + port: u16, + interval: Duration, +} + +fn run(rx: &Receiver, shared: &Arc>) { + let mut target: Option = None; + let mut stream: Option = None; + let mut rxbuf = String::new(); + let mut state = KpaState::default(); + let mut last_poll = Instant::now(); + let mut last_attempt: Option = None; + + loop { + // 1. Drain pending commands (non-blocking). + loop { + match rx.try_recv() { + Ok(Cmd::Connect { + host, + port, + interval_ms, + }) => { + let same = target + .as_ref() + .is_some_and(|t| t.host == host && t.port == port); + if same && stream.is_some() { + // Already on this target — just update the interval. + if let Some(t) = target.as_mut() { + t.interval = Duration::from_millis(interval_ms.max(50)); + } + } else { + target = Some(Target { + host, + port, + interval: Duration::from_millis(interval_ms.max(50)), + }); + stream = None; + last_attempt = None; + state = KpaState::default(); + publish(shared, Conn::Connecting, &state, None); + } + } + Ok(Cmd::Disconnect) => { + target = None; + stream = None; + rxbuf.clear(); + state = KpaState::default(); + publish(shared, Conn::Disconnected, &state, None); + } + Ok(Cmd::Send(cmd)) => { + if let Some(s) = stream.as_mut() { + if s.write_all(cmd.as_bytes()).is_err() { + stream = None; + publish(shared, Conn::Connecting, &state, Some("write failed")); + } + } + } + Err(TryRecvError::Empty) => break, + Err(TryRecvError::Disconnected) => return, // app gone + } + } + + // 2. Establish the socket if we have a target and none open. + if let (Some(t), None) = (target.as_ref(), stream.as_ref()) { + let due = last_attempt.is_none_or(|a| a.elapsed() >= RECONNECT_BACKOFF); + if due { + last_attempt = Some(Instant::now()); + match connect(t) { + Ok(s) => { + rxbuf.clear(); + state = KpaState::default(); + // Identity + firmware + serial once, then the first poll. + let _ = s.try_clone().map(|mut w| { + let _ = w.write_all(k4_kpa::IDENT.as_bytes()); + let _ = w.write_all(k4_kpa::POLL.as_bytes()); + }); + last_poll = Instant::now(); + stream = Some(s); + publish(shared, Conn::Connected, &state, None); + } + Err(e) => { + publish(shared, Conn::Connecting, &state, Some(&e)); + } + } + } + } + + // 3. Service an open socket: poll on interval, read + parse replies. + if let (Some(t), Some(s)) = (target.as_ref(), stream.as_mut()) { + if last_poll.elapsed() >= t.interval { + if s.write_all(k4_kpa::POLL.as_bytes()).is_err() { + stream = None; + publish(shared, Conn::Connecting, &state, Some("poll write failed")); + continue; + } + last_poll = Instant::now(); + } + match read_available(s, &mut rxbuf) { + Ok(true) => { + // Apply complete `;`-terminated replies; keep any tail. + if let Some(cut) = rxbuf.rfind(';') { + let complete: String = rxbuf.drain(..=cut).collect(); + k4_kpa::apply(&mut state, &complete); + publish(shared, Conn::Connected, &state, None); + } + } + Ok(false) => {} // just a timeout, no data + Err(e) => { + stream = None; + publish(shared, Conn::Connecting, &state, Some(&e)); + } + } + } + + // Idle a beat when there is nothing to do, so a disconnected worker + // does not spin. + if stream.is_none() { + thread::sleep(READ_SLICE); + } + } +} + +fn connect(t: &Target) -> Result { + // Resolve then connect with a bounded timeout so a wrong host can't hang. + let addr = format!("{}:{}", t.host, t.port); + let sock = addr + .to_socket_addrs_first() + .ok_or_else(|| format!("cannot resolve {addr}"))?; + let stream = TcpStream::connect_timeout(&sock, CONNECT_TIMEOUT) + .map_err(|e| format!("connect failed: {e}"))?; + stream + .set_read_timeout(Some(READ_SLICE)) + .map_err(|e| format!("socket setup failed: {e}"))?; + Ok(stream) +} + +/// Read whatever is available within one [`READ_SLICE`], appending UTF-8 text to +/// `buf`. `Ok(true)` = bytes were read, `Ok(false)` = timed out with nothing, +/// `Err` = the connection is broken. +fn read_available(s: &mut TcpStream, buf: &mut String) -> Result { + let mut tmp = [0u8; 512]; + match s.read(&mut tmp) { + Ok(0) => Err("amplifier closed the connection".to_string()), + Ok(n) => { + buf.push_str(&String::from_utf8_lossy(&tmp[..n])); + Ok(true) + } + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => Ok(false), + Err(e) if e.kind() == std::io::ErrorKind::TimedOut => Ok(false), + Err(e) => Err(format!("read failed: {e}")), + } +} + +fn publish(shared: &Arc>, conn: Conn, state: &KpaState, error: Option<&str>) { + if let Ok(mut g) = shared.lock() { + g.conn = conn; + g.state = state.clone(); + g.error = error.map(str::to_string); + } +} + +/// Minimal single-address resolution so the worker needs no async resolver. +trait ResolveFirst { + fn to_socket_addrs_first(&self) -> Option; +} +impl ResolveFirst for str { + fn to_socket_addrs_first(&self) -> Option { + use std::net::ToSocketAddrs; + self.to_socket_addrs().ok().and_then(|mut it| it.next()) + } +} diff --git a/app/src/main.rs b/app/src/main.rs index 6c56318..e608663 100644 --- a/app/src/main.rs +++ b/app/src/main.rs @@ -7,6 +7,7 @@ //! ADR-15): a dark layered theme, banded frame, grids of two-line state //! buttons, and proportional S-meter bars (FR-UI-08..15). +mod kpa; mod meter; mod spectrum; mod tips; @@ -84,8 +85,7 @@ fn diag_window_settings() -> iced::window::Settings { /// for the amplifier's host/port/poll settings. fn kpa1500_window_settings() -> iced::window::Settings { iced::window::Settings { - size: iced::Size::new(420.0, 320.0), - resizable: false, + size: iced::Size::new(460.0, 600.0), icon: app_icon(), ..Default::default() } @@ -225,6 +225,13 @@ struct App { kpa1500_host: String, kpa1500_port: String, kpa1500_poll: String, + // KPA1500 client worker (FR-AMP-03): a shared snapshot the worker writes + // and the UI copies on tick, a command channel, and the desired-connected + // state so the connection can be reconciled against K4 connectivity. + kpa_shared: Arc>, + kpa_tx: Sender, + kpa: kpa::Shared, + kpa_want: bool, // Diagnostics log: show/hide + follow-newest (auto-scroll). show_log: bool, log_autoscroll: bool, @@ -783,6 +790,10 @@ enum Message { Kpa1500HostChanged(String), Kpa1500PortChanged(String), Kpa1500PollChanged(String), + // KPA1500 amplifier controls (FR-AMP-03). + KpaSetMode(bool), + KpaSetAtu(bool), + KpaAntenna(u8), ToggleLogAutoscroll, LogFilterChanged(String), /// A text-editor action from the read-only log view (selection/scroll; edits @@ -911,6 +922,11 @@ impl App { let kpa1500_host = prefs.kpa1500_host.clone(); let kpa1500_port = prefs.kpa1500_port.to_string(); let kpa1500_poll = prefs.kpa1500_poll_ms.to_string(); + // The amplifier worker starts idle (disconnected); the tick reconciler + // connects it once the K4 is up and support is enabled. + let kpa_shared = Arc::new(Mutex::new(kpa::Shared::default())); + let (kpa_tx, kpa_rx) = mpsc::channel(); + kpa::spawn(kpa_rx, Arc::clone(&kpa_shared)); // Open the main window; the daemon starts with none (FR-DIAG-04). let (main_window, open_main) = iced::window::open(iced::window::Settings { @@ -1030,6 +1046,10 @@ impl App { kpa1500_host, kpa1500_port, kpa1500_poll, + kpa_shared, + kpa_tx, + kpa: kpa::Shared::default(), + kpa_want: false, show_log: false, log_autoscroll: true, log_refresh_div: 0, @@ -1360,6 +1380,30 @@ impl App { self.save_config(); } + /// Tell the amplifier worker to (re)connect using the current form values + /// (FR-AMP-03). Port/poll fall back to the defaults if the fields are empty. + fn kpa_connect(&self) { + let port = self.kpa1500_port.parse::().unwrap_or(1500); + let interval_ms = self + .kpa1500_poll + .parse::() + .ok() + .filter(|ms| *ms >= 50) + .unwrap_or(500); + let _ = self.kpa_tx.send(kpa::Cmd::Connect { + host: self.kpa1500_host.clone(), + port, + interval_ms, + }); + } + + /// Send a control command to the amplifier if it is connected. + fn kpa_send(&self, cmd: impl Into) { + if self.kpa.conn == kpa::Conn::Connected { + let _ = self.kpa_tx.send(kpa::Cmd::Send(cmd.into())); + } + } + fn save_config(&self) { let Ok(port) = self.port.parse::() else { return; @@ -2492,6 +2536,10 @@ impl App { // the enable toggle and settings survive independently. self.kpa1500_config_window = None; self.save_config(); + // Apply any host/port/poll edit to a live connection. + if self.kpa_want { + self.kpa_connect(); + } } } // KPA1500 support (FR-AMP-01): the enable toggle, the detached @@ -2503,6 +2551,9 @@ impl App { Message::ToggleKpa1500Window => { if let Some(id) = self.kpa1500_config_window.take() { self.save_config(); + if self.kpa_want { + self.kpa_connect(); + } return iced::window::close(id); } let (id, open) = iced::window::open(kpa1500_window_settings()); @@ -2520,6 +2571,13 @@ impl App { Message::Kpa1500PollChanged(v) => { self.kpa1500_poll = v.chars().filter(char::is_ascii_digit).take(5).collect(); } + Message::KpaSetMode(operate) => self.kpa_send(k4_kpa::cat::set_mode(operate)), + Message::KpaSetAtu(inline) => self.kpa_send(k4_kpa::cat::set_atu_mode(inline)), + Message::KpaAntenna(n) => { + if let Some(cmd) = k4_kpa::cat::select_antenna(n) { + self.kpa_send(cmd); + } + } Message::ToggleLogAutoscroll => { self.log_autoscroll = !self.log_autoscroll; if self.log_autoscroll { @@ -2763,6 +2821,24 @@ impl App { if let Ok(snap) = self.snapshot.lock() { self.ui = snap.clone(); } + // Copy the amplifier snapshot and reconcile its connection + // (FR-AMP-03): connect only while the K4 is up and support is on + // with a host set; disconnect otherwise. Driven off the tick so + // it follows K4 connect/disconnect, the enable toggle, and host + // edits without wiring each event by hand. + if let Ok(g) = self.kpa_shared.lock() { + self.kpa = g.clone(); + } + let want = + self.kpa1500_enabled && self.ui.connected && !self.kpa1500_host.is_empty(); + if want != self.kpa_want { + self.kpa_want = want; + if want { + self.kpa_connect(); + } else { + let _ = self.kpa_tx.send(kpa::Cmd::Disconnect); + } + } // Seed the config screens from the radio's reported values once // per connection, as the connect GET burst lands (FR-UI-19). if self.ui.phase == ui::ConnPhase::Connected { @@ -6107,6 +6183,7 @@ impl App { .push(host_row) .push(port_row) .push(poll_row) + .push(self.kpa_status_view()) .push( Container::new( Row::new() @@ -6116,7 +6193,7 @@ impl App { .width(Length::Fill) .padding([8, 0]), ); - Container::new(col) + Container::new(scrollable(col)) .style(panel_style) .padding(16) .width(Length::Fill) @@ -6124,6 +6201,111 @@ impl App { .into() } + /// The live-status half of the KPA1500 window (FR-AMP-03): connection state, + /// and — once connected — the amplifier's telemetry and its controls. + fn kpa_status_view(&self) -> Element<'_, Message> { + let dim = role_color(ui::ColorRole::Inactive); + let conn_text = match self.kpa.conn { + kpa::Conn::Disconnected => "not connected", + kpa::Conn::Connecting => "connecting…", + kpa::Conn::Connected => "connected", + }; + let mut col = Column::new() + .spacing(8) + .push(Text::new("Amplifier").size(12).color(dim)) + .push(Text::new(conn_text).size(12)); + // Surface a transport error while not connected (offline, wrong host). + if self.kpa.conn != kpa::Conn::Connected { + if let Some(err) = &self.kpa.error { + col = col.push( + Text::new(err.clone()) + .size(11) + .color(role_color(ui::ColorRole::Caution)), + ); + } + return col.into(); + } + + let s = &self.kpa.state; + let row = |name: &'static str, val: String| { + Row::new() + .spacing(8) + .push( + Text::new(name) + .size(12) + .color(dim) + .width(Length::Fixed(84.0)), + ) + .push(Text::new(val).size(12)) + }; + let dash = || "—".to_string(); + let mode_txt = match s.mode { + Some(k4_kpa::Mode::Operate) => "OPERATE".to_string(), + Some(k4_kpa::Mode::Standby) => "STANDBY".to_string(), + None => dash(), + }; + let power_txt = match (s.forward_w, s.swr()) { + (Some(w), Some(swr)) => format!("{w} W SWR {swr:.1}"), + (Some(w), None) => format!("{w} W"), + _ => dash(), + }; + col = col + .push(row("Mode", mode_txt)) + .push(row("Power", power_txt)) + .push(row( + "Temp", + s.temp_c.map_or_else(dash, |t| format!("{t} °C")), + )) + .push(row( + "Band", + s.band_name().map(str::to_string).unwrap_or_else(dash), + )) + .push(row( + "Antenna", + s.antenna.map_or_else(dash, |a| format!("ANT {a}")), + )) + .push(row( + "ATU", + match s.atu_mode_inline { + Some(true) => "in line".to_string(), + Some(false) => "bypassed".to_string(), + None => dash(), + }, + )) + .push(row("Fan", s.fan.map_or_else(dash, |f| f.to_string()))) + .push(row( + "Fault", + s.fault_text() + .map(str::to_string) + .unwrap_or_else(|| "none".to_string()), + )); + if let Some(fw) = &s.firmware { + col = col.push(row("Firmware", fw.clone())); + } + + // Controls. Operate/Standby keys the amp in/out of line; the ATU and + // antenna mirror the front panel. (A full ATU tune needs the K4 keyed + // and is deferred to a later slice.) + let controls = Column::new() + .spacing(6) + .push(Text::new("Controls").size(12).color(dim)) + .push( + Row::new() + .spacing(6) + .push(small_btn("OPERATE", Message::KpaSetMode(true))) + .push(small_btn("STANDBY", Message::KpaSetMode(false))), + ) + .push( + Row::new() + .spacing(6) + .push(small_btn("ATU IN", Message::KpaSetAtu(true))) + .push(small_btn("ATU BYP", Message::KpaSetAtu(false))) + .push(small_btn("ANT 1", Message::KpaAntenna(1))) + .push(small_btn("ANT 2", Message::KpaAntenna(2))), + ); + col.push(controls).into() + } + /// Per-window title (daemon). fn title(&self, window: iced::window::Id) -> String { if Some(window) == self.diag_window { @@ -6329,12 +6511,37 @@ impl App { } _ => Space::with_width(Length::Shrink).into(), }; + // KPA1500 amp indicator (FR-AMP-03): shown only when support is enabled; + // a click opens the amp window. Colour and wording follow the telemetry. + let amp_note: Element = if self.kpa1500_enabled { + let (label, role) = ui::amp_indicator( + self.kpa.conn == kpa::Conn::Connecting, + self.kpa.conn == kpa::Conn::Connected, + &self.kpa.state, + ); + let color = role_color(role); + Button::new(Text::new(label).size(12).color(color)) + .style(move |_t: &Theme, status: button::Status| button::Style { + background: None, + text_color: match status { + button::Status::Hovered | button::Status::Pressed => Color::WHITE, + _ => color, + }, + ..button::Style::default() + }) + .padding(0) + .on_press(Message::ToggleKpa1500Window) + .into() + } else { + Space::with_width(Length::Shrink).into() + }; let header = Row::new() .spacing(12) .align_y(Alignment::Center) .push(Text::new("K4 REMOTE").size(20)) .push(status_ind) .push(update_note) + .push(amp_note) .push( Text::new(self.ui.status.clone()) .size(12) diff --git a/app/src/ui.rs b/app/src/ui.rs index e744e2c..ea59ac8 100644 --- a/app/src/ui.rs +++ b/app/src/ui.rs @@ -1909,10 +1909,106 @@ pub fn arm_flash_lit(remaining: u8) -> bool { remaining != 0 && (remaining - 1) / 3 % 2 == 1 } +/// The KPA1500 amplifier's top-bar indicator: a short label and a colour role, +/// derived from the connection state and the latest telemetry (FR-AMP-03). +/// +/// A fault wins over everything (red); otherwise Operate is green with the live +/// power/SWR, Standby is amber, and not-yet-connected is dim. Pure so the +/// wording and colour precedence can be tested without a socket. +pub fn amp_indicator( + connecting: bool, + connected: bool, + s: &k4_kpa::KpaState, +) -> (String, ColorRole) { + if !connected { + let label = if connecting { "AMP …" } else { "AMP —" }; + return (label.to_string(), ColorRole::Inactive); + } + if s.is_faulted() { + let code = s.fault.unwrap_or(0); + return (format!("AMP FAULT {code:02X}"), ColorRole::OnAir); + } + match s.mode { + Some(k4_kpa::Mode::Operate) => { + let mut label = String::from("AMP OPER"); + if let Some(w) = s.forward_w { + label.push_str(&format!(" {w} W")); + } + if let Some(swr) = s.swr() { + label.push_str(&format!(" SWR {swr:.1}")); + } + // Warn on a high SWR even in Operate, so it does not read as "all fine". + let role = if s.swr_tenths.is_some_and(|t| t >= 20) { + ColorRole::Caution + } else { + ColorRole::VfoB + }; + (label, role) + } + Some(k4_kpa::Mode::Standby) => ("AMP STBY".to_string(), ColorRole::TxActive), + None => ("AMP …".to_string(), ColorRole::Inactive), + } +} + #[cfg(test)] mod tests { use super::*; + /// The amp indicator's colour precedence and wording: fault beats mode, + /// Operate is green (Caution on high SWR) with live power/SWR, Standby is + /// amber, and a down link is dim. + /// trace: FR-AMP-03 + #[test] + fn fr_amp_03_amp_indicator_precedence_and_wording() { + use k4_kpa::{KpaState, Mode}; + // Disconnected / connecting are dim. + assert_eq!( + amp_indicator(false, false, &KpaState::default()), + ("AMP —".to_string(), ColorRole::Inactive) + ); + assert_eq!( + amp_indicator(true, false, &KpaState::default()).1, + ColorRole::Inactive + ); + // A fault wins over mode and paints red. + let faulted = KpaState { + mode: Some(Mode::Operate), + fault: Some(0x91), + ..Default::default() + }; + let (label, role) = amp_indicator(false, true, &faulted); + assert_eq!(role, ColorRole::OnAir); + assert!(label.contains("FAULT 91"), "{label:?}"); + // Operate shows power + SWR and is green at a good SWR. + let good = KpaState { + mode: Some(Mode::Operate), + forward_w: Some(1200), + swr_tenths: Some(13), + ..Default::default() + }; + let (label, role) = amp_indicator(false, true, &good); + assert_eq!(role, ColorRole::VfoB); + assert!( + label.contains("1200 W") && label.contains("SWR 1.3"), + "{label:?}" + ); + // High SWR in Operate warns (Caution) rather than reading as fine. + let high = KpaState { + swr_tenths: Some(25), + ..good + }; + assert_eq!(amp_indicator(false, true, &high).1, ColorRole::Caution); + // Standby is amber. + let stby = KpaState { + mode: Some(Mode::Standby), + ..Default::default() + }; + assert_eq!( + amp_indicator(false, true, &stby), + ("AMP STBY".to_string(), ColorRole::TxActive) + ); + } + /// Each connection phase reports a distinct status label to the UI, and each /// failure kind maps to a distinguishable human-readable reason. /// diff --git a/docs/requirements/system-requirements.md b/docs/requirements/system-requirements.md index e33df9a..6508cc8 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.53" +version: "0.54" updated: 2026-08-06 authors: - Simon Keimer (DC0SK) @@ -299,6 +299,7 @@ syntax per the Programmer's Reference D12, cross-checked vs QK4 (`R-EXT-03`).* | ID | Statement | Up | Pri | Ver | Acceptance criteria | |---|---|---|---|---|---| | `FR-AMP-01` | provide **KPA1500 amplifier support**, opt-in (default off): an enable toggle in Settings and a **separate configuration window** for the amplifier's connection — host, TCP port (default **1500**) and telemetry poll interval — with the settings persisted across sessions. The link is to the KPA1500's **own Ethernet CAT server** (KPA1500 Programming Reference `^CP`/`^IP`), a second connection alongside the K4, **not** the K4's one-way `EC` passthrough (D12 `EC` is SET-only and, per its own note, makes the K4 ignore the amplifier's replies until restart — unusable for a telemetry-aware panel). Live metering and control of the amplifier are a deliberate follow-up, out of scope for this requirement. Only the KPA1500 is in scope; the KPA500 and KAT500 are serial-only, have no Ethernet server, and are excluded. | STK-11 | S | T/D | `Prefs` defaults `kpa1500_enabled=false`, `kpa1500_port=1500`, `kpa1500_poll_ms=500`; the enable flag and host/port/poll survive a TOML round-trip, and a pre-feature config (no KPA fields) loads with support off (test `fr_amp_01_kpa1500_defaults_off_and_persists`); in the app, the Settings toggle enables support and the button opens the separate KPA1500 configuration window (demo). | +| `FR-AMP-03` | **connect to the KPA1500 and show it live**: a background worker opens the amplifier's TCP command server, polls it on the configured interval, parses the replies (via `FR-AMP-02`) and publishes them to the UI; a **top-status-bar amp indicator** (shown only while support is enabled) reflects Operate/Standby/fault with live power + SWR and opens the amp window on click; the **KPA1500 window** shows the connection state, live telemetry (mode, power, SWR, temperature, band, antenna, ATU state, fan, fault, firmware) and **controls** (Operate/Standby, ATU in/bypass, antenna). The connection is **gated on K4 connectivity** — it comes up only while the K4 is connected and support is enabled with a host set, and drops otherwise; a transport error is surfaced, not silent. A full ATU tune (which needs the K4 keyed) is a later slice. | STK-11 | S | T/D | The amp indicator's colour precedence and wording are a pure function of connection + telemetry — fault (red) beats mode, Operate is green (Caution on SWR ≥ 2.0) with live power/SWR, Standby amber, down-link dim (test `fr_amp_03_amp_indicator_precedence_and_wording`); on a real/mock amplifier the worker connects, the panel fills and the controls key the amp, and the link follows K4 connect/disconnect (demo/HIL). | | `FR-AMP-02` | provide a **pure KPA1500 CAT codec** (`k4-kpa` crate) — **encode** the control commands (operate/standby `^OS`, ATU tune `^FT`/`^FE`, ATU mode `^AM`, antenna `^AN`, power `^ON`) in their exact Programming-Reference wire forms, and **parse** the amplifier's telemetry responses (`^OS` mode, `^WS`/`^PWF`/`^PWR` power, `^SW` SWR, `^TM` temperature, `^FL` fault code + description, `^BN` band, `^AI`/`^AM` ATU state, `^AN` antenna, `^FS` fan, `^TP` tune-in-progress, `^RVM` firmware, `^SN` serial, `^I` identity) into a `KpaState` whose fields stay `None` until first reported. Dependency-free and offline; the socket I/O and poll loop are a later slice. | STK-11 | S | T | Control encoders emit the documented `^…;` strings and antenna select uses the two-digit `^AN01;`..`^AN32;` form (never `^AN0;` = "next"); each telemetry RESP populates its field; fault codes decode from hex and map to text; a malformed or bare-tag fragment neither matches nor blanks an already-known field (tests `fr_amp_02_*`). | --- @@ -389,6 +390,7 @@ syntax per the Programmer's Reference D12, cross-checked vs QK4 (`R-EXT-03`).* | 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. | | 2026-07-26 | 0.50 | DC0SK | Added FR-UI-UPD-02 (automatic update check + top-area notification), requested by DC0SK; **recorded, not yet implemented**. It is a deliberate, operator-chosen relaxation of FR-UI-UPD-01, which made the update check *manual-only* on the reasoning that "a radio-control app should not make unannounced outbound connections, and a remote station may be on a metered link." The automatic check is therefore constrained to bound that cost: default-on but **opt-out in Settings**, **once per start** rather than on a timer, and **silent** unless it finds a substantiated newer release — so a metered link sees at most one small request per launch, and only a real update ever draws attention. The notification lives in the top status area beside the connection indicator (not a modal), as a clickable link to the release page, reusing FR-UI-UPD-01's numeric, never-spurious comparison. Also fixed a stale/duplicated `version` block in this document's YAML frontmatter (a merge artifact: two `version:` keys) — set to 0.50 / 2026-07-26. | +| 2026-08-06 | 0.54 | DC0SK | Added **FR-AMP-03 (KPA1500 live client)** and built it: a background worker (`app/src/kpa.rs`) mirroring the K4 worker's shared-snapshot model — owns the amp's TCP socket, polls `k4_kpa::POLL` on interval, parses replies, and publishes a `KpaState` + connection status the UI copies on tick. A top-bar amp indicator (enabled-only, click opens the amp window) and the amp window's live telemetry + Operate/Standby/ATU/antenna controls. Connection is **reconciled off the tick** against `enabled && K4-connected && host-set`, so it follows the K4 link, the enable toggle and host edits without per-event wiring; transport errors surface in the window. The indicator's colour/wording precedence is a pure, tested function (`fr_amp_03_*`). Deferred: a full ATU tune (needs the K4 keyed — couples to the arm-gated TX path). Evidence: L1 for the indicator; the socket path is D/HIL (a mock or real amp). | | 2026-08-06 | 0.53 | DC0SK | Added **FR-AMP-02 (KPA1500 CAT codec)** and built it: the new dependency-free `k4-kpa` crate, encoders + response parser + `KpaState`, from the KPA1500 Programming Reference V3 wire formats (read end to end — not guessed, the `RO`/`RA` lesson). Notable format facts pinned down: `^WS` returns forward power **and** SWR in one reply (`^WS1204 014;`); `^SW` gives SWR in tenths; `^FL` is a **hex** fault byte with a documented code table; `^AN` two-digit setters (`^AN01;`) avoid the `^AN0;`="next-antenna" trap; `^AMI;`/`^AMB;` are the ATU-mode form (distinct from the `^AI` relay state). Parser keeps every field `None` until first heard and — a bug the tests caught — never blanks a known field on a malformed/bare-tag fragment. Pure L1 slice: **no** socket I/O or UI yet (next slice wires the poll loop + a mini-panel), so this carries unit evidence only. Tests `fr_amp_02_*` in `k4-kpa`. | | 2026-08-06 | 0.52 | DC0SK | Added **section O: FR-AMP-01 (KPA1500 amplifier support)** and implemented its first slice — the enable toggle + a separate configuration window. Decision on record: the KPA1500 is reached over **its own Ethernet CAT server** (KPA1500 Programming Reference `^CP`/`^IP`), a second connection alongside the K4 — **not** the K4's `EC` passthrough. D12's `EC` is SET-only and its own note says the K4 ignores all incoming RS232 data after an `EC` until restart, so it cannot back a telemetry-aware amp panel; the reference project (QK4) confirms the amp's-own-Ethernet approach with a full polling client. Scope this change: opt-in `Prefs` (enabled/host/port=1500/poll=500 ms) with persistence, a Settings toggle, and a detached window mirroring the diagnostics-window pattern. Live metering/control is the next slice. KPA500/KAT500 excluded and said so — serial-only, no Ethernet server, absent even from QK4. Config-layer test `fr_amp_01_kpa1500_defaults_off_and_persists` covers the default-off + round-trip + legacy-config path. | | 2026-07-26 | 0.51 | DC0SK | **Implemented FR-UI-UPD-02** (recorded in 0.50). Default-on `auto_update_check` preference with a Settings toggle; a single start-up check reusing the operator-initiated path (`update::check_now`), off the UI thread; and a top-area notification beside the connection indicator — a clickable `● update ` link, shown only for an `Available` status, silent for checking/up-to-date/failed. Raised from priority C to **S** now that it is built and tested. The metered-link concern from FR-UI-UPD-01 is honoured by construction: one request per launch, opt-out, and nothing shown unless there is a real update. Verified the link and the toggle on screen; the start-up check was seen overwriting a seeded status, confirming it actually runs. | diff --git a/docs/test/coverage.generated.md b/docs/test/coverage.generated.md index 9fdcf01..1d19a50 100644 --- a/docs/test/coverage.generated.md +++ b/docs/test/coverage.generated.md @@ -6,6 +6,7 @@ Legend: ✅ test-traced · 🟡 waived (see r3-waivers.md) · ⚪ not test-requi |---|---|---|---| | `FR-AMP-01` | S | T/D | ✅ | | `FR-AMP-02` | S | T | ✅ | +| `FR-AMP-03` | S | T/D | ✅ | | `FR-ANT-01` | C | T | ✅ | | `FR-ANT-02` | S | T | ✅ | | `FR-ATU-01` | S | T/D | ✅ | diff --git a/docs/test/test-strategy.md b/docs/test/test-strategy.md index 05c6c34..33030da 100644 --- a/docs/test/test-strategy.md +++ b/docs/test/test-strategy.md @@ -1,7 +1,7 @@ --- title: "Test Strategy & Traceability" status: Draft -version: "4.5" +version: "4.6" updated: 2026-08-06 authors: - Simon Keimer (DC0SK) @@ -437,6 +437,7 @@ FR-SES-MULTI, FR-DIAG-02, etc. — get `TC` IDs when promoted to `Approved`.)* | 2026-07-25 | 4.0 | DC0SK | Release **v0.8.0**. Minor: six backlog features and two operating fixes since 0.7.0, nearly all validated on DC0SK's live K4. Added: VFO lock read-back + tuning refusal, DATA rate select, `ACN` antenna names, on-screen macros (Fn → MACROS, reusing the K-Pod table), a DTMF keypad, and transverter band setup (two-column BAND screen). Fixed: TX TEST now flashes distinct from a real transmit (finishing FR-TX-TUNE-01's flashing indication), and DATA sub-mode/rate switching lag — the same read-back fight the sliders had, fixed with the standing optimistic-override pattern. **What is left is now honestly the hard part:** the audio-character (`MX`/`BL`/`FX`/`AL`) and message (`DARM`) items are blocked on two hardware questions only the operator can answer — whether radio-side audio settings reach the remote stream, and whose microphone `DARM` records. Version bumped in Cargo.toml (workspace), lockfile, README, user manual. 326 tests. | | 2026-07-26 | 4.1 | DC0SK | **FR-UI-UPD-02 implemented** — automatic update check + top-area notification. Default-on preference (opt-out in Settings), one start-up check off the UI thread reusing `update::check_now` (so the whole never-spurious comparison from FR-UI-UPD-01 comes for free), and a clickable `● update ` link beside the connection indicator, shown only for an `Available` result. The metered-link caution FR-UI-UPD-01 was written around is met by construction, not overridden: one request per launch, opt-out, silent unless there is a real update. Tested at the config layer (default-on + persistence) and verified on screen — including watching the start-up check overwrite a seeded status, which confirmed it runs. 327 tests. | | 2026-07-27 | 4.2 | DC0SK | Release **v0.9.0**. Minor: the addition since 0.8.0 is FR-UI-UPD-02, the automatic update check (opt-out, once per start, silent unless a real update is found, clickable link in the top status area). Numbered **0.9.0, not 0.8.1** — a new feature is a minor bump under semver, and the changelog files it under Added. It was briefly tagged v0.8.1 by mistake; the tag and its in-flight release build were removed before any release artifact published, and it was retagged. Version bumped in Cargo.toml (workspace), lockfile, README, user manual. 327 tests. | +| 2026-08-06 | 4.6 | DC0SK | **FR-AMP-03 (KPA1500 live client).** New `app/src/kpa.rs` worker thread (TCP connect with timeout, interval poll, `k4_kpa::apply` parse, shared-snapshot publish, reconnect backoff, control-command send) wired into the app like the K4 worker; a tick reconciler brings the amp up/down with K4 connectivity + the enable toggle; a top-bar amp indicator and the amp window's live telemetry + Operate/Standby/ATU/antenna controls. Pure indicator logic (fault>mode precedence, Operate/Standby/down colours, Caution on SWR≥2.0) is unit-tested — `ui::amp_indicator` + `fr_amp_03_amp_indicator_precedence_and_wording`. The worker/socket path is **not** unit-covered (it is I/O): evidence there is D/HIL against a real or mock KPA1500, so this slice is L1 for the indicator and awaits an on-amp run for the rest. Deferred: full ATU tune (couples to the K4 arm-gated TX path). Build + clippy + trace gate green. | | 2026-08-06 | 4.5 | DC0SK | **FR-AMP-02 (KPA1500 CAT codec).** New dependency-free `k4-kpa` crate: control encoders (`^OS`/`^FT`/`^FE`/`^AM`/`^AN`/`^ON`) + a response parser into `KpaState`, from the KPA1500 Programming Reference V3 (all 59 pages read). Nine L1 tests (`fr_amp_02_*`): encoder wire forms, two-digit antenna select, each telemetry field, `^WS` combined power+SWR, hex fault decode + description, identity/firmware/serial (RVM not mistaken for RV), parse bounds, and a noise/fragment test that **caught a real clobber bug** — a `parse().ok()` assignment was blanking a good reading when a bare-tag fragment (`^TM` with no value) arrived; fixed so a non-match never overwrites a known field. Pure slice — no socket I/O or UI yet, so evidence is unit-only (L1); the poll loop + mini-panel are the next slice. Crate added to the workspace; `xtask trace` covers FR-AMP-02. | | 2026-08-06 | 4.4 | DC0SK | **FR-AMP-01 (KPA1500 support) — enable + config-window slice.** New opt-in `Prefs` fields (`kpa1500_enabled`/`_host`/`_port`/`_poll_ms`) with serde defaults; a Settings toggle + a "Configuration…" button opening a **detached** KPA1500 window (host/port/poll), built by mirroring the existing diagnostics-window daemon pattern (open/close/ESC/title/view-dispatch). Persistence via `save_config`, with the port/poll buffers falling back to 1500/500 rather than saving an unusable value. Config-layer test `fr_amp_01_kpa1500_defaults_off_and_persists` (L1): default-off, port/poll defaults, TOML round-trip of a configured amp, and a legacy-config load. The amp **client** (its own-Ethernet CAT polling, telemetry, mini-panel, control) is the next slice — this change is the enable + connection surface only. Build + clippy + trace gate green. | | 2026-08-06 | 4.3 | DC0SK | **FR-UI-UPD-02 field-verified (L4)** on DC0SK's machine and live link. Four on-link cases, all pass: U1 silent when already current (no link shown); U2 the notification renders and its link opens the release page, exercised by running a version-lowered build so the up-to-date GitHub `latest` reads as newer; U3 the Settings toggle suppresses the start-up check when off and restores it when on; U4 offline is quiet — no error dialog, no stuck "Checking…", just no link. The pure comparison/parse layer was already unit-tested (FR-UI-UPD-01); this closes the wiring on real network conditions, so FR-UI-UPD-02 now carries a field trace like the rest of the operating set. No code change. |