diff --git a/app.go b/app.go index 2e040a1..ce91d0e 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() } @@ -18101,7 +18106,9 @@ func ampTypeLabel(t string) string { case strings.HasPrefix(t, "spe"): 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" + 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() @@ -18258,6 +18278,22 @@ func (a *App) feedAmpBandFollow(s cat.RigState) { inst.catemu.SetFrequency(s.FreqHz) inst.catemu.SetMode(s.Mode) } + // A KPA is TOLD its band, on the link it is already on. + // + // The emulator above exists because an Acom polls a transceiver and has + // no command to be given a band; the KPA has one (^BN), so it needs + // neither a second serial port nor a pretend rig. Sent from a goroutine + // because this runs on the CAT state-change path, and nothing about the + // rig should wait on an amplifier's link. + // Asked for, like every other write to somebody's station. The option is + // the same one an Acom uses — "keep the amplifier on the radio's band" is + // one idea to an operator, whatever it takes underneath — and it is off + // for the operator who has wired the amplifier straight to the rig and + // does not want a second voice telling it where to be. + if inst.kpa != nil && inst.cfg.FreqOut && s.Band != "" { + k, band := inst.kpa, s.Band + go func() { _ = k.SetBand(band) }() + } } } @@ -18284,6 +18320,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 +18347,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 +18431,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 +18469,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 @@ -18522,7 +18569,7 @@ func (a *App) GetACOMStatus() acom.Status { // protocol has explicit commands for each, unlike the SPE's toggle key. func (a *App) ACOMSetOperate(on bool) error { if a.acom == nil { - return fmt.Errorf("ACOM amplifier not connected — enable it in Settings → Amplifier") + return fmt.Errorf("Acom amplifier not connected — enable it in Settings → Amplifier") } return a.acom.Operate(on) } @@ -18531,7 +18578,7 @@ func (a *App) ACOMSetOperate(on bool) error { // the power-on pins wired in the cable) or off (false, the OFF data command). func (a *App) ACOMSetPower(on bool) error { if a.acom == nil { - return fmt.Errorf("ACOM amplifier not connected — enable it in Settings → Amplifier") + return fmt.Errorf("Acom amplifier not connected — enable it in Settings → Amplifier") } if on { return a.acom.PowerOn() diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 index 693b40b..b826f3b 100644 --- a/frontend/package.json.md5 +++ b/frontend/package.json.md5 @@ -1 +1 @@ -f9b41e192918fa2511f68cd1b361fcd3 \ No newline at end of file +704fe1bf370b669665df0606fae8a69d \ No newline at end of file diff --git a/frontend/src/components/AmpCard.tsx b/frontend/src/components/AmpCard.tsx index a02e642..ff2893b 100644 --- a/frontend/src/components/AmpCard.tsx +++ b/frontend/src/components/AmpCard.tsx @@ -47,7 +47,7 @@ function powerLevelLabel(pl?: string): string { } } -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 }; export function AmpCard({ amp, flex, t }: { amp: Amp; flex: any; t: (k: string, v?: any) => string }) { // Peak-hold so the jittery VITA-49 meters read steadily (own ref per card). @@ -64,6 +64,7 @@ export function AmpCard({ amp, flex, t }: { amp: Amp; flex: any; t: (k: string, const isSPE = !!amp.spe; const isACOM = !!amp.acom; + const isKPA = !!amp.kpa; if (isSPE) { const spe = amp.spe; @@ -127,7 +128,7 @@ export function AmpCard({ amp, flex, t }: { amp: Amp; flex: any; t: (k: string, if (isACOM) { const acom = amp.acom; return ( - +
+ {/* The amplifier answers while its main supplies are off — a sleeping + microcontroller stays awake for exactly that — so ON is offered + over the network as well, unlike the SPE and Acom. */} +
+ + +
+ + + {kpa.connected ? (kpa.tuning ? t('flxp.kpaTuning') : (kpa.power_on ? 'ON' : 'OFF')) : t('flxp.acomOffline')} + + {kpa.connected && ( + + {kpa.band ? `${kpa.band} · ` : ''}{kpa.fwd_w}W · SWR {Number(kpa.swr ?? 0).toFixed(1)} · {kpa.temp_c}°C · {kpa.volt_v}V {kpa.cur_a}A + + )} +
+ {kpa.fault_text && ( + ⚠ {kpa.fault_text} + )} +
+ {kpa.connected && ( + (f > 0.9 ? '#dc2626' : f > 0.75 ? '#f59e0b' : '#ea580c')} /> + )} + + ); + } + // PowerGenius XL — OPERATE + meters ride on the Flex; fan mode on the GSCP link. const pg = amp.pgxl || {}; const viaFlex = !!flex?.amp_available; diff --git a/frontend/src/components/AmpWidget.tsx b/frontend/src/components/AmpWidget.tsx index 1bf6555..1721ceb 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..eaada39 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,28 @@ 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, + freq_out: v.startsWith('kpa') ? true : amps[i].freq_out, + 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, @@ -4167,6 +4197,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged const brand = brandOf(amp.type); const isPGXL = brand === 'pgxl'; const isACOM = brand === 'acom'; + const isKPA = brand === 'kpa'; const isSerial = !isPGXL && amp.transport === 'serial'; return (
@@ -4194,7 +4225,8 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged 4O3A SPE - ACOM + Acom + Elecraft
@@ -4284,10 +4316,27 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged )} {/* Band-follow, for any amp that takes its band from a transceiver - CAT link (ACOM and SPE both do) — never PowerGenius, which is + CAT link (Acom and SPE both do) — never PowerGenius, which is driven over its network protocol. A SECOND serial port, - separate from the metering one above. */} - {!isPGXL && ( + separate from the metering one above. + Never a KPA either: it has a band command of its own (^BN), + so OpsLog tells it directly on the link it is already using. + Offering a second serial port and a transceiver emulator for + that would be a workaround for a problem this amplifier does + not have. */} + {isKPA && ( +
+ +

{t('amp.kpaBandFollowHint')}

+
+ )} + {!isPGXL && !isKPA && (