feat: Support for Power Genius XL
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -3382,7 +3382,7 @@ export default function App() {
|
||||
|
||||
{/* ===== CW decoder strip (only when enabled AND mode is CW) ===== */}
|
||||
{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')} />
|
||||
{/* Input-level meter — if this stays flat with a strong signal, the RX
|
||||
audio device is wrong/silent rather than a decode problem. */}
|
||||
|
||||
@@ -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<any>) => {
|
||||
hold.current[key] = Date.now() + 900;
|
||||
setSt((p) => ({ ...p, [key]: val }));
|
||||
@@ -430,6 +441,22 @@ export function FlexPanel() {
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{st.amp_operate ? 'Amplifier is in line (transmitting through PA).' : 'Amplifier bypassed (standby).'}
|
||||
</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" />
|
||||
{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>
|
||||
|
||||
@@ -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<Record<SectionId, string>> = {
|
||||
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 (
|
||||
<>
|
||||
<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() {
|
||||
return (
|
||||
<>
|
||||
@@ -3808,6 +3858,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
winkeyer: WinkeyerPanel,
|
||||
antenna: UltrabeamPanel,
|
||||
antgenius: AntGeniusPanelSettings,
|
||||
pgxl: PGXLPanelSettings,
|
||||
audio: AudioPanel,
|
||||
};
|
||||
|
||||
|
||||
Vendored
+9
@@ -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<main.MySQLSettings>;
|
||||
|
||||
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 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 PGXLSetFanMode(arg1:string):Promise<void>;
|
||||
|
||||
export function PickAudioFolder():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 SavePGXLSettings(arg1:main.PGXLSettings):Promise<void>;
|
||||
|
||||
export function SavePOTAToken(arg1:string):Promise<void>;
|
||||
|
||||
export function SaveProfile(arg1:profile.Profile):Promise<profile.Profile>;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user