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
+62 -1
View File
@@ -24,6 +24,7 @@ import {
GetDBConnectionInfo, GetLogbookRevision,
GetUltrabeamStatus, SetUltrabeamDirection,
GetAntGeniusStatus, GetAntGeniusSettings, AntGeniusActivate,
GetTunerGeniusStatus, GetTunerGeniusSettings, TunerGeniusAutotune, TunerGeniusSetBypass, TunerGeniusSetOperate,
OpenExternalURL,
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus, SendClusterCommand,
ListClusterServers, ClusterSpotStatuses, SendClusterSpot,
@@ -69,6 +70,7 @@ import { WorldMap, LocatorMap } from '@/components/MainMap';
import { FlexPanel } from '@/components/FlexPanel';
import { IcomPanel } from '@/components/IcomPanel';
import { AntGeniusPanel, type AGStatus } from '@/components/AntGeniusPanel';
import { TunerGeniusPanel, type TGStatus } from '@/components/TunerGeniusPanel';
import { FilterBuilder, type QueryFilter } from '@/components/FilterBuilder';
import { AwardsPanel } from '@/components/AwardsPanel';
import { StatsPanel } from '@/components/StatsPanel';
@@ -439,6 +441,8 @@ export default function App() {
const [ubStatus, setUbStatus] = useState<{ enabled: boolean; connected: boolean; direction: number; moving: boolean }>({ enabled: false, connected: false, direction: 0, moving: false });
const [agStatus, setAgStatus] = useState<AGStatus>({ connected: false, port_a: 0, port_b: 0, antennas: [] });
const [agEnabled, setAgEnabled] = useState(false);
const [tgStatus, setTgStatus] = useState<TGStatus>({ connected: false });
const [tgEnabled, setTgEnabled] = useState(false);
// Per-port optimistic selection that the status poll must not revert until the
// device confirms it (or it expires) — otherwise a stale poll right after a
// click reverts the UI and the click looks like it did nothing.
@@ -1473,6 +1477,7 @@ export default function App() {
// Portable UI toggles (mirrored to the DB via writeUiPref / syncPortablePrefs).
const [showRotor, setShowRotor] = useState(() => localStorage.getItem('opslog.showRotor') !== '0');
const [showAntGenius, setShowAntGenius] = useState(() => localStorage.getItem('opslog.showAntGenius') !== '0');
const [showTuner, setShowTuner] = useState(() => localStorage.getItem('opslog.showTuner') !== '0');
const [showBeamOnMap, setShowBeamOnMap] = useState(() => localStorage.getItem('opslog.showBeamOnMap') !== '0');
// Award code → scanned field (e.g. POTA→pota_ref, WWFF→wwff). Used to route
@@ -1668,6 +1673,36 @@ export default function App() {
AntGeniusActivate(port, antenna).catch((e) => setError(String(e?.message ?? e)));
};
// Poll the Tuner Genius XL for SWR / power / operating state. Re-read the
// enabled flag each tick so toggling it in Settings shows/hides the widget
// without an app restart.
useEffect(() => {
let alive = true;
const tick = async () => {
try { const en: any = await GetTunerGeniusSettings(); if (alive) setTgEnabled(!!en?.enabled); } catch {}
try {
const s = (await GetTunerGeniusStatus()) as TGStatus;
if (!alive || !s) return;
setTgStatus((prev) => (JSON.stringify(prev) === JSON.stringify(s) ? prev : s));
} catch {}
};
tick();
const id = window.setInterval(tick, 1500);
return () => { alive = false; window.clearInterval(id); };
}, []);
const tgTune = () => {
setTgStatus((s) => ({ ...s, tuning: true })); // optimistic
TunerGeniusAutotune().catch((e) => setError(String(e?.message ?? e)));
};
const tgBypass = (on: boolean) => {
setTgStatus((s) => ({ ...s, bypass: on }));
TunerGeniusSetBypass(on).catch((e) => setError(String(e?.message ?? e)));
};
const tgOperate = (on: boolean) => {
setTgStatus((s) => ({ ...s, operate: on }));
TunerGeniusSetOperate(on).catch((e) => setError(String(e?.message ?? e)));
};
// RX band auto-follows the TX band (only differs for cross-band work).
useEffect(() => { setBandRx(band); }, [band]);
@@ -4093,6 +4128,21 @@ export default function App() {
{showAntGenius && agStatus.connected && <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-success" />}
</button>
)}
{tgEnabled && (
<button
type="button"
onClick={() => { const v = !showTuner; setShowTuner(v); writeUiPref('opslog.showTuner', v ? '1' : '0'); }}
title={showTuner ? 'Tuner Genius — shown · click to hide' : 'Tuner Genius · click to show'}
className={cn(
'relative inline-flex items-center justify-center size-7 rounded-md border transition-colors',
showTuner ? 'border-success-border bg-success-muted text-success-muted-foreground hover:bg-success-muted'
: 'border-border text-muted-foreground hover:bg-muted',
)}
>
<Zap className="size-4" />
{showTuner && tgStatus.connected && <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-success" />}
</button>
)}
{chatAvailable && (
<button
type="button"
@@ -4561,7 +4611,7 @@ export default function App() {
{/* Reserved free space to the right. The WinKeyer CW keyer and/or the
Digital Voice Keyer take this slot when enabled (Log4OM-style);
otherwise it shows the QRZ profile photo. */}
{!compact && (chatShown || wkEnabled || dvkEnabled || lookupResult?.image_url || (showRotor && (rotatorHeading.enabled || dxPath)) || (showAntGenius && agEnabled) || (showLiveStations && dbConn?.backend === 'mysql')) && (
{!compact && (chatShown || wkEnabled || dvkEnabled || lookupResult?.image_url || (showRotor && (rotatorHeading.enabled || dxPath)) || (showAntGenius && agEnabled) || (showTuner && tgEnabled) || (showLiveStations && dbConn?.backend === 'mysql')) && (
// relative + absolute inner (like the F1-F5 panel): a taller widget (e.g.
// the DVK with Auto CQ) can't grow the row — the row height stays set by
// the entry strip and each widget fills that height, scrolling inside.
@@ -4648,6 +4698,17 @@ export default function App() {
/>
</div>
)}
{showTuner && tgEnabled && (
<div className="w-[230px] shrink-0 min-h-0">
<TunerGeniusPanel
status={tgStatus}
onTune={tgTune}
onBypass={tgBypass}
onOperate={tgOperate}
onClose={() => { setShowTuner(false); writeUiPref('opslog.showTuner', '0'); }}
/>
</div>
)}
{dvkEnabled && (
<div className="w-[320px] shrink-0 min-h-0">
<DvkPanel