feat: control the 4O3A Tuner Genius XL directly over TCP (port 9010)

New internal/tunergenius client speaking the same "Genius Series" text API
as the Antenna Genius / PowerGenius XL: banner on connect (with optional
"AUTH" for remote access), "C<seq>|<cmd>\n" commands, "R<seq>|<code>|<msg>"
replies and the "S<seq>|status k=v …" snapshot; async "M|<msg>" info lines
are consumed inline. Polls status ~1.5s and exposes SWR (return-loss dB
converted to a VSWR ratio), forward power (dBm→W), and the operate/bypass/
tuning/active-channel state. Actions: autotune, global bypass, operate/standby.

Controlled directly (not via the radio) so OpsLog uses only one of the box's
four connection slots, per the device's protocol doc.

Wiring:
- app.go: tunergenius.* settings keys, Get/Save/start, GetTunerGeniusStatus,
  TunerGeniusAutotune/SetBypass/SetOperate; started in the background at launch.
- Settings → Tuner Genius panel (enable + IP + optional remote code).
- Docked TunerGeniusPanel widget (SWR/power readouts + Tune/Bypass/Operate),
  top-bar toggle, shown when enabled — mirrors the Antenna Genius widget.
- i18n EN/FR (sec.tunergenius, tg2.*, tgp.*).

