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,