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 {