feat: SPE Expert amplifier control (serial/TCP) — OPERATE toggle + live status

Implements the SPE Application Programmer's Guide protocol: packets
0x55 0x55 0x55|CNT|DATA|CHK (sum%256), OPERATE key 0x0D (toggles STANDBY/OPERATE)
and STATUS request 0x90 whose 0xAA-framed reply is a 19-field CSV (mode, RX/TX,
band, power level, output W, SWR, V/I, temp, warnings, alarms). internal/spe
polls status ~1/s over USB serial (go.bug.st/serial, 8N1) or TCP (RS232-to-
Ethernet bridge) — same codec, different transport.

Wired via startPGXL (starts the SPE client for spe* types), bindings GetSPEStatus
/ SPESetOperate, and a live status card + OPERATE/STANDBY toggle in the Amplifier
settings panel. Only the two example-anchored commands are sent (safe); other
keystroke codes were ambiguous in the guide's table. Untested on hardware.
This commit is contained in:
2026-07-20 17:59:24 +02:00
parent 666b933114
commit 2e39615554
6 changed files with 438 additions and 11 deletions
+47 -3
View File
@@ -13,7 +13,7 @@ import {
GetRotatorSettings, SaveRotatorSettings, TestRotator, RotatorPark, RotatorStop,
GetUltrabeamSettings, SaveUltrabeamSettings, TestUltrabeam,
GetAntGeniusSettings, SaveAntGeniusSettings,
GetPGXLSettings, SavePGXLSettings,
GetPGXLSettings, SavePGXLSettings, GetSPEStatus, SPESetOperate,
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts,
GetAudioSettings, SaveAudioSettings, ListAudioInputDevices, ListAudioOutputDevices, PickAudioFolder, TestPTT,
GetClublogCtyInfo, SetClublogCtyEnabled, DownloadClublogCty,
@@ -648,6 +648,49 @@ type RelayRuleUI = { device_id: string; relay: number; mode: string; freq_lo_khz
type StationDevUI = { id: string; type: string; name: string; labels: string[] };
const RELAY_BANDS = ['160m', '80m', '60m', '40m', '30m', '20m', '17m', '15m', '12m', '10m', '6m', '4m', '2m', '70cm'];
const relayCountUI = (type: string) => (type === 'kmtronic' || type === 'denkovi' ? 8 : 5);
// Live SPE Expert amplifier status + OPERATE/STANDBY toggle. Module-scoped (not a
// nested component) so it isn't remounted on every parent render. Polls once a
// second while shown.
function SPEStatusCard() {
const [st, setSt] = useState<any>({ connected: false });
useEffect(() => {
let alive = true;
const tick = () => GetSPEStatus().then((s) => alive && setSt(s || {})).catch(() => {});
tick();
const id = window.setInterval(tick, 1000);
return () => { alive = false; window.clearInterval(id); };
}, []);
const operate = !!st.operate;
return (
<div className="rounded-md border border-border p-3 space-y-2 text-xs max-w-xl">
<div className="flex items-center gap-2">
<span className={cn('size-2 rounded-full', st.connected ? 'bg-success animate-pulse' : 'bg-danger')} />
<span className="font-semibold">{st.connected ? `SPE ${st.model || 'Expert'}` : 'SPE — not connected'}</span>
{!st.connected && st.last_error && <span className="text-danger truncate text-[10px]">{st.last_error}</span>}
<span className="flex-1" />
<Button size="sm" variant={operate ? 'default' : 'outline'} disabled={!st.connected}
onClick={() => SPESetOperate(!operate).catch(() => {})}
title="Toggle OPERATE / STANDBY">
{operate ? 'OPERATE' : 'STANDBY'}
</Button>
</div>
{st.connected && (
<div className="grid grid-cols-4 gap-x-3 gap-y-1 font-mono text-[11px]">
<div>{st.tx ? 'TX' : 'RX'}</div>
<div>Band {st.band}</div>
<div>Pwr {st.power_level}</div>
<div>{st.output_w} W</div>
<div>SWR {Number(st.swr_ant ?? 0).toFixed(2)}</div>
<div>{st.temp_c}°C</div>
<div>{st.volt_pa} V</div>
<div>{st.curr_pa} A</div>
{(st.warnings || st.alarms) && <div className="col-span-4 text-warning"> {st.warnings} {st.alarms}</div>}
</div>
)}
</div>
);
}
function RelayAutoPanel() {
const { t } = useI18n();
const [enabled, setEnabled] = useState(false);
@@ -2724,9 +2767,10 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</div>
)}
{!isPGXL && pgxl.enabled && <SPEStatusCard />}
{!isPGXL && (
<p className="text-xs text-warning">
SPE Expert control is not wired up yet these settings are saved, but OpsLog can't command the amp until its serial protocol is implemented.
<p className="text-[10px] text-muted-foreground">
SPE control uses the amplifier's proprietary serial protocol (OPERATE toggle + live status). Save to (re)connect. Band / power-level / antenna are still managed on the amp from the transceiver CAT.
</p>
)}
</div>
+5
View File
@@ -11,6 +11,7 @@ import {awardref} from '../models';
import {cluster} from '../models';
import {extsvc} from '../models';
import {powergenius} from '../models';
import {spe} from '../models';
import {solar} from '../models';
import {winkeyer} from '../models';
import {alerts} from '../models';
@@ -406,6 +407,8 @@ export function GetRotatorHeading():Promise<main.RotatorHeading>;
export function GetRotatorSettings():Promise<main.RotatorSettings>;
export function GetSPEStatus():Promise<spe.Status>;
export function GetSecretStatus():Promise<main.SecretStatus>;
export function GetSlotStats():Promise<qso.SlotStats>;
@@ -716,6 +719,8 @@ export function RotatorStop():Promise<void>;
export function RunBackupNow():Promise<string>;
export function SPESetOperate(arg1:boolean):Promise<void>;
export function SaveADIFFile():Promise<string>;
export function SaveADIFMonitor(arg1:main.ADIFMonitorConfig):Promise<void>;
+8
View File
@@ -770,6 +770,10 @@ export function GetRotatorSettings() {
return window['go']['main']['App']['GetRotatorSettings']();
}
export function GetSPEStatus() {
return window['go']['main']['App']['GetSPEStatus']();
}
export function GetSecretStatus() {
return window['go']['main']['App']['GetSecretStatus']();
}
@@ -1390,6 +1394,10 @@ export function RunBackupNow() {
return window['go']['main']['App']['RunBackupNow']();
}
export function SPESetOperate(arg1) {
return window['go']['main']['App']['SPESetOperate'](arg1);
}
export function SaveADIFFile() {
return window['go']['main']['App']['SaveADIFFile']();
}
+47
View File
@@ -4108,6 +4108,53 @@ export namespace solar {
}
export namespace spe {
export class Status {
connected: boolean;
last_error?: string;
model?: string;
operate: boolean;
tx: boolean;
input?: string;
band?: string;
power_level?: string;
output_w: number;
swr_atu: number;
swr_ant: number;
volt_pa: number;
curr_pa: number;
temp_c: number;
warnings?: string;
alarms?: 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.last_error = source["last_error"];
this.model = source["model"];
this.operate = source["operate"];
this.tx = source["tx"];
this.input = source["input"];
this.band = source["band"];
this.power_level = source["power_level"];
this.output_w = source["output_w"];
this.swr_atu = source["swr_atu"];
this.swr_ant = source["swr_ant"];
this.volt_pa = source["volt_pa"];
this.curr_pa = source["curr_pa"];
this.temp_c = source["temp_c"];
this.warnings = source["warnings"];
this.alarms = source["alarms"];
}
}
}
export namespace udp {
export class Config {