Command verbs (operate/bypass/autotune/status/auth) come straight from the
4O3A "Tuner Genius XL — Protocol Description"; UNTESTED on hardware.
This commit is contained in:
2026-07-25 10:32:00 +02:00
parent b4f0e0bc29
commit 933d601c03
10 changed files with 849 additions and 4 deletions
+48 -1
View File
@@ -13,6 +13,7 @@ import {
GetRotatorSettings, SaveRotatorSettings, TestRotator, RotatorPark, RotatorStop,
GetUltrabeamSettings, SaveUltrabeamSettings, TestUltrabeam,
GetAntGeniusSettings, SaveAntGeniusSettings,
GetTunerGeniusSettings, SaveTunerGeniusSettings,
GetAmplifiers, SaveAmplifiers, GetAmpStatuses, AmpOperate,
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts,
GetAudioSettings, SaveAudioSettings, ListAudioInputDevices, ListAudioOutputDevices, PickAudioFolder, TestPTT,
@@ -186,6 +187,7 @@ type SectionId =
| 'winkeyer'
| 'antenna'
| 'antgenius'
| 'tunergenius'
| 'pgxl'
| 'flex'
| 'relayauto'
@@ -204,6 +206,7 @@ function buildTree(flexAvailable: boolean, t: (k: string) => string): TreeNode[]
{ kind: 'item', label: t('sec.winkeyer'), id: 'winkeyer' },
{ kind: 'item', label: t('sec.antenna'), id: 'antenna' },
{ kind: 'item', label: t('sec.antgenius'), id: 'antgenius' },
{ kind: 'item', label: t('sec.tunergenius'), id: 'tunergenius' },
{ kind: 'item', label: t('sec.pgxl'), id: 'pgxl' },
...(flexAvailable ? [{ kind: 'item', label: t('sec.flex'), id: 'flex' } as TreeNode] : []),
{ kind: 'item', label: t('sec.relayauto'), id: 'relayauto' },
@@ -250,7 +253,7 @@ const SECTION_KEY: Partial<Record<SectionId, string>> = {
adifmon: 'sec.adifmon',
uscounties: 'sec.uscounties',
awards: 'sec.awards', cat: 'sec.cat', rotator: 'sec.rotator', winkeyer: 'sec.winkeyer', antenna: 'sec.antenna',
antgenius: 'sec.antgenius', pgxl: 'sec.pgxl', flex: 'sec.flex', audio: 'sec.audio', general: 'sec.general', email: 'sec.email',
antgenius: 'sec.antgenius', tunergenius: 'sec.tunergenius', pgxl: 'sec.pgxl', flex: 'sec.flex', audio: 'sec.audio', general: 'sec.general', email: 'sec.email',
relayauto: 'sec.relayauto',
};
@@ -276,6 +279,7 @@ const SECTION_LABELS: Partial<Record<SectionId, string>> = {
winkeyer: 'CW Keyer',
antenna: 'Ultrabeam / Steppir',
antgenius: 'Antenna Genius',
tunergenius: 'Tuner Genius',
pgxl: 'Amplifier',
flex: 'FlexRadio',
relayauto: 'Relay auto-control',
@@ -1063,6 +1067,7 @@ 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; password: string }>({ enabled: false, host: '', password: '' });
const [tunergenius, setTunergenius] = useState<{ enabled: boolean; host: string; password: string }>({ enabled: false, host: '', password: '' });
// Amplifier list — operators can run SEVERAL amps (even two SPEs combined),
// each with its own connection. Saved as a whole via SaveAmplifiers.
@@ -1390,6 +1395,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 { setTunergenius(await GetTunerGeniusSettings() as any); } catch {}
try { setAmps(((await GetAmplifiers()) ?? []) as AmpUI[]); } catch {}
setBackupCfg(b as any);
setQslDefaults(qd as any);
@@ -1430,6 +1436,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 { setTunergenius(await GetTunerGeniusSettings() as any); } catch {}
try { setAmps(((await GetAmplifiers()) ?? []) as AmpUI[]); } catch {}
try { setBackupCfg(await GetBackupSettings() as any); } catch {}
try { setQslDefaults(await GetQSLDefaults() as any); } catch {}
@@ -1599,6 +1606,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
await SaveRotatorSettings(rotator as any);
await SaveUltrabeamSettings(ultrabeam as any);
await SaveAntGeniusSettings(antgenius as any);
await SaveTunerGeniusSettings(tunergenius as any);
await SaveAmplifiers(amps as any);
await SaveWinkeyerSettings(wk as any);
await SaveAudioSettings(audioCfg as any);
@@ -2719,6 +2727,44 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
);
}
function TunerGeniusPanelSettings() {
return (
<>
<SectionHeader
title="Tuner Genius XL (4O3A)"
hint={t('tg2.hint')}
/>
<div className="space-y-4 max-w-xl">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={tunergenius.enabled} onCheckedChange={(c) => setTunergenius((s) => ({ ...s, enabled: !!c }))} />
{t('tg2.enable')}
</label>
<div className="space-y-1">
<Label>Host / IP</Label>
<Input
value={tunergenius.host ?? ''}
onChange={(e) => setTunergenius((s) => ({ ...s, host: e.target.value }))}
placeholder="192.168.1.61"
className="font-mono"
/>
<p className="text-xs text-muted-foreground">{t('tg2.portHint')}</p>
</div>
<div className="space-y-1">
<Label>{t('tg2.password')}</Label>
<Input
type="password"
value={tunergenius.password ?? ''}
onChange={(e) => setTunergenius((s) => ({ ...s, password: e.target.value }))}
placeholder={t('tg2.passwordPh')}
className="font-mono"
/>
<p className="text-xs text-muted-foreground">{t('tg2.passwordHint')}</p>
</div>
</div>
</>
);
}
function PGXLPanelSettings() {
// The stored `type` stays a flat value ("spe13", "acom700", "pgxl"); the UI
// presents it as brand + model.
@@ -5035,6 +5081,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
winkeyer: WinkeyerPanel,
antenna: UltrabeamPanel,
antgenius: AntGeniusPanelSettings,
tunergenius: TunerGeniusPanelSettings,
pgxl: PGXLPanelSettings,
flex: () => <FlexBandAntennasPanel bands={lists.bands ?? []} />,
audio: AudioPanel,
@@ -0,0 +1,130 @@
import { Zap, X, Power, Radio } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
export type TGStatus = {
connected: boolean; host?: string; last_error?: string;
fwd_dbm?: number; fwd_w?: number; swr_db?: number; vswr?: number; freq_mhz?: number;
operate?: boolean; bypass?: boolean; tuning?: boolean; active?: number;
antenna?: number; three_way?: boolean; message?: string;
};
// swrColour picks a status colour for the VSWR readout: green ≤1.5, amber ≤2.0,
// red above (the usual "safe / caution / high" ATU thresholds).
function swrColour(vswr?: number): string {
if (!vswr || vswr <= 0) return 'text-muted-foreground';
if (vswr <= 1.5) return 'text-success';
if (vswr <= 2.0) return 'text-warning';
return 'text-danger';
}
// TunerGeniusPanel — control widget for a 4O3A Tuner Genius XL ATU. Shows the
// live SWR / forward power and offers Tune (autotune), Bypass and Operate/Standby.
export function TunerGeniusPanel({ status, onTune, onBypass, onOperate, onClose }: {
status: TGStatus;
onTune: () => void;
onBypass: (on: boolean) => void;
onOperate: (on: boolean) => void;
onClose: () => void;
}) {
const { t } = useI18n();
const vswr = status.vswr && status.vswr > 0 ? status.vswr : undefined;
return (
<div className="h-full flex flex-col rounded-xl border border-border bg-gradient-to-b from-card to-muted/30 shadow-sm overflow-hidden">
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/40 shrink-0">
<Zap className={cn('size-4', status.connected ? 'text-success drop-shadow-[0_0_3px_rgba(16,185,129,0.55)]' : 'text-muted-foreground')} />
<span className="text-xs font-bold uppercase tracking-[0.18em] text-foreground/80">Tuner Genius</span>
<span className="flex-1" />
<span className="inline-flex items-center gap-1.5 text-[10px] font-mono uppercase tracking-wider">
<span className={cn('size-1.5 rounded-full', status.connected ? 'bg-success shadow-[0_0_6px_rgba(16,185,129,0.8)] animate-pulse' : 'bg-danger')} />
<span className={status.connected ? 'text-success' : 'text-danger'}>{status.connected ? t('tgp.online') : t('tgp.offline')}</span>
</span>
<button type="button" onClick={onClose} className="ml-1 text-muted-foreground hover:text-foreground transition-colors" title={t('tgp.close')}>
<X className="size-3.5" />
</button>
</div>
{!status.connected ? (
<div className="flex-1 min-h-0 flex flex-col items-center justify-center text-xs gap-2 p-3">
<div className="text-muted-foreground italic animate-pulse">{t('tgp.connecting')}</div>
{status.last_error && <div className="text-danger font-mono text-[10px] break-words text-center px-2">{status.last_error}</div>}
</div>
) : (
<div className="flex-1 min-h-0 overflow-y-auto p-2.5 space-y-2.5">
{/* SWR + forward power readouts */}
<div className="grid grid-cols-2 gap-2">
<div className="rounded-lg border border-border bg-card/70 px-2 py-1.5 text-center">
<div className="text-[9px] uppercase tracking-wider text-muted-foreground">{t('tgp.swr')}</div>
<div className={cn('text-lg font-bold font-mono leading-tight', swrColour(vswr))}>
{vswr ? `${vswr.toFixed(2)}:1` : '—'}
</div>
</div>
<div className="rounded-lg border border-border bg-card/70 px-2 py-1.5 text-center">
<div className="text-[9px] uppercase tracking-wider text-muted-foreground">{t('tgp.power')}</div>
<div className="text-lg font-bold font-mono leading-tight text-foreground/80">
{status.fwd_w && status.fwd_w >= 1 ? `${Math.round(status.fwd_w)} W` : '—'}
</div>
</div>
</div>
{status.message && (
<div className="rounded-lg border border-warning-border bg-warning-muted text-warning-muted-foreground text-[10px] px-2 py-1 text-center break-words">
{status.message}
</div>
)}
{/* Tune */}
<button
type="button"
onClick={onTune}
disabled={status.tuning}
title={t('tgp.tuneHint')}
className={cn(
'w-full rounded-lg text-sm font-bold py-2 border transition-all active:scale-[0.98]',
status.tuning
? 'bg-gradient-to-b from-amber-400 to-amber-600 text-white border-amber-300/60 shadow-[0_0_10px_rgba(245,158,11,0.5)] animate-pulse'
: 'bg-gradient-to-b from-primary/90 to-primary text-primary-foreground border-primary/40 hover:brightness-110',
)}
>
<span className="inline-flex items-center justify-center gap-2">
<Radio className="size-4" />
{status.tuning ? t('tgp.tuning') : t('tgp.tune')}
</span>
</button>
{/* Bypass + Operate toggles */}
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => onBypass(!status.bypass)}
title={t('tgp.bypassHint')}
className={cn(
'rounded-lg text-xs font-bold py-1.5 border transition-all active:scale-95',
status.bypass
? 'bg-gradient-to-b from-sky-400 to-sky-600 text-white border-sky-300/60 shadow-[0_0_9px_rgba(14,165,233,0.45)]'
: 'bg-card text-muted-foreground border-border hover:bg-muted hover:text-foreground',
)}
>
{t('tgp.bypass')}
</button>
<button
type="button"
onClick={() => onOperate(!status.operate)}
title={t('tgp.operateHint')}
className={cn(
'inline-flex items-center justify-center gap-1.5 rounded-lg text-xs font-bold py-1.5 border transition-all active:scale-95',
status.operate
? 'bg-gradient-to-b from-emerald-400 to-emerald-600 text-white border-emerald-300/60 shadow-[0_0_9px_rgba(16,185,129,0.45)]'
: 'bg-card text-muted-foreground border-border hover:bg-muted hover:text-foreground',
)}
>
<Power className="size-3.5" />
{status.operate ? t('tgp.operate') : t('tgp.standby')}
</button>
</div>
</div>
)}
</div>
);
}