feat: Support for Power Genius XL

This commit is contained in:
2026-06-21 20:27:53 +02:00
parent b302d4d87b
commit 5d9765be09
8 changed files with 459 additions and 2 deletions
+85
View File
@@ -41,6 +41,7 @@ import (
"hamlog/internal/rotator/pst" "hamlog/internal/rotator/pst"
"hamlog/internal/settings" "hamlog/internal/settings"
"hamlog/internal/antgenius" "hamlog/internal/antgenius"
"hamlog/internal/powergenius"
"hamlog/internal/ultrabeam" "hamlog/internal/ultrabeam"
"hamlog/internal/winkeyer" "hamlog/internal/winkeyer"
@@ -146,6 +147,11 @@ const (
keyAntGeniusEnabled = "antgenius.enabled" keyAntGeniusEnabled = "antgenius.enabled"
keyAntGeniusHost = "antgenius.host" 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. // WinKeyer CW keyer (serial) — Hardware → CW Keyer.
keyWKEnabled = "winkeyer.enabled" keyWKEnabled = "winkeyer.enabled"
keyWKPort = "winkeyer.port" keyWKPort = "winkeyer.port"
@@ -390,6 +396,7 @@ type App struct {
ultrabeam *ultrabeam.Client // Ultrabeam antenna (TCP); nil when disabled ultrabeam *ultrabeam.Client // Ultrabeam antenna (TCP); nil when disabled
ubFollowStop chan struct{} // stops the "follow frequency" loop; nil when off 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 audioMgr *audio.Manager
qsoRec *audio.Recorder // continuous QSO recorder (rolling pre-roll) qsoRec *audio.Recorder // continuous QSO recorder (rolling pre-roll)
cwMu sync.Mutex // guards the CW decoder lifecycle cwMu sync.Mutex // guards the CW decoder lifecycle
@@ -824,6 +831,8 @@ func (a *App) startup(ctx context.Context) {
a.startUltrabeam() a.startUltrabeam()
// Antenna Genius switch: connect in the background if enabled. // Antenna Genius switch: connect in the background if enabled.
a.startAntGenius() 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 // Autostart: launch the active profile's configured external programs that
// aren't already running (WSJT-X, JTAlert, rotator control, …). Background // 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) 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 --- // --- WinKeyer (CW keyer) bindings ---
// WKMacro is one CW message slot (F1…): a short label + the macro text, which // WKMacro is one CW message slot (F1…): a short label + the macro text, which
+1 -1
View File
@@ -3382,7 +3382,7 @@ export default function App() {
{/* ===== CW decoder strip (only when enabled AND mode is CW) ===== */} {/* ===== CW decoder strip (only when enabled AND mode is CW) ===== */}
{cwOn && ( {cwOn && (
<div className="ml-2.5 my-1.5 w-[45%] flex items-center gap-2 rounded-md border border-emerald-300/70 bg-emerald-50/60 px-2 py-1.5 text-xs"> <div className="ml-2.5 mt-1.5 -mb-1 w-[45%] flex items-center gap-2 rounded-md border border-emerald-300/70 bg-emerald-50/60 px-2 py-1.5 text-xs">
<Ear className={cn('size-4 shrink-0', cwStatus.active ? 'text-emerald-600' : 'text-muted-foreground')} /> <Ear className={cn('size-4 shrink-0', cwStatus.active ? 'text-emerald-600' : 'text-muted-foreground')} />
{/* Input-level meter — if this stays flat with a strong signal, the RX {/* Input-level meter — if this stays flat with a strong signal, the RX
audio device is wrong/silent rather than a decode problem. */} audio device is wrong/silent rather than a decode problem. */}
+27
View File
@@ -4,6 +4,7 @@ import {
GetFlexState, FlexSetPower, FlexSetTunePower, FlexTune, FlexSetVox, FlexSetVoxLevel, FlexSetVoxDelay, GetFlexState, FlexSetPower, FlexSetTunePower, FlexTune, FlexSetVox, FlexSetVoxLevel, FlexSetVoxDelay,
FlexSetProcessor, FlexSetProcessorLevel, FlexSetMon, FlexSetMonLevel, FlexSetMic, FlexSetProcessor, FlexSetProcessorLevel, FlexSetMon, FlexSetMonLevel, FlexSetMic,
FlexMox, FlexAmpOperate, FlexMox, FlexAmpOperate,
GetPGXLStatus, PGXLSetFanMode,
FlexSetAGCMode, FlexSetAGCThreshold, FlexSetAudioLevel, FlexSetAGCMode, FlexSetAGCThreshold, FlexSetAudioLevel,
FlexSetNB, FlexSetNBLevel, FlexSetNR, FlexSetNRLevel, FlexSetANF, FlexSetANFLevel, FlexSetNB, FlexSetNBLevel, FlexSetNR, FlexSetNRLevel, FlexSetANF, FlexSetANFLevel,
FlexSetAPF, FlexSetAPFLevel, FlexSetCWSpeed, FlexSetCWPitch, FlexSetCWBreakInDelay, FlexSetAPF, FlexSetAPFLevel, FlexSetCWSpeed, FlexSetCWPitch, FlexSetCWBreakInDelay,
@@ -215,6 +216,16 @@ export function FlexPanel() {
return () => { alive = false; window.clearInterval(id); }; 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<any>) => { const change = (key: keyof FlexState, val: number | boolean | string, send: () => Promise<any>) => {
hold.current[key] = Date.now() + 900; hold.current[key] = Date.now() + 900;
setSt((p) => ({ ...p, [key]: val })); setSt((p) => ({ ...p, [key]: val }));
@@ -430,6 +441,22 @@ export function FlexPanel() {
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{st.amp_operate ? 'Amplifier is in line (transmitting through PA).' : 'Amplifier bypassed (standby).'} {st.amp_operate ? 'Amplifier is in line (transmitting through PA).' : 'Amplifier bypassed (standby).'}
</span> </span>
{/* Fan mode — only when the PowerGenius direct connection is up
(configured in Settings → PowerGenius). */}
{pg.connected && (
<label className="flex items-center gap-1.5 text-xs">
<span className="text-muted-foreground">Fan</span>
<select
value={(pg.fan_mode || 'CONTEST').toUpperCase()}
onChange={(e) => { const v = e.target.value; setPg((s) => ({ ...s, fan_mode: v })); PGXLSetFanMode(v).catch(() => {}); }}
className="h-8 rounded-md border border-orange-300 bg-card px-2 text-xs font-semibold text-orange-800 outline-none focus:border-orange-500"
>
<option value="STANDARD">Standard</option>
<option value="CONTEST">Contest</option>
<option value="BROADCAST">Broadcast</option>
</select>
</label>
)}
<div className="flex-1" /> <div className="flex-1" />
{st.amp_fault && st.amp_fault !== 'NONE' && ( {st.amp_fault && st.amp_fault !== 'NONE' && (
<span className="px-2 py-1 rounded bg-rose-100 text-rose-800 text-xs font-bold">FAULT: {st.amp_fault}</span> <span className="px-2 py-1 rounded bg-rose-100 text-rose-800 text-xs font-bold">FAULT: {st.amp_fault}</span>
+51
View File
@@ -13,6 +13,7 @@ import {
GetRotatorSettings, SaveRotatorSettings, TestRotator, RotatorPark, RotatorStop, GetRotatorSettings, SaveRotatorSettings, TestRotator, RotatorPark, RotatorStop,
GetUltrabeamSettings, SaveUltrabeamSettings, TestUltrabeam, GetUltrabeamSettings, SaveUltrabeamSettings, TestUltrabeam,
GetAntGeniusSettings, SaveAntGeniusSettings, GetAntGeniusSettings, SaveAntGeniusSettings,
GetPGXLSettings, SavePGXLSettings,
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts, GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts,
GetAudioSettings, SaveAudioSettings, ListAudioInputDevices, ListAudioOutputDevices, PickAudioFolder, TestPTT, GetAudioSettings, SaveAudioSettings, ListAudioInputDevices, ListAudioOutputDevices, PickAudioFolder, TestPTT,
GetClublogCtyInfo, SetClublogCtyEnabled, DownloadClublogCty, GetClublogCtyInfo, SetClublogCtyEnabled, DownloadClublogCty,
@@ -172,6 +173,7 @@ type SectionId =
| 'winkeyer' | 'winkeyer'
| 'antenna' | 'antenna'
| 'antgenius' | 'antgenius'
| 'pgxl'
| 'audio'; | 'audio';
type TreeNode = type TreeNode =
@@ -210,6 +212,7 @@ const TREE: TreeNode[] = [
{ kind: 'item', label: 'CW Keyer', id: 'winkeyer' }, { kind: 'item', label: 'CW Keyer', id: 'winkeyer' },
{ kind: 'item', label: 'Antenna', id: 'antenna' }, { kind: 'item', label: 'Antenna', id: 'antenna' },
{ kind: 'item', label: 'Antenna Genius', id: 'antgenius' }, { kind: 'item', label: 'Antenna Genius', id: 'antgenius' },
{ kind: 'item', label: 'PowerGenius', id: 'pgxl' },
{ kind: 'item', label: 'Audio devices', id: 'audio' }, { kind: 'item', label: 'Audio devices', id: 'audio' },
], ],
}, },
@@ -236,6 +239,7 @@ const SECTION_LABELS: Partial<Record<SectionId, string>> = {
winkeyer: 'CW Keyer', winkeyer: 'CW Keyer',
antenna: 'Antenna', antenna: 'Antenna',
antgenius: 'Antenna Genius', antgenius: 'Antenna Genius',
pgxl: 'PowerGenius',
audio: 'Audio devices', 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. // Antenna Genius (4O3A) switch settings — TCP port is fixed at 9007.
const [antgenius, setAntgenius] = useState<{ enabled: boolean; host: string }>({ enabled: false, host: '' }); 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. // WinKeyer CW keyer settings + macro editor.
type WKMac = { label: string; text: string }; type WKMac = { label: string; text: string };
type WKSettings = { type WKSettings = {
@@ -892,6 +899,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
setRotator(r); setRotator(r);
try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {} try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {}
try { setAntgenius(await GetAntGeniusSettings() as any); } catch {} try { setAntgenius(await GetAntGeniusSettings() as any); } catch {}
try { setPgxl(await GetPGXLSettings() as any); } catch {}
setBackupCfg(b as any); setBackupCfg(b as any);
setQslDefaults(qd as any); setQslDefaults(qd as any);
setExtSvc(es 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 { setRotator(await GetRotatorSettings() as any); } catch {}
try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {} try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {}
try { setAntgenius(await GetAntGeniusSettings() 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 { setBackupCfg(await GetBackupSettings() as any); } catch {}
try { setQslDefaults(await GetQSLDefaults() as any); } catch {} try { setQslDefaults(await GetQSLDefaults() as any); } catch {}
try { setExtSvc(await GetExternalServices() 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 SaveRotatorSettings(rotator as any);
await SaveUltrabeamSettings(ultrabeam as any); await SaveUltrabeamSettings(ultrabeam as any);
await SaveAntGeniusSettings(antgenius as any); await SaveAntGeniusSettings(antgenius as any);
await SavePGXLSettings(pgxl as any);
await SaveWinkeyerSettings(wk as any); await SaveWinkeyerSettings(wk as any);
await SaveAudioSettings(audioCfg as any); await SaveAudioSettings(audioCfg as any);
await SaveEmailSettings(emailCfg as any); await SaveEmailSettings(emailCfg as any);
@@ -2052,6 +2062,46 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
); );
} }
function PGXLPanelSettings() {
return (
<>
<SectionHeader
title="PowerGenius XL"
hint="OpsLog reads the amp's operate state through the FlexRadio, but the fan mode is only reachable on the PowerGenius XL's own TCP control port. Enter its IP to add a fan-mode selector next to Operate in the FlexRadio panel."
/>
<div className="space-y-4 max-w-xl">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={pgxl.enabled} onCheckedChange={(c) => setPgxl((s) => ({ ...s, enabled: !!c }))} />
Enable PowerGenius fan control
</label>
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1 col-span-2">
<Label>Host / IP</Label>
<Input
value={pgxl.host ?? ''}
onChange={(e) => setPgxl((s) => ({ ...s, host: e.target.value }))}
placeholder="192.168.1.70"
className="font-mono"
/>
</div>
<div className="space-y-1">
<Label>TCP port</Label>
<Input
type="number" min={1} max={65535}
value={pgxl.port}
onChange={(e) => setPgxl((s) => ({ ...s, port: parseInt(e.target.value) || 9006 }))}
className="font-mono"
/>
</div>
</div>
<p className="text-xs text-muted-foreground">
Default port is 9006. Once enabled, a fan-mode dropdown (Standard / Contest / Broadcast) appears next to the amplifier Operate button in the FlexRadio tab.
</p>
</div>
</>
);
}
function RotatorPanel() { function RotatorPanel() {
return ( return (
<> <>
@@ -3808,6 +3858,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
winkeyer: WinkeyerPanel, winkeyer: WinkeyerPanel,
antenna: UltrabeamPanel, antenna: UltrabeamPanel,
antgenius: AntGeniusPanelSettings, antgenius: AntGeniusPanelSettings,
pgxl: PGXLPanelSettings,
audio: AudioPanel, audio: AudioPanel,
}; };
+9
View File
@@ -10,6 +10,7 @@ import {award} from '../models';
import {awardref} from '../models'; import {awardref} from '../models';
import {cluster} from '../models'; import {cluster} from '../models';
import {extsvc} from '../models'; import {extsvc} from '../models';
import {powergenius} from '../models';
import {winkeyer} from '../models'; import {winkeyer} from '../models';
import {audio} from '../models'; import {audio} from '../models';
import {operating} from '../models'; import {operating} from '../models';
@@ -272,6 +273,10 @@ export function GetMySQLSettings():Promise<main.MySQLSettings>;
export function GetOnlineOperators():Promise<Array<main.ChatPresence>>; export function GetOnlineOperators():Promise<Array<main.ChatPresence>>;
export function GetPGXLSettings():Promise<main.PGXLSettings>;
export function GetPGXLStatus():Promise<powergenius.Status>;
export function GetPOTAToken():Promise<string>; export function GetPOTAToken():Promise<string>;
export function GetQSLDefaults():Promise<main.QSLDefaults>; export function GetQSLDefaults():Promise<main.QSLDefaults>;
@@ -374,6 +379,8 @@ export function OpenExternalURL(arg1:string):Promise<void>;
export function OperatingDefaultForBand(arg1:string):Promise<operating.BandDefault>; export function OperatingDefaultForBand(arg1:string):Promise<operating.BandDefault>;
export function PGXLSetFanMode(arg1:string):Promise<void>;
export function PickAudioFolder():Promise<string>; export function PickAudioFolder():Promise<string>;
export function PickBackupFolder():Promise<string>; export function PickBackupFolder():Promise<string>;
@@ -486,6 +493,8 @@ export function SaveOperatingAntenna(arg1:operating.Antenna):Promise<operating.A
export function SaveOperatingStation(arg1:operating.Station):Promise<operating.Station>; export function SaveOperatingStation(arg1:operating.Station):Promise<operating.Station>;
export function SavePGXLSettings(arg1:main.PGXLSettings):Promise<void>;
export function SavePOTAToken(arg1:string):Promise<void>; export function SavePOTAToken(arg1:string):Promise<void>;
export function SaveProfile(arg1:profile.Profile):Promise<profile.Profile>; export function SaveProfile(arg1:profile.Profile):Promise<profile.Profile>;
+16
View File
@@ -514,6 +514,14 @@ export function GetOnlineOperators() {
return window['go']['main']['App']['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() { export function GetPOTAToken() {
return window['go']['main']['App']['GetPOTAToken'](); return window['go']['main']['App']['GetPOTAToken']();
} }
@@ -718,6 +726,10 @@ export function OperatingDefaultForBand(arg1) {
return window['go']['main']['App']['OperatingDefaultForBand'](arg1); return window['go']['main']['App']['OperatingDefaultForBand'](arg1);
} }
export function PGXLSetFanMode(arg1) {
return window['go']['main']['App']['PGXLSetFanMode'](arg1);
}
export function PickAudioFolder() { export function PickAudioFolder() {
return window['go']['main']['App']['PickAudioFolder'](); return window['go']['main']['App']['PickAudioFolder']();
} }
@@ -942,6 +954,10 @@ export function SaveOperatingStation(arg1) {
return window['go']['main']['App']['SaveOperatingStation'](arg1); return window['go']['main']['App']['SaveOperatingStation'](arg1);
} }
export function SavePGXLSettings(arg1) {
return window['go']['main']['App']['SavePGXLSettings'](arg1);
}
export function SavePOTAToken(arg1) { export function SavePOTAToken(arg1) {
return window['go']['main']['App']['SavePOTAToken'](arg1); return window['go']['main']['App']['SavePOTAToken'](arg1);
} }
+43
View File
@@ -1473,6 +1473,22 @@ export namespace main {
this.database = source["database"]; 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 { export class POTAUnmatched {
activator: string; activator: string;
date: 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 namespace profile {
export class ProfileDB { export class ProfileDB {
+226
View File
@@ -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<id>|<cmd>\n"; replies are
// "R<id>|0|<k=v …>" and asynchronous "S0|<k=v …>".
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<id>|<cmd>\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<id>|0|<k=v …>" and "S0|<k=v …>" 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()
}