feat(kpa): the Elecraft amplifiers in the Settings, the card and the widget
Wired into the multi-amplifier support that already carries the SPE, the
ACOM and the PowerGenius: a fourth brand rather than a fourth mechanism.
The linking, the fan-out and the poll all work on it unchanged.
Three things the KPA does differently, and each is handled where it shows
rather than explained in a hint:
- A KPA500 has no network side at all. Choosing it puts the entry on
serial, and a configuration still asking for TCP is refused with a
line saying so instead of retrying an address that cannot answer.
- The amplifier answers ^ON while its main supplies are OFF — a sleeping
microcontroller stays awake for exactly that — so power-on stays
available over the network, unlike the SPE and ACOM which need their
serial control lines.
- Going to OPERATE clears the current fault, everything except
temperature. The widget says so under the fault, because the button
that fixes it is the one already on screen.
Defaults that leave a working configuration when the model is changed:
38400 baud, TCP port 1500, and a full-scale power mark of 500 or 1500 W
by model — a KPA500 read against a 1500 W scale looks idle at full
output.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
<div className="h-full flex flex-col gap-1.5">
|
||||
{showName && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={cn('size-1.5 rounded-full shrink-0', s.connected ? 'bg-success shadow-[0_0_6px_rgba(16,185,129,0.8)]' : 'bg-danger')} />
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider truncate">{amp.name || (spe ? 'SPE' : 'ACOM')}</span>
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider truncate">{amp.name || (spe ? 'SPE' : kpaAmp ? (s.model || 'KPA') : 'ACOM')}</span>
|
||||
{s.connected && s.band && <span className="ml-auto text-[9px] text-muted-foreground shrink-0">{s.band}</span>}
|
||||
</div>
|
||||
)}
|
||||
@@ -155,11 +167,17 @@ function AmpBlock({ amp, flex, showName, t }: {
|
||||
) : (
|
||||
<div className="text-[10px] text-center text-muted-foreground italic py-1">{t('ampw.offline')}</div>
|
||||
)}
|
||||
{(s.warnings || s.alarms || s.err_text) && (
|
||||
{(s.warnings || s.alarms || s.err_text || s.fault_text) && (
|
||||
<div className="rounded-md border border-danger-border bg-danger-muted text-danger-muted-foreground text-[9px] px-1.5 py-0.5 text-center break-words">
|
||||
{s.err_text || `${s.warnings || ''} ${s.alarms || ''}`.trim()}
|
||||
{s.fault_text || s.err_text || `${s.warnings || ''} ${s.alarms || ''}`.trim()}
|
||||
</div>
|
||||
)}
|
||||
{/* 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 && (
|
||||
<div className="text-[9px] text-center text-muted-foreground">{t('ampw.kpaClear')}</div>
|
||||
)}
|
||||
<PowerMeter label={t('flxp.outputPower')} watts={s.connected ? w : 0} maxWatts={hi} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<div className="rounded-md border border-border p-3 space-y-2 text-xs max-w-xl">
|
||||
@@ -948,7 +949,23 @@ function AmpStatusCard({ id }: { id: string }) {
|
||||
{st.err_text && <div className="col-span-4 text-warning">⚠ {st.err_text} ({st.err_code})</div>}
|
||||
</div>
|
||||
)}
|
||||
{st.connected && !isSPE && !isACOM && (
|
||||
{st.connected && isKPA && (
|
||||
<div className="grid grid-cols-4 gap-x-3 gap-y-1 font-mono text-[11px]">
|
||||
<div>{st.power_on ? 'ON' : 'OFF'}</div>
|
||||
<div>Band {st.band || '—'}</div>
|
||||
<div>{st.fwd_w} W</div>
|
||||
<div>SWR {Number(st.swr ?? 0).toFixed(1)}</div>
|
||||
<div>{st.volt_v} V</div>
|
||||
<div>{st.cur_a} A</div>
|
||||
<div>{st.temp_c}°C</div>
|
||||
<div>{st.tuning ? 'TUNING' : ''}</div>
|
||||
{/* 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 && <div className="col-span-4 text-warning">⚠ {st.fault_text}</div>}
|
||||
</div>
|
||||
)}
|
||||
{st.connected && !isSPE && !isACOM && !isKPA && (
|
||||
<div className="grid grid-cols-3 gap-x-3 gap-y-1 font-mono text-[11px]">
|
||||
<div>{st.state || ''}</div>
|
||||
<div>Fan {st.fan_mode || '—'}</div>
|
||||
@@ -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<AmpUI>) => 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
|
||||
<SelectItem value="pgxl">4O3A</SelectItem>
|
||||
<SelectItem value="spe">SPE</SelectItem>
|
||||
<SelectItem value="acom">ACOM</SelectItem>
|
||||
<SelectItem value="kpa">Elecraft</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user