diff --git a/CMakeLists.txt b/CMakeLists.txt
index 8d0af34d3c..4e8a18c3bc 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -1086,9 +1086,15 @@ set(CORE_SOURCES
src/core/SpeProtocol.cpp
src/core/SpeLegacyProtocol.cpp
src/core/SpeConnection.cpp
+ src/models/Kxpa100RadioAssociation.cpp
+ src/models/Kxpa100RadioAssociation.h
src/models/Kat500TxController.cpp
src/models/Kat500TxController.h
+ src/models/Kxpa100Model.cpp
+ src/models/Kxpa100Model_Maintenance.cpp
src/models/Kat500Model.cpp
+ src/core/Kxpa100Protocol.cpp
+ src/core/Kxpa100Maintenance.cpp
src/core/Kat500Protocol.cpp
src/core/Kat500Maintenance.cpp
src/core/Kpa500Protocol.cpp
@@ -1388,7 +1394,9 @@ set(GUI_SOURCES
src/gui/AmpApplet.cpp
src/gui/AcomApplet.cpp
src/gui/SpeApplet.cpp
+ src/gui/Kxpa100Applet.cpp
src/gui/Kat500Applet.cpp
+ src/gui/Kxpa100AdvancedWidget.cpp
src/gui/Kat500AdvancedWidget.cpp
src/gui/Kpa500Applet.cpp
src/gui/Kpa500AdvancedWidget.cpp
diff --git a/docs/architecture/aetherd-touchpoint-tags.json b/docs/architecture/aetherd-touchpoint-tags.json
index e283eadf93..bc91c63b24 100644
--- a/docs/architecture/aetherd-touchpoint-tags.json
+++ b/docs/architecture/aetherd-touchpoint-tags.json
@@ -1696,5 +1696,23 @@
"note": "Engine-owned external tuner sequence through typed RadioModel and captured TX input; KAT500 remains an independent peripheral. No vendor radio wire or GUI dependency.",
"split": "",
"confidence": "high"
+ },
+ "models/Kxpa100Model.h": {
+ "tag": "peripheral(elecraft)",
+ "note": "Confirmed state and bounded operations for the independent Elecraft KXPA100 serial/TCP accessory; no radio wire or GUI dependency.",
+ "split": "",
+ "confidence": "high"
+ },
+ "models/Kxpa100Types.h": {
+ "tag": "peripheral(elecraft)",
+ "note": "Typed KXPA100 accessory state, configuration, backup and fault records; no radio-family protocol.",
+ "split": "",
+ "confidence": "high"
+ },
+ "models/Kxpa100RadioAssociation.h": {
+ "tag": "peripheral(elecraft)",
+ "note": "Explicit per-radio amplifier telemetry association; no radio or tuner commands.",
+ "split": "",
+ "confidence": "high"
}
}
diff --git a/docs/architecture/aetherd-touchpoints.md b/docs/architecture/aetherd-touchpoints.md
index 068dd906e5..6f7bbdf473 100644
--- a/docs/architecture/aetherd-touchpoints.md
+++ b/docs/architecture/aetherd-touchpoints.md
@@ -4,7 +4,7 @@
Burndown manifest for the engine/UI decoupling ([RFC](../aetherd-headless-engine-design.md) §2, §10). One row per engine header the UI includes; converting a touchpoint means the UI reaches that surface through the versioned protocol instead of the header.
-**Totals:** 268 touchpoint headers (220 core, 48 models) — 268/268 tagged, 0/268 converted.
+**Totals:** 271 touchpoint headers (220 core, 51 models) — 271/271 tagged, 0/271 converted.
| Header | Includers | Tag | Status |
|---|---:|---|---|
@@ -18,7 +18,7 @@ Burndown manifest for the engine/UI decoupling ([RFC](../aetherd-headless-engine
| `core/AetherTxProfiles.h` | 1 | universal — Named-profile save/recall/import-export for the transmit DSP chain; operates on core-profile DSP state only. Mirror of core/AetherRxProfiles.h, and shares ChannelStripPresets' TX capture/apply. | unconverted |
| `core/AgcTCalibrator.h` | 1 | universal — Engine algo sweeping slice AGC threshold vs audio RMS/S-meter to recommend a value; only canonical state. | unconverted |
| `core/AppActivity.h` | 3 | ui-support — Process-lifetime activity hold (App Nap opt-out on macOS, process power-throttling opt-out on Windows; never blocks sleep) and startStreamThread(), the High-QoS thread start for stream threads; app plumbing, not radio state. | unconverted |
-| `core/AppSettings.h` | 110 | ui-support — Client-side XML settings store (SSDR.settings-style key/value persistence); app plumbing, not radio state. | unconverted |
+| `core/AppSettings.h` | 111 | ui-support — Client-side XML settings store (SSDR.settings-style key/value persistence); app plumbing, not radio state. | unconverted |
| `core/AudioDevicePreferencePolicy.h` | 1 | ui-support — Header-only policy over PC audio device ids: whether a device-list change keeps the engine's device, falls back to the system default or returns to the saved device, and whether a selection is written to the saved id (#6040); also which enumerable ids count as present and which as arrivals. Host audio, no radio state. | unconverted |
| `core/AudioEngine.h` | 48 | mixed(flex) — Client audio I/O + full RX/TX DSP chain (universal) fused with Flex VITA-49/DAX/Opus TX and Kiwi buffering | unconverted |
| `core/AudioOutputRouter.h` | 1 | ui-support — Registry fanning the user-selected QAudioDevice to local playback sinks; OS device plumbing, no radio state | unconverted |
@@ -168,7 +168,7 @@ Burndown manifest for the engine/UI decoupling ([RFC](../aetherd-headless-engine
| `core/TailnetShimTokenStore.h` | 1 | peripheral(tailnet-shim) — OS-keychain storage of the tailnet shim's admin token, keyed by the Flex chassis serial (RFC #6271). Not SmartSDR wire: AetherSDR's own HTTP API to a separate service (the shim) that happens to run in the radio's container runtime, so a direct-transport peripheral, NOT behind the IRadioBackend radio seam (same shape as core/PgxlConnection.h). | unconverted |
| `core/TciServer.h` | 3 | mixed(flex) — TCI WebSocket server for WSJT-X et al: protocol surface is canonical radio state, but audio/IQ rides Flex DAX | unconverted |
| `core/TgxlConnection.h` | 3 | peripheral(4o3a) — Direct TCP client for the 4O3A Tuner Genius XL (port 9010, relay/autotune), reverse-engineered from the 4O3A management app — a standalone accessory transport, not SmartSDR. Not radio-family wire; a peripheral accessory, NOT behind the IRadioBackend radio seam (reclassified from vendor(flex), #4087 follow-up). | unconverted |
-| `core/ThemeManager.h` | 181 | ui-support — Qt token-based theming singleton (colors/fonts/QSS, theme files, editor hooks) — pure client GUI plumbing, no radio state. | unconverted |
+| `core/ThemeManager.h` | 182 | ui-support — Qt token-based theming singleton (colors/fonts/QSS, theme files, editor hooks) — pure client GUI plumbing, no radio state. | unconverted |
| `core/ThreadCpuRing.h` | 2 | ui-support — Short host-thread CPU history used by Runtime Monitor peak and sparkline presentation. Diagnostic UI support, not radio state. | unconverted |
| `core/TimeFrameVoter.h` | 1 | universal — Shared AetherClock time-frame types plus confidence-weighted cross-frame bit voting over a sliding window. Map-agnostic pure DSP/logic — no Qt, no GUI, no vendor ties. | unconverted |
| `core/TxKeyingMarker.h` | 16 | ui-support — QWidget property marker guarding TX-keying controls from the automation bridge; GUI-shell plumbing, no radio state. | unconverted |
@@ -252,6 +252,9 @@ Burndown manifest for the engine/UI decoupling ([RFC](../aetherd-headless-engine
| `models/Kat500Model.h` | 4 | peripheral(elecraft) — Typed model facade for the independent Elecraft KAT500 serial/raw-TCP tuner, with confirmed operating/configuration state and bounded maintenance operations. No radio-family wire or carrier commands are exposed to GUI. | unconverted |
| `models/Kat500TxController.h` | 2 | peripheral(elecraft) — Engine-owned external tuner sequence through typed RadioModel and captured TX input; KAT500 remains an independent peripheral. No vendor radio wire or GUI dependency. | unconverted |
| `models/Kat500Types.h` | 2 | peripheral(elecraft) — Model-owned KAT500 status, configuration and maintenance value types for the typed peripheral facade. No transport or raw command path in GUI. | unconverted |
+| `models/Kxpa100Model.h` | 4 | peripheral(elecraft) — Confirmed state and bounded operations for the independent Elecraft KXPA100 serial/TCP accessory; no radio wire or GUI dependency. | unconverted |
+| `models/Kxpa100RadioAssociation.h` | 2 | peripheral(elecraft) — Explicit per-radio amplifier telemetry association; no radio or tuner commands. | unconverted |
+| `models/Kxpa100Types.h` | 2 | peripheral(elecraft) — Typed KXPA100 accessory state, configuration, backup and fault records; no radio-family protocol. | unconverted |
| `models/MemoryEntry.h` | 1 | universal — POD memory-channel record (freq/mode/offset/tone/squelch/filter/digital offsets) — canonical memories surface | unconverted |
| `models/MeterModel.h` | 7 | mixed(flex) — Meter registry+values are core-profile; VITA-49 raw scaling, TGXL/PGXL amp routing, COMPPEAK quirks are Flex | unconverted |
| `models/ModeVocabulary.h` | 1 | universal — Canonical mode vocabulary: which slice modes count as voice (SSB, AM/SAM, the FM family) and which as CW (CW/CWU/CWL). Radio-family-neutral state and validation data - every backend reports these spellings - split out of gui/VoiceModeGate.h so the engine can answer the question without reaching into the UI. Same precedent as core/CtcssTones.h. The panel-policy half (shouldAutoHideCopyAssist) stayed in gui. | unconverted |
diff --git a/docs/user/docs/kxpa100-amplifier-control.md b/docs/user/docs/kxpa100-amplifier-control.md
new file mode 100644
index 0000000000..07df95d0bc
--- /dev/null
+++ b/docs/user/docs/kxpa100-amplifier-control.md
@@ -0,0 +1,122 @@
+---
+title: "KXPA100 Amplifier and Tuner"
+slug: "/kxpa100-amplifier-control"
+description: "Connect an Elecraft KXPA100, control its amplifier and optional tuner, and associate its amplifier metering with a radio."
+status: "Experimental"
+applies_to: ["All radios"]
+platforms: ["Linux", "macOS", "Windows"]
+---
+
+:::info[Status]
+
+**Status:** Experimental · **Applies to:** All radios · **Platforms:** Linux, macOS, Windows
+
+:::
+
+The **KX100** applet combines KXPA100 amplifier controls, meters and optional KXAT100 tuner controls. Selections and measurements come from device readback. Connecting does not key the radio or load stored client-side amplifier settings.
+
+## Requirements
+
+Use a local serial connection or raw TCP serial bridge such as ser2net or the companion **aether-serial-bridge** project for remote access. Only one client can own the serial port. Network mode does not support Telnet/RFC 2217 negotiation. The bridge controls baud, 8N1 format and handshake lines.
+
+Configuration, backup restore and memory tools support the verified firmware **01.39** layout. Other firmware shows an availability explanation. Use Elecraft's official Utility for firmware updates; AetherSDR has no firmware updater or raw Command Tester.
+
+Connect the radio PTT output to the KXPA100 **PA KEY** input as required by your station. Software association does not create that physical keying connection. Turn on the amplifier locally: the protocol has no remote power command. **Standby** bypasses amplification; it does not turn off DC power.
+
+## Setup
+
+1. Open **Settings → Radio Setup... → Peripherals**, click **Add**, and select **Elecraft KXPA100**.
+2. Select **Serial** and the port/baud, or **Network** and your bridge host/port. Click **Connect**.
+3. The **KX100** applet button becomes available after identification. It remains available while the transport is open, including stale replies, and disappears when the connection closes.
+4. To use amplifier output on the shared **VU** and **PWR** meters, select the intended radio and click **Use with current radio** in the peripheral page or applet **Settings**. This explicitly associates the amplifier serial number with that radio.
+
+Connection preferences are client settings. The metering association belongs to the selected radio and works with or without the optional KXAT100 tuner. Switching radios does not transfer that association.
+
+For **Network**, enter a hostname or IP address without a URL scheme, path, spaces or control characters, and a port from 1 to 65535. Invalid entries are rejected before connecting.
+
+## Using the applet
+
+**Standby** and **Operate** control the PA. **Auto**, **Manual** and **Bypass** control the separate tuner mode. **ANT 1** and **ANT 2** select the tuner antenna. The selected state changes after readback. Missing tuner hardware leaves tuner controls dimmed with an explanation while PA controls remain available.
+
+**Tune** starts a tuner search; it does not generate a radio carrier. Supply RF according to the Elecraft instructions. **Cancel** requests an end to the search. **Clear fault** clears the current fault, leaving stored fault history intact.
+
+Full view shows output power and SWR, followed by **DRV** input power measured by the KXPA100 (0–10 W), reflected power, dissipated power, temperature, supply voltage and drain current. Power meters use watts; SWR uses a 1–5 scale. The pill retains the exact reading beyond the scale. SWR is the last observed measurement and can remain after RF stops. Press and hold the **SWR** pill to preview bypass SWR without changing any relays; release to return to adjusted SWR. A dash means the reading is unavailable.
+
+**Mini** keeps the **OPR/STBY** PA toggle, **Tune/Cancel**, a mode/antenna/status readout, output power and SWR. **Full** restores the other controls. Expanded drawers and small popouts scroll when needed.
+
+The **ATU BYP** pill toggles the physical matching-network bypass relay. It leaves Auto/Manual mode unchanged; a recall or tune may engage the network again. Use the main **Bypass** mode button to disable tuning. **ATT** toggles the input attenuator when software control is available. The rear-panel switch or firmware protection can override it. Hover over these pills for the complete meaning. Requests require fresh, idle readback with no RF.
+
+
+
+*Full view with replayed telemetry illustrating the meters.*
+
+
+
+### Manual, History and Settings
+
+**Manual** exposes capacitance in pF and inductance in nH, with each relay labeled by its component value. Numeric **Set** selects the nearest available relay combination to the entered total. The capacitor-side control changes which side of the matching network contains the capacitors. **Match frequency** selects the kHz frequency for **Recall** and **Memorize**. These commands require 1800–54000 kHz; an unknown frequency sends nothing. Recall loads a stored match; Memorize saves and verifies the current match. Neither generates RF.
+
+**History** plots forward power, reflected power and last-observed SWR, with a selectable time window, scales and legends. Missing replies leave gaps. The graph shrinks as well as grows with its popout.
+
+**Settings** shows radio association, identity, firmware, serial, DIRECT/NETWORK endpoint, freshness and operation results. **Open Advanced settings** opens the same maintenance page as Peripherals. **Debug** shows bounded diagnostics; **Verbose** includes wire traffic. New entries follow the bottom unless you scroll up or pause. Debug TX means serial output, not RF transmission.
+
+### Radio controls and shared metering
+
+**TX Controls** always operates the radio's own tuner and memories. Connecting or associating a KXPA100 never replaces ATU/MEM, adds an overlay, or bypasses the internal tuner. A radio without an internal tuner shows those controls dimmed with an explanation. Use **KX100 → Tune** and **Manual → Memorize** for the external tuner.
+
+The shared **VU** and **PWR** meters use associated KXPA100 output on the amplifier scale when PA Operate and idle-tuner readback are fresh. This does not require an installed KXAT100. An operating radio-integrated amplifier has priority, followed by an operating KPA500; confirmed KAT500 KEY BLOCK retains the radio fallback for that KPA500 path. Otherwise fresh eligible KXPA100 telemetry is used, falling back to the radio when unavailable. The KX100 applet always shows the amplifier's own readings. **TX Controls** keeps radio watts/SWR and the radio scale; its RF Power and Tune Pwr readouts show percentages.
+
+
+
+*Current Mac capture while disconnected; no RF or simulated meter values.*
+
+## Using Advanced
+
+Open **Peripherals → Elecraft KXPA100 → Advanced**, or the applet shortcut. Opening the page performs no maintenance action.
+
+### Configuration
+
+Click **Read configuration** before editing. The matrix shows enabled ANT1/ANT2 choices for eleven bands and an **All bands** row. Checkboxes stage edits; **Readback** stays authoritative. Mixed values remain mixed until edited. At least one antenna must remain enabled on every band. **Apply changed fields** sends only deliberate changes and verifies readback. **Cancel edits** discards staging.
+
+### Backup and restore
+
+**Read and save backup…** reads both EEPROM regions, totaling 17,408 bytes, and atomically writes a native `.KXPAConfig` file. **Choose backup…** validates size, coverage and checksum locally and previews identity. Choosing a file sends nothing to the amplifier.
+
+Restore requires firmware 01.39, matching backup serial/firmware, fresh identification, idle state and no RF. Review the preview, check **Confirm backup replacement**, and click **Restore and verify**. Each permitted block is acknowledged and read back; identity and fault-history regions are preserved. Confirmed blocks remain applied if canceled or interrupted. There is no blind retry. Successful restore restarts the device; reconnect and inspect readbacks before use.
+
+### Faults, memories and reset
+
+**Read fault history** shows newest records first, elapsed device seconds, frequency, fault-specific value/unit and raw bytes. Elapsed time is not a calendar timestamp. Unknown records remain visible. **Export CSV…** saves the displayed records.
+
+**Inspect bin** reads stored matches at the selected kHz frequency without changing the live matching network. For erasure, choose band and antenna, review the target, check **Confirm selected erase**, and click **Erase selected memories**. Single-frequency erasure is not offered. **Factory reset** requires its separate confirmation and clears settings and matches, then restarts the unit. These actions never run merely because the device connects.
+
+## Troubleshooting
+
+### A control is disabled
+
+Read its tooltip or open Settings for the reason. Wait for identification and fresh idle measurements. Stop RF before switching antennas, relays or configuration. Tuner-only controls require the optional KXAT100. Advanced schema-dependent tools require firmware 01.39.
+
+### VU/PWR does not show KXPA100 output
+
+Confirm **Use with current radio** was applied to the intended radio, the amplifier reports Operate, and power/idle readbacks are fresh. A higher-priority amplifier or active tuner search prevents KXPA100 metering. A missing physical PA KEY cable cannot be fixed by association.
+
+### Radio association cannot be saved
+
+A rejected settings write leaves the previous association in effect and logs an `aether.kxpa100` warning. Applet **Settings** also displays the failure beside the operation result. Resolve the read-only or busy settings store, then click **Use with current radio** again. Association changes are not retried automatically.
+
+### An operation fails or the connection closes
+
+A timeout closes the stream to prevent a late response from confirming a later request. Reconnect, inspect the current state and make a new deliberate request. Writes are not replayed automatically. A canceled restore can leave some confirmed blocks applied; use the retained backup to recover deliberately.
+
+For a repeated idle disconnect, enable **Settings → Debug → Verbose** and retain the replies around the timeout. SWR replies such as `^SW01.3;` and `^SW013;` both mean 1.3:1 and are supported. The documented zero reply `^SW00.0;` is also valid and does not cause a disconnect.
+
+### ANT1 appears after power-off
+
+The physical off-state routes to ANT1. Reconfirm the desired antenna after power cycling and before transmitting. Do not infer ANT2 from a previous session or saved UI preference.
+
+## See also
+
+- [Peripherals](./peripherals.md)
+- [Amplifiers](./amplifiers.md)
+- [KAT500 Tuner Control](./kat500-tuner-control.md)
+- [Elecraft KXPA100 software](https://elecraft.com/pages/kxpa100-software)
diff --git a/docs/user/docs/peripherals.md b/docs/user/docs/peripherals.md
index 0debab0b1e..ff2b4d2d14 100644
--- a/docs/user/docs/peripherals.md
+++ b/docs/user/docs/peripherals.md
@@ -74,6 +74,8 @@ device's own web interface. Connection defaults for every device type are
under [Reference](#reference).
+The **Elecraft KXPA100** page combines amplifier and optional tuner controls. Click **Use with current radio** to associate it explicitly; this enables its VU/PWR metering and applet frequency context for that radio. TX Controls remains radio-side. The **Advanced** section provides antenna configuration, native backup/restore, faults and memory tools. See [KXPA100 Amplifier and Tuner](./kxpa100-amplifier-control.md).
+
### Connecting automatically
TGXL, PGXL, Antenna Genius and ShackSwitch each have a toggle, **on by
@@ -268,6 +270,7 @@ The fields are locked while the device is connected.
- [Amplifiers](./amplifiers.md)
- [TGXL Tuner Control](./tgxl-tuner-control.md)
- [KAT500 Tuner Control](./kat500-tuner-control.md)
+- [KXPA100 Amplifier and Tuner](./kxpa100-amplifier-control.md)
- [ShackSwitch](./shackswitch.md)
- [Green Heron Everyware](./green-heron-everyware.md)
- [AetherSweep](./aethersweep.md)
diff --git a/docs/user/sidebars.js b/docs/user/sidebars.js
index 66f0d94e29..5ecfc7e0f2 100644
--- a/docs/user/sidebars.js
+++ b/docs/user/sidebars.js
@@ -116,6 +116,7 @@ const sidebars = {
"amplifiers",
"tgxl-tuner-control",
"kat500-tuner-control",
+ "kxpa100-amplifier-control",
"shackswitch",
"green-heron-everyware",
"usb-cable-management"
diff --git a/docs/user/static/img/kxpa100/kxpa100-advanced.png b/docs/user/static/img/kxpa100/kxpa100-advanced.png
new file mode 100644
index 0000000000..be0731fc1d
Binary files /dev/null and b/docs/user/static/img/kxpa100/kxpa100-advanced.png differ
diff --git a/docs/user/static/img/kxpa100/kxpa100-full-replay.png b/docs/user/static/img/kxpa100/kxpa100-full-replay.png
new file mode 100644
index 0000000000..a2cfee608a
Binary files /dev/null and b/docs/user/static/img/kxpa100/kxpa100-full-replay.png differ
diff --git a/docs/user/static/img/kxpa100/kxpa100-mini.png b/docs/user/static/img/kxpa100/kxpa100-mini.png
new file mode 100644
index 0000000000..c157110f7b
Binary files /dev/null and b/docs/user/static/img/kxpa100/kxpa100-mini.png differ
diff --git a/docs/user/static/img/kxpa100/tx-controls-radio-only.png b/docs/user/static/img/kxpa100/tx-controls-radio-only.png
new file mode 100644
index 0000000000..ca0bf7a353
Binary files /dev/null and b/docs/user/static/img/kxpa100/tx-controls-radio-only.png differ
diff --git a/src/core/Kxpa100Maintenance.cpp b/src/core/Kxpa100Maintenance.cpp
new file mode 100644
index 0000000000..062789fc48
--- /dev/null
+++ b/src/core/Kxpa100Maintenance.cpp
@@ -0,0 +1,206 @@
+#include "Kxpa100Maintenance.h"
+#include
+#include
+#include
+
+namespace AetherSDR::Kxpa100 {
+namespace {
+std::optional unhex(const QByteArray& hex)
+{
+ if (hex.size() % 2) { return {}; }
+ for (const char c : hex) {
+ if (!((c >= '0' && c <= '9') || (c >= 'A' && c <= 'F') || (c >= 'a' && c <= 'f'))) { return {}; }
+ }
+ return QByteArray::fromHex(hex);
+}
+quint32 crc32(quint32 crc, const QByteArray& bytes)
+{
+ // Utility 1.14.3.4: reflected IEEE, initial FFFFFFFF, no final XOR;
+ // line endings do not participate. All other bytes, including comments, do.
+ for (const char byte : bytes) {
+ crc ^= static_cast(byte);
+ for (int bit = 0; bit < 8; ++bit) { crc = (crc >> 1) ^ ((crc & 1U) ? 0xedb88320U : 0U); }
+ }
+ return crc;
+}
+bool identityValid(const Backup& backup)
+{
+ static const QRegularExpression firmware(QStringLiteral("^[0-9]{2}\\.[0-9]{2}$"));
+ static const QRegularExpression serial(QStringLiteral("^[0-9]{5}$"));
+ return firmware.match(backup.firmware).hasMatch() && serial.match(backup.serial).hasMatch();
+}
+QByteArray framed(const QByteArray& prefix, int address, int length, const QByteArray& data = {})
+{
+ QByteArray bytes;
+ bytes.append(static_cast(address >> 8)); bytes.append(static_cast(address));
+ bytes.append(static_cast(length)); bytes += data;
+ int sum = 0; for (const char byte : bytes) { sum += static_cast(byte); }
+ bytes.append(static_cast(-sum));
+ return prefix + bytes.toHex().toUpper() + ';';
+}
+bool rangeValid(bool external, int address, int length)
+{
+ return address >= 0 && length > 0 && length <= 64 && address <= (external ? 16384 : 1024) - length;
+}
+}
+QByteArray buildEepromRead(bool external, int address, int length)
+{
+ return rangeValid(external,address,length) ? framed(external ? "^ERS" : "^ERP", address,length) : QByteArray{};
+}
+QByteArray buildEepromWrite(bool external, int address, const QByteArray& data)
+{
+ if (!rangeValid(external, address, static_cast(data.size())) ||
+ address < (external ? 0 : 32) || address + data.size() > (external ? 0x3800 : 0x400) ||
+ address / 256 != (address + data.size() - 1) / 256) { return {}; }
+ return framed(external ? "^EWS" : "^EWP", address,static_cast(data.size()),data);
+}
+std::optional parseEepromReply(const QByteArray& frame)
+{
+ if (frame.size() < 12 || frame.size() > 140 ||
+ !(frame.startsWith("^ERP") || frame.startsWith("^ERS") || frame.startsWith("^EWP") || frame.startsWith("^EWS"))) { return {}; }
+ const auto bytes = unhex(frame.mid(4));
+ if (!bytes || bytes->size() < 4) { return {}; }
+ int sum = 0; for (const char byte : *bytes) { sum += static_cast(byte); }
+ if (sum % 256) { return {}; }
+ EepromReply reply;
+ reply.external = frame[3] == 'S'; reply.writeAck = frame[2] == 'W';
+ reply.address = static_cast((*bytes)[0]) * 256 + static_cast((*bytes)[1]);
+ reply.length = static_cast((*bytes)[2]);
+ if (!rangeValid(reply.external,reply.address,reply.length) || bytes->size() != (reply.writeAck ? 4 : reply.length + 4)) { return {}; }
+ if (!reply.writeAck) { reply.data = bytes->mid(3,reply.length); }
+ return reply;
+}
+QByteArray encodeBackup(const Backup& backup, const QDateTime& savedAt)
+{
+ if (!identityValid(backup) || backup.internal.size() != 1024 || backup.external.size() != 16384) { return {}; }
+ QList lines{
+ "*KXPA Configuration saved on " + savedAt.toString(QStringLiteral("yyyy-MM-dd 'at' HH:mm:ss")).toLatin1(),
+ "*Do not edit this file!", "KXPA Utility Revision = AetherSDR",
+ "KXPA Firmware Revision = " + backup.firmware.toLatin1(), "KXPA Serial Number = " + backup.serial.toLatin1()};
+ for (int address = 0; address < 1024; address += 32) {
+ const QByteArray hex = QByteArray::number(address,16).rightJustified(4,'0').toUpper();
+ lines.append("MEM2 " + hex.left(2) + ' ' + hex.mid(2) + " 20 " + backup.internal.mid(address,32).toHex().toUpper());
+ }
+ for (int address = 0; address < 16384; address += 64) {
+ lines.append("MEM3 " + QByteArray::number(address,16).rightJustified(4,'0').toUpper() + " 40 " + backup.external.mid(address,64).toHex().toUpper());
+ }
+ QByteArray result; quint32 crc = 0xffffffffU;
+ for (const QByteArray& line : lines) { crc = crc32(crc,line); result += line + "\r\n"; }
+ result += "CRC32 = 0x" + QByteArray::number(crc,16).rightJustified(8,'0').toUpper() + "\r\n";
+ return result;
+}
+std::optional decodeBackup(const QByteArray& bytes, QString* error)
+{
+ auto fail = [error](const QString& message) -> std::optional { if (error) { *error = message; } return {}; };
+ if (bytes.isEmpty() || bytes.size() > 64 * 1024) { return fail(QStringLiteral("Invalid backup file size.")); }
+ Backup backup; backup.internal.resize(1024); backup.external.resize(16384);
+ QSet internalSeen, externalSeen;
+ bool trailer = false, utility = false;
+ quint32 crc = 0xffffffffU;
+ const QList lines = bytes.split('\n');
+ for (int index = 0; index < lines.size(); ++index) {
+ QByteArray line = lines[index]; if (line.endsWith('\r')) { line.chop(1); }
+ if (line.isEmpty() && index == lines.size()-1) { continue; }
+ if (line.isEmpty() || line.size() > 512 || trailer) { return fail(QStringLiteral("Invalid backup line or trailing content.")); }
+ for (const char c : line) { if (c < 32 || c > 126) { return fail(QStringLiteral("Backup must contain ASCII text.")); } }
+ if (line.startsWith("CRC32 = 0x")) {
+ const auto value = unhex(line.mid(10));
+ bool ok = false; const quint32 expected = line.mid(10).toUInt(&ok,16);
+ if (!value || value->size() != 4 || !ok || expected != crc) { return fail(QStringLiteral("Backup checksum mismatch.")); }
+ trailer = true; continue;
+ }
+ crc = crc32(crc,line);
+ if (line.startsWith('*')) { continue; }
+ if (line.startsWith("KXPA Utility Revision = ")) {
+ if (utility) { return fail(QStringLiteral("Duplicate metadata.")); } utility = true; continue;
+ }
+ if (line.startsWith("KXPA Firmware Revision = ")) {
+ if (!backup.firmware.isEmpty()) { return fail(QStringLiteral("Duplicate firmware.")); }
+ backup.firmware = QString::fromLatin1(line.mid(25)); continue;
+ }
+ if (line.startsWith("KXPA Serial Number = ")) {
+ if (!backup.serial.isEmpty()) { return fail(QStringLiteral("Duplicate serial.")); }
+ backup.serial = QString::fromLatin1(line.mid(21)); continue;
+ }
+ const QList fields = line.split(' ');
+ const bool internal = fields.value(0) == "MEM2";
+ if ((!internal && fields.value(0) != "MEM3") || fields.size() != (internal ? 5 : 4)) { return fail(QStringLiteral("Unknown backup record.")); }
+ const QByteArray addressText = internal ? fields[1] + fields[2] : fields[1];
+ const QByteArray countText = fields[internal ? 3 : 2];
+ if (addressText.size() != 4 || countText.size() != 2 || !unhex(addressText) || !unhex(countText)) { return fail(QStringLiteral("Invalid record address.")); }
+ const int address = addressText.toInt(nullptr,16); const int count = countText.toInt(nullptr,16);
+ const auto data = unhex(fields.last());
+ if (!data || data->size() != count || !rangeValid(!internal,address,count)) { return fail(QStringLiteral("Invalid record length.")); }
+ QByteArray& memory = internal ? backup.internal : backup.external;
+ QSet& seen = internal ? internalSeen : externalSeen;
+ for (int offset = 0; offset < count; ++offset) {
+ if (seen.contains(address + offset)) { return fail(QStringLiteral("Overlapping backup records.")); }
+ seen.insert(address + offset); memory[address + offset] = (*data)[offset];
+ }
+ }
+ if (!trailer || !utility || !identityValid(backup) || internalSeen.size() != 1024 || externalSeen.size() != 16384) {
+ return fail(QStringLiteral("Backup is incomplete or has invalid identity metadata."));
+ }
+ if (error) { error->clear(); } return backup;
+}
+bool saveBackup(const QString& path, const Backup& backup, QString* error)
+{
+ const QByteArray bytes = encodeBackup(backup,QDateTime::currentDateTime());
+ if (bytes.isEmpty()) { if (error) { *error = QStringLiteral("Cannot save an incomplete backup."); } return false; }
+ QSaveFile file(path);
+ if (!file.open(QIODevice::WriteOnly) || file.write(bytes) != bytes.size() || !file.commit()) {
+ if (error) { *error = file.errorString(); } return false;
+ }
+ return true;
+}
+QString restoreAllowed(const Backup& backup, const QString& serial, const QString& firmware)
+{
+ if (!identityValid(backup) || backup.internal.size() != 1024 || backup.external.size() != 16384) {
+ return QStringLiteral("Backup is incomplete.");
+ }
+ if (backup.serial != serial) { return QStringLiteral("This backup belongs to another amplifier and contains its calibration."); }
+ if (backup.firmware != firmware) { return QStringLiteral("Backup firmware does not match the connected amplifier."); }
+ if (firmware != QStringLiteral("01.39")) { return QStringLiteral("Restore is verified only for firmware 01.39."); }
+ return {};
+}
+QList decodeFaultHistory(int newestSlot, const QByteArray& bytes)
+{
+ QList records;
+ if (newestSlot < 0 || newestSlot > 255 || bytes.size() != 2048) { return records; }
+ for (int offset = 0; offset < 256; ++offset) {
+ FaultRecord record; record.slot = (newestSlot - offset + 256) % 256;
+ record.raw = bytes.mid(record.slot * 8, 8);
+ if (record.raw == QByteArray(8, char(0xff))) { continue; }
+ const auto byte = [&](int i) { return static_cast(record.raw[i]); };
+ const auto word = [&](int i) { return byte(i) + 256 * byte(i + 1); };
+ record.code = record.raw[0];
+ record.elapsedSeconds = byte(1) + 256U * byte(2) + 65536U * byte(3);
+ record.frequencyKhz = word(4);
+ // Utility 1.14.3.4 binary history uses centiwatts; live ASCII FL uses tenths.
+ double divisor = 1;
+ switch (record.code) {
+ case 'A': case 'S': case 'C': case 'T': divisor = 10; break;
+ case 'D': case 'P': case 'R': divisor = 100; break;
+ case 'H': case 'L': divisor = 1000; break;
+ default: break;
+ }
+ record.value = word(6) / divisor;
+ record.unit = record.code == 'I' ? QStringLiteral("raw") : faultUnit(record.code);
+ record.description = record.code == 'N' ? QStringLiteral("Power On") : currentFaultName(record.code);
+ records.append(record);
+ }
+ return records;
+}
+QByteArray faultHistoryCsv(const QList& records)
+{
+ QByteArray csv("Slot,Elapsed seconds,Code,Fault,Frequency kHz,Value,Unit,Raw\r\n");
+ for (const FaultRecord& record : records) {
+ QString description = record.description; description.replace('"', QStringLiteral("\"\""));
+ csv += QByteArray::number(record.slot) + ',' + QByteArray::number(record.elapsedSeconds) + ',' +
+ QByteArray::number(quint8(record.code)) + ",\"" + description.toUtf8() + "\"," +
+ QByteArray::number(record.frequencyKhz) + ',' + QByteArray::number(record.value, 'f', 3) + ',' +
+ record.unit.toUtf8() + ',' + record.raw.toHex().toUpper() + "\r\n";
+ }
+ return csv;
+}
+} // namespace AetherSDR::Kxpa100
diff --git a/src/core/Kxpa100Maintenance.h b/src/core/Kxpa100Maintenance.h
new file mode 100644
index 0000000000..217167d650
--- /dev/null
+++ b/src/core/Kxpa100Maintenance.h
@@ -0,0 +1,22 @@
+#pragma once
+#include "models/Kxpa100Types.h"
+
+namespace AetherSDR::Kxpa100 {
+struct EepromReply {
+ bool external = false;
+ bool writeAck = false;
+ int address = 0;
+ int length = 0;
+ QByteArray data;
+};
+QByteArray buildEepromRead(bool external, int address, int length);
+// Captured firmware 01.39 restore regions: P[0020,0400), S[0000,3800).
+QByteArray buildEepromWrite(bool external, int address, const QByteArray& data);
+std::optional parseEepromReply(const QByteArray& frame);
+QByteArray encodeBackup(const Backup& backup, const QDateTime& savedAt);
+std::optional decodeBackup(const QByteArray& bytes, QString* error = nullptr);
+bool saveBackup(const QString& path, const Backup& backup, QString* error = nullptr);
+QString restoreAllowed(const Backup& backup, const QString& serial, const QString& firmware);
+QList decodeFaultHistory(int newestSlot, const QByteArray& bytes);
+QByteArray faultHistoryCsv(const QList& records);
+} // namespace AetherSDR::Kxpa100
diff --git a/src/core/Kxpa100Protocol.cpp b/src/core/Kxpa100Protocol.cpp
new file mode 100644
index 0000000000..040d230f89
--- /dev/null
+++ b/src/core/Kxpa100Protocol.cpp
@@ -0,0 +1,232 @@
+#include "Kxpa100Protocol.h"
+#include
+#include
+#include
+#include
+
+namespace AetherSDR::Kxpa100 {
+namespace {
+std::optional number(const QByteArray& text, int width, int low, int high, int base = 10)
+{
+ if (text.size() != width) { return {}; }
+ for (const char c : text) {
+ if (!((c >= '0' && c <= '9') || (base == 16 && c >= 'A' && c <= 'F'))) { return {}; }
+ }
+ bool ok = false;
+ const int value = text.toInt(&ok, base);
+ return ok && value >= low && value <= high ? std::optional(value) : std::nullopt;
+}
+QByteArray bandCode(int band) { return QByteArray::number(band).rightJustified(2, '0'); }
+}
+void FrameParser::feed(const QByteArray& bytes)
+{
+ for (const char c : bytes) {
+ if (c == ';') {
+ const QByteArray frame = m_buffer.trimmed();
+ m_buffer.clear();
+ const bool accepted = !m_discard;
+ m_discard = false;
+ if (accepted && m_callback) { m_callback(frame); }
+ } else if (c == '\r' || c == '\n') {
+ if (!m_discard && !m_buffer.isEmpty() && !m_buffer.endsWith(' ')) { m_buffer.append(' '); }
+ } else if (c < 32 || c > 126 || m_buffer.size() >= 512) {
+ m_buffer.clear(); m_discard = true;
+ } else if (!m_discard) {
+ m_buffer.append(c);
+ }
+ }
+}
+bool applyStatus(const QByteArray& frame, Status& status)
+{
+ if (frame == "^IKXPA100") { status.identity = QStringLiteral("KXPA100"); return true; }
+ if (frame.startsWith("^RV")) {
+ static const QRegularExpression version(QStringLiteral("^\\^RV[0-9]{2}\\.[0-9]{2}$"));
+ if (!version.match(QString::fromLatin1(frame)).hasMatch()) { return false; }
+ status.firmware = QString::fromLatin1(frame.mid(3)); return true;
+ }
+ if (frame.startsWith("^SN")) {
+ if (!number(frame.mid(3), 5, 0, 99999)) { return false; }
+ status.serial = QString::fromLatin1(frame.mid(3)); return true;
+ }
+ const std::array*>, 4> booleans{{
+ {"^OP", &status.operate}, {"^TU", &status.tunerPresent}, {"^TP", &status.tuning}, {"^XP", &status.transceiverPolling}}};
+ for (const auto& [prefix, target] : booleans) {
+ const QByteArray key(prefix);
+ if (frame == key + '0' || frame == key + '1') { *target = frame.endsWith('1'); return true; }
+ }
+ if (frame == "^MDA" || frame == "^MDM" || frame == "^MDB") {
+ status.mode = frame.endsWith('A') ? Mode::Auto : frame.endsWith('M') ? Mode::Manual : Mode::Bypass; return true;
+ }
+ if (frame == "^BYB" || frame == "^BYN") { status.bypassed = frame.endsWith('B'); return true; }
+ if (frame == "^SIT" || frame == "^SIA") {
+ status.side = frame.endsWith('T') ? CapacitorSide::Transmitter : CapacitorSide::Antenna; return true;
+ }
+ struct Integer { const char* prefix; int width, low, high, base; std::optional* target; };
+ const std::array integers{{
+ {"^AN",1,1,2,10,&status.antenna}, {"^BN",2,0,10,10,&status.band},
+ {"^AT",1,0,2,10,&status.attenuator}, {"^F",5,0,99999,10,&status.frequencyKhz},
+ {"^CR",2,0,255,16,&status.capacitorMask}, {"^LR",2,0,255,16,&status.inductorMask},
+ {"^XI",1,0,9,10,&status.transceiverInterface}}};
+ for (const Integer& field : integers) {
+ const QByteArray prefix(field.prefix);
+ if (frame.startsWith(prefix)) {
+ if (const auto value = number(frame.mid(prefix.size()), field.width, field.low, field.high, field.base)) {
+ *field.target = *value; return true;
+ }
+ }
+ }
+ struct Measurement { const char* prefix; int width; double scale; std::optional* target; };
+ const std::array measurements{{
+ {"^PI",4,10,&status.inputWatts}, {"^PF",4,10,&status.forwardWatts},
+ {"^PV",4,10,&status.reflectedWatts}, {"^PD",4,10,&status.dissipatedWatts},
+ {"^PC",4,10,&status.drainAmps}, {"^TM",4,10,&status.temperatureC},
+ {"^SV",5,1000,&status.supplyVolts}, {"^SB",3,10,&status.bypassSwr}}};
+ for (const Measurement& field : measurements) {
+ const QByteArray prefix(field.prefix);
+ if (frame.startsWith(prefix)) {
+ if (const auto value = number(frame.mid(prefix.size()), field.width, 0, field.width == 5 ? 99999 : 9999)) {
+ *field.target = *value / field.scale; return true;
+ }
+ }
+ }
+ if (frame.startsWith("^SW")) {
+ QByteArray digits = frame.mid(3);
+ // The documented nn.n format includes zero; compact firmware replies use nnn.
+ const bool decimal = digits.size() == 4 && digits[2] == '.';
+ if (decimal) { digits.remove(2, 1); }
+ const auto value = number(digits, 3, decimal ? 0 : 10, 999);
+ if (!value) { return false; }
+ status.swr = *value / 10.0; return true;
+ }
+ if (frame.size() == 4 && frame.startsWith("^AD") && QByteArray("DFIJNV").contains(frame[3])) {
+ status.attenuatorReason = frame[3]; return true;
+ }
+ if (frame.startsWith("^FL") && (frame.size() == 9 || frame == "^FLN")) {
+ const char code = frame[3];
+ if (!QByteArray("NACDHILPRST").contains(code)) { return false; }
+ const auto value = frame == "^FLN" ? std::optional(0) : number(frame.mid(4), 5, 0, 99999);
+ if (!value) { return false; }
+ status.faultCode = code;
+ status.faultDetail = *value / (code == 'N' ? 1.0 : code == 'H' || code == 'L' ? 1000.0 : 10.0);
+ return true;
+ }
+ return false;
+}
+bool matchesStatusQuery(const QByteArray& query, const QByteArray& frame)
+{
+ if (query == ";") { return frame.isEmpty(); }
+ if (!query.endsWith(';')) { return false; }
+ const QByteArray prefix = query.chopped(1);
+ if (!frame.startsWith(prefix)) { return false; }
+ if (query == "^F;" && (frame.size() != 7 || frame[2] < '0' || frame[2] > '9')) { return false; }
+ Status scratch;
+ return applyStatus(frame, scratch);
+}
+QByteArray buildFrequencyCommand(const QByteArray& command, int khz)
+{
+ if (!isSupportedFrequency(khz) || (command != "^SM" && command != "^MT" && command != "^DM")) { return {}; }
+ return command + QByteArray::number(khz).rightJustified(5, '0') + ';';
+}
+QByteArray buildErase(int band, int antenna)
+{
+ if (!((band >= 0 && band <= 10) || band == 15) || antenna < 0 || antenna > 2) { return {}; }
+ return "^EM" + bandCode(band) + QByteArray::number(antenna) + ';';
+}
+QByteArray configurationSet(int band, int mask)
+{
+ return band >= 0 && band < 11 && mask >= 1 && mask <= 3
+ ? "^AE" + bandCode(band) + QByteArray::number(mask) + ';' : QByteArray{};
+}
+std::optional parseConfiguration(const QByteArray& frame)
+{
+ if (!frame.startsWith("^AEA") || frame.size() != 15) { return {}; }
+ Configuration result;
+ for (int band = 0; band < 11; ++band) {
+ const auto value = number(frame.mid(4 + band, 1), 1, 1, 3);
+ if (!value) { return {}; }
+ result[band] = *value;
+ }
+ return result;
+}
+QString bandName(int band)
+{
+ static const QStringList names{QStringLiteral("160m"),QStringLiteral("80m"),QStringLiteral("60m"),QStringLiteral("40m"),
+ QStringLiteral("30m"),QStringLiteral("20m"),QStringLiteral("17m"),QStringLiteral("15m"),QStringLiteral("12m"),QStringLiteral("10m"),QStringLiteral("6m")};
+ return band >= 0 && band < names.size() ? names[band] : QStringLiteral("Unknown band");
+}
+int relayTotal(bool capacitor, int mask)
+{
+ constexpr std::array capacitors{10,22,40,82,150,300,660,1360};
+ constexpr std::array inductors{50,110,230,480,1000,2100,4400,9000};
+ if (mask < 0 || mask > 255) { return 0; }
+ int total = 0;
+ for (int bit = 0; bit < 8; ++bit) { if (mask & (1 << bit)) { total += (capacitor ? capacitors : inductors)[bit]; } }
+ return total;
+}
+int relayMaskForValue(bool capacitor, int value)
+{
+ int closest = 0;
+ qint64 distance = std::numeric_limits::max();
+ for (int mask = 0; mask < 256; ++mask) {
+ const qint64 delta = std::abs(qint64(relayTotal(capacitor, mask)) - value);
+ if (delta < distance) { closest = mask; distance = delta; }
+ }
+ return closest;
+}
+QString currentFaultName(char code)
+{
+ switch (code) {
+ case 'N': return QStringLiteral("No fault");
+ case 'A': return QStringLiteral("No tuner match");
+ case 'C': return QStringLiteral("High drain current");
+ case 'D': return QStringLiteral("High dissipation");
+ case 'H': return QStringLiteral("High supply voltage");
+ case 'I': return QStringLiteral("High input power");
+ case 'L': return QStringLiteral("Low supply voltage");
+ case 'P': return QStringLiteral("High output power");
+ case 'R': return QStringLiteral("High reflected power");
+ case 'S': return QStringLiteral("High SWR");
+ case 'T': return QStringLiteral("High temperature");
+ default: return QStringLiteral("Unknown fault (%1)").arg(quint8(code));
+ }
+}
+QString faultUnit(char code)
+{
+ switch (code) {
+ case 'N': return QStringLiteral("power-ons");
+ case 'A': case 'S': return QStringLiteral(":1");
+ case 'C': return QStringLiteral("A");
+ case 'D': case 'P': case 'R': case 'I': return QStringLiteral("W");
+ case 'H': case 'L': return QStringLiteral("V");
+ case 'T': return QStringLiteral("°C");
+ default: return QStringLiteral("raw");
+ }
+}
+std::optional decodeMemoryBin(const QByteArray& frame)
+{
+ static const QRegularExpression pattern(QStringLiteral(
+ "^\\^DM F([0-9]{5}) BN([0-9]{2}) BIN([0-9]{3}) FR([0-9]{5})-([0-9]{5})\\s+"
+ "AN1 (EMPTY|BY[BN] SI[TA] CR[0-9A-F]{2} LR[0-9A-F]{2})\\s+"
+ "AN2 (EMPTY|BY[BN] SI[TA] CR[0-9A-F]{2} LR[0-9A-F]{2})$"));
+ const auto match = pattern.match(QString::fromLatin1(frame).simplified());
+ if (!match.hasMatch()) { return {}; }
+ MemoryBin result;
+ result.frequencyKhz = match.captured(1).toInt(); result.band = match.captured(2).toInt();
+ result.index = match.captured(3).toInt(); result.firstKhz = match.captured(4).toInt(); result.lastKhz = match.captured(5).toInt();
+ if (result.band > 10 || result.firstKhz < 1500 || result.lastKhz > 60000 || result.firstKhz > result.lastKhz ||
+ result.frequencyKhz < result.firstKhz || result.frequencyKhz > result.lastKhz) { return {}; }
+ for (int antenna = 1; antenna <= 2; ++antenna) {
+ MemoryMatch entry; entry.antenna = antenna;
+ const QString text = match.captured(5 + antenna);
+ entry.empty = text == QStringLiteral("EMPTY");
+ if (!entry.empty) {
+ const QStringList fields = text.split(' ');
+ entry.bypassed = fields[0].endsWith('B');
+ entry.side = fields[1].endsWith('T') ? CapacitorSide::Transmitter : CapacitorSide::Antenna;
+ entry.capacitorMask = fields[2].mid(2).toInt(nullptr, 16); entry.inductorMask = fields[3].mid(2).toInt(nullptr, 16);
+ }
+ result.matches.append(entry);
+ }
+ return result;
+}
+}
diff --git a/src/core/Kxpa100Protocol.h b/src/core/Kxpa100Protocol.h
new file mode 100644
index 0000000000..4402e5f22c
--- /dev/null
+++ b/src/core/Kxpa100Protocol.h
@@ -0,0 +1,24 @@
+#pragma once
+#include "models/Kxpa100Types.h"
+#include
+#include
+
+namespace AetherSDR::Kxpa100 {
+class FrameParser {
+public:
+ void setCallback(std::function callback) { m_callback = std::move(callback); }
+ void feed(const QByteArray& bytes);
+ void reset() { m_buffer.clear(); m_discard = false; }
+private:
+ QByteArray m_buffer;
+ bool m_discard = false;
+ std::function m_callback;
+};
+bool applyStatus(const QByteArray& frame, Status& status);
+bool matchesStatusQuery(const QByteArray& query, const QByteArray& frame);
+QByteArray buildFrequencyCommand(const QByteArray& command, int khz);
+QByteArray buildErase(int band, int antenna);
+QByteArray configurationSet(int band, int mask);
+std::optional parseConfiguration(const QByteArray& frame);
+std::optional decodeMemoryBin(const QByteArray& frame);
+}
diff --git a/src/gui/AppletPanel.cpp b/src/gui/AppletPanel.cpp
index 6158f77ee8..db852b4617 100644
--- a/src/gui/AppletPanel.cpp
+++ b/src/gui/AppletPanel.cpp
@@ -17,6 +17,7 @@
#include "VkampApplet.h"
#include "Kpa500Applet.h"
#include "Kat500Applet.h"
+#include "Kxpa100Applet.h"
#include "Kpa1500Applet.h"
#include "LpMeterApplet.h"
#include "TxApplet.h"
@@ -927,6 +928,23 @@ AppletPanel::AppletPanel(QWidget* parent) : QWidget(parent)
m_appletOrder.append(entry);
}
+ m_kxpa100Applet = new Kxpa100Applet;
+ {
+ auto entry = makeEntry("KXPA100", "Elecraft KXPA100 Amplifier / Tuner", m_kxpa100Applet,
+ false, m_drawer, m_drawerLayout, "KX100");
+ entry.btn->setObjectName(QStringLiteral("kxpa100BarButton"));
+ entry.btn->setAccessibleName(tr("Elecraft KXPA100 amplifier and tuner"));
+ markHardwareConditional("KXPA100");
+ if (auto* container = qobject_cast(entry.widget)) {
+ container->setDefaultFloatingSize(QSize(560, 720));
+ connect(container, &ContainerWidget::dockModeChanged, m_kxpa100Applet,
+ [this](ContainerWidget::DockMode mode) {
+ m_kxpa100Applet->setFloating(mode != ContainerWidget::DockMode::PanelDocked);
+ });
+ }
+ m_appletOrder.append(entry);
+ }
+
// Elecraft KPA1500 — independent of AMP (PGXL), ACOM, SPE and VKAMP
// for the same reason they are independent of each other: a station can
// have any combination of them connected at once. Deliberately NOT in
@@ -1458,6 +1476,7 @@ QList AppletPanel::appletCatalog() const
{QStringLiteral("SPE"), QStringLiteral("Amplifiers")},
{QStringLiteral("KPA500"), QStringLiteral("Amplifiers")},
{QStringLiteral("KAT500"), QStringLiteral("Transmit")},
+ {QStringLiteral("KXPA100"), QStringLiteral("Transmit")},
{QStringLiteral("VKAMP"), QStringLiteral("Amplifiers")},
{QStringLiteral("KPA1500"), QStringLiteral("Amplifiers")},
{QStringLiteral("EQ"), QStringLiteral("Audio & DSP")},
@@ -1902,6 +1921,12 @@ void AppletPanel::setKat500Visible(bool visible)
applyBarLayout();
}
+void AppletPanel::setKxpa100Visible(bool visible)
+{
+ updateHardwareAvailability("KXPA100", "Applet_KXPA100", visible);
+ applyBarLayout();
+}
+
void AppletPanel::setKpa500Visible(bool visible)
{
updateHardwareAvailability("KPA500", "Applet_KPA500", visible);
diff --git a/src/gui/AppletPanel.h b/src/gui/AppletPanel.h
index 6c7a695f99..2d80a60be1 100644
--- a/src/gui/AppletPanel.h
+++ b/src/gui/AppletPanel.h
@@ -38,6 +38,7 @@ class SpeApplet;
class VkampApplet;
class Kpa500Applet;
class Kat500Applet;
+class Kxpa100Applet;
class Kpa1500Applet;
class LpMeterApplet;
class TxApplet;
@@ -112,6 +113,7 @@ class AppletPanel : public QWidget {
SpeApplet* speApplet() { return m_speApplet; }
VkampApplet* vkampApplet() { return m_vkampApplet; }
Kat500Applet* kat500Applet() { return m_kat500Applet; }
+ Kxpa100Applet* kxpa100Applet() { return m_kxpa100Applet; }
Kpa500Applet* kpa500Applet() { return m_kpa500Applet; }
Kpa1500Applet* kpa1500Applet() { return m_kpa1500Applet; }
LpMeterApplet* lpMeterApplet() { return m_lpMeterApplet; }
@@ -201,6 +203,7 @@ class AppletPanel : public QWidget {
// serial amplifier connection. Fully independent of all other amp applets.
void setKpa500Visible(bool visible);
void setKat500Visible(bool visible);
+ void setKxpa100Visible(bool visible);
// Show/hide the KPA1500 button and applet based on a direct Elecraft
// KPA1500 connection (#4097). Independent of every other amplifier
// applet for the same reason setVkampVisible is: these are separate,
@@ -429,6 +432,7 @@ class AppletPanel : public QWidget {
LpMeterApplet* m_lpMeterApplet{nullptr};
QPushButton* m_vkampBtn{nullptr};
Kat500Applet* m_kat500Applet{nullptr};
+ Kxpa100Applet* m_kxpa100Applet{nullptr};
Kpa500Applet* m_kpa500Applet{nullptr};
QPushButton* m_kpa500Btn{nullptr};
TxApplet* m_txApplet{nullptr};
diff --git a/src/gui/Kxpa100AdvancedWidget.cpp b/src/gui/Kxpa100AdvancedWidget.cpp
new file mode 100644
index 0000000000..12891e796d
--- /dev/null
+++ b/src/gui/Kxpa100AdvancedWidget.cpp
@@ -0,0 +1,633 @@
+#include "Kxpa100AdvancedWidget.h"
+#include "CanonIndicators.h"
+#include "models/Kxpa100Model.h"
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+namespace AetherSDR
+{
+namespace
+{
+void available(QWidget* widget, const QString& reason)
+{
+ widget->setEnabled(reason.isEmpty());
+ widget->setToolTip(reason);
+ widget->setAccessibleDescription(reason);
+}
+bool save(const QString& path, const QByteArray& data, QString* error)
+{
+ QSaveFile file(path);
+ if (!file.open(QIODevice::WriteOnly) || file.write(data) != data.size() || !file.commit()) {
+ *error = file.errorString();
+ return false;
+ }
+ return true;
+}
+void fillTable(QTableWidget* table, int row, const QStringList& values)
+{
+ for (int c = 0; c < values.size(); ++c) {
+ table->setItem(row, c, new QTableWidgetItem(values[c]));
+ }
+}
+} // namespace
+Kxpa100AdvancedWidget::Kxpa100AdvancedWidget(Kxpa100Model* model, QWidget* parent)
+ : QWidget(parent), m_model(model)
+{
+ setObjectName("kxpa100Advanced");
+ setAccessibleName(tr("KXPA100 Advanced settings and maintenance"));
+ auto* root = new QVBoxLayout(this);
+ root->setContentsMargins(0, 4, 0, 0);
+ root->setSpacing(6);
+ const auto label = [this](const QString& text, const char* name, QLayout* layout) {
+ auto* l = new QLabel(text, this);
+ l->setObjectName(name);
+ l->setTextFormat(Qt::PlainText);
+ l->setWordWrap(true);
+ layout->addWidget(l);
+ return l;
+ };
+ const auto button = [this](const QString& title, const char* name, QLayout* layout) {
+ auto* b = new QPushButton(title, this);
+ b->setObjectName(name);
+ b->setAccessibleName(title);
+ b->setAutoDefault(false);
+ b->setFixedHeight(22);
+ layout->addWidget(b);
+ return b;
+ };
+ applyCanonSheet(this, [] {
+ return QStringLiteral(
+ "QWidget#kxpa100Advanced, QWidget#kxpa100Advanced QLabel, QWidget#kxpa100Advanced QCheckBox, "
+ "QWidget#kxpa100Advanced QComboBox, QWidget#kxpa100Advanced QSpinBox, "
+ "QWidget#kxpa100Advanced QTableWidget, QWidget#kxpa100Advanced QHeaderView::section, "
+ "QWidget#kxpa100Advanced QLineEdit, QWidget#kxpa100Advanced QProgressBar { font-size: 10px; }"
+ "QWidget#kxpa100Advanced QLabel { color: {{color.canon.muted}}; }"
+ "QWidget#kxpa100Advanced QGroupBox { font-size: 10px; font-weight: bold; }"
+ "QWidget#kxpa100Advanced QPushButton { font-size: 10px; padding: 0 4px; }"
+ "QWidget#kxpa100Advanced QTableWidget::item { padding: 1px 3px; }"
+ "QWidget#kxpa100Advanced QHeaderView::section { padding: 2px 3px; }");
+ });
+ m_identity = label({}, "kxAdvancedIdentity", root);
+ m_availability = label({}, "kxAdvancedAvailability", root);
+ auto* config = new QGroupBox(tr("Configuration — staged edits"), this);
+ auto* configuration = new QVBoxLayout(config);
+ auto* actions = new QHBoxLayout;
+ auto* read = button(tr("Read configuration"), "kxReadConfiguration", actions);
+ configuration->addLayout(actions);
+ label(tr("Read before editing. ANT1 and ANT2 checkboxes stage enabled antennas per band. "
+ "The All bands row applies a deliberate change to every band. At least one antenna must remain enabled. "
+ "Apply verifies device readback; Cancel edits discards only staging."),"kxConfigurationHelp",configuration);
+ m_configuration = new QTableWidget(12,4,this);
+ m_configuration->setObjectName("kxConfiguration");
+ m_configuration->setAccessibleName(tr("Confirmed and staged KXPA100 antenna enables"));
+ m_configuration->setHorizontalHeaderLabels({tr("Band"),tr("ANT1"),tr("ANT2"),tr("Readback")});
+ m_configuration->verticalHeader()->hide();
+ m_configuration->horizontalHeader()->setSectionResizeMode(QHeaderView::Stretch);
+ m_configuration->verticalHeader()->setMinimumSectionSize(22);
+ m_configuration->verticalHeader()->setDefaultSectionSize(22);
+ m_configuration->verticalHeader()->setSectionResizeMode(QHeaderView::Fixed);
+ m_configuration->setMinimumHeight(292); m_configuration->setMaximumHeight(310);
+ m_configuration->setWordWrap(false);
+ configuration->addWidget(m_configuration);
+ auto* edits = new QVBoxLayout;
+ auto* apply = button(tr("Apply changed fields"), "kxApplyConfiguration", edits);
+ auto* discard = button(tr("Cancel edits"), "kxDiscardConfiguration", edits);
+ configuration->addLayout(edits);
+ root->addWidget(config);
+ auto* backupGroup = new QGroupBox(tr("Complete native backup and restore"), this);
+ auto* backup = new QVBoxLayout(backupGroup);
+ auto* files = new QVBoxLayout;
+ auto* saveBackup = button(tr("Read and save backup…"), "kxSaveBackup", files);
+ auto* choose = button(tr("Choose backup…"), "kxChooseBackup", files);
+ backup->addLayout(files);
+ m_preview = label(tr("No backup selected."), "kxBackupPreview", backup);
+ label(tr("A .KXPAConfig backup contains both EEPROM regions (17,408 bytes). Restore verifies each "
+ "allowed block and excludes amplifier identity and fault history. Cancellation or failure cannot "
+ "undo blocks already written. A successful verified restore requests a restart; reconnect to inspect settings."),
+ "kxBackupHelp", backup);
+ m_restoreConfirm =
+ new QCheckBox(tr("Confirm backup replacement"), this);
+ m_restoreConfirm->setObjectName("kxConfirmRestore");
+ backup->addWidget(m_restoreConfirm);
+ label(tr("Replace this tuner's settings and stored matches with the previewed backup."),
+ "kxRestoreConfirmationHelp", backup);
+ auto* restore = button(tr("Restore and verify"), "kxRestoreBackup", backup);
+ root->addWidget(backupGroup);
+ auto* faultGroup = new QGroupBox(tr("Stored fault history — newest first"), this);
+ auto* faults = new QVBoxLayout(faultGroup);
+ auto* faultActions = new QVBoxLayout;
+ auto* readFaultsButton = button(tr("Read fault history"), "kxReadFaults", faultActions);
+ auto* exportFaults = button(tr("Export CSV…"), "kxExportFaults", faultActions);
+ faults->addLayout(faultActions);
+ label(
+ tr("Elapsed device time is not a calendar timestamp. Power On history is distinct from the current fault state. "
+ "Unknown records retain raw bytes. Clear fault in the applet clears only the current fault."),
+ "kxFaultHistoryHelp", faults);
+ m_faults = new QTableWidget(0, 7, this);
+ m_faults->setObjectName("kxStoredFaults");
+ m_faults->setAccessibleName(tr("Stored tuner fault history"));
+ m_faults->setHorizontalHeaderLabels({tr("Slot"), tr("Elapsed s"), tr("Fault"), tr("Freq kHz"), tr("Value"), tr("Unit"), tr("Raw bytes")});
+ faults->addWidget(m_faults);
+ root->addWidget(faultGroup);
+ auto* memoriesGroup = new QGroupBox(tr("Memories and reset"), this);
+ auto* memories = new QVBoxLayout(memoriesGroup);
+ auto* memoryActions = new QVBoxLayout;
+ m_memoryFrequency = new QSpinBox(this);
+ m_memoryFrequency->setObjectName("kxInspectFrequency");
+ m_memoryFrequency->setAccessibleName(
+ tr("Memory inspection frequency in kHz"));
+ m_memoryFrequency->setRange(0, 54000);
+ m_memoryFrequency->setSuffix(tr(" kHz"));
+ m_memoryFrequency->setSpecialValueText(tr("Select frequency"));
+ memoryActions->addWidget(m_memoryFrequency);
+ auto* inspect = button(tr("Inspect bin"), "kxInspectMemories", memoryActions);
+ memories->addLayout(memoryActions);
+ m_memorySummary = label(tr("No bin inspected."), "kxMemorySummary", memories);
+ m_memories = new QTableWidget(0, 6, this);
+ m_memories->setObjectName("kxStoredMemories");
+ m_memories->setAccessibleName(tr("Stored matches, independent of live tuner state"));
+ m_memories->setHorizontalHeaderLabels(
+ {tr("ANT"), tr("Bypassed"), tr("C mask"), tr("L mask"), tr("C side"), tr("State")});
+ memories->addWidget(m_memories);
+ auto* eraseSelectors = new QHBoxLayout;
+ m_eraseBand = new QComboBox(this);
+ m_eraseBand->setObjectName("kxEraseBand");
+ m_eraseBand->setAccessibleName(tr("Band to erase"));
+ m_eraseBand->addItem(tr("All 11 bands"), 15);
+ for (int i = 0; i < 11; ++i) {
+ m_eraseBand->addItem(Kxpa100::bandName(i), i);
+ }
+ eraseSelectors->addWidget(m_eraseBand);
+ m_eraseAntenna = new QComboBox(this);
+ m_eraseAntenna->setObjectName("kxEraseAntenna");
+ m_eraseAntenna->setAccessibleName(tr("Antenna memories to erase"));
+ m_eraseAntenna->addItem(tr("All antennas"), 0);
+ for (int i = 1; i <= 2; ++i) {
+ m_eraseAntenna->addItem(tr("ANT %1").arg(i), i);
+ }
+ eraseSelectors->addWidget(m_eraseAntenna);
+ memories->addLayout(eraseSelectors);
+ auto* eraseTarget = label({}, "kxEraseTarget", memories);
+ m_eraseConfirm = new QCheckBox(tr("Confirm selected erase"), this);
+ m_eraseConfirm->setObjectName("kxConfirmErase");
+ memories->addWidget(m_eraseConfirm);
+ auto* erase = button(tr("Erase selected memories"), "kxEraseMemories", memories);
+ m_resetConfirm = new QCheckBox(
+ tr("Confirm factory reset"), this);
+ m_resetConfirm->setObjectName("kxConfirmReset");
+ memories->addWidget(m_resetConfirm);
+ label(tr("Erase settings and stored matches, restart, and reconnect at the default baud."),
+ "kxResetConfirmationHelp", memories);
+ auto* reset = button(tr("Factory reset"), "kxFactoryReset", memories);
+ root->addWidget(memoriesGroup);
+ for (auto* table : {m_faults, m_memories}) {
+ table->setEditTriggers(QAbstractItemView::NoEditTriggers);
+ table->horizontalHeader()->setSectionResizeMode(QHeaderView::ResizeToContents);
+ table->horizontalHeader()->setStretchLastSection(true);
+ table->setMinimumHeight(150);
+ table->setMaximumHeight(230);
+ }
+ m_progress = new QProgressBar(this);
+ m_progress->setObjectName("kxMaintenanceProgress");
+ m_progress->setAccessibleName(tr("Tuner operation progress"));
+ root->addWidget(m_progress);
+ m_status = label(tr("Ready. No operation has run."), "kxMaintenanceStatus", root);
+ auto* cancel = button(tr("Cancel operation"), "kxCancelMaintenance", root);
+ connect(m_configuration,&QTableWidget::itemChanged,this,[this](QTableWidgetItem* item) {
+ if (item->column()==1 || item->column()==2) { stageField(item->row(),item->column()); }
+ });
+ connect(m_memoryFrequency,&QSpinBox::valueChanged,this,&Kxpa100AdvancedWidget::refreshAvailability);
+ connect(discard, &QPushButton::clicked, this, [this] {
+ m_staged.reset();
+ renderConfiguration();
+ refreshAvailability();
+ });
+ connect(read, &QPushButton::clicked, this, [this] {
+ if (m_model) {
+ m_staged.reset();
+ m_ownOperation = true;
+ m_model->readConfiguration();
+ }
+ });
+ connect(apply, &QPushButton::clicked, this, [this] {
+ if (m_model && m_staged.has_value()) {
+ m_applying = true;
+ m_ownOperation = true;
+ m_model->applyConfiguration(*m_staged);
+ }
+ });
+ connect(saveBackup, &QPushButton::clicked, this, [this] { openFileDialog(FileAction::Backup); });
+ connect(choose, &QPushButton::clicked, this, [this] { openFileDialog(FileAction::Choose); });
+ connect(exportFaults, &QPushButton::clicked, this, [this] { openFileDialog(FileAction::Faults); });
+ connect(restore, &QPushButton::clicked, this, [this] {
+ if (m_model && m_backup && restoreReason().isEmpty() && m_restoreConfirm->isChecked()) {
+ m_ownOperation = true;
+ m_model->restoreBackup(*m_backup, true);
+ }
+ });
+ connect(readFaultsButton, &QPushButton::clicked, this, [this] {
+ if (m_model) {
+ m_ownOperation = true;
+ m_model->readFaultHistory();
+ }
+ });
+ connect(inspect, &QPushButton::clicked, this, [this] {
+ if (m_model) {
+ m_ownOperation = true;
+ m_model->inspectMemories(m_memoryFrequency->value());
+ }
+ });
+ connect(erase, &QPushButton::clicked, this, [this] {
+ if (m_model && m_eraseConfirm->isChecked()) {
+ m_ownOperation = true;
+ m_model->eraseMemories(m_eraseBand->currentData().toInt(), m_eraseAntenna->currentData().toInt(),
+ true);
+ }
+ });
+ connect(reset, &QPushButton::clicked, this, [this] {
+ if (m_model && m_resetConfirm->isChecked()) {
+ m_ownOperation = true;
+ m_model->factoryReset(true);
+ }
+ });
+ connect(cancel, &QPushButton::clicked, this, [this] {
+ if (m_model && m_ownOperation) {
+ m_model->cancelOperation();
+ }
+ });
+ for (auto* check : {m_restoreConfirm, m_eraseConfirm, m_resetConfirm}) {
+ check->setAccessibleName(check == m_restoreConfirm
+ ? tr("Confirm replacing this tuner's settings and stored matches with the previewed backup")
+ : check == m_resetConfirm
+ ? tr("Confirm factory reset: erase settings and matches, restart, and reconnect at the default baud")
+ : check->text());
+ connect(check, &QCheckBox::toggled, this, &Kxpa100AdvancedWidget::refreshAvailability);
+ }
+ const auto targetChanged = [this, eraseTarget] {
+ m_eraseConfirm->setChecked(false);
+ const QString target = tr("Erase target: %1 / %2").arg(m_eraseBand->currentText(), m_eraseAntenna->currentText());
+ eraseTarget->setText(target);
+ m_eraseConfirm->setAccessibleName(tr("Confirm selected erase. %1").arg(target));
+ refreshAvailability();
+ };
+ connect(m_eraseBand, &QComboBox::currentIndexChanged, this, targetChanged);
+ connect(m_eraseAntenna, &QComboBox::currentIndexChanged, this, targetChanged);
+ targetChanged();
+ if (model) {
+ connect(model, &Kxpa100Model::configurationRead, this, &Kxpa100AdvancedWidget::configurationRead);
+ connect(model, &Kxpa100Model::availabilityChanged, this, &Kxpa100AdvancedWidget::refreshAvailability);
+ connect(model, &Kxpa100Model::statusUpdated, this, &Kxpa100AdvancedWidget::refreshAvailability);
+ connect(model, &Kxpa100Model::operationBusyChanged, this, &Kxpa100AdvancedWidget::refreshAvailability);
+ connect(model, &Kxpa100Model::connected, this, &Kxpa100AdvancedWidget::clearDeviceData);
+ connect(model, &Kxpa100Model::disconnected, this, &Kxpa100AdvancedWidget::clearDeviceData);
+ connect(model, &Kxpa100Model::operationProgress, this, [this](int done, int total) {
+ if (m_ownOperation) {
+ m_progress->setRange(0, total);
+ m_progress->setValue(done);
+ }
+ });
+ connect(model, &Kxpa100Model::operationFinished, this, [this](const QString& message) {
+ if (m_ownOperation) {
+ if (m_applying) {
+ m_staged.reset();
+ m_confirmed = m_model->configuration();
+ renderConfiguration();
+ }
+ m_applying = false;
+ m_ownOperation = false;
+ m_restoreConfirm->setChecked(false);
+ m_eraseConfirm->setChecked(false);
+ m_resetConfirm->setChecked(false);
+ showStatus(message);
+ refreshAvailability();
+ }
+ });
+ connect(model, &Kxpa100Model::operationFailed, this, [this](const QString& reason) {
+ if (m_ownOperation) {
+ m_applying = false;
+ m_ownOperation = false;
+ m_savePath.clear();
+ showStatus(
+ reason +
+ tr(" Confirmed changes may already have applied; review readbacks before retrying."));
+ renderConfiguration();
+ refreshAvailability();
+ }
+ });
+ connect(model, &Kxpa100Model::backupRead, this, [this](const Kxpa100::Backup& data) {
+ if (m_savePath.isEmpty()) {
+ return;
+ }
+ QString error;
+ const QString path = m_savePath;
+ m_savePath.clear();
+ const bool saved = Kxpa100Model::saveBackupFile(path, data, &error);
+ showStatus(saved ? tr("Complete backup saved: %1").arg(path)
+ : tr("Backup save failed: %1").arg(error));
+ });
+ connect(model, &Kxpa100Model::faultHistoryRead, this, &Kxpa100AdvancedWidget::readFaults);
+ connect(model, &Kxpa100Model::memoriesRead, this, &Kxpa100AdvancedWidget::readMemories);
+ connect(model, &QObject::destroyed, this, [this] {
+ m_model = nullptr;
+ clearDeviceData();
+ });
+ }
+ renderConfiguration();
+ refreshAvailability();
+}
+Kxpa100AdvancedWidget::~Kxpa100AdvancedWidget()
+{
+ if (m_fileDialog) {
+ QFileDialog* dialog = m_fileDialog.data();
+ m_fileDialog.clear();
+ disconnect(dialog, nullptr, this, nullptr);
+ dialog->reject();
+ // Native sheets can still be unwinding their completion callback.
+ // QWidget child teardown must not delete them synchronously.
+ dialog->setParent(nullptr);
+ dialog->deleteLater();
+ }
+ if (m_model && m_ownOperation) {
+ m_model->cancelOperation();
+ }
+}
+void Kxpa100AdvancedWidget::showStatus(const QString& message)
+{
+ m_status->setText(message);
+ m_status->setAccessibleDescription(message);
+ QAccessibleEvent event(m_status, QAccessible::NameChanged);
+ QAccessible::updateAccessibility(&event);
+}
+QString Kxpa100AdvancedWidget::schemaReason(bool writing) const
+{
+ if (!m_model) {
+ return tr("Connect a KXPA100 first.");
+ }
+ const QString reason = m_model->unavailableReason(writing);
+ if (!reason.isEmpty()) {
+ return reason;
+ }
+ if (m_model->status().firmware != "01.39") {
+ return tr("Configuration and memory schema support is verified for firmware 01.39 only. Use the "
+ "official Utility for other firmware.");
+ }
+ return {};
+}
+QString Kxpa100AdvancedWidget::restoreReason() const
+{
+ const QString reason = schemaReason(true);
+ if (!reason.isEmpty()) {
+ return reason;
+ }
+ if (!m_backup) {
+ return tr("Choose a complete validated .KXPAConfig backup first.");
+ }
+ if (m_backup->firmware != m_model->status().firmware || m_backup->serial != m_model->status().serial) {
+ return tr("Backup firmware and serial must match this amplifier.");
+ }
+ return {};
+}
+void Kxpa100AdvancedWidget::refreshAvailability()
+{
+ const QString readReason = schemaReason(false), writeReason = schemaReason(true);
+ const QString tunerWrite = !writeReason.isEmpty() ? writeReason
+ : m_model->unavailableReason(true, true);
+ const QString tunerRead = !readReason.isEmpty() ? readReason
+ : m_model->unavailableReason(false, true);
+ const bool busy = m_model && m_model->operationBusy();
+ m_identity->setText(m_model ? tr("%1 · %2 · S/N %3 · Firmware %4")
+ .arg(m_model->description(), m_model->status().identity,
+ m_model->status().serial, m_model->status().firmware)
+ : tr("Disconnected"));
+ m_availability->setText(!readReason.isEmpty() ? readReason
+ : writeReason.isEmpty()
+ ? tr("Ready. Apply sends changed fields and verifies readback.")
+ : writeReason);
+ for (const char* name :
+ {"kxReadConfiguration", "kxSaveBackup", "kxReadFaults", "kxInspectMemories"}) {
+ available(findChild(name), readReason);
+ }
+ available(m_configuration, !tunerRead.isEmpty() ? tunerRead
+ : !m_confirmed
+ ? tr("Read complete configuration before editing.")
+ : QString());
+ available(findChild("kxApplyConfiguration"), !tunerWrite.isEmpty() ? tunerWrite
+ : !m_staged
+ ? tr("Deliberately edit a field first.")
+ : QString());
+ available(findChild("kxDiscardConfiguration"),
+ busy ? tr("Wait for the operation to finish.") : QString());
+ available(findChild("kxChooseBackup"),
+ busy ? tr("Wait for the operation to finish.") : QString());
+ available(m_restoreConfirm, restoreReason());
+ available(findChild("kxRestoreBackup"),
+ !restoreReason().isEmpty() ? restoreReason()
+ : !m_restoreConfirm->isChecked() ? tr("Confirm replacement of stored settings first.")
+ : QString());
+ available(findChild("kxExportFaults"), !m_faultsLoaded ? tr("Read fault history first.")
+ : busy ? tr("Wait for the operation to finish.")
+ : QString());
+ available(findChild("kxReadConfiguration"),tunerRead);
+ available(findChild("kxInspectMemories"),!tunerRead.isEmpty() ? tunerRead
+ : Kxpa100::isSupportedFrequency(m_memoryFrequency->value()) ? QString() : tr("Select 1800–54000 kHz."));
+ available(m_eraseConfirm, tunerWrite);
+ available(m_resetConfirm, writeReason);
+ available(findChild("kxEraseMemories"),
+ !tunerWrite.isEmpty() ? tunerWrite
+ : !m_eraseConfirm->isChecked() ? tr("Confirm the selected band and antenna erase target first.")
+ : QString());
+ available(findChild("kxFactoryReset"),
+ !writeReason.isEmpty() ? writeReason
+ : !m_resetConfirm->isChecked() ? tr("Confirm factory reset and default-baud reconnect first.")
+ : QString());
+ available(findChild("kxCancelMaintenance"),
+ !busy || !m_ownOperation ? tr("No operation is running from this panel.") : QString());
+ m_progress->setVisible(busy && m_ownOperation);
+ available(m_eraseBand, busy ? tr("Wait for the operation to finish.") : QString());
+ available(m_eraseAntenna, busy ? tr("Wait for the operation to finish.") : QString());
+}
+void Kxpa100AdvancedWidget::configurationRead(const Kxpa100::Configuration& values)
+{
+ m_confirmed=values;
+ if (m_staged && *m_staged==values) { m_staged.reset(); }
+ renderConfiguration(); refreshAvailability();
+}
+void Kxpa100AdvancedWidget::renderConfiguration()
+{
+ const QSignalBlocker blocker(m_configuration);
+ for (int row=0;row<12;++row) {
+ auto* name=new QTableWidgetItem(row==0 ? tr("All bands") : Kxpa100::bandName(row-1));
+ name->setFlags(Qt::ItemIsEnabled|Qt::ItemIsSelectable); m_configuration->setItem(row,0,name);
+ const auto values=m_staged ? m_staged : m_confirmed;
+ for (int antenna=1;antenna<=2;++antenna) {
+ auto* item=new QTableWidgetItem;
+ item->setFlags(Qt::ItemIsEnabled|Qt::ItemIsSelectable|Qt::ItemIsUserCheckable);
+ int on=0; const int start=row==0 ? 0 : row-1, end=row==0 ? 10 : row-1;
+ for (int band=start;band<=end;++band) { if (values && ((*values)[band] & (1<<(antenna-1)))) { ++on; } }
+ item->setCheckState(on==0 ? Qt::Unchecked : on==end-start+1 ? Qt::Checked : Qt::PartiallyChecked);
+ item->setData(Qt::AccessibleTextRole,tr("%1 ANT%2 enabled").arg(name->text()).arg(antenna));
+ item->setToolTip(tr("Stage this antenna enable for %1; Apply sends it to the device.").arg(name->text()));
+ item->setData(Qt::AccessibleDescriptionRole,item->toolTip()); m_configuration->setItem(row,antenna,item);
+ }
+ QString readback=tr("Unknown");
+ if (m_confirmed) {
+ const auto value=(*m_confirmed)[row==0 ? 0 : row-1];
+ const bool mixed=row==0 && std::any_of(m_confirmed->begin(),m_confirmed->end(),[value](int mask) { return mask!=value; });
+ readback=mixed ? tr("Mixed") : value==3 ? tr("ANT1 + 2") : tr("ANT%1").arg(value);
+ }
+ auto* actual=new QTableWidgetItem(readback); actual->setFlags(Qt::ItemIsEnabled|Qt::ItemIsSelectable);
+ m_configuration->setItem(row,3,actual);
+ }
+}
+void Kxpa100AdvancedWidget::stageField(int row,int column)
+{
+ if (!m_confirmed || row<0 || row>=12 || column<1 || column>2) { return; }
+ const bool on=m_configuration->item(row,column)->checkState()==Qt::Checked;
+ Kxpa100::Configuration staged=m_staged.value_or(*m_confirmed);
+ const int start=row==0 ? 0 : row-1, end=row==0 ? 10 : row-1, bit=1<<(column-1);
+ for (int band=start;band<=end;++band) {
+ staged[band]=on ? staged[band]|bit : staged[band]&~bit;
+ if (staged[band]==0) { showStatus(tr("At least one antenna must remain enabled on every band.")); renderConfiguration(); return; }
+ }
+ m_staged=staged==*m_confirmed ? std::nullopt : std::optional(staged);
+ renderConfiguration(); refreshAvailability(); showStatus(tr("Antenna changes staged. Nothing has been sent."));
+}
+void Kxpa100AdvancedWidget::clearDeviceData()
+{
+ m_confirmed.reset();
+ m_staged.reset();
+ m_savePath.clear();
+ m_faultRecords.clear();
+ m_faultsLoaded = false;
+ m_faults->setRowCount(0);
+ m_memories->setRowCount(0);
+ m_memorySummary->setText(tr("No bin inspected for this session."));
+ m_restoreConfirm->setChecked(false);
+ m_eraseConfirm->setChecked(false);
+ m_resetConfirm->setChecked(false);
+ m_applying = false;
+ m_ownOperation = false;
+ renderConfiguration();
+ refreshAvailability();
+}
+void Kxpa100AdvancedWidget::openFileDialog(FileAction action)
+{
+ if (m_fileDialog) {
+ m_fileDialog->raise();
+ m_fileDialog->activateWindow();
+ return;
+ }
+ auto* dialog = new QFileDialog(this);
+ m_fileDialog = dialog;
+ dialog->setObjectName("kxMaintenanceFileDialog");
+ dialog->setAccessibleName(tr("KXPA100 maintenance file selection"));
+ dialog->setWindowTitle(action == FileAction::Choose ? tr("Choose KXPA100 backup")
+ : action == FileAction::Backup ? tr("Save complete KXPA100 backup")
+ : tr("Export fault history"));
+ dialog->setNameFilter(action == FileAction::Faults
+ ? tr("CSV files (*.csv)")
+ : tr("KXPA100 configuration (*.KXPAConfig);;All files (*)"));
+ dialog->setAcceptMode(action == FileAction::Choose ? QFileDialog::AcceptOpen : QFileDialog::AcceptSave);
+ dialog->setFileMode(action == FileAction::Choose ? QFileDialog::ExistingFile : QFileDialog::AnyFile);
+ dialog->setDefaultSuffix(action == FileAction::Faults ? "csv" : "KXPAConfig");
+ if (action != FileAction::Choose) {
+ dialog->selectFile(
+ action == FileAction::Faults
+ ? "kxpa100-fault-history.csv"
+ : QStringLiteral("SN%1.KXPAConfig").arg(m_model ? m_model->status().serial : QString()));
+ }
+ connect(dialog, &QDialog::finished, this, [this, dialog, action](int result) {
+ const QStringList paths = dialog->selectedFiles();
+ m_fileDialog.clear();
+ dialog->deleteLater();
+ if (result != QDialog::Accepted || paths.isEmpty()) {
+ return;
+ }
+ if (action == FileAction::Choose) {
+ loadBackup(paths.first());
+ } else if (action == FileAction::Backup) {
+ const QString reason = schemaReason(false);
+ if (!reason.isEmpty()) {
+ showStatus(reason);
+ return;
+ }
+ m_savePath = paths.first();
+ m_ownOperation = true;
+ if (!m_model->readBackup()) {
+ m_savePath.clear();
+ }
+ } else {
+ if (!m_faultsLoaded) {
+ showStatus(tr("Read fault history again before exporting."));
+ return;
+ }
+ QString error;
+ const bool saved = save(paths.first(), Kxpa100Model::faultsCsv(m_faultRecords), &error);
+ showStatus(saved ? tr("Fault history exported.") : error);
+ }
+ });
+ dialog->open();
+}
+bool Kxpa100AdvancedWidget::loadBackup(const QString& path)
+{
+ m_backup.reset();
+ m_restoreConfirm->setChecked(false);
+ QFile file(path);
+ QString error;
+ if (!file.open(QIODevice::ReadOnly)) {
+ error = file.errorString();
+ } else if (file.size() > 64 * 1024) {
+ error = tr("Backup exceeds the 64 KiB limit.");
+ } else {
+ m_backup = Kxpa100Model::decodeBackupFile(file.read(64 * 1024 + 1), &error);
+ }
+ m_preview->setText(m_backup ? tr("%1\nS/N %2 · Firmware %3 · 17,408 bytes · complete coverage and CRC "
+ "verified\nRestore excludes identity and fault history.")
+ .arg(QFileInfo(path).fileName(), m_backup->serial, m_backup->firmware)
+ : tr("No valid backup selected."));
+ showStatus(m_backup ? tr("Backup validated for preview. No tuner settings changed.") : error);
+ refreshAvailability();
+ return m_backup.has_value();
+}
+void Kxpa100AdvancedWidget::readFaults(const QList& records)
+{
+ m_faultRecords=records; m_faultsLoaded=true; m_faults->setRowCount(records.size());
+ for (int i=0;isetText(tr("%1–%2 kHz · %3 · bin %4. Read-only inspection; live relays are unchanged.")
+ .arg(bin.firstKhz).arg(bin.lastKhz).arg(Kxpa100::bandName(bin.band)).arg(bin.index));
+ m_memories->setRowCount(bin.matches.size());
+ for (int i=0;i
+#include
+#include
+class QLabel;
+class QPushButton;
+class QCheckBox;
+class QComboBox;
+class QTableWidget;
+class QProgressBar;
+class QFileDialog;
+class QSpinBox;
+namespace AetherSDR
+{
+class Kxpa100Model;
+class Kxpa100AdvancedWidget : public QWidget
+{
+ Q_OBJECT
+ public:
+ explicit Kxpa100AdvancedWidget(Kxpa100Model* model, QWidget* parent = nullptr);
+ ~Kxpa100AdvancedWidget() override;
+
+ private:
+ friend class Kxpa100AdvancedWidgetTest;
+ enum class FileAction { Backup, Choose, Faults };
+ void refreshAvailability();
+ void renderConfiguration();
+ void configurationRead(const Kxpa100::Configuration& values);
+ void stageField(int row, int column);
+ void clearDeviceData();
+ void showStatus(const QString& message);
+ void openFileDialog(FileAction action);
+ bool loadBackup(const QString& path);
+ void readFaults(const QList& records);
+ void readMemories(const Kxpa100::MemoryBin& bin);
+ QString schemaReason(bool writing) const;
+ QString restoreReason() const;
+ QPointer m_model;
+ QPointer m_fileDialog;
+ std::optional m_confirmed, m_staged;
+ std::optional m_backup;
+ QString m_savePath;
+ QList m_faultRecords;
+ bool m_ownOperation = false, m_applying = false, m_faultsLoaded = false;
+ QLabel* m_identity;
+ QLabel* m_availability;
+ QLabel* m_status;
+ QLabel* m_preview;
+ QLabel* m_memorySummary;
+ QComboBox* m_eraseBand;
+ QComboBox* m_eraseAntenna;
+ QTableWidget* m_configuration;
+ QTableWidget* m_faults;
+ QTableWidget* m_memories;
+ QCheckBox* m_restoreConfirm;
+ QCheckBox* m_eraseConfirm;
+ QCheckBox* m_resetConfirm;
+ QSpinBox* m_memoryFrequency;
+ QProgressBar* m_progress;
+};
+} // namespace AetherSDR
diff --git a/src/gui/Kxpa100Applet.cpp b/src/gui/Kxpa100Applet.cpp
new file mode 100644
index 0000000000..bec737d2f9
--- /dev/null
+++ b/src/gui/Kxpa100Applet.cpp
@@ -0,0 +1,1013 @@
+#include "Kxpa100Applet.h"
+#include "CanonIndicators.h"
+#include "CanonWindow.h"
+#include "HGauge.h"
+#include "core/AppSettings.h"
+#include "core/ThemeManager.h"
+#include "models/Kxpa100Model.h"
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+namespace AetherSDR
+{
+class Kxpa100DebugOutput : public QPlainTextEdit
+{
+ public:
+ explicit Kxpa100DebugOutput(QWidget* parent) : QPlainTextEdit(parent)
+ {
+ connect(verticalScrollBar(), &QScrollBar::valueChanged, this, [this](int value) {
+ if (!m_updating) {
+ m_followLatest = value == verticalScrollBar()->maximum();
+ }
+ });
+ }
+ void appendDiagnostic(const QString& line)
+ {
+ const QScopedValueRollback guard(m_updating, true);
+ appendPlainText(line);
+ followLatest();
+ }
+ void showLatest(const QString& content)
+ {
+ const QScopedValueRollback guard(m_updating, true);
+ m_followLatest = true;
+ setPlainText(content);
+ followLatest();
+ }
+
+ protected:
+ void resizeEvent(QResizeEvent* event) override
+ {
+ const QScopedValueRollback guard(m_updating, true);
+ QPlainTextEdit::resizeEvent(event);
+ followLatest();
+ }
+ void showEvent(QShowEvent* event) override
+ {
+ const QScopedValueRollback guard(m_updating, true);
+ QPlainTextEdit::showEvent(event);
+ followLatest();
+ }
+
+ private:
+ void followLatest()
+ {
+ if (m_followLatest) {
+ verticalScrollBar()->setValue(verticalScrollBar()->maximum());
+ }
+ }
+ bool m_followLatest = true;
+ bool m_updating = false;
+};
+namespace
+{
+class HistoryGraphLabel : public QLabel
+{
+ public:
+ using QLabel::QLabel;
+ // The rendered pixmap must not become the layout's preferred size.
+ QSize sizeHint() const override { return QSize(220, 270); }
+ QSize minimumSizeHint() const override { return sizeHint(); }
+};
+void availability(QWidget* widget, const QString& reason)
+{
+ widget->setEnabled(reason.isEmpty());
+ widget->setToolTip(reason);
+ widget->setAccessibleDescription(reason);
+}
+QString flag(std::optional value)
+{
+ return value ? (*value ? QStringLiteral("ON") : QStringLiteral("OFF")) : QStringLiteral("unknown");
+}
+QString reading(std::optional value)
+{
+ return value ? QString::number(*value, 'f', 2) : QStringLiteral("—");
+}
+template
+void text(TextWidget* label, const QString& value)
+{
+ if (label->text() == value) {
+ return;
+ }
+ label->setText(value);
+ label->setAccessibleName(value);
+ QAccessibleEvent event(label, QAccessible::NameChanged);
+ QAccessible::updateAccessibility(&event);
+}
+template
+void pill(TextWidget* label, const QString& value, const QString& detail, const char* tone)
+{
+ text(label, value);
+ label->setToolTip(detail);
+ label->setAccessibleDescription(detail);
+ const QString state = QString::fromLatin1(tone);
+ if (label->property("kxTone").toString() != state) {
+ label->setProperty("kxTone", state);
+ label->style()->unpolish(label);
+ label->style()->polish(label);
+ label->update();
+ }
+}
+QString checkRules()
+{
+ auto& theme = ThemeManager::instance();
+ QString rules = canonIndicatorRules();
+ for (bool enabled : {true, false}) {
+ for (bool checked : {false, true}) {
+ const bool active = enabled && checked;
+ const QColor fill = theme.color(active ? "color.accessory.key.meffa.active.background"
+ : "color.canon.control");
+ const QColor edge = theme.color(enabled ? "color.canon.inkSoft" : "color.canon.muted");
+ const QColor ink = theme.color(active ? "color.accessory.key.meffa.active.foreground"
+ : "color.canon.muted");
+ const QString key = fill.name(QColor::HexArgb) + edge.name(QColor::HexArgb) +
+ ink.name(QColor::HexArgb);
+ const QString path = themedImagePath(
+ "kx-checks", checked ? "checked" : "unchecked", key, QSize(18, 18), 3,
+ [fill, edge, ink, checked](QPainter& painter) {
+ painter.setPen(QPen(edge, 1.5));
+ painter.setBrush(fill);
+ painter.drawRoundedRect(QRectF(1.5, 1.5, 15, 15), 3, 3);
+ if (checked) {
+ QPainterPath tick;
+ tick.moveTo(5, 9);
+ tick.lineTo(8, 12);
+ tick.lineTo(13, 6);
+ painter.setBrush(Qt::NoBrush);
+ painter.setPen(QPen(ink, 2, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin));
+ painter.drawPath(tick);
+ }
+ });
+ if (!path.isEmpty()) {
+ rules += QStringLiteral("QCheckBox::indicator:%1:%2 { image: url(\"%3\"); }")
+ .arg(checked ? "checked" : "unchecked", enabled ? "enabled" : "disabled", path);
+ }
+ }
+ }
+ return rules;
+}
+QJsonObject preferences()
+{
+ return QJsonDocument::fromJson(AppSettings::instance().value("Kxpa100Applet").toString().toUtf8())
+ .object();
+}
+} // namespace
+Kxpa100Applet::Kxpa100Applet(QWidget* parent) : CanonApplet(parent)
+{
+ setObjectName("Kxpa100Applet");
+ setAccessibleName(tr("Elecraft KXPA100 amplifier and tuner"));
+ setCloseButtonVisible(false);
+ theme::setContainer(this, QStringLiteral("canon/kxpa100"));
+ setMinimumWidth(238);
+ m_clock.start();
+ const QJsonObject prefs = preferences();
+ auto* body = new QVBoxLayout(bodyWidget());
+ body->setContentsMargins(0, 0, 0, 0);
+ m_scroll = new QScrollArea(bodyWidget());
+ m_scroll->setObjectName("kxScroll");
+ m_scroll->setAccessibleName(tr("Tuner controls and telemetry"));
+ m_scroll->setWidgetResizable(true);
+ m_scroll->setFrameShape(QFrame::NoFrame);
+ m_scroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
+ m_content = new QWidget;
+ m_content->setObjectName("kxContent");
+ m_scroll->viewport()->setObjectName("kxViewport");
+ m_scroll->setWidget(m_content);
+ body->addWidget(m_scroll);
+ auto* root = new QVBoxLayout(m_content);
+ root->setContentsMargins(4, 2, 4, 2);
+ root->setSpacing(2);
+ root->setSizeConstraint(QLayout::SetMinAndMaxSize);
+ const auto button = [this](const QString& label, const QString& name, QLayout* layout) {
+ auto* b = new QPushButton(label, this);
+ b->setObjectName(name);
+ b->setAccessibleName(label);
+ b->setAutoDefault(false);
+ b->setFixedHeight(22);
+ layout->addWidget(b);
+ return b;
+ };
+ const auto label = [this](const QString& name, QLayout* layout) {
+ auto* l = new QLabel(this);
+ l->setObjectName(name);
+ l->setWordWrap(true);
+ l->setTextFormat(Qt::PlainText);
+ l->setAccessibleName(name);
+ layout->addWidget(l);
+ return l;
+ };
+ const auto statusPill = [label](const QString& name, QLayout* layout) {
+ QLabel* cell = label(name, layout);
+ cell->setProperty("kxPill", true);
+ cell->setWordWrap(false);
+ cell->setAlignment(Qt::AlignCenter);
+ cell->setFixedHeight(18);
+ return cell;
+ };
+ auto* header = new QHBoxLayout;
+ header->setSpacing(4);
+ m_power = button(tr("—"), "kxPower", header);
+ m_power->setCheckable(true);
+ m_power->setFixedWidth(44);
+ m_frequency = statusPill("kxFrequency", header);
+ header->setStretch(1, 1);
+ m_mini = button(tr("Mini"), "kxMini", header);
+ m_mini->setCheckable(true);
+ m_mini->setFixedWidth(38);
+ root->addLayout(header);
+ m_paPane = new QWidget(this);
+ auto* pa = new QHBoxLayout(m_paPane);
+ pa->setContentsMargins(0,0,0,0); pa->setSpacing(2);
+ auto* standby = button(tr("Standby"), "kxStandby", pa);
+ auto* operate = button(tr("Operate"), "kxOperate", pa);
+ standby->setCheckable(true); operate->setCheckable(true);
+ connect(standby,&QPushButton::clicked,this,[this] { if (m_model) { m_model->setOperate(false); } refresh(); });
+ connect(operate,&QPushButton::clicked,this,[this] { if (m_model) { m_model->setOperate(true); } refresh(); });
+ root->addWidget(m_paPane);
+ auto* tuning = new QHBoxLayout;
+ tuning->setSpacing(4);
+ m_tune = button(tr("Tune"), "kxTune", tuning);
+ m_tune->setProperty("aetherTxKeying", true);
+ m_tune->setFixedWidth(60);
+ m_fault = statusPill("kxFault", tuning);
+ tuning->setStretch(1, 1);
+ auto* clear = button(tr("Clear fault"), "kxClearFault", tuning);
+ clear->setFixedWidth(72);
+ root->addLayout(tuning);
+ m_miniState = statusPill("kxMiniState", root);
+ m_modesPane = new QWidget(this);
+ auto* modes = new QHBoxLayout(m_modesPane);
+ modes->setContentsMargins(0, 0, 0, 0);
+ modes->setSpacing(2);
+ const QStringList names{tr("Auto"), tr("Manual"), tr("Bypass")};
+ for (int i = 0; i < 3; ++i) {
+ m_modes[i] = button(names[i], "kx" + names[i], modes);
+ m_modes[i]->setCheckable(true);
+ connect(m_modes[i], &QPushButton::clicked, this, [this, i] {
+ if (m_model) {
+ m_model->setMode(static_cast(i));
+ }
+ refresh();
+ });
+ }
+ root->addWidget(m_modesPane);
+ m_antennasPane = new QWidget(this);
+ auto* ants = new QHBoxLayout(m_antennasPane);
+ ants->setContentsMargins(0, 0, 0, 0);
+ ants->setSpacing(2);
+ for (int i = 0; i < 2; ++i) {
+ m_antennas[i] = button(tr("ANT %1").arg(i + 1), QStringLiteral("kxAntenna%1").arg(i + 1), ants);
+ m_antennas[i]->setCheckable(true);
+ connect(m_antennas[i], &QPushButton::clicked, this, [this, i] {
+ if (m_model) {
+ m_model->setAntenna(i + 1);
+ }
+ refresh();
+ });
+ }
+ root->addWidget(m_antennasPane);
+ const auto meter = [this, statusPill](int index, const QString& name, float maximum, QVBoxLayout* layout) {
+ auto* row = new QHBoxLayout; row->setSpacing(4);
+ m_meterLabels[index] = statusPill(QStringLiteral("kxMeterLabel%1").arg(index), row);
+ m_meterLabels[index]->setFixedWidth(72);
+ QVector ticks;
+ for (int step = 0; step <= 20; ++step) {
+ const float value = maximum * step / 20;
+ ticks.append({value, step % 5 == 0 ? QString::number(value,'g',3) : QString()});
+ }
+ m_meters[index] = new HGauge(0,maximum,maximum*0.9f,"","",ticks,this,maximum*0.75f);
+ m_meters[index]->setObjectName(name); m_meters[index]->setAccessibleName(name);
+ m_meters[index]->setFixedHeight(24); row->addWidget(m_meters[index],1); layout->addLayout(row);
+ };
+ meter(0,"kxForwardGauge",100,root);
+ auto* swrRow = new QHBoxLayout;
+ m_swr = button(tr("SWR —"), "kxSwr", swrRow);
+ m_swr->setProperty("kxPill", true);
+ m_swr->setFixedHeight(18);
+ m_swr->installEventFilter(this);
+ connect(m_swr, &QPushButton::pressed, this, &Kxpa100Applet::refresh);
+ connect(m_swr, &QPushButton::released, this, &Kxpa100Applet::refresh);
+ m_swr->setFixedWidth(72);
+ swrRow->setSpacing(4);
+ QVector swrTicks;
+ for (int step = 10; step <= 50; ++step) {
+ const float value = step / 10.0f;
+ swrTicks.append({value, step % 10 == 0 ? QString::number(value) : QString()});
+ }
+ m_swrGauge = new HGauge(1, 5, 3.0f, "", "", swrTicks, this, 2.0f);
+ m_swrGauge->setFixedHeight(24);
+ m_swrGauge->setObjectName("kxSwrGauge");
+ swrRow->addWidget(m_swrGauge, 1);
+ root->addLayout(swrRow);
+ m_auxPane = new QWidget(this);
+ auto* auxiliary = new QVBoxLayout(m_auxPane); auxiliary->setContentsMargins(0,0,0,0); auxiliary->setSpacing(2);
+ meter(1,"kxInputGauge",10,auxiliary); meter(2,"kxReflectedGauge",20,auxiliary);
+ meter(3,"kxDissipatedGauge",100,auxiliary); meter(4,"kxTemperatureGauge",100,auxiliary);
+ meter(5,"kxSupplyGauge",16,auxiliary); meter(6,"kxCurrentGauge",25,auxiliary);
+ root->addWidget(m_auxPane);
+ m_physicalPane = new QWidget(this);
+ m_physicalPane->setObjectName("kxPhysicalState");
+ auto* states = new QHBoxLayout(m_physicalPane);
+ states->setContentsMargins(0, 0, 0, 0);
+ states->setSpacing(3);
+ const QStringList physicalNames{"kxBypassedStatus", "kxAttenuatorStatus"};
+ for (int i = 0; i < 2; ++i) {
+ m_physical[i] = button(QString(), physicalNames[i], states);
+ m_physical[i]->setProperty("kxPill", true);
+ m_physical[i]->setProperty("kxSignalToggle", true);
+ m_physical[i]->setCheckable(true);
+ m_physical[i]->setFixedHeight(18);
+ m_physical[i]->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Fixed);
+ states->setStretch(i, 1);
+ connect(m_physical[i], &QPushButton::clicked, this, [this, i] {
+ if (m_model) {
+ const auto& status = m_model->status();
+ if (i == 0 && status.bypassed) {
+ m_model->setBypassed(!*status.bypassed);
+ } else if (i == 1 && status.attenuator) {
+ m_model->setAttenuator(!*status.attenuator);
+
+ }
+ }
+ refresh();
+ });
+ }
+ m_readiness = statusPill("kxReadiness", states);
+ root->addWidget(m_physicalPane);
+ auto* tools = new QHBoxLayout;
+ tools->setSpacing(3);
+ m_manualToggle = button(tr("▸ Manual"), "kxManualToggle", tools);
+ m_manualToggle->setAccessibleName(tr("Manual controls"));
+ m_manualToggle->setCheckable(true);
+ m_manualPane = new QWidget(this);
+ m_manualPane->setObjectName("kxManualPane");
+ auto* manual = new QVBoxLayout(m_manualPane);
+ manual->setContentsMargins(0, 0, 0, 0);
+ auto* explanation = label("kxManualHelp", manual);
+ explanation->setText(
+ tr("Relay and frequency requests change the signal path. Controls show confirmed readback."));
+ for (int kind = 0; kind < 2; ++kind) {
+ auto* row = new QHBoxLayout;
+ row->addWidget(new QLabel(kind == 0 ? tr("C (pF)") : tr("L (nH)"), this));
+ auto* numeric = new QSpinBox(this);
+ numeric->setObjectName(kind == 0 ? "kxCapacitance" : "kxInductance");
+ numeric->setAccessibleName(kind == 0 ? tr("Capacitance in picofarads")
+ : tr("Inductance in nanohenries"));
+ numeric->setRange(0, Kxpa100::relayTotal(kind == 0, 255));
+ row->addWidget(numeric, 1);
+ if (kind == 0) {
+ m_cap = numeric;
+ } else {
+ m_ind = numeric;
+ }
+ auto* request = button(tr("Set"), kind == 0 ? "kxSetCapacitance" : "kxSetInductance", row);
+ manual->addLayout(row);
+ connect(request, &QPushButton::clicked, this, [this, numeric, kind] {
+ if (m_model) {
+ const int mask = Kxpa100::relayMaskForValue(kind == 0, numeric->value());
+ if (kind == 0) {
+ m_model->setCapacitorMask(mask);
+ } else {
+ m_model->setInductorMask(mask);
+ }
+ }
+ refresh();
+ });
+ auto* bits = new QGridLayout;
+ bits->setHorizontalSpacing(2);
+ for (int position = 0; position < 8; ++position) {
+ const int bit = 7 - position;
+ const int value = Kxpa100::relayTotal(kind == 0, 1 << bit);
+ auto* check = new QCheckBox(QString::number(value), this);
+ check->setProperty("kxRelay", true);
+ check->setObjectName(QStringLiteral("kx%1Bit%2").arg(kind == 0 ? "C" : "L").arg(bit));
+ check->setAccessibleName(
+ (kind == 0 ? tr("Capacitor %1 picofarads, relay bit %2")
+ : tr("Inductor %1 nanohenries, relay bit %2")).arg(value).arg(bit));
+ bits->addWidget(check, position / 4, position % 4);
+ (kind == 0 ? m_capBits : m_indBits)[bit] = check;
+ connect(check, &QCheckBox::clicked, this, [this, kind, bit](bool on) {
+ if (m_model) {
+ const auto value =
+ kind == 0 ? m_model->status().capacitorMask : m_model->status().inductorMask;
+ if (value) {
+ const int mask = on ? (*value | (1 << bit)) : (*value & ~(1 << bit));
+ if (kind == 0) {
+ m_model->setCapacitorMask(mask);
+ } else {
+ m_model->setInductorMask(mask);
+ }
+ }
+ }
+ refresh();
+ });
+ }
+ manual->addLayout(bits);
+ }
+ auto* frequencyRow = new QHBoxLayout;
+ m_frequencyRecall = new QSpinBox(this);
+ m_frequencyRecall->setObjectName("kxRecallFrequency");
+ m_frequencyRecall->setAccessibleName(tr("Memory frequency in kHz"));
+ m_frequencyRecall->setRange(0, 99999);
+ m_frequencyRecall->setSpecialValueText(tr("Select frequency"));
+ m_frequencyRecall->setToolTip(tr("Starts at the confirmed tuner frequency. Enter 1800–54000 kHz to choose a different memory; edits are preserved."));
+ m_frequencyRecall->setAccessibleDescription(m_frequencyRecall->toolTip());
+ connect(m_frequencyRecall, &QSpinBox::valueChanged, this, [this] {
+ m_frequencyEdited = true;
+ refresh();
+ });
+ m_frequencyRecall->setSuffix(tr(" kHz"));
+ auto* memoryRow = new QHBoxLayout;
+ auto* memoryLabel = new QLabel(tr("Match frequency"), this);
+ memoryLabel->setObjectName("kxRecallFrequencyLabel");
+ memoryLabel->setBuddy(m_frequencyRecall);
+ memoryRow->addWidget(memoryLabel);
+ memoryRow->addWidget(m_frequencyRecall, 1);
+ manual->addLayout(memoryRow);
+ auto* recall = button(tr("Recall"), "kxRecall", frequencyRow);
+ auto* memorize = button(tr("Memorize"), "kxMemorize", frequencyRow);
+ manual->addLayout(frequencyRow);
+ connect(recall, &QPushButton::clicked, this, [this] {
+ if (m_model) {
+ m_model->recallFrequency(m_frequencyRecall->value());
+ }
+ });
+ connect(memorize, &QPushButton::clicked, this, [this] {
+ if (m_model) {
+ m_model->memorize(m_frequencyRecall->value());
+ }
+ });
+ m_historyToggle = button(tr("▸ History"), "kxHistory", tools);
+ m_settings = button(tr("▸ Settings"), "kxSettings", tools);
+ root->addLayout(tools);
+ root->addWidget(m_manualPane);
+ m_historyToggle->setCheckable(true);
+ m_settings->setCheckable(true);
+ m_historyPane = new QWidget(this);
+ auto* history = new QVBoxLayout(m_historyPane);
+ history->setContentsMargins(0, 0, 0, 0);
+ m_historyWindow = new QComboBox(this);
+ m_historyWindow->setAccessibleName(tr("History time window"));
+ m_historyWindow->addItem(tr("Last minute"),60000); m_historyWindow->addItem(tr("Last 5 minutes"),300000);
+ m_historyWindow->addItem(tr("Last 15 minutes"),900000); m_historyWindow->setCurrentIndex(1);
+ history->addWidget(m_historyWindow);
+ connect(m_historyWindow,&QComboBox::currentIndexChanged,this,&Kxpa100Applet::renderHistory);
+ m_graph = new HistoryGraphLabel(this);
+ m_graph->setObjectName("kxHistoryGraph");
+ m_graph->setAccessibleName(tr("SWR and forward/reflected power history"));
+ history->addWidget(m_graph);
+ m_graph->setMinimumHeight(270);
+ m_graph->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Expanding);
+ m_graph->installEventFilter(this);
+ root->addWidget(m_historyPane, 1);
+ m_settingsPane = new QWidget(this);
+ auto* settings = new QVBoxLayout(m_settingsPane);
+ settings->setContentsMargins(0, 0, 0, 0);
+ m_association = label("kxRadioAssociation",settings);
+ m_association->setText(tr("No associated radio. Select a radio, then associate this amplifier."));
+ m_associate = button(tr("Use with current radio"),"kxAssociateRadio",settings);
+ availability(m_associate,tr("Select a radio first."));
+ connect(m_associate,&QPushButton::clicked,this,&Kxpa100Applet::associateRadioRequested);
+ auto* signalPathTitle = label("kxSignalPathTitle", manual);
+ signalPathTitle->setText(tr("Signal path"));
+ auto* signalPath = new QVBoxLayout;
+ signalPath->setSpacing(2);
+ manual->addLayout(signalPath);
+ m_side = new QCheckBox(tr("Capacitor on antenna side"), this);
+ m_side->setObjectName("kxCapacitorSide");
+ signalPath->addWidget(m_side);
+ connect(m_side, &QCheckBox::clicked, this, [this](bool on) {
+ if (m_model) {
+ m_model->setCapacitorSide(on ? Kxpa100::CapacitorSide::Antenna
+ : Kxpa100::CapacitorSide::Transmitter);
+ }
+ refresh();
+ });
+ m_identity = label("kxIdentity", settings);
+ m_observed = label("kxLastObserved", settings);
+ m_availability = label("kxAvailability", settings);
+ m_result = label("kxOperationResult", settings);
+ auto* help = label("kxHistoryHelp", settings);
+ help->setText(tr("History shows measured forward/reflected watts and last observed SWR. Gaps mean missing replies. "
+ "Hold the SWR pill with the mouse to compare bypass SWR; release to return. "
+ "Last SWR and RF-counter frequency can remain after RF stops. Debug TX means serial writes, not RF."));
+ auto* advanced = button(tr("Open Advanced settings"), "kxAdvancedSettings", settings);
+ connect(advanced, &QPushButton::clicked, this, &Kxpa100Applet::advancedSettingsRequested);
+ m_debugToggle = button(tr("▸ Debug"), "kxDebug", settings);
+ m_debugToggle->setCheckable(true);
+ m_debugPane = new QWidget(this);
+ auto* debug = new QVBoxLayout(m_debugPane);
+ debug->setContentsMargins(0, 0, 0, 0);
+ m_verbose = new QCheckBox(tr("Verbose"), this);
+ m_verbose->setObjectName("kxVerbose");
+ m_verbose->setChecked(prefs.value("Verbose").toBool(false));
+ debug->addWidget(m_verbose);
+ auto* debugActions = new QHBoxLayout;
+ m_pause = new QCheckBox(tr("Pause display"), this);
+ debug->addWidget(m_pause);
+ auto* copy = button(tr("Copy"), "kxDebugCopy", debugActions);
+ auto* save = button(tr("Save…"), "kxDebugSave", debugActions);
+ auto* clearDebug = button(tr("Clear"), "kxDebugClear", debugActions);
+ debug->addLayout(debugActions);
+ m_debug = new Kxpa100DebugOutput(this);
+ m_debug->setObjectName("kxDebugOutput");
+ m_debug->setAccessibleName(tr("Tuner protocol diagnostics"));
+ m_debug->setReadOnly(true);
+ m_debug->setLineWrapMode(QPlainTextEdit::NoWrap);
+ m_debug->setMaximumBlockCount(1000);
+ m_debug->setFixedHeight(160);
+ debug->addWidget(m_debug);
+ settings->addWidget(m_debugPane);
+ root->addWidget(m_settingsPane);
+ root->addStretch();
+ connect(m_power, &QPushButton::clicked, this, [this] {
+ if (m_model && m_model->status().operate) {
+ m_model->setOperate(!*m_model->status().operate);
+ }
+ refresh();
+ });
+ connect(m_tune, &QPushButton::clicked, this, [this] {
+ if (m_model) {
+ if (m_model->status().tuning.value_or(false)) {
+ m_model->cancelTune();
+ } else {
+ m_model->startTune();
+ }
+ }
+ refresh();
+ });
+ connect(clear, &QPushButton::clicked, this, [this] {
+ if (m_model) {
+ m_model->clearFault();
+ }
+ });
+ for (auto* b : {m_mini, m_manualToggle, m_settings, m_historyToggle, m_debugToggle}) {
+ connect(b, &QPushButton::toggled, this, [this] {
+ savePreferences();
+ presentation();
+ });
+ }
+ connect(m_verbose, &QCheckBox::toggled, this, [this](bool on) {
+ if (m_model) {
+ m_model->setVerbose(on);
+ }
+ savePreferences();
+ });
+ connect(m_pause, &QCheckBox::toggled, this, [this](bool paused) {
+ if (!paused && m_model) {
+ m_debug->showLatest(m_model->debugText());
+ }
+ });
+ connect(copy, &QPushButton::clicked, this,
+ [this] { QApplication::clipboard()->setText(m_debug->toPlainText()); });
+ connect(save, &QPushButton::clicked, this, &Kxpa100Applet::saveDebug);
+ connect(clearDebug, &QPushButton::clicked, this, [this] {
+ if (m_model) {
+ m_model->clearDebug();
+ }
+ });
+ m_mini->setChecked(prefs.value("MiniMode").toBool(false));
+ m_historyToggle->setChecked(prefs.value("HistoryVisible").toBool(false));
+ m_timer.setInterval(500);
+ connect(&m_timer, &QTimer::timeout, this, &Kxpa100Applet::refresh);
+ m_timer.start();
+ applyCanonSheet(bodyWidget(), [this] {
+ for (auto* meter : m_meters) { meter->setTickMarkColor(ThemeManager::instance().color(this,"color.canon.muted")); }
+ m_swrGauge->setTickMarkColor(ThemeManager::instance().color(this, "color.canon.muted"));
+ return checkRules() + QStringLiteral(
+ "QWidget#kxContent, QWidget#kxViewport, QScrollArea#kxScroll { background: {{color.canon.ground}}; border: none; }"
+ "QLabel, QCheckBox { color: {{color.canon.muted}}; font-size: 10px; }"
+ "QCheckBox[kxRelay=true] { spacing: 2px; }"
+ "QLabel[kxPill=true] { font-size: 9px; font-weight: bold; background: {{color.canon.nested}}; "
+ "border: 1px solid {{color.canon.lineHi}}; border-radius: 3px; padding: 0 2px; }"
+ "QLabel[kxTone=ok] { color: {{color.accent.success}}; }"
+ "QLabel[kxTone=warn] { color: {{color.accent.warning}}; }"
+ "QLabel[kxTone=error] { color: {{color.accent.danger}}; }"
+ "QPushButton { padding: 0 4px; font-size: 10px; font-weight: bold; "
+ "color: {{color.canon.inkSoft}}; background: {{color.canon.control}}; "
+ "border-radius: 3px; border: 1px solid {{color.canon.lineHi}}; } "
+ "QPushButton:hover { background: {{color.canon.nested}}; color: {{color.canon.aqua}}; } "
+ "QSpinBox { font-size: 10px; min-height: 18px; color: {{color.canon.inkSoft}}; "
+ "background: {{color.canon.nested}}; border: 1px solid {{color.canon.inkSoft}}; border-radius: 3px; } "
+ "QSpinBox:focus { border-color: {{color.canon.cyan}}; } "
+ "QSpinBox:disabled { color: {{color.canon.muted}}; border-color: {{color.canon.muted}}; } "
+ "QPushButton[kxPill=true] { font-size: 9px; padding: 0 2px; color: {{color.canon.muted}}; "
+ "background: {{color.canon.ground}}; border-color: {{color.canon.line}}; } "
+ "QPushButton[kxPill=true]:focus { border-color: {{color.canon.cyan}}; } "
+ "QPushButton[kxTone=warn] { color: {{color.accent.warning}}; } "
+ "QPushButton[kxTone=live] { color: {{color.canon.inkSoft}}; } "
+ "QPlainTextEdit { font-size: 10px; font-family: monospace; color: {{color.canon.inkSoft}}; } "
+ "QPushButton:checked:enabled { background: "
+ "{{color.accessory.key.meffa.active.background}}; color: "
+ "{{color.accessory.key.meffa.active.foreground}}; } QPushButton#kxBypass:checked:enabled { "
+ "background: {{color.accessory.key.standby.background}}; color: "
+ "{{color.accessory.key.standby.foreground}}; } QPushButton:disabled { color: "
+ "{{color.text.secondary}}; background: {{color.background.2}}; } "
+ "QPushButton:checked:disabled { background: {{color.accessory.key.meffa.active.background}}; "
+ "color: {{color.text.secondary}}; border-color: {{color.accessory.key.meffa.active.border}}; } "
+ "QPushButton#kxBypass:checked:disabled { background: {{color.accessory.key.standby.background}}; "
+ "color: {{color.text.secondary}}; border-color: {{color.accessory.key.standby.foreground}}; }"
+ "QPushButton[kxSignalToggle=true] { background: {{color.canon.nested}}; border-color: {{color.canon.lineHi}}; }"
+ "QPushButton[kxSignalToggle=true]:checked { background: {{color.canon.nested}}; "
+ "color: {{color.accent.warning}}; border-color: {{color.canon.lineHi}}; }"
+ "QPushButton[kxSignalToggle=true]:hover:enabled { border-color: {{color.canon.cyan}}; }"
+ "QPushButton[kxSignalToggle=true]:disabled { color: {{color.canon.muted}}; "
+ "background: {{color.canon.nested}}; border-color: {{color.canon.line}}; }");
+ });
+ presentation();
+ refresh();
+}
+Kxpa100Applet::~Kxpa100Applet()
+{
+ if (m_fileDialog) {
+ QFileDialog* dialog = m_fileDialog.data();
+ m_fileDialog.clear();
+ disconnect(dialog, nullptr, this, nullptr);
+ dialog->reject();
+ // Let the native sheet finish closing before QObject destruction.
+ dialog->setParent(nullptr);
+ dialog->deleteLater();
+ }
+}
+void Kxpa100Applet::setModel(Kxpa100Model* model)
+{
+ if (m_model) {
+ disconnect(m_model, nullptr, this, nullptr);
+ }
+ m_model = model;
+ if (!m_pause->isChecked()) {
+ m_debug->showLatest(model ? model->debugText() : QString());
+ }
+ m_frequencyEdited = false;
+ { const QSignalBlocker blocker(m_frequencyRecall); m_frequencyRecall->setValue(0); }
+ if (model) {
+ model->setVerbose(m_verbose->isChecked());
+ connect(model, &Kxpa100Model::statusUpdated, this, &Kxpa100Applet::refresh);
+ connect(model, &Kxpa100Model::operationFinished, this,
+ [this](const QString& message) { text(m_result, message); });
+ connect(model, &Kxpa100Model::operationFailed, this,
+ [this](const QString& message) { text(m_result, message); });
+ connect(model, &Kxpa100Model::connectionFailed, this,
+ [this](const QString& message) { text(m_result, message); });
+ connect(model, &Kxpa100Model::availabilityChanged, this, &Kxpa100Applet::refresh);
+ connect(model, &Kxpa100Model::configurationRead, this, &Kxpa100Applet::refresh);
+ connect(model, &Kxpa100Model::diagnostic, this, [this](const QString& line) {
+ if (!m_pause->isChecked()) {
+ m_debug->appendDiagnostic(line);
+ }
+ });
+ connect(model, &Kxpa100Model::debugChanged, this, [this] {
+ if (m_model && !m_pause->isChecked() && m_model->debugText().isEmpty()) {
+ m_debug->showLatest(QString());
+ }
+ });
+ connect(model, &Kxpa100Model::telemetryReceived, this,
+ [this](const QString& command, const Kxpa100::Status& state) {
+ const int i = command == "^SW" ? 0 : command == "^PF" ? 1 : command == "^PV" ? 2 : -1;
+ if (i < 0) {
+ return;
+ }
+ std::optional value;
+ if (i == 0) {
+ value = state.swr;
+ } else {
+ const auto count = i == 1 ? state.forwardWatts : state.reflectedWatts;
+ if (count) {
+ value = *count;
+ }
+ }
+ m_history[i].push_back({m_clock.elapsed(), value});
+ while (m_history[i].size() > 4096) {
+ m_history[i].pop_front();
+ }
+ });
+ connect(model, &Kxpa100Model::disconnected, this, [this] {
+ for (auto& series : m_history) {
+ series.push_back({m_clock.elapsed(), std::nullopt});
+ }
+ refresh();
+ });
+ }
+ refresh();
+}
+void Kxpa100Applet::setRadioContext(const QString& radioName, int txFrequencyKhz, bool associated)
+{
+ m_txFrequencyKhz = txFrequencyKhz; m_associated = associated;
+ text(m_association,radioName.isEmpty() ? tr("No radio selected.") : associated
+ ? tr("Associated with %1. VU/PWR metering and frequency context apply only to this radio.").arg(radioName)
+ : tr("Selected radio: %1. Associate it for VU/PWR metering and frequency context.").arg(radioName));
+ availability(m_associate,radioName.isEmpty() ? tr("Select a radio first.") : QString());
+ m_associate->setText(associated ? tr("Remove radio association") : tr("Use with current radio"));
+ refresh();
+}
+void Kxpa100Applet::setAssociationResult(bool saved)
+{
+ text(m_result, saved ? tr("Radio association updated.")
+ : tr("Radio association was not saved. Connect an identified radio and KXPA100, and check that settings are writable. The previous association is unchanged."));
+}
+void Kxpa100Applet::refresh()
+{
+ const Kxpa100::Status s = m_model ? m_model->status() : Kxpa100::Status{};
+ const QString reason = m_model ? m_model->unavailableReason(true) : tr("Connect a KXPA100 first.");
+ const QString tunerReason = m_model ? m_model->unavailableReason(true,true) : reason;
+ const bool responding = m_model && m_model->isResponding();
+ const bool tuning = s.tuning == true;
+ const auto fresh = [this](const char* key) { const auto age=m_model ? m_model->fieldAgeMs(key) : -1; return age>=0 && age<=5000; };
+ const bool rf = responding && s.forwardWatts.value_or(0)>0 && fresh("^PF");
+ const int frequency = m_associated && m_txFrequencyKhz>0 ? m_txFrequencyKhz : s.frequencyKhz.value_or(0);
+ text(m_frequency,tr("%1 · %2").arg(s.band ? Kxpa100::bandName(*s.band) : tr("Band —"),
+ frequency>0 ? tr("%1 kHz").arg(frequency) : tr("Freq —")));
+ m_frequency->setToolTip(m_associated ? tr("Associated radio TX frequency; band is device readback.") : tr("Last RF-counter frequency; not an indication of current transmission."));
+ m_frequency->setAccessibleDescription(m_frequency->toolTip());
+ text(m_power,s.operate ? (*s.operate ? tr("OPR") : tr("STBY")) : tr("PA ?"));
+ m_power->setChecked(s.operate==true); availability(m_power,reason);
+ m_power->setAccessibleName(tr("PA operate/standby, confirmed %1").arg(m_power->text()));
+ for (const bool operate : {false,true}) {
+ auto* control=findChild(operate ? "kxOperate" : "kxStandby");
+ control->setChecked(s.operate && *s.operate==operate); availability(control,reason);
+ }
+ text(m_tune,tuning ? tr("Cancel") : tr("Tune"));
+ availability(m_tune,tuning && responding && !m_model->operationBusy() ? QString() : tunerReason);
+ for (int i=0;i<3;++i) {
+ m_modes[i]->setChecked(s.mode && static_cast(*s.mode)==i); availability(m_modes[i],tunerReason);
+ }
+ for (int i=0;i<2;++i) {
+ m_antennas[i]->setChecked(s.antenna==i+1);
+ QString blocked=tunerReason;
+ if (blocked.isEmpty() && m_model->configuration() && s.band && !((*m_model->configuration())[*s.band] & (1<("kxClearFault"),reason);
+ const QString fault=s.faultCode ? Kxpa100::currentFaultName(*s.faultCode) : tr("Fault unknown");
+ pill(m_fault,s.faultCode ? (*s.faultCode=='N' ? tr("NO FAULT") : tr("FAULT %1").arg(QChar(*s.faultCode))) : tr("FAULT ?"),
+ fault,s.faultCode && *s.faultCode=='N' ? "ok" : "error");
+ const std::array,7> values{s.forwardWatts,s.inputWatts,s.reflectedWatts,s.dissipatedWatts,s.temperatureC,s.supplyVolts,s.drainAmps};
+ const QStringList names{tr("OUT"),tr("DRV"),tr("REFL"),tr("DISS"),tr("TEMP"),tr("SUPPLY"),tr("DRAIN")};
+ const QStringList units{tr("W"),tr("W"),tr("W"),tr("W"),tr("°C"),tr("V"),tr("A")};
+ for (int i=0;i<7;++i) {
+ const QString value=values[i] ? QString::number(*values[i],'f',1) : QStringLiteral("—");
+ const QString label=tr("%1 %2%3").arg(names[i],value,units[i]);
+ pill(m_meterLabels[i],label,tr("KXPA100 %1: %2 %3. %4").arg(names[i],value,units[i],responding ? QString() : tr("Readback is stale.")),"muted");
+ m_meters[i]->setValue(values[i].value_or(0)); m_meters[i]->setAccessibleName(label);
+ if (i == 1) {
+ const QString source = tr("DRV: input power measured by the KXPA100, in watts. Scale 0–10 W.");
+ const QString description = source + " " + m_meterLabels[i]->accessibleDescription();
+ m_meterLabels[i]->setToolTip(description);
+ m_meterLabels[i]->setAccessibleDescription(description);
+ m_meters[i]->setToolTip(description);
+ m_meters[i]->setAccessibleDescription(description);
+ }
+ }
+ const bool preview=m_swr->isDown(); const auto swr=preview ? s.bypassSwr : s.swr;
+ const QString swrText=tr("%1 %2").arg(preview ? tr("BYP") : tr("SWR"),reading(swr));
+ const QString swrHelp=tr("Last observed %1 SWR. Hold with the mouse to preview bypass SWR; release to return. "
+ "This changes the display only. SWR may remain after RF stops. Scale 1–5; the pill preserves values above 5.").arg(preview ? tr("bypass") : tr("adjusted"));
+ pill(m_swr,swrText,swrHelp,preview ? "warn" : rf ? "live" : "muted");
+ if (preview!=m_showingBypass) { m_swrGauge->setValueImmediate(swr.value_or(1)); }
+ else { m_swrGauge->setValue(swr.value_or(1)); }
+ m_showingBypass=preview; m_swrGauge->setAccessibleName(swrText); m_swrGauge->setAccessibleDescription(swrHelp);
+ const std::array,2> physical{s.bypassed,s.attenuator ? std::optional(*s.attenuator!=0) : std::nullopt};
+ const QStringList descriptions{
+ tr("ATU matching-network bypass relay. Auto/Manual mode stays unchanged; a recall or tune may engage it again. Click to toggle."),
+ tr("Input attenuator. Firmware may engage it for protection. The rear-panel switch overrides software control. Click to toggle.")};
+ for (int i=0;i<2;++i) {
+ QString blocked=i==0 ? tunerReason : reason;
+ if (blocked.isEmpty() && !physical[i]) { blocked=tr("Wait for confirmed state."); }
+ if (i==1 && s.attenuator==2) { blocked=tr("The rear-panel switch holds the attenuator on."); }
+ m_physical[i]->setChecked(physical[i]==true); availability(m_physical[i],blocked);
+ pill(m_physical[i],tr("%1 %2").arg(i==0 ? tr("ATU BYP") : tr("ATT"),flag(physical[i])),
+ descriptions[i]+QStringLiteral("\n")+blocked,physical[i]==true ? "warn" : "muted");
+ }
+ const QString readiness=!m_model || !m_model->isDetected() ? tr("OFFLINE") : !responding ? tr("STALE") : tuning ? tr("TUNING") : rf ? tr("RF") : tr("IDLE");
+ pill(m_readiness,readiness,tr("RF is based on amplifier power readback, not radio PTT."),tuning || !responding ? "warn" : "ok");
+ const QStringList modes{tr("AUTO"),tr("MANUAL"),tr("BYPASS")};
+ const QString mode=s.tunerPresent==false ? tr("NO ATU") : s.mode ? modes[int(*s.mode)] : tr("ATU ?");
+ pill(m_miniState,tr("%1 · ANT %2 · %3").arg(mode,s.antenna ? QString::number(*s.antenna) : QStringLiteral("?"),
+ s.faultCode && *s.faultCode!='N' ? fault : readiness),tr("Tuner mode and antenna are readbacks. Physical ATU bypass: %1.").arg(flag(s.bypassed)),
+ s.mode==Kxpa100::Mode::Bypass || s.bypassed==true || !responding ? "warn" : "ok");
+ text(m_identity,m_model ? tr("%1\n%2 · Firmware %3 · S/N %4").arg(m_model->description(),s.identity,s.firmware,s.serial) : tr("No amplifier connected."));
+ text(m_availability,tr("%1\nPower / SWR reply age: %2 / %3 ms").arg(reason.isEmpty() ? tr("Ready for an operator request.") : reason)
+ .arg(m_model ? m_model->fieldAgeMs("^PF") : -1).arg(m_model ? m_model->fieldAgeMs("^SW") : -1));
+ text(m_observed,tr("Last RF frequency: %1\nAdjusted / bypass SWR: %2 / %3\nOptional tuner: %4")
+ .arg(s.frequencyKhz.value_or(0)>0 ? tr("%1 kHz").arg(*s.frequencyKhz) : tr("Unavailable"),reading(s.swr),reading(s.bypassSwr),flag(s.tunerPresent)));
+ for (auto* control : m_manualPane->findChildren()) {
+ if (qobject_cast(control) || qobject_cast(control) || qobject_cast(control)) { availability(control,tunerReason); }
+ }
+ if (!m_frequencyEdited) {
+ const QSignalBlocker blocker(m_frequencyRecall);
+ m_frequencyRecall->setValue(Kxpa100::isSupportedFrequency(frequency) ? frequency : 0);
+ }
+ const QString frequencyReason=!tunerReason.isEmpty() ? tunerReason : Kxpa100::isSupportedFrequency(m_frequencyRecall->value()) ? QString() : tr("Select 1800–54000 kHz before recall or save.");
+ availability(findChild("kxRecall"),frequencyReason); availability(findChild("kxMemorize"),frequencyReason);
+ for (int kind=0;kind<2;++kind) {
+ const auto mask=kind==0 ? s.capacitorMask : s.inductorMask; auto* numeric=kind==0 ? m_cap : m_ind;
+ const QSignalBlocker blocker(numeric);
+ if (mask && mask!=m_lastRelayMasks[kind]) { numeric->setValue(Kxpa100::relayTotal(kind==0,*mask)); }
+ m_lastRelayMasks[kind]=mask;
+ for (int bit=0;bit<8;++bit) {
+ auto* check=(kind==0 ? m_capBits : m_indBits)[bit]; const QSignalBlocker blocked(check);
+ check->setChecked(mask && (*mask & (1<setChecked(s.side==Kxpa100::CapacitorSide::Antenna); }
+ if (m_historyPane->isVisible()) { renderHistory(); }
+}
+void Kxpa100Applet::presentation()
+{
+ const QStringList titles{tr("Manual"), tr("History"), tr("Settings"), tr("Debug")};
+ const std::array drawers{m_manualToggle, m_historyToggle, m_settings, m_debugToggle};
+ for (int i = 0; i < 4; ++i) {
+ drawers[i]->setText(QStringLiteral("%1 %2").arg(drawers[i]->isChecked() ? "▾" : "▸", titles[i]));
+ }
+ const bool mini = m_mini->isChecked();
+ m_mini->setText(mini ? tr("Full") : tr("Mini"));
+ m_mini->setAccessibleName(mini ? tr("Show full tuner controls") : tr("Show mini tuner controls"));
+ m_paPane->setVisible(!mini);
+ m_power->setVisible(mini);
+ m_auxPane->setVisible(!mini);
+ m_modesPane->setVisible(!mini);
+ m_antennasPane->setVisible(!mini);
+ m_physicalPane->setVisible(!mini);
+ findChild("kxClearFault")->setVisible(!mini);
+ m_miniState->setVisible(mini);
+ m_manualToggle->setVisible(!mini);
+ m_manualPane->setVisible(!mini && m_manualToggle->isChecked());
+ m_settings->setVisible(!mini);
+ m_historyToggle->setVisible(!mini);
+ m_settingsPane->setVisible(!mini && m_settings->isChecked());
+ m_historyPane->setVisible(!mini && m_historyToggle->isChecked());
+ m_debugPane->setVisible(m_debugToggle->isChecked());
+ updateGeometry();
+}
+void Kxpa100Applet::savePreferences()
+{
+ QJsonObject p;
+ p["MiniMode"] = m_mini->isChecked();
+ p["HistoryVisible"] = m_historyToggle->isChecked();
+ p["Verbose"] = m_verbose->isChecked();
+ AppSettings::instance().setValue("Kxpa100Applet",
+ QString::fromUtf8(QJsonDocument(p).toJson(QJsonDocument::Compact)));
+ AppSettings::instance().save();
+}
+void Kxpa100Applet::setFloating(bool floating)
+{
+ m_floating = floating;
+ updateGeometry();
+}
+QSize Kxpa100Applet::sizeHint() const
+{
+ return QSize(300, std::min(700, m_content->sizeHint().height() + 2));
+}
+QSize Kxpa100Applet::minimumSizeHint() const
+{
+ return QSize(238, m_mini && m_mini->isChecked() ? 124 : 376);
+}
+bool Kxpa100Applet::eventFilter(QObject* watched, QEvent* event)
+{
+ if (watched == m_swr && (event->type() == QEvent::FocusOut || event->type() == QEvent::Hide ||
+ event->type() == QEvent::WindowDeactivate) && m_swr->isDown()) {
+ m_swr->setDown(false);
+ refresh();
+ }
+ if (watched == m_graph && event->type() == QEvent::Resize) {
+ renderHistory();
+ }
+ return CanonApplet::eventFilter(watched, event);
+}
+void Kxpa100Applet::renderHistory()
+{
+ const QSize size = m_graph->size();
+ if (size.isEmpty()) {
+ return;
+ }
+ const qreal dpr = m_graph->devicePixelRatioF();
+ QPixmap pixmap(size * dpr);
+ pixmap.setDevicePixelRatio(dpr);
+ auto& theme = ThemeManager::instance();
+ pixmap.fill(theme.color(this, "color.canon.ground"));
+ QPainter p(&pixmap);
+ QFont font = m_graph->font();
+ font.setPixelSize(9);
+ p.setFont(font);
+ const QStringList names{tr("SWR · last observed"), tr("Forward · W"), tr("Reflected · W")};
+ const qint64 now = m_clock.elapsed();
+ const int window = m_historyWindow->currentData().toInt();
+ QStringList description{tr("Telemetry history in watts. "
+ "SWR is last observed, not proof of RF. Gaps mean missing replies.")};
+ for (int i = 0; i < 3; ++i) {
+ const qreal top = i * size.height() / 3.0;
+ const QRectF area(4, top + 20, size.width() - 38, size.height() / 3.0 - 37);
+ const double minimum = i == 0 ? 1 : 0;
+ double maximum = i == 0 ? 5 : 1;
+ std::optional latest;
+ for (const Point& point : m_history[i]) {
+ if (point.time >= now - window) {
+ latest = point.value;
+ if (i != 0 && point.value) {
+ maximum = std::max(maximum, *point.value);
+ }
+ }
+ }
+ const QColor trace = theme.color(this, i == 0 ? "color.canon.cyan" : "color.canon.aqua");
+ const QString current = latest ? QString::number(*latest, 'f', i == 0 ? 2 : 0) : QStringLiteral("—");
+ p.setPen(QPen(trace, 2));
+ p.drawLine(QPointF(4, top + 10), QPointF(13, top + 10));
+ p.drawText(QRectF(18, top, size.width() - 65, 18), Qt::AlignVCenter, names[i]);
+ p.drawText(QRectF(size.width() - 46, top, 42, 18), Qt::AlignVCenter | Qt::AlignRight, current);
+ p.setPen(theme.color(this, "color.canon.lineHi"));
+ p.drawRect(area);
+ p.drawLine(QPointF(area.center().x(), area.top()), QPointF(area.center().x(), area.bottom()));
+ p.drawLine(QPointF(area.left(), area.center().y()), QPointF(area.right(), area.center().y()));
+ p.setPen(theme.color(this, "color.canon.muted"));
+ p.drawText(QRectF(area.right() + 3, area.top() - 5, 30, 12), Qt::AlignLeft, QString::number(maximum));
+ p.drawText(QRectF(area.right() + 3, area.bottom() - 8, 30, 12), Qt::AlignLeft, QString::number(minimum));
+ p.drawText(QRectF(area.left(), area.bottom() + 2, area.width(), 12), Qt::AlignLeft, tr("−%1 min").arg(window/60000));
+ p.drawText(QRectF(area.left(), area.bottom() + 2, area.width(), 12), Qt::AlignRight, tr("now"));
+ QPainterPath path;
+ bool segment = false;
+ qint64 prior = -10000;
+ p.setPen(QPen(trace, 1.5));
+ for (const Point& point : m_history[i]) {
+ if (point.time < now - window) {
+ continue;
+ }
+ if (!point.value) {
+ segment = false;
+ continue;
+ }
+ const QPointF pos(area.left() + area.width() * (point.time - (now - window)) / double(window),
+ area.bottom() - area.height() *
+ std::clamp((*point.value - minimum) / (maximum - minimum), 0.0, 1.0));
+ if (!segment || point.time - prior > 5000) {
+ path.moveTo(pos);
+ p.drawPoint(pos);
+ } else {
+ path.lineTo(pos);
+ }
+ segment = true;
+ prior = point.time;
+ }
+ p.drawPath(path);
+ description.append(tr("%1: latest %2; scale %3 to %4.")
+ .arg(names[i], current, QString::number(minimum), QString::number(maximum)));
+ }
+ m_graph->setAccessibleDescription(description.join(QLatin1Char(' ')));
+ m_graph->setPixmap(pixmap);
+}
+void Kxpa100Applet::saveDebug()
+{
+ if (m_fileDialog) {
+ m_fileDialog->raise();
+ return;
+ }
+ auto* dialog = new QFileDialog(this);
+ m_fileDialog = dialog;
+ dialog->setObjectName("kxDebugFileDialog");
+ dialog->setWindowTitle(tr("Save KXPA100 debug log"));
+ dialog->setNameFilter(tr("Text files (*.txt)"));
+ dialog->setAcceptMode(QFileDialog::AcceptSave);
+ dialog->selectFile("kxpa100-debug.txt");
+ connect(dialog, &QDialog::finished, this, [this, dialog](int result) {
+ const QStringList paths = dialog->selectedFiles();
+ m_fileDialog.clear();
+ dialog->deleteLater();
+ if (result != QDialog::Accepted || paths.isEmpty()) {
+ return;
+ }
+ QSaveFile file(paths.first());
+ const QByteArray data = m_debug->toPlainText().toUtf8();
+ if (!file.open(QIODevice::WriteOnly) || file.write(data) != data.size() || !file.commit()) {
+ text(m_availability, tr("Debug save failed: %1").arg(file.errorString()));
+ }
+ });
+ dialog->open();
+}
+} // namespace AetherSDR
diff --git a/src/gui/Kxpa100Applet.h b/src/gui/Kxpa100Applet.h
new file mode 100644
index 0000000000..2a4207e375
--- /dev/null
+++ b/src/gui/Kxpa100Applet.h
@@ -0,0 +1,107 @@
+#pragma once
+#include "CanonSurface.h"
+#include "models/Kxpa100Types.h"
+#include
+#include
+#include
+#include
+#include
+class QLabel;
+class QPushButton;
+class QCheckBox;
+class QScrollArea;
+class QSpinBox;
+class QFileDialog;
+class QComboBox;
+namespace AetherSDR
+{
+class Kxpa100Model;
+class Kxpa100DebugOutput;
+class HGauge;
+class Kxpa100Applet : public CanonApplet
+{
+ Q_OBJECT
+ public:
+ explicit Kxpa100Applet(QWidget* parent = nullptr);
+ ~Kxpa100Applet() override;
+ void setModel(Kxpa100Model* model);
+ void setFloating(bool floating);
+ void setRadioContext(const QString& radioName, int txFrequencyKhz, bool associated);
+ void setAssociationResult(bool saved);
+ QSize sizeHint() const override;
+ QSize minimumSizeHint() const override;
+ signals:
+ void advancedSettingsRequested();
+ void associateRadioRequested();
+
+ protected:
+ bool eventFilter(QObject* watched, QEvent* event) override;
+
+ private:
+ void refresh();
+ void presentation();
+ void savePreferences();
+ void renderHistory();
+ void saveDebug();
+ QPointer m_model;
+ QPointer m_fileDialog;
+ QScrollArea* m_scroll;
+ QWidget* m_content;
+ QWidget* m_modesPane;
+ QWidget* m_paPane;
+ QWidget* m_auxPane;
+ QComboBox* m_historyWindow;
+ QLabel* m_association;
+ QPushButton* m_associate;
+ std::array m_meterLabels{};
+ std::array m_meters{};
+ QWidget* m_antennasPane;
+ QWidget* m_physicalPane;
+ QWidget* m_manualPane;
+ QWidget* m_settingsPane;
+ QWidget* m_historyPane;
+ QWidget* m_debugPane;
+ QPushButton* m_power;
+ QPushButton* m_tune;
+ QPushButton* m_mini = nullptr;
+ QPushButton* m_manualToggle;
+ QPushButton* m_settings;
+ QPushButton* m_historyToggle;
+ QPushButton* m_debugToggle;
+ std::array m_modes{};
+ std::array m_antennas{};
+ std::array m_capBits{}, m_indBits{};
+ QCheckBox* m_side;
+ QCheckBox* m_verbose;
+ QCheckBox* m_pause;
+ QSpinBox* m_cap;
+ QSpinBox* m_ind;
+ QSpinBox* m_frequencyRecall;
+ QLabel* m_frequency;
+ QPushButton* m_swr;
+ std::array m_physical{};
+ QLabel* m_readiness;
+ QLabel* m_miniState;
+ QLabel* m_fault;
+ QLabel* m_identity;
+ QLabel* m_observed;
+ QLabel* m_availability;
+ QLabel* m_result;
+ QLabel* m_graph;
+ HGauge* m_swrGauge;
+ Kxpa100DebugOutput* m_debug;
+ struct Point {
+ qint64 time;
+ std::optional value;
+ };
+ std::array, 3> m_history;
+ QElapsedTimer m_clock;
+ QTimer m_timer;
+ std::array, 2> m_lastRelayMasks{};
+ bool m_frequencyEdited = false;
+ bool m_floating = false;
+ bool m_showingBypass = false;
+ int m_txFrequencyKhz = 0;
+ bool m_associated = false;
+};
+} // namespace AetherSDR
diff --git a/src/gui/MainWindow.cpp b/src/gui/MainWindow.cpp
index f3f677daf6..e753e55d15 100644
--- a/src/gui/MainWindow.cpp
+++ b/src/gui/MainWindow.cpp
@@ -3583,7 +3583,7 @@ RadioSetupDialog* MainWindow::openRadioSetupPage(const QString& page)
&m_tgxlConn, &m_pgxlConn, &m_antennaGenius,
m_kiwiSdrManager, &m_acomConn, &m_speConn,
&m_vkampConn, &m_lpMeterConn, &m_kpa1500Conn, &m_kpa500Conn,
- this, &m_kat500Model);
+ this, &m_kat500Model, &m_kxpa100Model);
dlg->setAttribute(Qt::WA_DeleteOnClose);
m_radioSetupDialog = dlg;
}
diff --git a/src/gui/MainWindow.h b/src/gui/MainWindow.h
index 246c03341c..3d20b83ea9 100644
--- a/src/gui/MainWindow.h
+++ b/src/gui/MainWindow.h
@@ -97,6 +97,7 @@
#include "core/VkampConnection.h"
#include "core/Kpa500Connection.h"
#include "models/Kat500Model.h"
+#include "models/Kxpa100Model.h"
#include "core/Kpa1500Connection.h"
#include "core/DxccColorProvider.h"
@@ -1339,6 +1340,8 @@ private slots:
SpeConnection m_speConn; // SPE Expert amplifier, serial or ser2net
VkampConnection m_vkampConn; // VK3AMP amplifier, TCP control/status + UDP telemetry
Kat500Model m_kat500Model;
+ Kxpa100Model m_kxpa100Model;
+ class Kxpa100RadioAssociation* m_kxpa100Association = nullptr;
QPointer m_kat500PreTuneDialog;
Kpa500Connection m_kpa500Conn; // Elecraft KPA500 amplifier, serial or raw TCP
Kpa1500Connection m_kpa1500Conn; // Elecraft KPA1500 amplifier, TCP control/status on port 1500 (#4097)
diff --git a/src/gui/MainWindow_Wiring.cpp b/src/gui/MainWindow_Wiring.cpp
index e1cdef686e..1d7a3d8469 100644
--- a/src/gui/MainWindow_Wiring.cpp
+++ b/src/gui/MainWindow_Wiring.cpp
@@ -44,6 +44,8 @@
#include "LpMeterApplet.h"
#include "Kpa500Applet.h"
#include "Kat500Applet.h"
+#include "Kxpa100Applet.h"
+#include "models/Kxpa100RadioAssociation.h"
#include "AtuPreTuneDialog.h"
#include "models/Kat500TxController.h"
#include "HealthApplet.h"
@@ -1765,6 +1767,12 @@ bool MainWindow::reattachSliceVisualsToPanadapter(SliceModel* s)
void MainWindow::refreshTunerPortFrequency()
{
+ if (m_appletPanel && m_appletPanel->kxpa100Applet()) {
+ m_appletPanel->kxpa100Applet()->setRadioContext(
+ m_radioModel.isConnected() ? m_radioModel.nickname() : QString(),
+ Kxpa100RadioAssociation::effectiveTxFrequencyKhz(&m_radioModel),
+ (m_kxpa100Association && m_kxpa100Association->associated()));
+ }
// Match the active plan's segments to the KPA's fixed band selectors.
// These broad search windows classify bands; the plan owns their edges.
SliceModel* kpaTx = m_radioModel.isConnected() && m_radioModel.backendCanTransmit()
@@ -6451,7 +6459,9 @@ SharedTxMeterSelection MainWindow::sharedTxMeterSelection()
&& m_radioModel.amplifier().operate();
const bool kpa500Active = m_kpa500Conn.hasLiveTxMeters();
return selectSharedTxMeters(maxWatts, amplifierActive, kpa500Active,
- m_kat500Model.ampKeyBlockConfirmed());
+ m_kat500Model.ampKeyBlockConfirmed(),
+ m_kxpa100Association && m_kxpa100Association->associated()
+ && m_kxpa100Model.amplifierMeterEligible());
}
// One owner for amplifier TX meters on the shared readouts (S-Meter,
@@ -6543,7 +6553,8 @@ void MainWindow::wireMeters()
// (~100 W) and amp (~1500 W) values race into the same widget. (#2927)
connect(&m_radioModel.meterModel(), &MeterModel::txMetersChanged,
this, [this](float fwd, float swr, bool swrValid) {
- if (sharedTxMeterSelection().source == SharedTxMeterSource::Amplifier) {
+ if (sharedTxMeterSelection().source == SharedTxMeterSource::Amplifier
+ || sharedTxMeterSelection().source == SharedTxMeterSource::Kxpa100) {
return;
}
// Absent SWR is forwarded as 1.0: RadioSwrValidityFilter downstream
@@ -6565,7 +6576,8 @@ void MainWindow::wireMeters()
&MeterModel::directionalPowerMetersChanged,
this, [this](float fwd, float reflected, float swr,
bool swrValid, bool reflectedPowerMeasured) {
- if (sharedTxMeterSelection().source == SharedTxMeterSource::Amplifier) {
+ if (sharedTxMeterSelection().source == SharedTxMeterSource::Amplifier
+ || sharedTxMeterSelection().source == SharedTxMeterSource::Kxpa100) {
return;
}
// The cross-needle already clamps sub-1.0 values to 1.0 (its rest
@@ -6660,6 +6672,7 @@ void MainWindow::wireMeters()
m_kpa500Conn.setAutoReconnect(
ar && PeripheralSettings::autoConnect(QStringLiteral("kpa500")));
m_kat500Model.setAutoReconnect(ar && PeripheralSettings::autoConnect(QStringLiteral("kat500")));
+ m_kxpa100Model.setAutoReconnect(ar && PeripheralSettings::autoConnect(QStringLiteral("kxpa100")));
}
// Wire TgxlConnection to TunerModel
@@ -7646,6 +7659,39 @@ void MainWindow::wireMeters()
#endif
}
+ m_kxpa100Association = new Kxpa100RadioAssociation(&m_radioModel, &m_kxpa100Model, this);
+ connect(m_kxpa100Association, &Kxpa100RadioAssociation::changed,
+ this, &MainWindow::refreshTunerPortFrequency);
+ auto* kx = m_appletPanel->kxpa100Applet();
+ kx->setModel(&m_kxpa100Model);
+ connect(&m_kxpa100Model, &Kxpa100Model::detectedChanged, m_appletPanel, &AppletPanel::setKxpa100Visible);
+ connect(kx, &Kxpa100Applet::advancedSettingsRequested, this, [this] {
+ if (auto* dialog = openRadioSetupPage(QStringLiteral("Peripherals"))) { dialog->revealKxpa100AdvancedSettings(); }
+ });
+ connect(kx, &Kxpa100Applet::associateRadioRequested, this, [this, kx] {
+ const bool saved = m_kxpa100Association && m_kxpa100Association->associated()
+ ? Kxpa100RadioAssociation::dissociate(&m_radioModel, &m_kxpa100Model)
+ : Kxpa100RadioAssociation::associate(&m_radioModel, &m_kxpa100Model);
+ refreshTunerPortFrequency();
+ kx->setAssociationResult(saved);
+ });
+ connect(&m_kxpa100Model, &Kxpa100Model::associationChanged, this, &MainWindow::refreshTunerPortFrequency);
+ connect(&m_kxpa100Model, &Kxpa100Model::availabilityChanged, this, &MainWindow::refreshTunerPortFrequency);
+ connect(&m_radioModel, &RadioModel::infoChanged, this, &MainWindow::refreshTunerPortFrequency);
+ if (PeripheralSettings::autoConnect(QStringLiteral("kxpa100"))) {
+ const QString mode = PeripheralSettings::deviceString("Kxpa100", "ConnectionMode", "Serial");
+ if (mode == "Network") {
+ const QString host = PeripheralSettings::deviceString("Kxpa100", "ManualIp").trimmed();
+ const int port = PeripheralSettings::deviceInt("Kxpa100", "ManualPort", 2000);
+ if (!host.isEmpty() && port > 0 && port <= 65535) { m_kxpa100Model.connectToHost(host, static_cast(port)); }
+ } else {
+#ifdef HAVE_SERIALPORT
+ const QString port = PeripheralSettings::deviceString("Kxpa100", "SerialPort");
+ if (!port.isEmpty()) { m_kxpa100Model.connectSerial(port, PeripheralSettings::deviceInt("Kxpa100", "BaudRate", 38400)); }
+#endif
+ }
+ }
+
// Data and scale use the same confirmed selection.
auto updatePowerScale = [this]() {
const SharedTxMeterSelection selection = sharedTxMeterSelection();
@@ -7671,6 +7717,21 @@ void MainWindow::wireMeters()
m_appletPanel->healthApplet()->setPowerScale(radioMaxWatts, integratedAmp);
m_appletPanel->setMeterPowerScale(selection.maxWatts, selection.highPowerAmplifier);
};
+ connect(&m_kxpa100Model, &Kxpa100Model::statusUpdated, this, updatePowerScale);
+ connect(&m_kxpa100Model, &Kxpa100Model::availabilityChanged, this, updatePowerScale);
+ connect(m_kxpa100Association, &Kxpa100RadioAssociation::changed, this, updatePowerScale);
+ connect(&m_kxpa100Model, &Kxpa100Model::statusUpdated, this, [this](const Kxpa100::Status& status) {
+ if (sharedTxMeterSelection().source != SharedTxMeterSource::Kxpa100) { return; }
+ const float watts = status.forwardWatts.value_or(0);
+ const float swr = watts > 0 ? status.swr.value_or(1) : 1;
+ m_appletPanel->setMeterTxValues(watts, swr);
+ const bool reflectedFresh = m_kxpa100Model.fieldAgeMs("^PV") >= 0 && m_kxpa100Model.fieldAgeMs("^PV") <= 5000;
+ m_appletPanel->setCrossNeedleDirectionalValues(watts, status.reflectedWatts.value_or(0), swr, reflectedFresh);
+#ifdef HAVE_HIDAPI
+ m_tmate2TxWatts = watts;
+ if (m_radioModel.transmitModel().isTransmitting()) { updateTMate2Display(); updateTMate2Indicators(); }
+#endif
+ });
connect(&m_kat500Model, &Kat500Model::statusUpdated, this, updatePowerScale);
connect(&m_kat500Model, &Kat500Model::availabilityChanged, this, updatePowerScale);
updatePowerScale();
diff --git a/src/gui/RadioSetupDialog.cpp b/src/gui/RadioSetupDialog.cpp
index 1220d14a83..4e786af7a5 100644
--- a/src/gui/RadioSetupDialog.cpp
+++ b/src/gui/RadioSetupDialog.cpp
@@ -1272,12 +1272,12 @@ RadioSetupDialog::RadioSetupDialog(RadioModel* model, AudioEngine* audio,
Kpa1500Connection* kpa1500,
Kpa500Connection* kpa500,
QWidget* parent,
- Kat500Model* kat500)
+ Kat500Model* kat500, Kxpa100Model* kxpa100)
: CanonWindow(QStringLiteral("Radio Setup"), parent, Kind::Workspace),
m_model(model), m_audio(audio),
m_tgxl(tgxl), m_pgxl(pgxl), m_ag(ag),
m_kiwiSdrManager(kiwiSdrManager), m_acom(acom), m_spe(spe), m_vkamp(vkamp), m_lpMeter(lpMeter),
- m_kpa1500(kpa1500), m_kpa500(kpa500), m_kat500(kat500)
+ m_kpa1500(kpa1500), m_kpa500(kpa500), m_kat500(kat500), m_kxpa100(kxpa100)
{
theme::setContainer(this, QStringLiteral("canon/radioSetup"));
// Size and position persist under the key it used as a PersistentDialog,
diff --git a/src/gui/RadioSetupDialog.h b/src/gui/RadioSetupDialog.h
index 976f14fa95..0eb02d03ed 100644
--- a/src/gui/RadioSetupDialog.h
+++ b/src/gui/RadioSetupDialog.h
@@ -43,6 +43,7 @@ class VkampConnection;
class Kpa1500Connection;
class Kpa500Connection;
class Kat500Model;
+class Kxpa100Model;
class LpMeterConnection;
struct PeripheralDeviceUi;
@@ -64,7 +65,8 @@ class RadioSetupDialog : public CanonWindow {
Kpa1500Connection* kpa1500 = nullptr,
Kpa500Connection* kpa500 = nullptr,
QWidget* parent = nullptr,
- Kat500Model* kat500 = nullptr);
+ Kat500Model* kat500 = nullptr,
+ Kxpa100Model* kxpa100 = nullptr);
void selectTab(const QString& tabName);
void done(int result) override;
// Like selectTab("Serial & Controllers"), but also scrolls the page so
@@ -74,6 +76,7 @@ class RadioSetupDialog : public CanonWindow {
void revealFlexControlSettings();
void revealKpa500AdvancedSettings();
void revealKat500AdvancedSettings();
+ void revealKxpa100AdvancedSettings();
void refreshFlexControlButtonActions();
void setFlexControlConnectionStatus(bool connected, const QString& port = {});
// Result of the automation-bridge start the Network-tab toggle kicked off
@@ -217,6 +220,9 @@ class RadioSetupDialog : public CanonWindow {
void buildKat500Device(PeripheralDeviceUi& ui, QWidget* stackParent,
const std::function& refresh,
const std::shared_ptr>>& pageReseeds);
+ void buildKxpa100Device(PeripheralDeviceUi& ui, QWidget* stackParent,
+ const std::function& refresh,
+ const std::shared_ptr>>& pageReseeds);
void buildKpa1500Device(PeripheralDeviceUi& ui, QWidget* stackParent,
const std::function& refresh);
PeripheralDeviceUi* peripheralDevice(const QString& id) const;
@@ -261,6 +267,7 @@ class RadioSetupDialog : public CanonWindow {
Kpa1500Connection* m_kpa1500{nullptr};
Kpa500Connection* m_kpa500{nullptr};
Kat500Model* m_kat500{nullptr};
+ Kxpa100Model* m_kxpa100{nullptr};
QTreeWidget* m_navigation{nullptr};
QStackedWidget* m_pages{nullptr};
QLabel* m_pageTitle{nullptr};
diff --git a/src/gui/RadioSetupDialog_Peripherals.cpp b/src/gui/RadioSetupDialog_Peripherals.cpp
index 16cf97ca46..a623646049 100644
--- a/src/gui/RadioSetupDialog_Peripherals.cpp
+++ b/src/gui/RadioSetupDialog_Peripherals.cpp
@@ -14,6 +14,9 @@
#include "RadioSetupDialog.h"
#include "RadioSetupDialogCommon.h"
#include "Kpa500AdvancedWidget.h"
+#include "Kxpa100AdvancedWidget.h"
+#include "models/Kxpa100Model.h"
+#include "models/Kxpa100RadioAssociation.h"
#include "Kat500AdvancedWidget.h"
#include "models/Kat500Model.h"
#include "PeripheralAuthConnectFlow.h"
@@ -87,6 +90,7 @@ struct PeripheralDeviceUi {
SerialOrNetwork, // ACOM, SPE Expert, LP-100A: serial port or ser2net host
Vkamp, // host + port, plus the amplifier model
Kpa1500, // host + port
+ Kxpa100, // KXPA100 amplifier with optional tuner
Kat500, // KAT500 serial or TCP tuner
Kpa500, // Serial with selectable baud, or raw TCP
};
@@ -505,7 +509,7 @@ void finishDetailPage(DetailPage& detail, PeripheralDeviceUi& ui,
detail.layout->addWidget(detail.group, 0, Qt::AlignTop);
if (ui.kind == PeripheralDeviceUi::Kind::AuthNetwork
- || (ui.kind == PeripheralDeviceUi::Kind::Kpa500 || ui.kind == PeripheralDeviceUi::Kind::Kat500)) {
+ || (ui.kind == PeripheralDeviceUi::Kind::Kpa500 || ui.kind == PeripheralDeviceUi::Kind::Kat500 || ui.kind == PeripheralDeviceUi::Kind::Kxpa100)) {
// TGXL and PGXL can also be reached through the radio, so for them the
// toggle chooses the direct link over the radio's relay.
const bool relayed = ui.id == QLatin1String("tgxl") || ui.id == QLatin1String("pgxl");
@@ -524,7 +528,7 @@ void finishDetailPage(DetailPage& detail, PeripheralDeviceUi& ui,
: RadioSetupDialog::tr(
"Connect to %1 on startup, when discovery reports it, and after a drop. "
"Turn off to connect only when you click Connect.").arg(ui.label));
- if ((ui.kind == PeripheralDeviceUi::Kind::Kpa500 || ui.kind == PeripheralDeviceUi::Kind::Kat500)) {
+ if ((ui.kind == PeripheralDeviceUi::Kind::Kpa500 || ui.kind == PeripheralDeviceUi::Kind::Kat500 || ui.kind == PeripheralDeviceUi::Kind::Kxpa100)) {
autoConnect->setAccessibleDescription(RadioSetupDialog::tr(
"Connect to %1 on startup. Turn off to connect only when you click Connect.")
.arg(ui.label));
@@ -1217,7 +1221,7 @@ void RadioSetupDialog::buildSerialNetworkDevice(PeripheralDeviceUi& ui, QWidget*
setTone(ui.deviceInfoLabel, Tone::Value);
addFormRow(detail, tr("Device Information"), ui.deviceInfoLabel);
}
- if ((ui.kind == PeripheralDeviceUi::Kind::Kpa500 || ui.kind == PeripheralDeviceUi::Kind::Kat500)) {
+ if ((ui.kind == PeripheralDeviceUi::Kind::Kpa500 || ui.kind == PeripheralDeviceUi::Kind::Kat500 || ui.kind == PeripheralDeviceUi::Kind::Kxpa100)) {
auto* help = new QLabel(ui.networkTooltip);
help->setWordWrap(true);
setTone(help, Tone::Hint);
@@ -1486,6 +1490,88 @@ void RadioSetupDialog::revealKat500AdvancedSettings()
});
}
+void RadioSetupDialog::buildKxpa100Device(PeripheralDeviceUi& ui, QWidget* stackParent,
+ const std::function& refresh,
+ const std::shared_ptr>>& pageReseeds)
+{
+#ifdef HAVE_SERIALPORT
+ ui.baudCombo = new QComboBox;
+ ui.baudCombo->setObjectName(QStringLiteral("peripheralBaud_%1").arg(ui.id));
+ ui.baudCombo->setAccessibleName(tr("KXPA100 baud rate"));
+ ui.baudCombo->setAccessibleDescription(tr("Local serial speed; must match the tuner. 8 data bits, no parity, 1 stop bit."));
+ for (int baud : {4800, 9600, 19200, 38400}) {
+ ui.baudCombo->addItem(QString::number(baud), baud);
+ }
+ const int savedBaud = PeripheralSettings::deviceInt(ui.settingsGroup, "BaudRate", 38400);
+ const int baudIndex = ui.baudCombo->findData(savedBaud);
+ ui.baudCombo->setCurrentIndex(baudIndex >= 0 ? baudIndex : 0);
+ ui.connectSerial = [this, u = &ui](const QString& port) {
+ const int baud = u->baudCombo->currentData().toInt();
+ PeripheralSettings::setDeviceInt(u->settingsGroup, "BaudRate", baud);
+ m_kxpa100->connectSerial(port, baud);
+ };
+#endif
+ ui.networkPlaceholder = tr("ser2net host, raw TCP mode");
+ ui.networkDescription = tr("IP address or host name of the raw TCP serial proxy");
+ ui.networkTooltip = tr("Use a raw TCP ser2net endpoint, with 8N1 and the amplifier's baud rate configured on the server. "
+ "Telnet/RFC 2217 and Elecraft Remote Server are not supported by this connection.");
+ buildSerialNetworkDevice(ui, stackParent, refresh, pageReseeds);
+ auto* advancedToggle = new QPushButton(tr("Advanced"), ui.page);
+ advancedToggle->setObjectName(QStringLiteral("peripheralAdvanced_kxpa100"));
+ advancedToggle->setAccessibleName(tr("KXPA100 advanced maintenance"));
+ advancedToggle->setAccessibleDescription(tr("Antenna configuration, backup and restore, stored fault history and tuner memories."));
+ advancedToggle->setCheckable(true);
+ auto* advanced = new Kxpa100AdvancedWidget(m_kxpa100, ui.page);
+ advanced->hide();
+ auto* layout = qobject_cast(ui.page->layout());
+ auto* associate = new QPushButton(tr("Use with current radio"), ui.page);
+ associate->setObjectName("kxAssociateRadio");
+ associate->setAccessibleName(tr("Associate KXPA100 with the connected radio"));
+ associate->setToolTip(tr("Bind this KXPA100 serial to this radio for VU/PWR metering and applet frequency context."));
+ layout->insertWidget(layout->count() - 1, associate);
+ connect(associate, &QPushButton::clicked, this, [this, associate] {
+ const bool saved = Kxpa100RadioAssociation::associate(m_model, m_kxpa100);
+ associate->setToolTip(saved ? tr("Association saved for the current radio.") : tr("Connect an identified radio and KXPA100 before associating them. Settings must be writable."));
+ });
+ layout->insertWidget(layout->count() - 1, advancedToggle, 0, Qt::AlignLeft);
+ layout->insertWidget(layout->count() - 1, advanced);
+ connect(advancedToggle, &QPushButton::toggled, advanced, &QWidget::setVisible);
+ connect(ui.autoConnectCheck, &QCheckBox::toggled, this, [this](bool on) {
+ m_kxpa100->setAutoReconnect(on && PeripheralSettings::autoReconnect());
+ });
+}
+
+void RadioSetupDialog::revealKxpa100AdvancedSettings()
+{
+ selectTab(QStringLiteral("Peripherals"));
+ if (QListWidget* devices = findChild(QStringLiteral("peripheralDeviceList"))) {
+ for (int row = 0; row < devices->count(); ++row) {
+ if (devices->item(row)->data(Qt::UserRole).toString() == QStringLiteral("kxpa100")) {
+ devices->setCurrentRow(row);
+ break;
+ }
+ }
+ }
+ QPointer toggle = findChild(QStringLiteral("peripheralAdvanced_kxpa100"));
+ if (!toggle) {
+ return;
+ }
+ toggle->setChecked(true);
+ toggle->setFocus(Qt::OtherFocusReason);
+ QTimer::singleShot(0, this, [toggle] {
+ if (!toggle) {
+ return;
+ }
+ for (QWidget* parent = toggle->parentWidget(); parent; parent = parent->parentWidget()) {
+ if (auto* area = qobject_cast(parent); area && area->widget()) {
+ const int y = toggle->mapTo(area->widget(), QPoint(0, 0)).y();
+ area->verticalScrollBar()->setValue(qMax(0, y - 8));
+ return;
+ }
+ }
+ });
+}
+
void RadioSetupDialog::buildKpa1500Device(PeripheralDeviceUi& ui, QWidget* stackParent,
const std::function& refresh)
{
@@ -1985,6 +2071,34 @@ QWidget* RadioSetupDialog::buildPeripheralsTab()
? tr("tuner not answering") : QString();
buildKat500Device(*ui, detailStack, refresh, serialReseeds);
}
+ if (m_kxpa100) {
+ auto ui = addDevice(QStringLiteral("kxpa100"), tr("Elecraft KXPA100"),
+ QStringLiteral("KXPA100 amplifier / tuner"),
+ PeripheralDeviceUi::Kind::Kxpa100, 2000);
+ ui->settingsGroup = QStringLiteral("Kxpa100");
+ ui->isConnected = [this]() { return m_kxpa100->isConnected(); };
+ ui->isConnecting = [this]() { return m_kxpa100->isConnecting(); };
+ ui->isNetworkConnection = [this]() { return m_kxpa100->isNetworkConnection(); };
+ ui->connectNetwork = [this](const QString& host, quint16 port) { m_kxpa100->connectToHost(host, port); };
+ ui->deviceInfo = [this]() {
+ const Kxpa100::Status& status = m_kxpa100->status();
+ return tr("%1\nFirmware: %2 · Serial: %3\n%4")
+ .arg(m_kxpa100->isConnected() ? m_kxpa100->description() : tr("Disconnected"),
+ status.firmware.isEmpty() ? tr("Not reported") : status.firmware,
+ status.serial.isEmpty() ? tr("Not reported") : status.serial,
+ m_kxpa100->unavailableReason(false));
+ };
+ ui->disconnectNow = [this]() { m_kxpa100->disconnectDevice(); };
+ ui->description = [this]() { return m_kxpa100->description(); };
+ ui->afterRemoval = [this]() {
+ m_kxpa100->disconnectDevice();
+ m_kxpa100->setAutoReconnect(PeripheralSettings::autoReconnect()
+ && PeripheralSettings::autoConnect("kxpa100"));
+ };
+ ui->state.note = m_kxpa100->isConnected() && !m_kxpa100->isResponding()
+ ? tr("amplifier not answering") : QString();
+ buildKxpa100Device(*ui, detailStack, refresh, serialReseeds);
+ }
if (m_lpMeter) {
auto ui = serialDevice(QStringLiteral("lp100a"), tr("LP-100A Meter"), QStringLiteral("LP-100A meter"),
QStringLiteral("Lp100a"), 2000, QStringLiteral("115200 8N1"),
@@ -2034,6 +2148,9 @@ QWidget* RadioSetupDialog::buildPeripheralsTab()
if (m_kat500) {
m_kat500->setAutoReconnect(on && PeripheralSettings::autoConnect("kat500"));
}
+ if (m_kxpa100) {
+ m_kxpa100->setAutoReconnect(on && PeripheralSettings::autoConnect("kxpa100"));
+ }
// VK3AMP takes this setting only at startup (#4919).
});
@@ -2271,7 +2388,7 @@ QWidget* RadioSetupDialog::buildPeripheralsTab()
}
} else {
PeripheralSettings::clearDeviceConnection(ui.settingsGroup);
- if ((ui.kind == PeripheralDeviceUi::Kind::Kpa500 || ui.kind == PeripheralDeviceUi::Kind::Kat500)) {
+ if ((ui.kind == PeripheralDeviceUi::Kind::Kpa500 || ui.kind == PeripheralDeviceUi::Kind::Kat500 || ui.kind == PeripheralDeviceUi::Kind::Kxpa100)) {
PeripheralSettings::clearDeviceField(ui.settingsGroup, "BaudRate");
PeripheralSettings::resetAutoConnect(ui.id);
const QSignalBlocker blocked(ui.autoConnectCheck);
@@ -2579,7 +2696,7 @@ QWidget* RadioSetupDialog::buildPeripheralsTab()
const bool connected = ui.isConnected();
ui.state.source = sourceOf(ui);
const bool connecting = effectivelyConnecting(ui);
- const bool kpaConnecting = (ui.kind == PeripheralDeviceUi::Kind::Kpa500 || ui.kind == PeripheralDeviceUi::Kind::Kat500) && connecting;
+ const bool kpaConnecting = (ui.kind == PeripheralDeviceUi::Kind::Kpa500 || ui.kind == PeripheralDeviceUi::Kind::Kat500 || ui.kind == PeripheralDeviceUi::Kind::Kxpa100) && connecting;
ui.connectButton->setText(connected ? tr("Disconnect")
: kpaConnecting ? tr("Cancel") : tr("Connect"));
applyStatusLabel(ui);
@@ -2807,6 +2924,11 @@ QWidget* RadioSetupDialog::buildPeripheralsTab()
connect(m_kat500, &Kat500Model::statusUpdated, this, refresh);
connect(m_kat500, &Kat500Model::availabilityChanged, this, refresh);
}
+ if (m_kxpa100) {
+ wireLinkDevice(QStringLiteral("kxpa100"), m_kxpa100);
+ connect(m_kxpa100, &Kxpa100Model::statusUpdated, this, refresh);
+ connect(m_kxpa100, &Kxpa100Model::availabilityChanged, this, refresh);
+ }
if (m_kpa500) {
wireLinkDevice(QStringLiteral("kpa500"), m_kpa500);
connect(m_kpa500, &Kpa500Connection::statusUpdated, this, refresh);
diff --git a/src/gui/SharedTxMeterSelection.h b/src/gui/SharedTxMeterSelection.h
index f03a120971..afe0ed805a 100644
--- a/src/gui/SharedTxMeterSelection.h
+++ b/src/gui/SharedTxMeterSelection.h
@@ -2,7 +2,7 @@
namespace AetherSDR {
-enum class SharedTxMeterSource { Radio, Amplifier, RadioKeyBlocked };
+enum class SharedTxMeterSource { Radio, Amplifier, RadioKeyBlocked, Kxpa100 };
struct SharedTxMeterSelection {
SharedTxMeterSource source{SharedTxMeterSource::Radio};
@@ -13,7 +13,8 @@ struct SharedTxMeterSelection {
// The KAT500 key-line override applies to the KPA500 path. A separately
// selected radio-integrated amplifier retains its existing meter priority.
inline SharedTxMeterSelection selectSharedTxMeters(int radioMaxWatts, bool amplifierActive,
- bool kpa500Active, bool keyBlockConfirmed)
+ bool kpa500Active, bool keyBlockConfirmed,
+ bool kxpa100Active = false)
{
if (amplifierActive) {
return {SharedTxMeterSource::Amplifier, radioMaxWatts, true};
@@ -24,6 +25,9 @@ inline SharedTxMeterSelection selectSharedTxMeters(int radioMaxWatts, bool ampli
}
return {SharedTxMeterSource::Amplifier, 500, false};
}
+ if (kxpa100Active) {
+ return {SharedTxMeterSource::Kxpa100, 100, false};
+ }
return {SharedTxMeterSource::Radio, radioMaxWatts, false};
}
diff --git a/src/models/Kxpa100Model.cpp b/src/models/Kxpa100Model.cpp
new file mode 100644
index 0000000000..6d92fd3de1
--- /dev/null
+++ b/src/models/Kxpa100Model.cpp
@@ -0,0 +1,468 @@
+#include "Kxpa100Model.h"
+#include "core/Kxpa100Protocol.h"
+#include "core/Kxpa100Maintenance.h"
+#include
+#include
+#include
+#ifdef HAVE_SERIALPORT
+#include
+#endif
+#include
+#include
+
+Q_LOGGING_CATEGORY(lcKxpa100, "aether.kxpa100")
+namespace AetherSDR {
+using namespace Kxpa100;
+namespace {
+const QList kPolls{"^OP;","^MD;","^AN;","^BY;","^AT;","^CR;","^LR;","^SI;","^SB;",
+ "^PV;","^PD;","^PC;","^SV;","^TM;","^F;","^BN;","^AD;","^TU;","^I;","^RV;","^SN;"};
+QByteArray hexMask(int mask) { return QByteArray::number(mask,16).rightJustified(2,'0').toUpper(); }
+}
+Kxpa100Model::Kxpa100Model(QObject* parent) : QObject(parent), m_parser(std::make_unique()),
+ m_socket(std::make_unique())
+{
+ m_socket->setReadBufferSize(4096);
+ m_clock.start(); m_now = [this] { return m_clock.elapsed(); };
+ m_timer.setInterval(50); m_reconnectTimer.setSingleShot(true); m_reconnectTimer.setInterval(5000);
+ connect(&m_timer,&QTimer::timeout,this,&Kxpa100Model::tick);
+ connect(&m_reconnectTimer,&QTimer::timeout,this,&Kxpa100Model::reconnect);
+ m_parser->setCallback([this](const QByteArray& frame) { receiveFrame(frame); });
+ connect(m_socket.get(),&QTcpSocket::connected,this,&Kxpa100Model::transportOpened);
+ connect(m_socket.get(),&QTcpSocket::readyRead,this,[this] { receiveBytes(m_socket->readAll()); });
+ connect(m_socket.get(),&QTcpSocket::disconnected,this,[this] { transportClosed(); });
+ connect(m_socket.get(),&QTcpSocket::errorOccurred,this,[this](QAbstractSocket::SocketError) {
+ transportClosed(m_socket->errorString());
+ });
+#ifdef HAVE_SERIALPORT
+ m_serial = std::make_unique();
+ connect(m_serial.get(),&QSerialPort::readyRead,this,[this] { receiveBytes(m_serial->readAll()); });
+ connect(m_serial.get(),&QSerialPort::errorOccurred,this,[this](QSerialPort::SerialPortError error) {
+ if (error == QSerialPort::ResourceError || error == QSerialPort::DeviceNotFoundError || error == QSerialPort::PermissionError) {
+ transportClosed(m_serial->errorString());
+ }
+ });
+#endif
+}
+Kxpa100Model::~Kxpa100Model() { m_autoReconnect = false; disconnectDevice(); }
+void Kxpa100Model::connectToHost(const QString& host, quint16 port)
+{
+ if (m_connected || m_connecting) { return; }
+ m_reconnectTimer.stop();
+ const QString trimmedHost = host.trimmed();
+ bool invalidHost = trimmedHost.isEmpty() || trimmedHost.size() > 253 || trimmedHost.contains('/');
+ for (QChar character : trimmedHost) {
+ invalidHost = invalidHost || character.isSpace() || !character.isPrint();
+ }
+ if (invalidHost || port == 0) {
+ emit connectionFailed(tr("Enter a TCP host and a port from 1 to 65535."));
+ return;
+ }
+ m_network = true; m_host = trimmedHost; m_port = port; m_connecting = true;
+ m_deliberateDisconnect = false; m_connectStarted = m_now(); m_timer.start();
+ m_socket->connectToHost(m_host,m_port); emit availabilityChanged();
+}
+void Kxpa100Model::connectSerial(const QString& portName, int baudRate)
+{
+#ifdef HAVE_SERIALPORT
+ if (m_connected || m_connecting || portName.trimmed().isEmpty() ||
+ (baudRate != 4800 && baudRate != 9600 && baudRate != 19200 && baudRate != 38400)) { return; }
+ m_network = false; m_portName = portName; m_baud = baudRate; m_deliberateDisconnect = false;
+ m_serial->setPortName(portName); m_serial->setBaudRate(baudRate);
+ m_serial->setDataBits(QSerialPort::Data8); m_serial->setParity(QSerialPort::NoParity);
+ m_serial->setStopBits(QSerialPort::OneStop); m_serial->setFlowControl(QSerialPort::NoFlowControl);
+ if (!m_serial->open(QIODevice::ReadWrite)) { transportClosed(m_serial->errorString()); return; }
+ transportOpened();
+#else
+ Q_UNUSED(portName); Q_UNUSED(baudRate);
+ emit connectionFailed(QStringLiteral("This build does not include serial-port support."));
+#endif
+}
+void Kxpa100Model::disconnectDevice()
+{
+ m_deliberateDisconnect = true; m_reconnectTimer.stop();
+ transportClosed();
+}
+void Kxpa100Model::reconnect()
+{
+ if (!m_autoReconnect || m_deliberateDisconnect || m_connected || m_connecting) { return; }
+ if (m_network) { connectToHost(m_host,m_port); } else { connectSerial(m_portName,m_baud); }
+}
+void Kxpa100Model::transportOpened()
+{
+ ++m_generation; m_connected = true; m_connecting = false; m_detected = false;
+ m_deferredTuneCancel = false; m_tuneEnded = false; m_status = {}; m_configuration.reset();
+ m_received.clear(); m_queue.clear(); m_active.reset(); m_parser->reset();
+ m_operation.clear(); m_settleGetter.clear(); m_settleVerify = {}; m_tunePermit = {};
+ m_lastReply = -10000; m_nextPoll = 0; m_pollIndex = 0; m_timer.start(); queueIdentity();
+ report(QStringLiteral("Transport connected: %1").arg(description())); emit connected(); emit availabilityChanged();
+}
+void Kxpa100Model::transportClosed(const QString& reason)
+{
+ if (m_closing) { return; }
+ m_closing = true;
+ const bool wasOpen = m_connected || m_connecting;
+ m_connected = false; m_connecting = false; m_timer.stop(); ++m_generation;
+ m_socket->abort();
+#ifdef HAVE_SERIALPORT
+ if (m_serial->isOpen()) { m_serial->close(); }
+#endif
+ if (operationBusy()) { fail(reason.isEmpty() ? QStringLiteral("Transport closed; pending operations discarded.") : reason); }
+ m_queue.clear(); m_active.reset(); m_parser->reset(); m_received.clear(); m_configuration.reset();
+ m_deferredTuneCancel = false;
+ if (m_detected) { m_detected = false; emit detectedChanged(false); }
+ if (wasOpen) { report(QStringLiteral("Transport closed.")); emit disconnected(); }
+ if (!reason.isEmpty()) { report(reason); emit connectionFailed(reason); }
+ m_closing = false; emit availabilityChanged();
+ if (m_autoReconnect && !m_deliberateDisconnect) { m_reconnectTimer.start(); }
+}
+bool Kxpa100Model::isResponding() const { return m_connected && m_now() - m_lastReply <= 5000; }
+QString Kxpa100Model::description() const
+{
+ return m_network ? QStringLiteral("NETWORK %1:%2 (bridge controls baud)").arg(m_host).arg(m_port)
+ : QStringLiteral("DIRECT %1 at %2 baud").arg(m_portName).arg(m_baud);
+}
+qint64 Kxpa100Model::fieldAgeMs(const QString& command) const
+{
+ const auto it = m_received.constFind(command);
+ return it == m_received.cend() ? -1 : std::max(0, m_now() - *it);
+}
+bool Kxpa100Model::fresh(const QString& key, int maxAge) const
+{
+ const qint64 age = fieldAgeMs(key);
+ return age >= 0 && age <= maxAge;
+}
+bool Kxpa100Model::idle() const
+{
+ return m_status.tuning == false && m_status.inputWatts == 0 && m_status.forwardWatts == 0 &&
+ fresh(QStringLiteral("^TP")) && fresh(QStringLiteral("^PI")) && fresh(QStringLiteral("^PF"));
+}
+QString Kxpa100Model::unavailableReason(bool writing, bool tuner) const
+{
+ if (!m_connected) { return QStringLiteral("Connect the KXPA100 first."); }
+ if (!m_detected) { return QStringLiteral("Waiting for KXPA100 identity, serial and firmware."); }
+ if (!isResponding()) { return QStringLiteral("KXPA100 readback is stale."); }
+ if (operationBusy() || m_deferredTuneCancel) { return QStringLiteral("Another KXPA100 operation is in progress."); }
+ if (tuner && m_status.tunerPresent != true) { return QStringLiteral("The optional KXAT100 tuner is absent or not yet identified."); }
+ if (writing && !idle()) { return QStringLiteral("Fresh idle tuner and zero input/output power are required."); }
+ return {};
+}
+bool Kxpa100Model::tunerEligible() const
+{
+ return m_connected && m_detected && isResponding() && m_status.tunerPresent == true &&
+ fresh(QStringLiteral("^TU")) && fresh(QStringLiteral("^MD")) && fresh(QStringLiteral("^AN")) &&
+ m_status.mode && m_status.mode != Mode::Bypass && m_status.antenna.has_value();
+}
+bool Kxpa100Model::amplifierMeterEligible() const
+{
+ return m_connected && m_detected && isResponding() && m_status.operate == true && m_status.tuning == false &&
+ fresh(QStringLiteral("^OP")) && fresh(QStringLiteral("^TP")) && fresh(QStringLiteral("^PF"));
+}
+
+void Kxpa100Model::report(const QString& text, bool polling)
+{
+ if (polling && !m_verbose) { return; }
+ qCDebug(lcKxpa100).noquote() << text;
+ const QString line = QDateTime::currentDateTime().toString(QStringLiteral("HH:mm:ss.zzz ")) + text;
+ m_debug.append(line); while (m_debug.size() > 1000) { m_debug.removeFirst(); }
+ emit diagnostic(line); emit debugChanged();
+}
+bool Kxpa100Model::write(const QByteArray& bytes)
+{
+ if (!m_connected || bytes.isEmpty()) { return false; }
+ bool success;
+ if (m_write) { success = m_write(bytes); }
+ else {
+ QIODevice* device = m_socket.get();
+#ifdef HAVE_SERIALPORT
+ if (!m_network) { device = m_serial.get(); }
+#endif
+ success = device->bytesToWrite() < 1024 && device->write(bytes) == bytes.size();
+ }
+ if (!success) { transportClosed(QStringLiteral("KXPA100 transport write failed.")); return false; }
+ report(QStringLiteral("TX %1").arg(QString::fromLatin1(bytes)), !m_operationWriting); return true;
+}
+void Kxpa100Model::enqueue(Request request)
+{
+ if (!m_connected || (request.mutation && !operationBusy())) { return; }
+ if (request.wire.isEmpty() || m_queue.size() >= 64) { fail(QStringLiteral("Invalid or excessive KXPA100 request queue.")); return; }
+ m_queue.enqueue(std::move(request));
+}
+void Kxpa100Model::query(const QByteArray& wire, std::function complete)
+{
+ enqueue({wire, wire.chopped(1), [wire](const QByteArray& frame) { return matchesStatusQuery(wire,frame); },
+ [complete = std::move(complete)](const QByteArray&) { if (complete) { complete(); } }});
+}
+void Kxpa100Model::queueIdentity()
+{
+ for (const QByteArray& command : {QByteArray("^I;"),QByteArray("^RV;"),QByteArray("^SN;"),QByteArray("^TU;"),
+ QByteArray("^TP;"),QByteArray("^PI;"),QByteArray("^PF;"),QByteArray("^MD;"),QByteArray("^AN;")}) { query(command); }
+}
+void Kxpa100Model::tick()
+{
+ const qint64 now = m_now();
+ if (m_connecting && now - m_connectStarted > 10000) { transportClosed(QStringLiteral("Connection timed out.")); return; }
+ if (!m_connected) { return; }
+ emit availabilityChanged();
+ if (!m_connected) { return; }
+ if (m_active) {
+ if (now - m_sentAt >= m_active->timeoutMs) {
+ // This protocol has no transaction IDs. Closing the transport is the
+ // only way to keep a timed-out response from satisfying a later GET.
+ transportClosed(QStringLiteral("KXPA100 reply timed out; completion is uncertain. No write was retried."));
+ }
+ return;
+ }
+ if (!m_settleGetter.isEmpty()) {
+ if (now >= m_settleDeadline) { fail(QStringLiteral("Requested value was not confirmed; no write was retried.")); return; }
+ if (m_queue.isEmpty() && now >= m_settleNext) { pollSettle(); }
+ }
+ if (m_deferredTuneCancel && !operationBusy()) {
+ m_deferredTuneCancel = false; cancelTune();
+ }
+ if (m_queue.isEmpty()) {
+ if (operationBusy() || now < m_nextPoll) { return; }
+ const QList> priority{{"^TP;",750},{"^PI;",750},{"^PF;",750},{"^SW;",1500},
+ {"^MD;",2500},{"^AN;",2500},{"^OP;",2500},{"^TU;",2500},{"^FL;",2500}};
+ for (const auto& [command, age] : priority) {
+ if (!fresh(QString::fromLatin1(command.chopped(1)),age)) { query(command); break; }
+ }
+ if (m_queue.isEmpty()) { query(kPolls[m_pollIndex++ % kPolls.size()]); }
+ m_nextPoll = now + 50;
+ }
+ if (m_queue.isEmpty()) { return; }
+ m_active = m_queue.dequeue(); m_sentAt = now;
+ if (m_active->mutation && ((m_active->requiresIdle && !idle()) ||
+ m_status.serial != m_operationSerial || m_status.firmware != m_operationFirmware)) {
+ fail(QStringLiteral("Write stopped: device identity or idle/RF readback is unsafe or stale.")); return;
+ }
+ if (m_active->mutation && m_tunePermit && !m_tunePermit()) { fail(QStringLiteral("Operation intent expired; request discarded.")); return; }
+ const QByteArray wire = m_active->wire; write(wire);
+}
+void Kxpa100Model::receiveBytes(const QByteArray& bytes) { m_parser->feed(bytes); }
+void Kxpa100Model::receiveFrame(const QByteArray& frame)
+{
+ if (!m_connected) { return; }
+ report(QStringLiteral("RX %1;").arg(QString::fromLatin1(frame)), true);
+ if (frame == "^FE") {
+ m_tuneEnded = true; emit telemetryReceived(QStringLiteral("^FE"), m_status); return;
+ }
+ Status parsed = m_status;
+ const bool telemetry = applyStatus(frame,parsed);
+ if (telemetry && ((!m_status.identity.isEmpty() && parsed.identity != m_status.identity) ||
+ (!m_status.serial.isEmpty() && parsed.serial != m_status.serial) ||
+ (!m_status.firmware.isEmpty() && parsed.firmware != m_status.firmware))) {
+ transportClosed(QStringLiteral("KXPA100 identity changed; pending operations discarded.")); return;
+ }
+ if (!m_active || !m_active->matches(frame)) { return; }
+ if (m_now() - m_sentAt >= m_active->timeoutMs) {
+ transportClosed(QStringLiteral("KXPA100 response arrived after its deadline; completion discarded.")); return;
+ }
+ Request request = std::move(*m_active); m_active.reset();
+ const quint64 generation = m_generation;
+ m_lastReply = m_now();
+ if (telemetry && !request.telemetryKey.isEmpty()) {
+ m_status = parsed; m_received[QString::fromLatin1(request.telemetryKey)] = m_now();
+ if (!m_detected && m_status.identity == QStringLiteral("KXPA100") && !m_status.serial.isEmpty() && !m_status.firmware.isEmpty()) {
+ m_detected = true; emit detectedChanged(true);
+ }
+ emit statusUpdated(m_status); emit telemetryReceived(QString::fromLatin1(request.telemetryKey),m_status);
+ }
+ if (generation == m_generation && m_connected && request.complete) { request.complete(frame); }
+}
+bool Kxpa100Model::begin(const QString& name, bool writing, bool tuner)
+{
+ const QString reason = unavailableReason(writing,tuner);
+ if (!reason.isEmpty()) { emit operationFailed(reason); return false; }
+ ++m_generation; m_queue.clear(); m_operation = name; m_operationWriting = writing;
+ m_operationSerial = m_status.serial; m_operationFirmware = m_status.firmware;
+ m_progress = 0; m_total = 0;
+ const quint64 generation = m_generation;
+ report(name); emit operationBusyChanged(true); emit availabilityChanged();
+ return m_connected && generation == m_generation && operationBusy();
+}
+void Kxpa100Model::finish(const QString& message)
+{
+ m_operation.clear(); m_operationWriting = false; m_tunePermit = {};
+ m_settleGetter.clear(); m_settleVerify = {}; m_edits.clear(); m_regionComplete = {};
+ m_nextPoll = m_now() + 50;
+ report(message); emit operationBusyChanged(false); emit operationFinished(message); emit availabilityChanged();
+}
+void Kxpa100Model::fail(const QString& reason)
+{
+ ++m_generation;
+ const bool busy = operationBusy();
+ m_queue.clear(); m_active.reset(); m_operation.clear(); m_operationWriting = false;
+ m_tunePermit = {}; m_settleGetter.clear(); m_settleVerify = {}; m_edits.clear();
+ m_regionComplete = {}; m_regionData.clear(); m_pendingBackup = {};
+ m_nextPoll = m_now() + 50;
+ qCWarning(lcKxpa100).noquote() << reason;
+ report(reason); if (busy) { emit operationBusyChanged(false); } emit operationFailed(reason); emit availabilityChanged();
+}
+void Kxpa100Model::cancelOperation()
+{
+ if (!operationBusy()) { return; }
+ if (m_active) { transportClosed(QStringLiteral("Operation canceled during a request. Already applied writes remain; reconnect to inspect.")); }
+ else { fail(QStringLiteral("Operation canceled. Already confirmed writes remain applied.")); }
+}
+void Kxpa100Model::preflight(std::function ready, bool requireIdle)
+{
+ query("^I;"); query("^RV;"); query("^SN;"); query("^TP;"); query("^PI;");
+ query("^PF;",[this,ready=std::move(ready),requireIdle] {
+ if (!operationBusy()) { return; }
+ if (m_status.serial != m_operationSerial || m_status.firmware != m_operationFirmware || (requireIdle && !idle())) {
+ fail(QStringLiteral("Operation stopped: device identity changed or RF/tuning is active.")); return;
+ }
+ ready();
+ });
+}
+bool Kxpa100Model::requestValue(const QByteArray& setter, const QByteArray& getter,
+ std::function verify, const QString& name, bool tuner)
+{
+ if (setter.isEmpty() || !begin(name,true,tuner)) { return false; }
+ preflight([this,setter,getter,verify=std::move(verify)] { settle(setter,getter,verify); }); return true;
+}
+void Kxpa100Model::settle(const QByteArray& setter, const QByteArray& getter,
+ std::function verify, bool requireIdle)
+{
+ m_settleGetter = getter; m_settleVerify = std::move(verify); m_settleDeadline = m_now() + 2500;
+ m_settleRequiresIdle = requireIdle;
+ enqueue({setter + getter,getter.chopped(1),[getter](const QByteArray& frame) { return matchesStatusQuery(getter,frame); },
+ [this](const QByteArray&) { confirmSettle(); },2000,true,requireIdle});
+}
+void Kxpa100Model::confirmSettle()
+{
+ if (m_settleGetter.isEmpty() || !operationBusy()) { return; }
+ if (m_now() >= m_settleDeadline) { fail(QStringLiteral("Readback arrived after the operation deadline.")); return; }
+ if (m_settleVerify(m_status)) { finish(QStringLiteral("Device readback confirmed.")); return; }
+ m_settleNext = m_now() + 100;
+}
+void Kxpa100Model::pollSettle()
+{
+ if (m_settleRequiresIdle && !idle()) { fail(QStringLiteral("RF or tuning became active while confirming the change.")); return; }
+ query(m_settleGetter,[this] { confirmSettle(); });
+}
+bool Kxpa100Model::setOperate(bool value)
+{
+ return requestValue(value ? "^OP1;" : "^OP0;","^OP;",[value](const Status& s) { return s.operate == value; },QStringLiteral("Set PA operation"));
+}
+bool Kxpa100Model::setMode(Mode value)
+{
+ const QByteArray command = value == Mode::Auto ? "^MDA;" : value == Mode::Manual ? "^MDM;" : "^MDB;";
+ return requestValue(command,"^MD;",[value](const Status& s) { return s.mode == value; },QStringLiteral("Set tuner mode"),true);
+}
+bool Kxpa100Model::setAntenna(int value)
+{
+ if (value < 1 || value > 2) { return false; }
+ if (m_configuration && m_status.band && !((*m_configuration)[*m_status.band] & (1 << (value - 1)))) {
+ emit operationFailed(QStringLiteral("This antenna is disabled on the current band.")); return false;
+ }
+ return requestValue("^AN" + QByteArray::number(value) + ';',"^AN;",[value](const Status& s) { return s.antenna == value; },QStringLiteral("Select antenna"),true);
+}
+bool Kxpa100Model::setCapacitorMask(int value)
+{
+ if (value < 0 || value > 255) { return false; }
+ return requestValue("^CR" + hexMask(value) + ';',"^CR;",[value](const Status& s) { return s.capacitorMask == value; },QStringLiteral("Set capacitance"),true);
+}
+bool Kxpa100Model::setInductorMask(int value)
+{
+ if (value < 0 || value > 255) { return false; }
+ return requestValue("^LR" + hexMask(value) + ';',"^LR;",[value](const Status& s) { return s.inductorMask == value; },QStringLiteral("Set inductance"),true);
+}
+bool Kxpa100Model::setCapacitorSide(CapacitorSide value)
+{
+ return requestValue(value == CapacitorSide::Transmitter ? "^SIT;" : "^SIA;","^SI;",
+ [value](const Status& s) { return s.side == value; },QStringLiteral("Set capacitor side"),true);
+}
+bool Kxpa100Model::setBypassed(bool value)
+{
+ return requestValue(value ? "^BYB;" : "^BYN;","^BY;",[value](const Status& s) { return s.bypassed == value; },QStringLiteral("Set physical ATU bypass"),true);
+}
+bool Kxpa100Model::setAttenuator(bool value)
+{
+ if (m_status.attenuator == 2) { emit operationFailed(QStringLiteral("The rear-panel switch holds the attenuator on.")); return false; }
+ return requestValue(value ? "^AT1;" : "^AT0;","^AT;",[value](const Status& s) { return s.attenuator == int(value); },QStringLiteral("Set attenuator"));
+}
+bool Kxpa100Model::clearFault()
+{
+ return requestValue("^FLC;","^FL;",[](const Status& s) { return s.faultCode == 'N'; },QStringLiteral("Clear current fault"));
+}
+bool Kxpa100Model::setBand(int band)
+{
+ if (band < 0 || band > 10) { return false; }
+ return requestValue("^BN" + QByteArray::number(band).rightJustified(2,'0') + ';',"^BN;",
+ [band](const Status& s) { return s.band == band; },QStringLiteral("Select band"));
+}
+bool Kxpa100Model::startTune(std::function permit)
+{
+ if (!begin(QStringLiteral("Start fresh tune"),true,true)) { return false; }
+ m_tunePermit = std::move(permit); m_tuneEnded = false;
+ preflight([this] { settle("^FT;","^TP;",[this](const Status& s) { return s.tuning == true || m_tuneEnded; }); }); return true;
+}
+bool Kxpa100Model::cancelTune()
+{
+ if (!begin(QStringLiteral("Cancel tuner search"),false,true)) { return false; }
+ m_operationWriting = true;
+ settle("^FE;","^TP;",[](const Status& s) { return s.tuning == false; },false); return true;
+}
+void Kxpa100Model::cancelTuneAfterPendingOperation()
+{
+ if (!m_connected || !m_detected) { return; }
+ if (operationBusy()) { m_deferredTuneCancel = true; return; }
+ cancelTune();
+}
+void Kxpa100Model::readMatch(std::function complete)
+{
+ for (const QByteArray& command : {QByteArray("^TP;"),QByteArray("^MD;"),QByteArray("^AN;"),QByteArray("^BY;"),
+ QByteArray("^SI;"),QByteArray("^CR;"),QByteArray("^SW;"),QByteArray("^SB;")}) { query(command); }
+ query("^LR;",std::move(complete));
+}
+bool Kxpa100Model::readMatchStatus()
+{
+ if (!begin(QStringLiteral("Read tuner match"),false,true)) { return false; }
+ readMatch([this] { finish(QStringLiteral("Tuner match readback complete.")); }); return true;
+}
+void Kxpa100Model::requestMemory(int frequencyKhz, const QByteArray& prefix, std::function complete)
+{
+ enqueue({prefix + buildFrequencyCommand("^DM",frequencyKhz),{},[](const QByteArray& frame) { return decodeMemoryBin(frame).has_value(); },
+ [this,frequencyKhz,complete=std::move(complete)](const QByteArray& frame) {
+ const MemoryBin bin = *decodeMemoryBin(frame);
+ if (bin.frequencyKhz != frequencyKhz) { fail(QStringLiteral("Memory reply is for a different frequency.")); return; }
+ m_memories = bin; emit memoriesRead(bin);
+ if (operationBusy() && complete) { complete(); }
+ },4000,!prefix.isEmpty()});
+}
+bool Kxpa100Model::inspectMemories(int frequencyKhz)
+{
+ if (!isSupportedFrequency(frequencyKhz) || !begin(QStringLiteral("Inspect tuner memories"),false,true)) { return false; }
+ requestMemory(frequencyKhz,{},[this] { finish(QStringLiteral("Memory readback complete.")); }); return true;
+}
+bool Kxpa100Model::memorize(int frequencyKhz, std::function permit)
+{
+ if (!isSupportedFrequency(frequencyKhz) || !begin(QStringLiteral("Save current match"),true,true)) { return false; }
+ m_tunePermit = std::move(permit);
+ preflight([this,frequencyKhz] {
+ readMatch([this,frequencyKhz] {
+ const Status expected = m_status;
+ if (!expected.antenna || !expected.bypassed || !expected.side || !expected.capacitorMask || !expected.inductorMask) {
+ fail(QStringLiteral("Current match is incomplete.")); return;
+ }
+ requestMemory(frequencyKhz,buildFrequencyCommand("^SM",frequencyKhz),[this,expected] {
+ const bool found = std::any_of(m_memories.matches.cbegin(),m_memories.matches.cend(),[&expected](const MemoryMatch& match) {
+ return !match.empty && match.antenna == expected.antenna && match.bypassed == expected.bypassed &&
+ (match.bypassed || (match.side == expected.side && match.capacitorMask == expected.capacitorMask && match.inductorMask == expected.inductorMask));
+ });
+ if (!found) { fail(QStringLiteral("Saved match was not confirmed by memory readback.")); return; }
+ finish(QStringLiteral("Saved match confirmed by memory readback."));
+ });
+ });
+ }); return true;
+}
+bool Kxpa100Model::recallFrequency(int frequencyKhz)
+{
+ if (!isSupportedFrequency(frequencyKhz) || !begin(QStringLiteral("Recall frequency match"),true,true)) { return false; }
+ preflight([this,frequencyKhz] {
+ enqueue({buildFrequencyCommand("^MT",frequencyKhz) + "^BN;","^BN",[](const QByteArray& frame) { return matchesStatusQuery("^BN;",frame); },
+ [this](const QByteArray&) { readMatch([this] { finish(QStringLiteral("Recall processed; current tuner settings read back.")); }); },2000,true});
+ }); return true;
+}
+} // namespace AetherSDR
diff --git a/src/models/Kxpa100Model.h b/src/models/Kxpa100Model.h
new file mode 100644
index 0000000000..944165ded0
--- /dev/null
+++ b/src/models/Kxpa100Model.h
@@ -0,0 +1,178 @@
+#pragma once
+#include "Kxpa100Types.h"
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+class QTcpSocket;
+class QSerialPort;
+namespace AetherSDR::Kxpa100 { class FrameParser; }
+namespace AetherSDR {
+class Kxpa100Model : public QObject {
+ Q_OBJECT
+public:
+ explicit Kxpa100Model(QObject* parent = nullptr);
+ ~Kxpa100Model() override;
+ void connectToHost(const QString& host, quint16 port);
+ void connectSerial(const QString& portName, int baudRate = 38400);
+ void disconnectDevice();
+ void setAutoReconnect(bool enabled) { m_autoReconnect = enabled; }
+ bool isConnected() const { return m_connected; }
+ bool isConnecting() const { return m_connecting; }
+ bool isDetected() const { return m_detected; }
+ bool isResponding() const;
+ bool isNetworkConnection() const { return m_network; }
+ QString description() const;
+ const Kxpa100::Status& status() const { return m_status; }
+ qint64 fieldAgeMs(const QString& command) const;
+ QString unavailableReason(bool writing = false, bool tuner = false) const;
+ bool tunerEligible() const;
+ bool amplifierMeterEligible() const;
+ bool operationBusy() const { return !m_operation.isEmpty(); }
+ QString operationName() const { return m_operation; }
+ bool setOperate(bool operate);
+ bool setMode(Kxpa100::Mode mode);
+ bool setAntenna(int antenna);
+ bool setCapacitorMask(int mask);
+ bool setInductorMask(int mask);
+ bool setCapacitorSide(Kxpa100::CapacitorSide side);
+ bool setBypassed(bool bypassed);
+ bool setAttenuator(bool enabled);
+ bool clearFault();
+ bool startTune(std::function permit = {});
+ bool cancelTune();
+ void cancelTuneAfterPendingOperation();
+ bool readMatchStatus();
+ bool memorize(int frequencyKhz, std::function permit = {});
+ bool recallFrequency(int frequencyKhz);
+ bool setBand(int band);
+ const std::optional& configuration() const { return m_configuration; }
+ bool readConfiguration();
+ bool applyConfiguration(const Kxpa100::Configuration& configuration);
+ bool readBackup();
+ bool restoreBackup(const Kxpa100::Backup& backup, bool confirmed);
+ const Kxpa100::Backup& backup() const { return m_backup; }
+ static QByteArray encodeBackupFile(const Kxpa100::Backup& backup);
+ static std::optional decodeBackupFile(const QByteArray& data, QString* error = nullptr);
+ static bool saveBackupFile(const QString& path, const Kxpa100::Backup& backup, QString* error = nullptr);
+ bool readFaultHistory();
+ const QList& faults() const { return m_faults; }
+ static QByteArray faultsCsv(const QList& faults);
+ bool inspectMemories(int frequencyKhz);
+ const Kxpa100::MemoryBin& memories() const { return m_memories; }
+ bool eraseMemories(int band, int antenna, bool confirmed);
+ bool factoryReset(bool confirmed);
+ void cancelOperation();
+ void setVerbose(bool enabled) { m_verbose = enabled; }
+ bool verbose() const { return m_verbose; }
+ QString debugText() const { return m_debug.join(QLatin1Char('\n')); }
+ void clearDebug() { m_debug.clear(); emit debugChanged(); }
+signals:
+ void associationChanged();
+ void connected();
+ void disconnected();
+ void connectionFailed(const QString& reason);
+ void detectedChanged(bool detected);
+ void statusUpdated(const AetherSDR::Kxpa100::Status& status);
+ void telemetryReceived(const QString& command, const AetherSDR::Kxpa100::Status& status);
+ void availabilityChanged();
+ void diagnostic(const QString& line);
+ void debugChanged();
+ void operationBusyChanged(bool busy);
+ void operationProgress(int completed, int total);
+ void operationFinished(const QString& message);
+ void operationFailed(const QString& reason);
+ void configurationRead(const AetherSDR::Kxpa100::Configuration& configuration);
+ void backupRead(const AetherSDR::Kxpa100::Backup& backup);
+ void faultHistoryRead(const QList& records);
+ void memoriesRead(const AetherSDR::Kxpa100::MemoryBin& bin);
+private:
+ friend class Kxpa100ModelTest;
+ struct Request {
+ QByteArray wire, telemetryKey;
+ std::function matches;
+ std::function complete;
+ int timeoutMs = 2000;
+ bool mutation = false, requiresIdle = true;
+ };
+ void transportOpened();
+ void transportClosed(const QString& reason = {});
+ void reconnect();
+ void receiveBytes(const QByteArray& bytes);
+ void receiveFrame(const QByteArray& frame);
+ void tick();
+ bool fresh(const QString& key, int maxAge = 5000) const;
+ bool idle() const;
+ bool write(const QByteArray& bytes);
+ void enqueue(Request request);
+ void query(const QByteArray& wire, std::function complete = {});
+ void queueIdentity();
+ bool begin(const QString& name, bool writing, bool tuner = false);
+ void finish(const QString& message);
+ void fail(const QString& reason);
+ void report(const QString& text, bool polling = false);
+ void preflight(std::function ready, bool requireIdle = true);
+ void readMatch(std::function complete);
+ bool requestValue(const QByteArray& setter, const QByteArray& getter,
+ std::function verify, const QString& name, bool tuner = false);
+ void settle(const QByteArray& setter, const QByteArray& getter,
+ std::function verify, bool requireIdle = true);
+ void pollSettle();
+ void confirmSettle();
+ void requestMemory(int frequencyKhz, const QByteArray& prefix, std::function complete);
+ void applyNextSetting();
+ void readRegion(bool external, int address, int end, std::function complete);
+ void readRegionBlock();
+ void restoreBlock();
+ void restoreNextRegion();
+ void restartAfterMaintenance(const QString& message);
+
+ std::unique_ptr m_parser;
+ std::unique_ptr m_socket;
+#ifdef HAVE_SERIALPORT
+ std::unique_ptr m_serial;
+#endif
+ QElapsedTimer m_clock;
+ QTimer m_timer, m_reconnectTimer;
+ std::function m_now;
+ std::function m_write;
+ QQueue m_queue;
+ std::optional m_active;
+ quint64 m_generation = 0;
+ qint64 m_sentAt = 0, m_lastReply = -10000, m_nextPoll = 0, m_connectStarted = 0;
+ int m_pollIndex = 0;
+ bool m_connected = false, m_connecting = false, m_detected = false, m_closing = false;
+ bool m_network = true, m_autoReconnect = false, m_deliberateDisconnect = false;
+ bool m_verbose = false, m_deferredTuneCancel = false, m_tuneEnded = false;
+ QString m_host, m_portName;
+ quint16 m_port = 0;
+ int m_baud = 38400;
+ Kxpa100::Status m_status;
+ QMap m_received;
+ QStringList m_debug;
+ QString m_operation, m_operationSerial, m_operationFirmware;
+ bool m_operationWriting = false;
+ int m_progress = 0, m_total = 0;
+ QByteArray m_settleGetter;
+ std::function m_settleVerify;
+ std::function m_tunePermit;
+ qint64 m_settleDeadline = 0, m_settleNext = 0;
+ bool m_settleRequiresIdle = true;
+ std::optional m_configuration;
+ QQueue> m_edits;
+ Kxpa100::Backup m_backup, m_pendingBackup, m_restore;
+ QList m_faults;
+ Kxpa100::MemoryBin m_memories;
+ bool m_regionExternal = false;
+ int m_regionAddress = 0, m_regionEnd = 0;
+ QByteArray m_regionData;
+ std::function m_regionComplete;
+ bool m_restoreExternal = false;
+ int m_restoreAddress = 0;
+};
+}
diff --git a/src/models/Kxpa100Model_Maintenance.cpp b/src/models/Kxpa100Model_Maintenance.cpp
new file mode 100644
index 0000000000..dedc64f6a6
--- /dev/null
+++ b/src/models/Kxpa100Model_Maintenance.cpp
@@ -0,0 +1,183 @@
+#include "Kxpa100Model.h"
+#include "core/Kxpa100Protocol.h"
+#include "core/Kxpa100Maintenance.h"
+#include
+#include
+namespace AetherSDR {
+using namespace Kxpa100;
+void Kxpa100Model::readRegion(bool external, int address, int end, std::function complete)
+{
+ m_regionExternal = external; m_regionAddress = address; m_regionEnd = end;
+ m_regionData.clear(); m_regionComplete = std::move(complete); readRegionBlock();
+}
+void Kxpa100Model::readRegionBlock()
+{
+ if (!m_connected || !operationBusy()) { return; }
+ if (m_regionAddress >= m_regionEnd) {
+ auto complete = std::move(m_regionComplete); QByteArray data = std::move(m_regionData);
+ if (complete) { complete(data); } return;
+ }
+ const int address = m_regionAddress; const int length = std::min(64,m_regionEnd-address); const bool external = m_regionExternal;
+ enqueue({buildEepromRead(external,address,length),{},[external,address,length](const QByteArray& frame) {
+ const auto reply = parseEepromReply(frame);
+ return reply && !reply->writeAck && reply->external == external && reply->address == address && reply->length == length;
+ },[this,length](const QByteArray& frame) {
+ m_regionData += parseEepromReply(frame)->data; m_regionAddress += length;
+ m_progress += length; emit operationProgress(m_progress,m_total); readRegionBlock();
+ }});
+}
+bool Kxpa100Model::readBackup()
+{
+ if (!begin(QStringLiteral("Read complete backup"),false)) { return false; }
+ if (m_status.firmware != QStringLiteral("01.39")) { fail(QStringLiteral("Complete EEPROM support is verified only for firmware 01.39.")); return false; }
+ m_backup = {}; m_pendingBackup = {}; m_total = 1024 + 16384;
+ preflight([this] {
+ m_pendingBackup.serial = m_status.serial; m_pendingBackup.firmware = m_status.firmware;
+ readRegion(false,0,1024,[this](QByteArray data) {
+ m_pendingBackup.internal = std::move(data);
+ readRegion(true,0,16384,[this](QByteArray data) {
+ m_pendingBackup.external = std::move(data);
+ preflight([this] {
+ m_backup = std::move(m_pendingBackup);
+ const Backup result = m_backup;
+ finish(QStringLiteral("Complete backup read, including fault history.")); emit backupRead(result);
+ });
+ });
+ });
+ }); return true;
+}
+bool Kxpa100Model::restoreBackup(const Backup& backup, bool confirmed)
+{
+ if (!confirmed) { emit operationFailed(QStringLiteral("Explicit restore confirmation is required.")); return false; }
+ const QString reason = restoreAllowed(backup,m_status.serial,m_status.firmware);
+ if (!reason.isEmpty()) { emit operationFailed(reason); return false; }
+ if (!begin(QStringLiteral("Restore and verify backup"),true)) { return false; }
+ m_restore = backup; m_restoreExternal = false; m_restoreAddress = 32; m_total = 992 + 14336;
+ preflight([this] { restoreBlock(); }); return true;
+}
+void Kxpa100Model::restoreBlock()
+{
+ if (!m_connected || !operationBusy()) { return; }
+ const int end = m_restoreExternal ? 0x3800 : 1024;
+ if (m_restoreAddress >= end) { restoreNextRegion(); return; }
+ if (!fresh(QStringLiteral("^PI"),1000) || !fresh(QStringLiteral("^PF"),1000) || !fresh(QStringLiteral("^TP"),1000)) {
+ preflight([this] { restoreBlock(); }); return;
+ }
+ const int address = m_restoreAddress; const bool external = m_restoreExternal;
+ const int count = std::min({64, end-address, 256-(address%256)});
+ const QByteArray data = (external ? m_restore.external : m_restore.internal).mid(address,count);
+ const int length = data.size();
+ enqueue({buildEepromWrite(external,address,data),{},[external,address,length](const QByteArray& frame) {
+ const auto reply = parseEepromReply(frame); return reply && reply->writeAck && reply->external == external && reply->address == address && reply->length == length;
+ },[this,external,address,data,length](const QByteArray&) {
+ enqueue({buildEepromRead(external,address,length),{},[external,address,length](const QByteArray& frame) {
+ const auto reply = parseEepromReply(frame); return reply && !reply->writeAck && reply->external == external && reply->address == address && reply->length == length;
+ },[this,data,length](const QByteArray& frame) {
+ if (parseEepromReply(frame)->data != data) { fail(QStringLiteral("EEPROM readback mismatch. Restore stopped without retry or restart.")); return; }
+ m_restoreAddress += length; m_progress += length; emit operationProgress(m_progress,m_total); restoreBlock();
+ }});
+ },3000,true});
+}
+void Kxpa100Model::restoreNextRegion()
+{
+ if (!m_connected || !operationBusy()) { return; }
+ if (!m_restoreExternal) { m_restoreExternal = true; m_restoreAddress = 0; restoreBlock(); }
+ else {
+ m_configuration.reset();
+ restartAfterMaintenance(QStringLiteral("Restore readback verified; identity and fault history preserved. Restart requested. Reconnect to verify restored settings."));
+ }
+}
+bool Kxpa100Model::readFaultHistory()
+{
+ if (!begin(QStringLiteral("Read fault history"),false)) { return false; }
+ if (m_status.firmware != QStringLiteral("01.39")) { fail(QStringLiteral("Fault-history decoding is verified only for firmware 01.39.")); return false; }
+ m_total = 2049;
+ preflight([this] {
+ readRegion(false,31,32,[this](QByteArray pointer) {
+ const int slot = static_cast(pointer[0]);
+ readRegion(true,0x3800,0x4000,[this,slot](QByteArray data) {
+ m_faults = decodeFaultHistory(slot,data); const QList result = m_faults;
+ finish(QStringLiteral("Fault history read; current fault unchanged.")); emit faultHistoryRead(result);
+ });
+ });
+ }); return true;
+}
+bool Kxpa100Model::readConfiguration()
+{
+ if (!begin(QStringLiteral("Read antenna configuration"),false,true)) { return false; }
+ enqueue({"^AEA;",{},[](const QByteArray& frame) { return parseConfiguration(frame).has_value(); },
+ [this](const QByteArray& frame) {
+ m_configuration = parseConfiguration(frame);
+ const Configuration result = *m_configuration;
+ finish(QStringLiteral("Antenna configuration read.")); emit configurationRead(result);
+ }}); return true;
+}
+bool Kxpa100Model::applyConfiguration(const Configuration& configuration)
+{
+ if (!m_configuration) { emit operationFailed(QStringLiteral("Read antenna configuration before applying changes.")); return false; }
+ for (const int mask : configuration) {
+ if (mask < 1 || mask > 3) { emit operationFailed(QStringLiteral("Enable at least one antenna on every band.")); return false; }
+ }
+ if (!begin(QStringLiteral("Apply antenna configuration"),true,true)) { return false; }
+ m_edits.clear();
+ for (int band = 0; band < 11; ++band) {
+ if ((*m_configuration)[band] != configuration[band]) { m_edits.enqueue({band,configuration[band]}); }
+ }
+ m_total = m_edits.size(); preflight([this] { applyNextSetting(); }); return true;
+}
+void Kxpa100Model::applyNextSetting()
+{
+ if (!m_connected || !operationBusy()) { return; }
+ if (m_edits.isEmpty()) {
+ const Configuration result = *m_configuration;
+ finish(QStringLiteral("All changed antenna settings confirmed.")); emit configurationRead(result); return;
+ }
+ if (!fresh(QStringLiteral("^PI"),1000) || !fresh(QStringLiteral("^PF"),1000) || !fresh(QStringLiteral("^TP"),1000)) {
+ preflight([this] { applyNextSetting(); }); return;
+ }
+ const auto [band,mask] = m_edits.dequeue();
+ enqueue({configurationSet(band,mask) + "^AEA;",{},[](const QByteArray& frame) { return parseConfiguration(frame).has_value(); },
+ [this,band,mask](const QByteArray& frame) {
+ m_configuration = parseConfiguration(frame);
+ const Configuration confirmed = *m_configuration;
+ const quint64 generation = m_generation;
+ emit configurationRead(confirmed);
+ if (!m_connected || generation != m_generation || !operationBusy()) { return; }
+ if (confirmed[band] != mask) { fail(QStringLiteral("Antenna setting was not confirmed; remaining edits discarded.")); return; }
+ emit operationProgress(++m_progress,m_total); applyNextSetting();
+ },2000,true});
+}
+bool Kxpa100Model::eraseMemories(int band, int antenna, bool confirmed)
+{
+ const QByteArray command = buildErase(band,antenna);
+ if (!confirmed || command.isEmpty() || !begin(QStringLiteral("Erase selected tuner memories"),true,true)) { return false; }
+ preflight([this,command] {
+ enqueue({command + "^RV;","^RV",[](const QByteArray& frame) { return matchesStatusQuery("^RV;",frame); },
+ [this](const QByteArray&) { finish(QStringLiteral("Erase command processed. Inspect affected frequencies to verify memory contents.")); },6000,true});
+ }); return true;
+}
+void Kxpa100Model::restartAfterMaintenance(const QString& message)
+{
+ if (!m_connected || !operationBusy()) { return; }
+ if (!idle()) { fail(QStringLiteral("Restart stopped: RF or tuning is active or stale. Confirmed writes remain applied.")); return; }
+ if (!write("^RS;")) { return; }
+ finish(message);
+ // Reset drops the serial session and can change its baud. Never replay writes
+ // or enable RF on reconnect; identification starts afresh.
+ disconnectDevice();
+}
+bool Kxpa100Model::factoryReset(bool confirmed)
+{
+ if (!confirmed || !begin(QStringLiteral("Reset configuration"),true)) { return false; }
+ preflight([this] {
+ enqueue({"^EC;^RV;","^RV",[](const QByteArray& frame) { return matchesStatusQuery("^RV;",frame); },
+ [this](const QByteArray&) {
+ preflight([this] { restartAfterMaintenance(QStringLiteral("Reset and restart requested. Reconnect at the device's default baud and verify configuration.")); });
+ },6000,true});
+ }); return true;
+}
+QByteArray Kxpa100Model::encodeBackupFile(const Backup& backup) { return encodeBackup(backup,QDateTime::currentDateTime()); }
+std::optional Kxpa100Model::decodeBackupFile(const QByteArray& bytes, QString* error) { return decodeBackup(bytes,error); }
+bool Kxpa100Model::saveBackupFile(const QString& path, const Backup& backup, QString* error) { return saveBackup(path,backup,error); }
+QByteArray Kxpa100Model::faultsCsv(const QList& faults) { return faultHistoryCsv(faults); }
+} // namespace AetherSDR
diff --git a/src/models/Kxpa100RadioAssociation.cpp b/src/models/Kxpa100RadioAssociation.cpp
new file mode 100644
index 0000000000..47d9e0ee88
--- /dev/null
+++ b/src/models/Kxpa100RadioAssociation.cpp
@@ -0,0 +1,95 @@
+#include "Kxpa100RadioAssociation.h"
+#include "Kxpa100Model.h"
+#include "RadioModel.h"
+#include "SliceModel.h"
+#include
+#include