diff --git a/app.go b/app.go
index 2e040a1..0ec1bc0 100644
--- a/app.go
+++ b/app.go
@@ -43,6 +43,7 @@ import (
"hamlog/internal/geo"
"hamlog/internal/gridcache"
"hamlog/internal/integrations/udp"
+ "hamlog/internal/kpa"
"hamlog/internal/lookup"
"hamlog/internal/lotwusers"
"hamlog/internal/netctl"
@@ -18075,6 +18076,7 @@ type ampInst struct {
pgxl *powergenius.Client
spe *spe.Client
acom *acom.Client
+ kpa *kpa.Client
catemu *catemu.Server // Kenwood-format responder for band-follow (ACOM)
}
@@ -18088,6 +18090,9 @@ func (i *ampInst) stopAll() {
if i.acom != nil {
i.acom.Stop()
}
+ if i.kpa != nil {
+ i.kpa.Stop()
+ }
if i.catemu != nil {
i.catemu.Stop()
}
@@ -18102,6 +18107,8 @@ func ampTypeLabel(t string) string {
return "SPE " + map[string]string{"spe13": "1.3K-FA", "spe15": "1.5K-FA", "spe2k": "2K-FA"}[t]
case strings.HasPrefix(t, "acom"):
return "ACOM " + strings.TrimPrefix(t, "acom") + "S"
+ case strings.HasPrefix(t, "kpa"):
+ return "Elecraft " + strings.ToUpper(t)
}
return t
}
@@ -18213,6 +18220,19 @@ func (a *App) startAmps() {
if a.acom == nil {
a.acom = inst.acom
}
+ case strings.HasPrefix(c.Type, "kpa"):
+ // A KPA500 has no network port at all, so a configuration asking for
+ // one is a mistake worth naming rather than a connection that never
+ // succeeds.
+ if strings.EqualFold(c.Type, "kpa500") && c.Transport == "tcp" {
+ applog.Printf("amp %s: a KPA500 has no network connection — use its serial port", c.Name)
+ continue
+ }
+ inst.kpa = kpa.New(kpa.Config{
+ Model: strings.ToUpper(c.Type), Transport: c.Transport,
+ ComPort: c.ComPort, Baud: c.Baud, Host: c.Host, Port: c.Port,
+ })
+ _ = inst.kpa.Start()
default: // spe*
inst.spe = spe.New(spe.Config{Transport: c.Transport, ComPort: c.ComPort, Baud: c.Baud, Host: c.Host, Port: c.Port})
_ = inst.spe.Start()
@@ -18284,6 +18304,7 @@ type AmpStatus struct {
PGXL *powergenius.Status `json:"pgxl,omitempty"`
SPE *spe.Status `json:"spe,omitempty"`
ACOM *acom.Status `json:"acom,omitempty"`
+ KPA *kpa.Status `json:"kpa,omitempty"`
}
// GetAmpStatuses returns the live state of every ENABLED amplifier, in the
@@ -18310,6 +18331,9 @@ func (a *App) GetAmpStatuses() []AmpStatus {
case inst.acom != nil:
v := inst.acom.GetStatus()
st.ACOM = &v
+ case inst.kpa != nil:
+ v := inst.kpa.GetStatus()
+ st.KPA = &v
}
}
out = append(out, st)
@@ -18391,6 +18415,8 @@ func (a *App) ampOperateOne(id string, on bool) error {
return inst.spe.Operate(on)
case inst.acom != nil:
return inst.acom.Operate(on)
+ case inst.kpa != nil:
+ return inst.kpa.Operate(on)
}
return fmt.Errorf("amplifier not running")
}
@@ -18427,6 +18453,11 @@ func (a *App) ampPowerOne(id string, on, linked bool) error {
return inst.acom.PowerOn()
}
return inst.acom.PowerOff()
+ case inst.kpa != nil:
+ // OFF is a real power-down on a KPA1500: the main supplies drop and the
+ // way back on is the front panel or Wake-on-LAN. The button that reaches
+ // this asks first — see the UI — because "off" here is not standby.
+ return inst.kpa.PowerOn(on)
}
// Not an error worth surfacing when linked: a PGXL alongside two SPEs simply
// has no power command on its direct link, and reporting that as a failure
diff --git a/frontend/src/components/AmpWidget.tsx b/frontend/src/components/AmpWidget.tsx
index 1bf6555..94563a9 100644
--- a/frontend/src/components/AmpWidget.tsx
+++ b/frontend/src/components/AmpWidget.tsx
@@ -15,7 +15,7 @@ import { AmpOperate, AmpPower, AmpPowerLevel, AmpFanMode, FlexAmpOperate } from
// With several amplifiers configured the caller passes a selection ("all" or an
// amp id) chosen from the toolbar icon's dropdown.
-type Amp = { id: string; name: string; type?: string; spe?: any; acom?: any; pgxl?: any };
+type Amp = { id: string; name: string; type?: string; spe?: any; acom?: any; kpa?: any; pgxl?: any };
// Full-scale watts for the output bar, from the model string.
function maxW(model?: string, fallback = 1300): number {
@@ -92,27 +92,39 @@ function OperateButton({ operate, disabled, onClick, t }: {
function AmpBlock({ amp, flex, showName, t }: {
amp: Amp; flex: any; showName: boolean; t: (k: string, v?: any) => string;
}) {
- const spe = amp.spe, acom = amp.acom;
+ const spe = amp.spe, acom = amp.acom, kpaAmp = amp.kpa;
const hold = usePeakHold();
- if (spe || acom) {
- const s = spe || acom;
+ // One block for the three amplifiers OpsLog drives over its own link. They
+ // report the same handful of things under different names, so the differences
+ // are named here rather than spread through the markup.
+ if (spe || acom || kpaAmp) {
+ const s = spe || acom || kpaAmp;
// The amp reports zero watts on receive, so its TX flag clears the meter as
// soon as the operator lets go — and the radio's own flag, when we have one,
// gets there first (the amp is polled on its own slower cycle).
const txing = typeof flex?.transmitting === 'boolean' ? flex.transmitting : s.tx !== false;
const w = hold('w', Number(spe ? s.output_w : s.fwd_w) || 0, txing);
const swr = Number(spe ? s.swr_ant : s.swr) || 0;
- const hi = spe ? maxW(s.model) : (Number(s.max_w) || 800);
+ // The full-scale mark. A KPA500 reading against a 1500 W scale would look
+ // idle at full output, so the model decides it.
+ const hi = spe ? maxW(s.model)
+ : kpaAmp ? (String(s.model).includes('500') ? 500 : 1500)
+ : (Number(s.max_w) || 800);
// Power ON drives the remote-on control lines, so it stays available while
// the amplifier is off and reporting nothing — but only over a serial link.
- const canPowerOn = spe ? (s.connected || s.transport === 'serial') : (s.port_open && s.transport === 'serial');
+ // A KPA answers ^ON while its main supplies are off — the sleeping
+ // microcontroller stays awake for exactly that — so power-on is available
+ // whenever the link itself is up, over serial and over the network alike.
+ const canPowerOn = spe ? (s.connected || s.transport === 'serial')
+ : kpaAmp ? !!s.connected
+ : (s.port_open && s.transport === 'serial');
return (
{showName && (
- {amp.name || (spe ? 'SPE' : 'ACOM')}
+ {amp.name || (spe ? 'SPE' : kpaAmp ? (s.model || 'KPA') : 'ACOM')}
{s.connected && s.band && {s.band}}
)}
@@ -155,11 +167,17 @@ function AmpBlock({ amp, flex, showName, t }: {
) : (
{t('ampw.offline')}
)}
- {(s.warnings || s.alarms || s.err_text) && (
+ {(s.warnings || s.alarms || s.err_text || s.fault_text) && (
- {s.err_text || `${s.warnings || ''} ${s.alarms || ''}`.trim()}
+ {s.fault_text || s.err_text || `${s.warnings || ''} ${s.alarms || ''}`.trim()}
)}
+ {/* Said where the fault is read, because it is the way out of it: on a
+ KPA, going to OPERATE clears the current fault — everything except
+ temperature, which clears by cooling. */}
+ {kpaAmp && s.fault_text && (
+
{t('ampw.kpaClear')}
+ )}
);
diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx
index aa252d8..dab807c 100644
--- a/frontend/src/components/SettingsModal.tsx
+++ b/frontend/src/components/SettingsModal.tsx
@@ -905,9 +905,10 @@ function AmpStatusCard({ id }: { id: string }) {
const t = window.setInterval(tick, 1000);
return () => { alive = false; window.clearInterval(t); };
}, [id]);
- const st: any = amp?.spe ?? amp?.acom ?? amp?.pgxl ?? { connected: false };
+ const st: any = amp?.spe ?? amp?.acom ?? amp?.kpa ?? amp?.pgxl ?? { connected: false };
const isSPE = !!amp?.spe;
const isACOM = !!amp?.acom;
+ const isKPA = !!amp?.kpa;
const operate = !!st.operate;
return (
@@ -948,7 +949,23 @@ function AmpStatusCard({ id }: { id: string }) {
{st.err_text &&
⚠ {st.err_text} ({st.err_code})
}
)}
- {st.connected && !isSPE && !isACOM && (
+ {st.connected && isKPA && (
+
+
{st.power_on ? 'ON' : 'OFF'}
+
Band {st.band || '—'}
+
{st.fwd_w} W
+
SWR {Number(st.swr ?? 0).toFixed(1)}
+
{st.volt_v} V
+
{st.cur_a} A
+
{st.temp_c}°C
+
{st.tuning ? 'TUNING' : ''}
+ {/* A fault has already put the amplifier in standby by itself, so it
+ is the one thing worth the width — and OPERATE is the way out of
+ it, which the button above already is. */}
+ {st.fault_text &&
⚠ {st.fault_text}
}
+
+ )}
+ {st.connected && !isSPE && !isACOM && !isKPA && (
{st.state || ''}
Fan {st.fan_mode || '—'}
@@ -4142,15 +4159,27 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
{ value: 'acom1200', label: '1200S' },
{ value: 'acom2020', label: '2020S' },
],
+ kpa: [
+ { value: 'kpa1500', label: 'KPA1500' },
+ { value: 'kpa500', label: 'KPA500' },
+ ],
};
- const brandOf = (ty: string) => (!ty || ty === 'pgxl') ? 'pgxl' : ty.startsWith('acom') ? 'acom' : 'spe';
+ const brandOf = (ty: string) => (!ty || ty === 'pgxl') ? 'pgxl'
+ : ty.startsWith('acom') ? 'acom'
+ : ty.startsWith('kpa') ? 'kpa' : 'spe';
const patchAmp = (i: number, patch: Partial
) => setAmps((l) => l.map((a, j) => (j === i ? { ...a, ...patch } : a)));
// Each family has a fixed serial speed: SPE talks 115200, the ACOM S-series is
// 9600 8N1 — preset it so switching brand just works. PGXL is TCP-only.
+ // Each family has its own fixed serial speed and its own default port, so
+ // switching model leaves a working configuration rather than one the
+ // operator has to repair. A KPA500 has no network side at all — it is put
+ // on serial here rather than being allowed to sit on a TCP setting that
+ // could never connect.
const applyType = (i: number, v: string) => patchAmp(i, {
type: v,
- transport: v === 'pgxl' ? 'tcp' : amps[i].transport,
- baud: v.startsWith('acom') ? 9600 : v.startsWith('spe') ? 115200 : amps[i].baud,
+ transport: v === 'pgxl' ? 'tcp' : v === 'kpa500' ? 'serial' : amps[i].transport,
+ baud: v.startsWith('acom') ? 9600 : v.startsWith('spe') ? 115200 : v.startsWith('kpa') ? 38400 : amps[i].baud,
+ port: v === 'kpa1500' ? 1500 : amps[i].port,
});
const addAmp = () => setAmps((l) => [...l, {
id: '', name: '', enabled: true, type: 'spe13', transport: 'tcp', host: '', port: 9008, com_port: '', baud: 115200,
@@ -4195,6 +4224,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
4O3A
SPE
ACOM
+ Elecraft
diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx
index 2355510..29ee785 100644
--- a/frontend/src/lib/i18n.tsx
+++ b/frontend/src/lib/i18n.tsx
@@ -419,7 +419,7 @@ const en: Dict = {
'agp.portDeselect': 'Port {letter} — click to deselect', 'agp.portSelect': 'Select on port {letter}', 'agp.online': 'online', 'agp.offline': 'offline', 'agp.close': 'Close', 'agp.connecting': 'Connecting…', 'agp.noAntennas': 'No antennas configured.', 'agp.filterOnHint': 'Showing antennas for {band} only — click to show all bands', 'agp.filterOffHint': 'Showing all antennas — click to show only the current band',
'ampw.title': 'Amplifier', 'ampw.all': 'All amplifiers', 'ampw.pick': 'Which amplifier the widget shows',
'ampw.showHint': 'Amplifier · click to show', 'ampw.hideHint': 'Amplifier — shown · click to hide',
- 'ampw.close': 'Close', 'ampw.offline': 'offline', 'ampw.none': 'No amplifier configured',
+ 'ampw.close': 'Close', 'ampw.offline': 'offline', 'ampw.kpaClear': 'OPERATE clears this fault', 'ampw.none': 'No amplifier configured',
'ampw.operate': 'Operate', 'ampw.standby': 'Standby', 'ampw.on': 'ON', 'ampw.off': 'OFF',
'ampw.lvlL': 'Low', 'ampw.lvlM': 'Mid', 'ampw.lvlH': 'High',
'ampw.pwr': 'Watts', 'ampw.swr': 'SWR', 'ampw.temp': 'Temp', 'ampw.id': 'Id (A)', 'ampw.fan': 'Fan mode',
@@ -891,7 +891,7 @@ const fr: Dict = {
'agp.portDeselect': 'Port {letter} — clic pour désélectionner', 'agp.portSelect': 'Sélectionner sur le port {letter}', 'agp.online': 'en ligne', 'agp.offline': 'hors ligne', 'agp.close': 'Fermer', 'agp.connecting': 'Connexion…', 'agp.noAntennas': 'Aucune antenne configurée.', 'agp.filterOnHint': 'Antennes du {band} uniquement — clic pour afficher toutes les bandes', 'agp.filterOffHint': 'Toutes les antennes affichées — clic pour n’afficher que la bande courante',
'ampw.title': 'Amplificateur', 'ampw.all': 'Tous les amplis', 'ampw.pick': 'Ampli affiché par le widget',
'ampw.showHint': 'Amplificateur · cliquer pour afficher', 'ampw.hideHint': 'Amplificateur — affiché · cliquer pour masquer',
- 'ampw.close': 'Fermer', 'ampw.offline': 'hors ligne', 'ampw.none': 'Aucun amplificateur configuré',
+ 'ampw.close': 'Fermer', 'ampw.offline': 'hors ligne', 'ampw.kpaClear': 'OPERATE efface ce défaut', 'ampw.none': 'Aucun amplificateur configuré',
'ampw.operate': 'Operate', 'ampw.standby': 'Standby', 'ampw.on': 'ON', 'ampw.off': 'OFF',
'ampw.lvlL': 'Low', 'ampw.lvlM': 'Mid', 'ampw.lvlH': 'High',
'ampw.pwr': 'Watts', 'ampw.swr': 'ROS', 'ampw.temp': 'Temp', 'ampw.id': 'Id (A)', 'ampw.fan': 'Mode ventil.',
diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts
index 1f5f42b..5c162da 100644
--- a/frontend/wailsjs/go/models.ts
+++ b/frontend/wailsjs/go/models.ts
@@ -1500,6 +1500,51 @@ export namespace extsvc {
}
+export namespace kpa {
+
+ export class Status {
+ connected: boolean;
+ transport: string;
+ model?: string;
+ last_error?: string;
+ power_on: boolean;
+ operate: boolean;
+ fwd_w: number;
+ swr: number;
+ volt_v: number;
+ cur_a: number;
+ temp_c: number;
+ band?: string;
+ tuning: boolean;
+ fault_code: number;
+ fault_text?: string;
+
+ static createFrom(source: any = {}) {
+ return new Status(source);
+ }
+
+ constructor(source: any = {}) {
+ if ('string' === typeof source) source = JSON.parse(source);
+ this.connected = source["connected"];
+ this.transport = source["transport"];
+ this.model = source["model"];
+ this.last_error = source["last_error"];
+ this.power_on = source["power_on"];
+ this.operate = source["operate"];
+ this.fwd_w = source["fwd_w"];
+ this.swr = source["swr"];
+ this.volt_v = source["volt_v"];
+ this.cur_a = source["cur_a"];
+ this.temp_c = source["temp_c"];
+ this.band = source["band"];
+ this.tuning = source["tuning"];
+ this.fault_code = source["fault_code"];
+ this.fault_text = source["fault_text"];
+ }
+ }
+
+}
+
export namespace lookup {
export class Result {
@@ -1701,6 +1746,7 @@ export namespace main {
pgxl?: powergenius.Status;
spe?: spe.Status;
acom?: acom.Status;
+ kpa?: kpa.Status;
static createFrom(source: any = {}) {
return new AmpStatus(source);
@@ -1714,6 +1760,7 @@ export namespace main {
this.pgxl = this.convertValues(source["pgxl"], powergenius.Status);
this.spe = this.convertValues(source["spe"], spe.Status);
this.acom = this.convertValues(source["acom"], acom.Status);
+ this.kpa = this.convertValues(source["kpa"], kpa.Status);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {