diff --git a/app.go b/app.go index b7567a5..31a16ce 100644 --- a/app.go +++ b/app.go @@ -41,6 +41,7 @@ import ( "hamlog/internal/rotator/pst" "hamlog/internal/settings" "hamlog/internal/antgenius" + "hamlog/internal/powergenius" "hamlog/internal/ultrabeam" "hamlog/internal/winkeyer" @@ -146,6 +147,11 @@ const ( keyAntGeniusEnabled = "antgenius.enabled" keyAntGeniusHost = "antgenius.host" + // PowerGenius XL (4O3A) amplifier fan-mode control — Hardware → PowerGenius. + keyPGXLEnabled = "pgxl.enabled" + keyPGXLHost = "pgxl.host" + keyPGXLPort = "pgxl.port" + // WinKeyer CW keyer (serial) — Hardware → CW Keyer. keyWKEnabled = "winkeyer.enabled" keyWKPort = "winkeyer.port" @@ -389,7 +395,8 @@ type App struct { clublog *clublog.Manager ultrabeam *ultrabeam.Client // Ultrabeam antenna (TCP); nil when disabled ubFollowStop chan struct{} // stops the "follow frequency" loop; nil when off - antgenius *antgenius.Client // Antenna Genius (4O3A) switch (TCP); nil when disabled + antgenius *antgenius.Client // Antenna Genius (4O3A) switch (TCP); nil when disabled + pgxl *powergenius.Client // PowerGenius XL (4O3A) amp fan control (TCP); nil when disabled audioMgr *audio.Manager qsoRec *audio.Recorder // continuous QSO recorder (rolling pre-roll) cwMu sync.Mutex // guards the CW decoder lifecycle @@ -824,6 +831,8 @@ func (a *App) startup(ctx context.Context) { a.startUltrabeam() // Antenna Genius switch: connect in the background if enabled. a.startAntGenius() + // PowerGenius XL amp fan control: connect in the background if enabled. + a.startPGXL() // Autostart: launch the active profile's configured external programs that // aren't already running (WSJT-X, JTAlert, rotator control, …). Background @@ -7923,6 +7932,82 @@ func (a *App) AntGeniusDeselect(port int) error { return a.antgenius.Activate(port, 0) } +// ── PowerGenius XL (4O3A) amplifier fan control (TCP, default port 9006) ───── + +// PGXLSettings is the JSON shape for the Hardware → PowerGenius panel. +type PGXLSettings struct { + Enabled bool `json:"enabled"` + Host string `json:"host"` + Port int `json:"port"` +} + +func (a *App) GetPGXLSettings() (PGXLSettings, error) { + out := PGXLSettings{Port: 9006} + if a.settings == nil { + return out, fmt.Errorf("db not initialized") + } + m, err := a.settings.GetMany(a.ctx, keyPGXLEnabled, keyPGXLHost, keyPGXLPort) + if err != nil { + return out, err + } + out.Enabled = m[keyPGXLEnabled] == "1" + out.Host = m[keyPGXLHost] + if p, _ := strconv.Atoi(m[keyPGXLPort]); p > 0 && p <= 65535 { + out.Port = p + } + return out, nil +} + +func (a *App) SavePGXLSettings(s PGXLSettings) error { + if a.settings == nil { + return fmt.Errorf("db not initialized") + } + if s.Port <= 0 || s.Port > 65535 { + s.Port = 9006 + } + for k, v := range map[string]string{ + keyPGXLEnabled: boolStr(s.Enabled), + keyPGXLHost: strings.TrimSpace(s.Host), + keyPGXLPort: strconv.Itoa(s.Port), + } { + if err := a.settings.Set(a.ctx, k, v); err != nil { + return err + } + } + a.startPGXL() + return nil +} + +// startPGXL stops any existing client and starts a fresh one if enabled. +func (a *App) startPGXL() { + if a.pgxl != nil { + a.pgxl.Stop() + a.pgxl = nil + } + s, err := a.GetPGXLSettings() + if err != nil || !s.Enabled || strings.TrimSpace(s.Host) == "" { + return + } + a.pgxl = powergenius.New(s.Host, s.Port) + _ = a.pgxl.Start() +} + +// GetPGXLStatus returns the amp's fan/connection state for the UI poll. +func (a *App) GetPGXLStatus() powergenius.Status { + if a.pgxl == nil { + return powergenius.Status{} + } + return a.pgxl.GetStatus() +} + +// PGXLSetFanMode sets the amplifier fan mode (STANDARD | CONTEST | BROADCAST). +func (a *App) PGXLSetFanMode(mode string) error { + if a.pgxl == nil { + return fmt.Errorf("PowerGenius not connected — enable it in Settings → PowerGenius") + } + return a.pgxl.SetFanMode(mode) +} + // --- WinKeyer (CW keyer) bindings --- // WKMacro is one CW message slot (F1…): a short label + the macro text, which diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 66308b8..54197c3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3382,7 +3382,7 @@ export default function App() { {/* ===== CW decoder strip (only when enabled AND mode is CW) ===== */} {cwOn && ( -
+
{/* Input-level meter — if this stays flat with a strong signal, the RX audio device is wrong/silent rather than a decode problem. */} diff --git a/frontend/src/components/FlexPanel.tsx b/frontend/src/components/FlexPanel.tsx index bf837ad..f5c7caa 100644 --- a/frontend/src/components/FlexPanel.tsx +++ b/frontend/src/components/FlexPanel.tsx @@ -4,6 +4,7 @@ import { GetFlexState, FlexSetPower, FlexSetTunePower, FlexTune, FlexSetVox, FlexSetVoxLevel, FlexSetVoxDelay, FlexSetProcessor, FlexSetProcessorLevel, FlexSetMon, FlexSetMonLevel, FlexSetMic, FlexMox, FlexAmpOperate, + GetPGXLStatus, PGXLSetFanMode, FlexSetAGCMode, FlexSetAGCThreshold, FlexSetAudioLevel, FlexSetNB, FlexSetNBLevel, FlexSetNR, FlexSetNRLevel, FlexSetANF, FlexSetANFLevel, FlexSetAPF, FlexSetAPFLevel, FlexSetCWSpeed, FlexSetCWPitch, FlexSetCWBreakInDelay, @@ -215,6 +216,16 @@ export function FlexPanel() { return () => { alive = false; window.clearInterval(id); }; }, []); + // PowerGenius XL direct connection (fan mode), independent of the Flex link. + const [pg, setPg] = useState<{ connected: boolean; fan_mode?: string }>({ connected: false }); + useEffect(() => { + let alive = true; + const tick = async () => { try { const s: any = await GetPGXLStatus(); if (alive) setPg(s || { connected: false }); } catch {} }; + tick(); + const id = window.setInterval(tick, 2000); + return () => { alive = false; window.clearInterval(id); }; + }, []); + const change = (key: keyof FlexState, val: number | boolean | string, send: () => Promise) => { hold.current[key] = Date.now() + 900; setSt((p) => ({ ...p, [key]: val })); @@ -430,6 +441,22 @@ export function FlexPanel() { {st.amp_operate ? 'Amplifier is in line (transmitting through PA).' : 'Amplifier bypassed (standby).'} + {/* Fan mode — only when the PowerGenius direct connection is up + (configured in Settings → PowerGenius). */} + {pg.connected && ( + + )}
{st.amp_fault && st.amp_fault !== 'NONE' && ( FAULT: {st.amp_fault} diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index 5d1a011..2d19d4a 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -13,6 +13,7 @@ import { GetRotatorSettings, SaveRotatorSettings, TestRotator, RotatorPark, RotatorStop, GetUltrabeamSettings, SaveUltrabeamSettings, TestUltrabeam, GetAntGeniusSettings, SaveAntGeniusSettings, + GetPGXLSettings, SavePGXLSettings, GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts, GetAudioSettings, SaveAudioSettings, ListAudioInputDevices, ListAudioOutputDevices, PickAudioFolder, TestPTT, GetClublogCtyInfo, SetClublogCtyEnabled, DownloadClublogCty, @@ -172,6 +173,7 @@ type SectionId = | 'winkeyer' | 'antenna' | 'antgenius' + | 'pgxl' | 'audio'; type TreeNode = @@ -210,6 +212,7 @@ const TREE: TreeNode[] = [ { kind: 'item', label: 'CW Keyer', id: 'winkeyer' }, { kind: 'item', label: 'Antenna', id: 'antenna' }, { kind: 'item', label: 'Antenna Genius', id: 'antgenius' }, + { kind: 'item', label: 'PowerGenius', id: 'pgxl' }, { kind: 'item', label: 'Audio devices', id: 'audio' }, ], }, @@ -236,6 +239,7 @@ const SECTION_LABELS: Partial> = { winkeyer: 'CW Keyer', antenna: 'Antenna', antgenius: 'Antenna Genius', + pgxl: 'PowerGenius', audio: 'Audio devices', }; @@ -634,6 +638,9 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan // Antenna Genius (4O3A) switch settings — TCP port is fixed at 9007. const [antgenius, setAntgenius] = useState<{ enabled: boolean; host: string }>({ enabled: false, host: '' }); + // PowerGenius XL (4O3A) amp fan-control settings. + const [pgxl, setPgxl] = useState<{ enabled: boolean; host: string; port: number }>({ enabled: false, host: '', port: 9006 }); + // WinKeyer CW keyer settings + macro editor. type WKMac = { label: string; text: string }; type WKSettings = { @@ -892,6 +899,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan setRotator(r); try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {} try { setAntgenius(await GetAntGeniusSettings() as any); } catch {} + try { setPgxl(await GetPGXLSettings() as any); } catch {} setBackupCfg(b as any); setQslDefaults(qd as any); setExtSvc(es as any); @@ -932,6 +940,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan try { setRotator(await GetRotatorSettings() as any); } catch {} try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {} try { setAntgenius(await GetAntGeniusSettings() as any); } catch {} + try { setPgxl(await GetPGXLSettings() as any); } catch {} try { setBackupCfg(await GetBackupSettings() as any); } catch {} try { setQslDefaults(await GetQSLDefaults() as any); } catch {} try { setExtSvc(await GetExternalServices() as any); } catch {} @@ -1100,6 +1109,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan await SaveRotatorSettings(rotator as any); await SaveUltrabeamSettings(ultrabeam as any); await SaveAntGeniusSettings(antgenius as any); + await SavePGXLSettings(pgxl as any); await SaveWinkeyerSettings(wk as any); await SaveAudioSettings(audioCfg as any); await SaveEmailSettings(emailCfg as any); @@ -2052,6 +2062,46 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan ); } + function PGXLPanelSettings() { + return ( + <> + +
+ +
+
+ + setPgxl((s) => ({ ...s, host: e.target.value }))} + placeholder="192.168.1.70" + className="font-mono" + /> +
+
+ + setPgxl((s) => ({ ...s, port: parseInt(e.target.value) || 9006 }))} + className="font-mono" + /> +
+
+

+ Default port is 9006. Once enabled, a fan-mode dropdown (Standard / Contest / Broadcast) appears next to the amplifier Operate button in the FlexRadio tab. +

+
+ + ); + } + function RotatorPanel() { return ( <> @@ -3808,6 +3858,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan winkeyer: WinkeyerPanel, antenna: UltrabeamPanel, antgenius: AntGeniusPanelSettings, + pgxl: PGXLPanelSettings, audio: AudioPanel, }; diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index 35618ce..252866b 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -10,6 +10,7 @@ import {award} from '../models'; import {awardref} from '../models'; import {cluster} from '../models'; import {extsvc} from '../models'; +import {powergenius} from '../models'; import {winkeyer} from '../models'; import {audio} from '../models'; import {operating} from '../models'; @@ -272,6 +273,10 @@ export function GetMySQLSettings():Promise; export function GetOnlineOperators():Promise>; +export function GetPGXLSettings():Promise; + +export function GetPGXLStatus():Promise; + export function GetPOTAToken():Promise; export function GetQSLDefaults():Promise; @@ -374,6 +379,8 @@ export function OpenExternalURL(arg1:string):Promise; export function OperatingDefaultForBand(arg1:string):Promise; +export function PGXLSetFanMode(arg1:string):Promise; + export function PickAudioFolder():Promise; export function PickBackupFolder():Promise; @@ -486,6 +493,8 @@ export function SaveOperatingAntenna(arg1:operating.Antenna):Promise; +export function SavePGXLSettings(arg1:main.PGXLSettings):Promise; + export function SavePOTAToken(arg1:string):Promise; export function SaveProfile(arg1:profile.Profile):Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 04f59d0..4b44987 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -514,6 +514,14 @@ export function GetOnlineOperators() { return window['go']['main']['App']['GetOnlineOperators'](); } +export function GetPGXLSettings() { + return window['go']['main']['App']['GetPGXLSettings'](); +} + +export function GetPGXLStatus() { + return window['go']['main']['App']['GetPGXLStatus'](); +} + export function GetPOTAToken() { return window['go']['main']['App']['GetPOTAToken'](); } @@ -718,6 +726,10 @@ export function OperatingDefaultForBand(arg1) { return window['go']['main']['App']['OperatingDefaultForBand'](arg1); } +export function PGXLSetFanMode(arg1) { + return window['go']['main']['App']['PGXLSetFanMode'](arg1); +} + export function PickAudioFolder() { return window['go']['main']['App']['PickAudioFolder'](); } @@ -942,6 +954,10 @@ export function SaveOperatingStation(arg1) { return window['go']['main']['App']['SaveOperatingStation'](arg1); } +export function SavePGXLSettings(arg1) { + return window['go']['main']['App']['SavePGXLSettings'](arg1); +} + export function SavePOTAToken(arg1) { return window['go']['main']['App']['SavePOTAToken'](arg1); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index d13d123..886f91d 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -1473,6 +1473,22 @@ export namespace main { this.database = source["database"]; } } + export class PGXLSettings { + enabled: boolean; + host: string; + port: number; + + static createFrom(source: any = {}) { + return new PGXLSettings(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.enabled = source["enabled"]; + this.host = source["host"]; + this.port = source["port"]; + } + } export class POTAUnmatched { activator: string; date: string; @@ -2109,6 +2125,33 @@ export namespace operating { } +export namespace powergenius { + + export class Status { + connected: boolean; + host?: string; + last_error?: string; + state?: string; + fan_mode?: string; + temperature: number; + + static createFrom(source: any = {}) { + return new Status(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.connected = source["connected"]; + this.host = source["host"]; + this.last_error = source["last_error"]; + this.state = source["state"]; + this.fan_mode = source["fan_mode"]; + this.temperature = source["temperature"]; + } + } + +} + export namespace profile { export class ProfileDB { diff --git a/internal/powergenius/powergenius.go b/internal/powergenius/powergenius.go new file mode 100644 index 0000000..4155667 --- /dev/null +++ b/internal/powergenius/powergenius.go @@ -0,0 +1,226 @@ +// Package powergenius drives a 4O3A PowerGenius XL amplifier over its TCP text +// API (same "Genius Series" line protocol as the Antenna Genius). OpsLog reads +// the amp's operate state via the FlexRadio amplifier object, but the fan mode +// is a PGXL-only setting only reachable on the amp's own control port — hence +// this small direct client. Commands are "C|\n"; replies are +// "R|0|" and asynchronous "S0|". +package powergenius + +import ( + "bufio" + "fmt" + "net" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" +) + +const ( + defaultPort = 9006 + dialTimeout = 5 * time.Second + ioTimeout = 3 * time.Second + pollEvery = 1500 * time.Millisecond + reconnectDelay = 2 * time.Second +) + +// Status is the snapshot the UI renders (only the bits OpsLog needs). +type Status struct { + Connected bool `json:"connected"` + Host string `json:"host,omitempty"` + LastError string `json:"last_error,omitempty"` + State string `json:"state,omitempty"` // IDLE / TRANSMIT_A … + FanMode string `json:"fan_mode,omitempty"` // STANDARD / CONTEST / BROADCAST + Temperature float64 `json:"temperature"` +} + +type Client struct { + host string + port int + + mu sync.Mutex // serialises command send/recv on the connection + conn net.Conn + reader *bufio.Reader + + statusMu sync.RWMutex + status Status + + cmdID atomic.Int64 + stop chan struct{} + running bool +} + +func New(host string, port int) *Client { + if port <= 0 || port > 65535 { + port = defaultPort + } + return &Client{host: host, port: port, stop: make(chan struct{}), status: Status{Host: host}} +} + +func (c *Client) Start() error { + 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() + if c.conn != nil { + c.conn.Close() + c.conn = nil + c.reader = nil + } + c.mu.Unlock() +} + +func (c *Client) GetStatus() Status { + c.statusMu.RLock() + defer c.statusMu.RUnlock() + return c.status +} + +func (c *Client) setStatus(fn func(*Status)) { + c.statusMu.Lock() + fn(&c.status) + c.statusMu.Unlock() +} + +// SetFanMode sets the amplifier fan mode (STANDARD | CONTEST | BROADCAST). +func (c *Client) SetFanMode(mode string) error { + m := strings.ToUpper(strings.TrimSpace(mode)) + switch m { + case "STANDARD", "CONTEST", "BROADCAST": + default: + return fmt.Errorf("powergenius: invalid fan mode %q", mode) + } + if _, err := c.command("setup fanmode=" + m); err != nil { + return err + } + c.setStatus(func(s *Status) { s.FanMode = m }) // optimistic + return nil +} + +// SetOperate puts the amp in OPERATE (1) or STANDBY (0). +func (c *Client) SetOperate(on bool) error { + v := "0" + if on { + v = "1" + } + _, err := c.command("operate=" + v) + return err +} + +func (c *Client) pollLoop() { + t := time.NewTicker(pollEvery) + defer t.Stop() + for { + select { + case <-c.stop: + return + case <-t.C: + if err := c.ensureConnected(); err != nil { + c.setStatus(func(s *Status) { s.Connected = false; s.LastError = "dial: " + err.Error() }) + continue + } + if _, err := c.command("status"); err != nil { + c.dropConn() + c.setStatus(func(s *Status) { s.Connected = false; s.LastError = err.Error() }) + } + } + } +} + +func (c *Client) ensureConnected() error { + c.mu.Lock() + defer c.mu.Unlock() + if c.conn != nil { + return nil + } + conn, err := net.DialTimeout("tcp", net.JoinHostPort(c.host, strconv.Itoa(c.port)), dialTimeout) + if err != nil { + return err + } + c.conn = conn + c.reader = bufio.NewReader(conn) + // Discard the version banner the device sends on connect. + _ = conn.SetReadDeadline(time.Now().Add(ioTimeout)) + _, _ = c.reader.ReadString('\n') + return nil +} + +func (c *Client) dropConn() { + c.mu.Lock() + if c.conn != nil { + c.conn.Close() + c.conn = nil + c.reader = nil + } + c.mu.Unlock() +} + +// command sends "C|\n" and parses the single-line reply into status. +func (c *Client) command(cmd string) (string, error) { + c.mu.Lock() + defer c.mu.Unlock() + if c.conn == nil || c.reader == nil { + return "", fmt.Errorf("powergenius: not connected") + } + id := c.cmdID.Add(1) + _ = c.conn.SetWriteDeadline(time.Now().Add(ioTimeout)) + if _, err := fmt.Fprintf(c.conn, "C%d|%s\n", id, cmd); err != nil { + return "", err + } + _ = c.conn.SetReadDeadline(time.Now().Add(ioTimeout)) + line, err := c.reader.ReadString('\n') + if err != nil { + return "", err + } + line = strings.TrimSpace(line) + c.parse(line) + return line, nil +} + +// parse handles "R|0|" and "S0|" status lines. +func (c *Client) parse(resp string) { + var data string + switch { + case strings.HasPrefix(resp, "R"): + p := strings.SplitN(resp, "|", 3) + if len(p) < 3 { + return + } + data = p[2] + case strings.HasPrefix(resp, "S"): + p := strings.SplitN(resp, "|", 2) + if len(p) < 2 { + return + } + data = p[1] + default: + return + } + c.statusMu.Lock() + c.status.Connected = true + c.status.LastError = "" + for _, pair := range strings.Fields(data) { + kv := strings.SplitN(pair, "=", 2) + if len(kv) != 2 { + continue + } + switch kv[0] { + case "state": + c.status.State = kv[1] + case "fanmode": + c.status.FanMode = strings.ToUpper(kv[1]) + case "temp": + c.status.Temperature, _ = strconv.ParseFloat(kv[1], 64) + } + } + c.statusMu.Unlock() +}