diff --git a/CHANGELOG.md b/CHANGELOG.md index 81e17dc..5053204 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,19 @@ during the 0.4.0 release, so earlier detail lives in the git history and in the change ledgers under [`docs/test/test-strategy.md`](docs/test/test-strategy.md) and [`docs/requirements/system-requirements.md`](docs/requirements/system-requirements.md). +## [Unreleased] + +### Added + +- **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. + ## [0.9.0] — 2026-07-27 ### Added diff --git a/app/src/main.rs b/app/src/main.rs index dd81f96..6c56318 100644 --- a/app/src/main.rs +++ b/app/src/main.rs @@ -80,6 +80,17 @@ fn diag_window_settings() -> iced::window::Settings { } } +/// The detached KPA1500 configuration window (FR-AMP-01): a small fixed dialog +/// 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, + icon: app_icon(), + ..Default::default() + } +} + struct App { // connection form host: String, @@ -205,6 +216,15 @@ struct App { main_window: iced::window::Id, diag_window: Option, diag_enabled: bool, + // KPA1500 amplifier support (FR-AMP-01): an opt-in toggle plus a detached + // configuration window (host/port/poll) for the amp's own Ethernet CAT + // server. The window id is present only while the dialog is open; the + // enable flag and the connection settings persist. + kpa1500_config_window: Option, + kpa1500_enabled: bool, + kpa1500_host: String, + kpa1500_port: String, + kpa1500_poll: String, // Diagnostics log: show/hide + follow-newest (auto-scroll). show_log: bool, log_autoscroll: bool, @@ -757,6 +777,12 @@ enum Message { ToggleDiagWindow, WindowOpened, WindowClosed(iced::window::Id), + // KPA1500 amplifier support (FR-AMP-01). + ToggleKpa1500, + ToggleKpa1500Window, + Kpa1500HostChanged(String), + Kpa1500PortChanged(String), + Kpa1500PollChanged(String), ToggleLogAutoscroll, LogFilterChanged(String), /// A text-editor action from the read-only log view (selection/scroll; edits @@ -881,6 +907,10 @@ impl App { let auto_update_check = prefs.auto_update_check; let diag_enabled = prefs.diagnostics_window; let tooltips = prefs.tooltips; + let kpa1500_enabled = prefs.kpa1500_enabled; + let kpa1500_host = prefs.kpa1500_host.clone(); + let kpa1500_port = prefs.kpa1500_port.to_string(); + let kpa1500_poll = prefs.kpa1500_poll_ms.to_string(); // Open the main window; the daemon starts with none (FR-DIAG-04). let (main_window, open_main) = iced::window::open(iced::window::Settings { @@ -994,6 +1024,12 @@ impl App { main_window, diag_window, diag_enabled, + // The config window opens on demand, not at start-up. + kpa1500_config_window: None, + kpa1500_enabled, + kpa1500_host, + kpa1500_port, + kpa1500_poll, show_log: false, log_autoscroll: true, log_refresh_div: 0, @@ -1353,6 +1389,22 @@ impl App { ptt_toggle: self.ptt_toggle, mode_aware_ui: self.mode_aware_ui, auto_update_check: self.auto_update_check, + kpa1500_enabled: self.kpa1500_enabled, + kpa1500_host: self.kpa1500_host.clone(), + // An empty or out-of-range field falls back to the default + // rather than persisting a value the amp can't use. + kpa1500_port: self + .kpa1500_port + .parse::() + .ok() + .filter(|p| *p != 0) + .unwrap_or(1500), + kpa1500_poll_ms: self + .kpa1500_poll + .parse::() + .ok() + .filter(|ms| *ms >= 50) + .unwrap_or(500), kpod_enabled: self.kpod_enabled, kpod_buttons: self.kpod_buttons.clone(), ..Default::default() @@ -2126,6 +2178,13 @@ impl App { return iced::window::close(id); } } + // ESC in the KPA1500 config window closes that window (FR-AMP-01). + if is_esc && self.kpa1500_config_window == Some(window) { + if let Some(id) = self.kpa1500_config_window.take() { + self.save_config(); + return iced::window::close(id); + } + } // ESC dismisses an open modal (Settings / About, FR-UI-23) or // cancels an in-progress hotkey capture, before other key handling. if matches!( @@ -2428,6 +2487,38 @@ impl App { self.diag_enabled = false; self.save_config(); } + if Some(id) == self.kpa1500_config_window { + // Closing the config window persists whatever was edited; + // the enable toggle and settings survive independently. + self.kpa1500_config_window = None; + self.save_config(); + } + } + // KPA1500 support (FR-AMP-01): the enable toggle, the detached + // configuration window, and its host/port/poll fields. + Message::ToggleKpa1500 => { + self.kpa1500_enabled = !self.kpa1500_enabled; + self.save_config(); + } + Message::ToggleKpa1500Window => { + if let Some(id) = self.kpa1500_config_window.take() { + self.save_config(); + return iced::window::close(id); + } + let (id, open) = iced::window::open(kpa1500_window_settings()); + self.kpa1500_config_window = Some(id); + return open.map(|_| Message::WindowOpened); + } + Message::Kpa1500HostChanged(v) => { + self.kpa1500_host = v.trim().to_string(); + } + Message::Kpa1500PortChanged(v) => { + // Keep only digits so the field can never hold an unparseable + // port; an empty field falls back to the default on save. + self.kpa1500_port = v.chars().filter(char::is_ascii_digit).take(5).collect(); + } + Message::Kpa1500PollChanged(v) => { + self.kpa1500_poll = v.chars().filter(char::is_ascii_digit).take(5).collect(); } Message::ToggleLogAutoscroll => { self.log_autoscroll = !self.log_autoscroll; @@ -5957,10 +6048,88 @@ impl App { .into() } + /// The detached KPA1500 configuration window body (FR-AMP-01): the enable + /// toggle plus the amplifier's connection settings. Editing a field updates + /// its buffer; closing the window (Done / ESC / the window control) persists + /// via `save_config`. Telemetry and control land in a later change — this + /// window is the enable + connection surface. + fn kpa1500_config_view(&self) -> Element<'_, Message> { + set_active_theme(self.effective_theme()); + let dim = role_color(ui::ColorRole::Inactive); + let label = |t: &'static str| Text::new(t).size(12).width(Length::Fixed(72.0)); + let host_row = Row::new() + .spacing(8) + .align_y(Alignment::Center) + .push(label("Host")) + .push( + TextInput::new("IP or hostname", &self.kpa1500_host) + .on_input(Message::Kpa1500HostChanged) + .size(13) + .width(Length::Fixed(210.0)), + ); + let port_row = Row::new() + .spacing(8) + .align_y(Alignment::Center) + .push(label("Port")) + .push( + TextInput::new("1500", &self.kpa1500_port) + .on_input(Message::Kpa1500PortChanged) + .size(13) + .width(Length::Fixed(90.0)), + ); + let poll_row = Row::new() + .spacing(8) + .align_y(Alignment::Center) + .push(label("Poll (ms)")) + .push( + TextInput::new("500", &self.kpa1500_poll) + .on_input(Message::Kpa1500PollChanged) + .size(13) + .width(Length::Fixed(90.0)), + ); + let col = Column::new() + .spacing(12) + .push(Text::new("KPA1500 Amplifier").size(16)) + .push( + Text::new( + "Connects to the amplifier's own Ethernet remote-control \ + server — a second link alongside the K4.", + ) + .size(11) + .color(dim), + ) + .push(small_btn_pair( + self.kpa1500_enabled, + "Support: ON", + "Support: OFF", + Message::ToggleKpa1500, + )) + .push(host_row) + .push(port_row) + .push(poll_row) + .push( + Container::new( + Row::new() + .push(horizontal_space()) + .push(small_btn("Done", Message::ToggleKpa1500Window)), + ) + .width(Length::Fill) + .padding([8, 0]), + ); + Container::new(col) + .style(panel_style) + .padding(16) + .width(Length::Fill) + .height(Length::Fill) + .into() + } + /// Per-window title (daemon). fn title(&self, window: iced::window::Id) -> String { if Some(window) == self.diag_window { "K4 Remote — Diagnostics".into() + } else if Some(window) == self.kpa1500_config_window { + "K4 Remote — KPA1500".into() } else { "K4 Remote".into() } @@ -5973,6 +6142,10 @@ impl App { if Some(window) == self.diag_window { return self.diag_window_view(); } + // The detached KPA1500 configuration window (FR-AMP-01). + if Some(window) == self.kpa1500_config_window { + return self.kpa1500_config_view(); + } let dim = role_color(ui::ColorRole::Inactive); // Header band: title, link state, status line, the A / B / A+B view @@ -7202,6 +7375,19 @@ impl App { .push(self.backup_section_view()) .push(Text::new("K-Pod function switches").size(12).color(dim)) .push(self.kpod_buttons_view()) + .push(Text::new("KPA1500 amplifier").size(12).color(dim)) + .push( + Row::new() + .spacing(8) + .align_y(Alignment::Center) + .push(small_btn_pair( + self.kpa1500_enabled, + "Support: ON", + "Support: OFF", + Message::ToggleKpa1500, + )) + .push(small_btn("Configuration…", Message::ToggleKpa1500Window)), + ) .push( Container::new( Row::new() diff --git a/crates/k4-config/src/lib.rs b/crates/k4-config/src/lib.rs index d97622c..9a3b442 100644 --- a/crates/k4-config/src/lib.rs +++ b/crates/k4-config/src/lib.rs @@ -121,6 +121,22 @@ pub struct Prefs { /// is found. #[serde(default = "default_true")] pub auto_update_check: bool, + /// Enable KPA1500 linear-amplifier support (FR-AMP-01). Default off + /// (opt-in). When on, the app talks to the amp over its **own** Ethernet + /// CAT server — a second connection alongside the K4 link, not the K4's + /// one-way `EC` passthrough. Host/port/poll are set in the separate + /// KPA1500 configuration window. + #[serde(default)] + pub kpa1500_enabled: bool, + /// KPA1500 remote-head host (IP or hostname). Empty until configured. + #[serde(default)] + pub kpa1500_host: String, + /// KPA1500 remote-head TCP command-server port. Default 1500. + #[serde(default = "default_kpa1500_port")] + pub kpa1500_port: u16, + /// KPA1500 telemetry poll interval, milliseconds. Default 500. + #[serde(default = "default_kpa1500_poll_ms")] + pub kpa1500_poll_ms: u16, /// Enable the Elecraft K-Pod USB control surface. Default off (opt-in); the /// app runs normally whether or not a K-Pod is attached. #[serde(default)] @@ -344,6 +360,16 @@ fn default_true() -> bool { true } +/// The KPA1500's TCP command-server port (its remote-head interface). +fn default_kpa1500_port() -> u16 { + 1500 +} + +/// Default KPA1500 telemetry poll interval, milliseconds. +fn default_kpa1500_poll_ms() -> u16 { + 500 +} + impl Default for Prefs { fn default() -> Self { Self { @@ -366,6 +392,10 @@ impl Default for Prefs { ptt_toggle: true, mode_aware_ui: true, auto_update_check: true, + kpa1500_enabled: false, + kpa1500_host: String::new(), + kpa1500_port: 1500, + kpa1500_poll_ms: 500, kpod_enabled: false, kpod_buttons: default_kpod_buttons(), } diff --git a/crates/k4-config/tests/config.rs b/crates/k4-config/tests/config.rs index c1386f4..774eb02 100644 --- a/crates/k4-config/tests/config.rs +++ b/crates/k4-config/tests/config.rs @@ -267,3 +267,38 @@ fn fr_ui_upd_02_auto_update_check_defaults_on_and_persists() { let back: Prefs = toml::from_str(&toml).expect("deserialize"); assert!(!back.auto_update_check, "opt-out is remembered"); } + +/// KPA1500 support is opt-in (default off) with a sensible default port/poll, +/// and the enable flag plus the connection settings survive a save/load +/// round-trip so the operator configures the amp once. +/// trace: FR-AMP-01 +#[test] +fn fr_amp_01_kpa1500_defaults_off_and_persists() { + let def = Prefs::default(); + assert!(!def.kpa1500_enabled, "default opt-in: support is off"); + assert_eq!(def.kpa1500_port, 1500, "the amp's command-server port"); + assert_eq!(def.kpa1500_poll_ms, 500); + assert!(def.kpa1500_host.is_empty()); + + // A configured amp round-trips through TOML unchanged. + let prefs = Prefs { + kpa1500_enabled: true, + kpa1500_host: "192.168.1.50".into(), + kpa1500_port: 1500, + kpa1500_poll_ms: 250, + ..Default::default() + }; + let toml = toml::to_string(&prefs).expect("serialize"); + let back: Prefs = toml::from_str(&toml).expect("deserialize"); + assert!(back.kpa1500_enabled); + assert_eq!(back.kpa1500_host, "192.168.1.50"); + assert_eq!(back.kpa1500_port, 1500); + assert_eq!(back.kpa1500_poll_ms, 250); + + // A config written before this feature (no KPA fields) loads with the + // opt-in default off — never surprising an upgrader with an amp link. + let legacy = "tune_step_hz = 100"; + let old: Prefs = toml::from_str(legacy).expect("legacy config"); + assert!(!old.kpa1500_enabled); + assert_eq!(old.kpa1500_port, 1500); +} diff --git a/docs/requirements/system-requirements.md b/docs/requirements/system-requirements.md index 3a5e004..5744928 100644 --- a/docs/requirements/system-requirements.md +++ b/docs/requirements/system-requirements.md @@ -1,8 +1,8 @@ --- title: "System Requirements Specification" status: Draft -version: "0.51" -updated: 2026-07-26 +version: "0.52" +updated: 2026-08-06 authors: - Simon Keimer (DC0SK) owns: [FR, NFR] @@ -294,6 +294,14 @@ syntax per the Programmer's Reference D12, cross-checked vs QK4 (`R-EXT-03`).* --- +## O. External Amplifier — `FR-AMP` + +| 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). | + +--- + ## Non-Functional Requirements — `NFR` | ID | Statement | Up | Pri | Ver | Acceptance criteria | @@ -380,4 +388,5 @@ 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.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 09eb4df..835411b 100644 --- a/docs/test/coverage.generated.md +++ b/docs/test/coverage.generated.md @@ -4,6 +4,7 @@ Legend: ✅ test-traced · 🟡 waived (see r3-waivers.md) · ⚪ not test-requi | Requirement | Pri | Ver | Status | |---|---|---|---| +| `FR-AMP-01` | 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 5d7f467..f9f35cd 100644 --- a/docs/test/test-strategy.md +++ b/docs/test/test-strategy.md @@ -1,8 +1,8 @@ --- title: "Test Strategy & Traceability" status: Draft -version: "4.2" -updated: 2026-07-26 +version: "4.4" +updated: 2026-08-06 authors: - Simon Keimer (DC0SK) owns: [TC] @@ -437,3 +437,5 @@ 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.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. |