diff --git a/app.go b/app.go index daf6f6f..09617b6 100644 --- a/app.go +++ b/app.go @@ -45,6 +45,7 @@ import ( "hamlog/internal/operating" "hamlog/internal/pota" "hamlog/internal/powergenius" + "hamlog/internal/spe" "hamlog/internal/profile" "hamlog/internal/qslcard" "hamlog/internal/qso" @@ -465,6 +466,7 @@ type App struct { motorInhibited atomic.Bool // TX currently inhibited by the motor-antenna watcher antgenius *antgenius.Client // Antenna Genius (4O3A) switch (TCP); nil when disabled pgxl *powergenius.Client // PowerGenius XL (4O3A) amp fan control (TCP); nil when disabled + spe *spe.Client // SPE Expert amplifier (serial/TCP); nil when disabled or not SPE audioMgr *audio.Manager qsoRec *audio.Recorder // continuous QSO recorder (rolling pre-roll) solar *solar.Manager // live space-weather (SFI/SSN/A/K) for the header + QSO stamping @@ -12219,22 +12221,48 @@ func (a *App) startPGXL() { go a.pgxl.Stop() a.pgxl = nil } + if a.spe != nil { + go a.spe.Stop() + a.spe = nil + } s, err := a.GetPGXLSettings() if err != nil || !s.Enabled { return } - // Only the PowerGenius XL (TCP) is driven for now. SPE Expert control (serial / - // RS232-to-Ethernet) is a separate protocol, not yet implemented — its settings - // are stored but no client is started. - if s.Type != "" && s.Type != "pgxl" { - applog.Printf("amplifier: type %q selected — control not implemented yet (settings saved)", s.Type) + if s.Type == "" || s.Type == "pgxl" { + if strings.TrimSpace(s.Host) == "" { + return + } + a.pgxl = powergenius.New(s.Host, s.Port) + _ = a.pgxl.Start() return } - if strings.TrimSpace(s.Host) == "" { + // SPE Expert — USB serial or an RS232-to-Ethernet bridge. + if s.Transport == "serial" && strings.TrimSpace(s.ComPort) == "" { return } - a.pgxl = powergenius.New(s.Host, s.Port) - _ = a.pgxl.Start() + if s.Transport == "tcp" && strings.TrimSpace(s.Host) == "" { + return + } + a.spe = spe.New(spe.Config{Transport: s.Transport, ComPort: s.ComPort, Baud: s.Baud, Host: s.Host, Port: s.Port}) + _ = a.spe.Start() +} + +// GetSPEStatus returns the SPE Expert amplifier state for the UI poll. +func (a *App) GetSPEStatus() spe.Status { + if a.spe == nil { + return spe.Status{} + } + return a.spe.GetStatus() +} + +// SPESetOperate puts the SPE amp in OPERATE (true) or STANDBY (false). The amp has +// a single OPERATE toggle key, so this sends it only when the state must change. +func (a *App) SPESetOperate(on bool) error { + if a.spe == nil { + return fmt.Errorf("SPE amplifier not connected — enable it in Settings → Amplifier") + } + return a.spe.Operate(on) } // GetPGXLStatus returns the amp's fan/connection state for the UI poll. diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index d64caab..04bb1dd 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -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({ 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 ( +
+
+ + {st.connected ? `SPE ${st.model || 'Expert'}` : 'SPE — not connected'} + {!st.connected && st.last_error && {st.last_error}} + + +
+ {st.connected && ( +
+
{st.tx ? 'TX' : 'RX'}
+
Band {st.band}
+
Pwr {st.power_level}
+
{st.output_w} W
+
SWR {Number(st.swr_ant ?? 0).toFixed(2)}
+
{st.temp_c}°C
+
{st.volt_pa} V
+
{st.curr_pa} A
+ {(st.warnings || st.alarms) &&
⚠ {st.warnings} {st.alarms}
} +
+ )} +
+ ); +} function RelayAutoPanel() { const { t } = useI18n(); const [enabled, setEnabled] = useState(false); @@ -2724,9 +2767,10 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan )} + {!isPGXL && pgxl.enabled && } {!isPGXL && ( -

- 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. +

+ 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.

)} diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index e029d0c..48a56b3 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -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; export function GetRotatorSettings():Promise; +export function GetSPEStatus():Promise; + export function GetSecretStatus():Promise; export function GetSlotStats():Promise; @@ -716,6 +719,8 @@ export function RotatorStop():Promise; export function RunBackupNow():Promise; +export function SPESetOperate(arg1:boolean):Promise; + export function SaveADIFFile():Promise; export function SaveADIFMonitor(arg1:main.ADIFMonitorConfig):Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 36f3e13..794373c 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -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'](); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 9a617eb..e2bf92e 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -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 { diff --git a/internal/spe/spe.go b/internal/spe/spe.go new file mode 100644 index 0000000..f0a9c2f --- /dev/null +++ b/internal/spe/spe.go @@ -0,0 +1,295 @@ +// Package spe drives the SPE Expert 1.3K-FA / 1.5K-FA / 2K-FA amplifiers over +// their proprietary serial protocol (SPE "Application Programmer's Guide" rev 1.1). +// The amp is reached either directly over USB (a virtual COM port) or over TCP via +// an RS232-to-Ethernet bridge — both are just an io.ReadWriteCloser to this code. +// +// Wire format (host → amp): 0x55 0x55 0x55 | CNT | DATA… | CHK +// CNT = number of DATA bytes, CHK = sum(DATA) mod 256. +// Status reply (amp → host): 0xAA 0xAA 0xAA | LEN | | chk0 chk1 | CR LF +// LEN is 0x43 (67); the payload is 19 comma-separated fixed fields. +// +// This MVP implements the two commands anchored by worked examples in the guide: +// OPERATE (0x0D, toggles STANDBY↔OPERATE) and STATUS (0x90). Other keystroke codes +// exist but the guide's command table did not extract unambiguously, so they are +// left out rather than risk sending the wrong key to the amplifier. +package spe + +import ( + "bufio" + "fmt" + "io" + "net" + "strconv" + "strings" + "sync" + "time" + + "go.bug.st/serial" +) + +const ( + cmdOperate byte = 0x0D // toggles STANDBY ↔ OPERATE + cmdStatus byte = 0x90 // request the status string + + syncHost = 0x55 + syncAmp = 0xAA + + dialTimeout = 5 * time.Second + ioTimeout = 3 * time.Second + pollEvery = 800 * time.Millisecond +) + +// Status is the decoded amplifier state for the UI. +type Status struct { + Connected bool `json:"connected"` + LastError string `json:"last_error,omitempty"` + Model string `json:"model,omitempty"` // "20K" / "13K" + Operate bool `json:"operate"` // true = OPERATE, false = STANDBY + TX bool `json:"tx"` // true = transmitting + Input string `json:"input,omitempty"` // "1" / "2" + Band string `json:"band,omitempty"` // raw 2-char band code + PowerLevel string `json:"power_level,omitempty"` // L / M / H + OutputW int `json:"output_w"` + SWRATU float64 `json:"swr_atu"` + SWRAnt float64 `json:"swr_ant"` + VoltPA float64 `json:"volt_pa"` + CurrPA float64 `json:"curr_pa"` + TempC int `json:"temp_c"` // heatsink (upper) temperature + Warnings string `json:"warnings,omitempty"` + Alarms string `json:"alarms,omitempty"` +} + +// Config selects the transport. +type Config struct { + Transport string // "serial" | "tcp" + ComPort string // serial + Baud int // serial + Host string // tcp + Port int // tcp +} + +type Client struct { + cfg Config + + mu sync.Mutex // serialises access to the connection + conn io.ReadWriteCloser + r *bufio.Reader + + statusMu sync.RWMutex + status Status + + stop chan struct{} + running bool +} + +func New(cfg Config) *Client { + if cfg.Baud <= 0 { + cfg.Baud = 115200 + } + return &Client{cfg: cfg, stop: make(chan struct{})} +} + +func (c *Client) Start() error { + if c.running { + return nil + } + c.running = true + go c.pollLoop() + return nil +} + +func (c *Client) Stop() { + if !c.running { + return + } + c.running = false + close(c.stop) + c.mu.Lock() + c.dropLocked() + c.mu.Unlock() +} + +func (c *Client) GetStatus() Status { + c.statusMu.RLock() + defer c.statusMu.RUnlock() + return c.status +} + +func (c *Client) setErr(err error) { + c.statusMu.Lock() + c.status.Connected = false + c.status.LastError = err.Error() + c.statusMu.Unlock() +} + +// Operate toggles the amplifier between STANDBY and OPERATE (the amp has a single +// OPERATE key that flips the state, so we send it only when the desired state +// differs from the last-read one). +func (c *Client) Operate(on bool) error { + if c.GetStatus().Operate == on { + return nil + } + return c.sendCmd(cmdOperate) +} + +// ToggleOperate flips STANDBY/OPERATE unconditionally. +func (c *Client) ToggleOperate() error { return c.sendCmd(cmdOperate) } + +func (c *Client) pollLoop() { + t := time.NewTicker(pollEvery) + defer t.Stop() + for { + select { + case <-c.stop: + return + case <-t.C: + if err := c.ensureConn(); err != nil { + c.setErr(fmt.Errorf("connect: %w", err)) + continue + } + if err := c.sendCmd(cmdStatus); err != nil { + c.mu.Lock() + c.dropLocked() + c.mu.Unlock() + c.setErr(err) + continue + } + c.readStatus() + } + } +} + +func (c *Client) ensureConn() error { + c.mu.Lock() + defer c.mu.Unlock() + if c.conn != nil { + return nil + } + var rwc io.ReadWriteCloser + var err error + if c.cfg.Transport == "tcp" { + var nc net.Conn + nc, err = net.DialTimeout("tcp", net.JoinHostPort(c.cfg.Host, strconv.Itoa(c.cfg.Port)), dialTimeout) + rwc = nc + } else { + rwc, err = serial.Open(c.cfg.ComPort, &serial.Mode{BaudRate: c.cfg.Baud}) + } + if err != nil { + return err + } + c.conn = rwc + c.r = bufio.NewReader(rwc) + return nil +} + +func (c *Client) dropLocked() { + if c.conn != nil { + c.conn.Close() + c.conn = nil + c.r = nil + } +} + +// sendCmd frames one keystroke code and writes it. Single-byte payload → CHK is +// the code itself. +func (c *Client) sendCmd(code byte) error { + c.mu.Lock() + defer c.mu.Unlock() + if c.conn == nil { + return fmt.Errorf("not connected") + } + if nc, ok := c.conn.(net.Conn); ok { + _ = nc.SetWriteDeadline(time.Now().Add(ioTimeout)) + } + pkt := []byte{syncHost, syncHost, syncHost, 0x01, code, code} + _, err := c.conn.Write(pkt) + return err +} + +// readStatus reads one amp packet and, when it's a status string, decodes it. ACK +// packets (short) are consumed and ignored. +func (c *Client) readStatus() { + c.mu.Lock() + r := c.r + if nc, ok := c.conn.(net.Conn); ok && nc != nil { + _ = nc.SetReadDeadline(time.Now().Add(ioTimeout)) + } + c.mu.Unlock() + if r == nil { + return + } + // Sync on three 0xAA bytes. + run := 0 + for run < 3 { + b, err := r.ReadByte() + if err != nil { + c.mu.Lock() + c.dropLocked() + c.mu.Unlock() + c.setErr(err) + return + } + if b == syncAmp { + run++ + } else { + run = 0 + } + } + length, err := r.ReadByte() + if err != nil { + return + } + data := make([]byte, int(length)) + if _, err := io.ReadFull(r, data); err != nil { + return + } + // Status strings are the long ones (LEN 0x43 = 67). Short packets are ACKs + // (1 checksum byte, no CRLF) — nothing else to consume for those. + if length >= 40 { + // consume the 2 checksum bytes + CR LF + _, _ = r.Discard(4) + c.decodeCSV(string(data)) + } else { + _, _ = r.Discard(1) // ACK checksum + } +} + +// decodeCSV parses the 19-field comma-separated status payload. +func (c *Client) decodeCSV(payload string) { + f := strings.Split(payload, ",") + get := func(i int) string { + if i < len(f) { + return strings.TrimSpace(f[i]) + } + return "" + } + pf := func(s string) float64 { v, _ := strconv.ParseFloat(strings.TrimSpace(s), 64); return v } + pi := func(s string) int { v, _ := strconv.Atoi(strings.TrimSpace(s)); return v } + + c.statusMu.Lock() + defer c.statusMu.Unlock() + c.status.Connected = true + c.status.LastError = "" + c.status.Model = get(0) + c.status.Operate = get(1) == "O" + c.status.TX = get(2) == "T" + c.status.Input = get(4) + c.status.Band = get(5) + c.status.PowerLevel = get(8) + c.status.OutputW = pi(get(9)) + c.status.SWRATU = pf(get(10)) + c.status.SWRAnt = pf(get(11)) + c.status.VoltPA = pf(get(12)) + c.status.CurrPA = pf(get(13)) + c.status.TempC = pi(get(14)) + if w := get(17); w != "" && w != "N" { + c.status.Warnings = w + } else { + c.status.Warnings = "" + } + if a := get(18); a != "" && a != "N" { + c.status.Alarms = a + } else { + c.status.Alarms = "" + } +}