From 9b677c6b3593bb062220cde49be9ace4274b6258 Mon Sep 17 00:00:00 2001 From: rouggy Date: Sat, 25 Jul 2026 10:47:11 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Tuner=20Genius=20XL=20=E2=80=94=20A/B?= =?UTF-8?q?=20channels,=20plus=20FlexRadio=20panel=20+=20Station=20Control?= =?UTF-8?q?=20cards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Push the tuner control further so it mirrors the native 4O3A app and the way the PowerGenius XL is surfaced. Backend (internal/tunergenius): - Status now carries both RF channels A and B (source/mode, band, frequency, bound Flex nickname, per-channel bypass, antenna, PTT), the active channel, the C1/L/C2 relay-network positions, and the 3-way-vs-SO2R hardware variant (learned once from the `info` reply). Flat freq/antenna still mirror the active channel for the compact widget. - New TunerGeniusActivate(ch) binding → `activate ch=N` (or `ant=N` on 3-way). Frontend: - New shared TunerCard, styled exactly like AmpCard (PWR/SWR meter bars, A/B channel selector with source+freq+antenna, Tune/Bypass/Operate). Used in BOTH the FlexRadio panel (its own card, like the PGXL) and Station Control. - Docked TunerGeniusPanel widget now shows the two channels A/B with their source/frequency/antenna and lets you click to make one active — the missing A/B state the user flagged. - i18n EN/FR for the new labels (channels, antenna, in-line/bypassed, title). Still UNTESTED on hardware — verify the per-channel field names/units and the activate behaviour on the real box. --- app.go | 9 + changelog.json | 4 +- frontend/src/App.tsx | 7 +- frontend/src/components/FlexPanel.tsx | 21 +++ .../src/components/StationControlPanel.tsx | 22 ++- frontend/src/components/TunerCard.tsx | 158 ++++++++++++++++++ frontend/src/components/TunerGeniusPanel.tsx | 52 +++++- frontend/src/lib/i18n.tsx | 2 + frontend/wailsjs/go/main/App.d.ts | 2 + frontend/wailsjs/go/main/App.js | 4 + frontend/wailsjs/go/models.ts | 62 ++++++- internal/tunergenius/tunergenius.go | 124 ++++++++++++-- 12 files changed, 439 insertions(+), 28 deletions(-) create mode 100644 frontend/src/components/TunerCard.tsx diff --git a/app.go b/app.go index bddd281..48a6677 100644 --- a/app.go +++ b/app.go @@ -13013,6 +13013,15 @@ func (a *App) TunerGeniusSetOperate(on bool) error { return a.tunergenius.SetOperate(on) } +// TunerGeniusActivate selects the active channel (1 = A, 2 = B; or antenna +// 1/2/3 on the 3-way variant). +func (a *App) TunerGeniusActivate(ch int) error { + if a.tunergenius == nil { + return fmt.Errorf("Tuner Genius not connected") + } + return a.tunergenius.Activate(ch) +} + // ── PowerGenius XL (4O3A) amplifier fan control (TCP, default port 9008) ───── // PGXLSettings is the JSON shape for the Hardware → Amplifier panel. It covers diff --git a/changelog.json b/changelog.json index 5474ad8..e998a75 100644 --- a/changelog.json +++ b/changelog.json @@ -3,7 +3,7 @@ "version": "0.21.1", "date": "2026-07-24", "en": [ - "New: 4O3A Tuner Genius XL control. Enable it in Settings → Tuner Genius (IP only; port is fixed at 9010), then a docked widget shows live SWR and forward power with Tune, Bypass and Operate/Standby buttons. Controlled directly over TCP, so it uses just one of the box's connection slots.", + "New: 4O3A Tuner Genius XL control. Enable it in Settings → Tuner Genius (IP only; port is fixed at 9010). Live SWR and forward power with Tune, Bypass and Operate/Standby, the two channels A/B (source, frequency and antenna) shown and click-selectable. Available as a docked widget, a card in the FlexRadio panel (like the PowerGenius) and a card in Station Control. Controlled directly over TCP, so it uses just one of the box's connection slots.", "Fixed the colour theme sometimes reverting to the default when reopening OpsLog — it's now restored reliably.", "ADIF export field picker: the per-group All / None buttons work again.", "Station Control: cards now pack tightly into balanced columns instead of leaving big gaps under shorter panels — a cleaner, more even dashboard.", @@ -12,7 +12,7 @@ "Fixed award references (in the entry strip's Awards tab) not clearing when the callsign changes — clicking one spot then another no longer keeps the previous station's references." ], "fr": [ - "Nouveau : contrôle du 4O3A Tuner Genius XL. Active-le dans Réglages → Tuner Genius (IP seulement ; port fixé à 9010), un widget ancré affiche alors le ROS et la puissance directe en direct avec les boutons Accord, Bypass et Operate/Standby. Piloté directement en TCP, il n'utilise qu'une des connexions de la boîte.", + "Nouveau : contrôle du 4O3A Tuner Genius XL. Active-le dans Réglages → Tuner Genius (IP seulement ; port fixé à 9010). ROS et puissance directe en direct avec Accord, Bypass et Operate/Standby, et les deux canaux A/B (source, fréquence et antenne) affichés et sélectionnables d'un clic. Disponible en widget ancré, en carte dans le panneau FlexRadio (comme le PowerGenius) et en carte dans Station Control. Piloté directement en TCP, il n'utilise qu'une des connexions de la boîte.", "Correction du thème qui revenait parfois au défaut à la réouverture d'OpsLog — il est maintenant restauré de façon fiable.", "Sélecteur de champs à l'export ADIF : les boutons Tout / Aucun par groupe refonctionnent.", "Station Control : les cartes se rangent en colonnes équilibrées et se tassent au lieu de laisser de gros trous sous les panneaux plus courts — tableau de bord plus propre et régulier.", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2e6a4bd..efcba58 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -24,7 +24,7 @@ import { GetDBConnectionInfo, GetLogbookRevision, GetUltrabeamStatus, SetUltrabeamDirection, GetAntGeniusStatus, GetAntGeniusSettings, AntGeniusActivate, - GetTunerGeniusStatus, GetTunerGeniusSettings, TunerGeniusAutotune, TunerGeniusSetBypass, TunerGeniusSetOperate, + GetTunerGeniusStatus, GetTunerGeniusSettings, TunerGeniusAutotune, TunerGeniusSetBypass, TunerGeniusSetOperate, TunerGeniusActivate, OpenExternalURL, ConnectAllClusters, DisconnectAllClusters, GetClusterStatus, SendClusterCommand, ListClusterServers, ClusterSpotStatuses, SendClusterSpot, @@ -1702,6 +1702,10 @@ export default function App() { setTgStatus((s) => ({ ...s, operate: on })); TunerGeniusSetOperate(on).catch((e) => setError(String(e?.message ?? e))); }; + const tgActivate = (ch: number) => { + setTgStatus((s) => ({ ...s, active: ch })); // optimistic + TunerGeniusActivate(ch).catch((e) => setError(String(e?.message ?? e))); + }; // RX band auto-follows the TX band (only differs for cross-band work). useEffect(() => { setBandRx(band); }, [band]); @@ -4705,6 +4709,7 @@ export default function App() { onTune={tgTune} onBypass={tgBypass} onOperate={tgOperate} + onActivate={tgActivate} onClose={() => { setShowTuner(false); writeUiPref('opslog.showTuner', '0'); }} /> diff --git a/frontend/src/components/FlexPanel.tsx b/frontend/src/components/FlexPanel.tsx index b946b95..5360cb0 100644 --- a/frontend/src/components/FlexPanel.tsx +++ b/frontend/src/components/FlexPanel.tsx @@ -5,6 +5,7 @@ import { FlexSetProcessor, FlexSetProcessorLevel, FlexSetMon, FlexSetMonLevel, FlexSetMic, FlexMox, FlexAmpOperate, GetPGXLStatus, PGXLSetFanMode, + GetTunerGeniusStatus, GetTunerGeniusSettings, GetAmpStatuses, AmpOperate, AmpPower, AmpPowerLevel, FlexSetAGCMode, FlexSetAGCThreshold, FlexSetAudioLevel, FlexSetMute, FlexSetRXAntenna, FlexSetTXAntenna, FlexSetSplit, FlexSetActiveSlice, FlexSetTXSlice, FlexSetRIT, FlexSetRITFreq, FlexSetXIT, FlexSetXITFreq, @@ -19,6 +20,8 @@ import { EventsOn } from '../../wailsjs/runtime/runtime'; import { cn } from '@/lib/utils'; import { useI18n } from '@/lib/i18n'; import { sMeterRST } from '@/lib/rst'; +import { TunerCard } from '@/components/TunerCard'; +import type { TGStatus } from '@/components/TunerGeniusPanel'; type FlexState = { available: boolean; model?: string; @@ -354,6 +357,20 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number return () => { alive = false; window.clearInterval(id); }; }, []); + // Tuner Genius XL direct connection — its own card in the Flex panel (like PGXL). + const [tg, setTg] = useState({ connected: false }); + const [tgEnabled, setTgEnabled] = useState(false); + useEffect(() => { + let alive = true; + const tick = async () => { + try { const en: any = await GetTunerGeniusSettings(); if (alive) setTgEnabled(!!en?.enabled); } catch {} + try { const s: any = await GetTunerGeniusStatus(); if (alive && s) setTg(s as TGStatus); } catch {} + }; + tick(); + const id = window.setInterval(tick, 1500); + return () => { alive = false; window.clearInterval(id); }; + }, []); + // Configured amplifiers (Settings → Amplifier) — possibly SEVERAL (some ops // run two SPEs in parallel). The card shows ONE at a time; the dropdown picks // which, and the choice is remembered per panel. @@ -1084,6 +1101,10 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number )} + {/* Tuner Genius XL — 4O3A ATU, its own card when enabled (Settings → + Tuner Genius). Same card shown in Station Control. */} + {tgEnabled && } + ); diff --git a/frontend/src/components/StationControlPanel.tsx b/frontend/src/components/StationControlPanel.tsx index 94b19ae..c8a83ce 100644 --- a/frontend/src/components/StationControlPanel.tsx +++ b/frontend/src/components/StationControlPanel.tsx @@ -9,12 +9,15 @@ import { useI18n } from '@/lib/i18n'; import { writeUiPref } from '@/lib/uiPref'; import { RotorCompass } from '@/components/RotorCompass'; import { AmpCard } from '@/components/AmpCard'; +import { TunerCard } from '@/components/TunerCard'; +import type { TGStatus } from '@/components/TunerGeniusPanel'; import { GetStationDevices, SaveStationDevices, GetStationStatus, StationSetRelay, GetRotatorHeading, RotatorGoTo, RotatorStop, GetUltrabeamStatus, SetUltrabeamDirection, UltrabeamRetract, MotorSetElement, MotorReadElements, ListDenkoviDevices, ListSerialPorts, TestStationDevice, GetAmpStatuses, GetFlexState, + GetTunerGeniusStatus, GetTunerGeniusSettings, } from '../../wailsjs/go/main/App'; type RotatorProps = { centerLat?: number | null; centerLon?: number | null; bearing?: number | null }; @@ -283,6 +286,21 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr const id = window.setInterval(load, 1500); return () => { alive = false; window.clearInterval(id); }; }, []); + // Tuner Genius XL (4O3A): a card here too, so operators without a FlexRadio + // panel still get the controls. Re-read the enabled flag so it appears/hides + // without a restart. + const [tg, setTg] = useState({ connected: false }); + const [tgEnabled, setTgEnabled] = useState(false); + useEffect(() => { + let alive = true; + const load = async () => { + try { const en: any = await GetTunerGeniusSettings(); if (alive) setTgEnabled(!!en?.enabled); } catch {} + try { const s: any = await GetTunerGeniusStatus(); if (alive && s) setTg(s as TGStatus); } catch {} + }; + load(); + const id = window.setInterval(load, 1500); + return () => { alive = false; window.clearInterval(id); }; + }, []); const loadDevices = useCallback(async () => { try { setDevices(((await GetStationDevices()) ?? []) as Device[]); } catch { /* db not ready */ } @@ -406,13 +424,15 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr } // One card per configured amplifier (identical to the Flex panel's card). for (const amp of amps) widgets.push({ id: `amp:${amp.id}`, node: }); + // Tuner Genius XL card (identical to the Flex panel's). + if (tgEnabled) widgets.push({ id: 'tuner', node: }); for (const dev of devices) widgets.push({ id: dev.id, node: deviceCard(dev) }); const rank = (id: string) => { const i = order.indexOf(id); return i < 0 ? 1e6 : i; }; const ordered = widgets.map((w, i) => ({ ...w, i })).sort((a, b) => (rank(a.id) - rank(b.id)) || (a.i - b.i)); const widgetIds = ordered.map((w) => w.id); - const noDevices = devices.length === 0 && !rot.enabled && !ant.enabled && amps.length === 0; + const noDevices = devices.length === 0 && !rot.enabled && !ant.enabled && amps.length === 0 && !tgEnabled; return (
diff --git a/frontend/src/components/TunerCard.tsx b/frontend/src/components/TunerCard.tsx new file mode 100644 index 0000000..854b558 --- /dev/null +++ b/frontend/src/components/TunerCard.tsx @@ -0,0 +1,158 @@ +import { Zap, Radio } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import type { TGStatus, TGChannel } from '@/components/TunerGeniusPanel'; +import { TunerGeniusAutotune, TunerGeniusSetBypass, TunerGeniusSetOperate, TunerGeniusActivate } from '../../wailsjs/go/main/App'; + +// TunerCard renders the 4O3A Tuner Genius XL exactly like the amplifier card +// (AmpCard) so Station Control and the FlexRadio panel show the SAME card. It +// mirrors the native app's two channels (A / B) with their source, frequency and +// antenna, a live PWR / SWR pair, and the Tune / Bypass / Operate actions. It +// drives the backend directly (no local state) — the caller's ~1.5s poll +// reconciles the display, just like AmpCard. + +const METER_SEGMENTS = 26; + +function MeterBar({ label, value, unit, lo, hi, accent = '#16a34a', display, segColor }: { + label: string; value: number; unit?: string; lo: number; hi: number; accent?: string; display?: string; + segColor?: (frac: number) => string; +}) { + const span = hi - lo; + const pct = span > 0 ? Math.max(0, Math.min(100, ((value - lo) / span) * 100)) : 0; + const lit = Math.round((pct / 100) * METER_SEGMENTS); + return ( +
+
+ {label} + + {display !== undefined ? display : ( + <>{Math.abs(value) >= 100 ? value.toFixed(0) : value.toFixed(1)}{unit} + )} + +
+
+ {Array.from({ length: METER_SEGMENTS }).map((_, i) => { + const on = i < lit; + const frac = i / METER_SEGMENTS; + const col = segColor ? segColor(frac) : (frac > 0.82 ? '#dc2626' : accent); + return ( +
+ ); + })} +
+
+ ); +} + +function Card({ icon: Icon, title, accent, children }: { icon: any; title: string; accent?: string; children: React.ReactNode }) { + return ( +
+
+ + {title} +
+
{children}
+
+ ); +} + +// ChannelButton — one of the two RF channels (A / B). Clicking it makes that +// channel active. Shows the source (RF Sense / Flex / CAT…), frequency and +// antenna, matching the two rows of the native 4O3A app. +function ChannelButton({ letter, ch, active, ptt, threeWay, onSelect, t }: { + letter: 'A' | 'B'; ch: TGChannel; active: boolean; ptt: boolean; threeWay: boolean; + onSelect: () => void; t: (k: string, v?: any) => string; +}) { + const cls = ptt + ? 'bg-gradient-to-b from-red-500 to-rose-600 text-white border-red-400/50 shadow-[0_0_10px_rgba(244,63,94,0.5)]' + : active + ? 'bg-gradient-to-b from-emerald-500 to-emerald-600 text-white border-emerald-400/50 shadow-[0_0_9px_rgba(16,185,129,0.4)]' + : 'bg-card text-foreground/80 border-border hover:bg-muted'; + const src = ch.mode_str || '—'; + const freq = ch.freq_mhz && ch.freq_mhz > 0 ? `${ch.freq_mhz.toFixed(3)} MHz` : '—'; + return ( + + ); +} + +export function TunerCard({ status, t }: { status: TGStatus; t: (k: string, v?: any) => string }) { + const connected = !!status.connected; + const vswr = status.vswr && status.vswr > 0 ? status.vswr : undefined; + const fwdW = status.fwd_w && status.fwd_w >= 1 ? status.fwd_w : 0; + const active = status.active ?? 1; + const a: TGChannel = status.a ?? {}; + const b: TGChannel = status.b ?? {}; + + const title = `${t('tgp.title')}${status.host ? ' · ' + status.host : ''}`; + return ( + +
+ + + + + + {connected ? (status.bypass ? t('tgp.bypassed') : t('tgp.inLine')) : t('tgp.offline')} + +
+ {status.message && ( + ⚠ {status.message} + )} +
+ + {connected && ( + <> + {/* Channel A / B selector — click to make active (activate ch=1/2). */} +
+ TunerGeniusActivate(1).catch(() => {})} t={t} /> + TunerGeniusActivate(2).catch(() => {})} t={t} /> +
+ + {/* PWR + SWR meters. */} +
+ = 1 ? `${Math.round(fwdW)} W` : '—'} + segColor={(f) => (f > 0.9 ? '#dc2626' : f > 0.75 ? '#f59e0b' : '#ea580c')} /> + (f > 0.5 ? '#dc2626' : f > 0.25 ? '#f59e0b' : '#16a34a')} /> +
+ + )} + + ); +} diff --git a/frontend/src/components/TunerGeniusPanel.tsx b/frontend/src/components/TunerGeniusPanel.tsx index 472c5db..3268c7b 100644 --- a/frontend/src/components/TunerGeniusPanel.tsx +++ b/frontend/src/components/TunerGeniusPanel.tsx @@ -2,11 +2,17 @@ import { Zap, X, Power, Radio } from 'lucide-react'; import { cn } from '@/lib/utils'; import { useI18n } from '@/lib/i18n'; +export type TGChannel = { + ptt?: boolean; band?: number; mode?: number; mode_str?: string; flex?: string; + freq_mhz?: number; bypass?: boolean; antenna?: number; +}; 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; + a?: TGChannel; b?: TGChannel; + relay_c1?: number; relay_l?: number; relay_c2?: number; }; // swrColour picks a status colour for the VSWR readout: green ≤1.5, amber ≤2.0, @@ -18,17 +24,51 @@ function swrColour(vswr?: number): string { 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 }: { +// ChanRow — a compact A/B channel line in the docked widget. Highlights the +// active channel (green) or TX (red), shows the source + frequency + antenna, +// and clicking it makes that channel active. +function ChanRow({ letter, ch, active, onSelect, t }: { + letter: 'A' | 'B'; ch: TGChannel; active: boolean; onSelect: () => void; + t: (k: string, v?: any) => string; +}) { + const ptt = !!ch.ptt; + const cls = ptt + ? 'bg-gradient-to-r from-red-500 to-rose-600 text-white border-red-400/40' + : active + ? 'bg-gradient-to-r from-emerald-500 to-emerald-600 text-white border-emerald-400/40' + : 'bg-card/70 text-foreground/80 border-border hover:bg-muted/60'; + const src = ch.mode_str || '—'; + const freq = ch.freq_mhz && ch.freq_mhz > 0 ? `${ch.freq_mhz.toFixed(3)}` : '—'; + return ( + + ); +} + +// TunerGeniusPanel — compact docked widget for a 4O3A Tuner Genius XL ATU. Shows +// the live SWR / forward power, the two RF channels (A / B, click to activate), +// and Tune / Bypass / Operate. A fuller card (TunerCard) is shown in the FlexRadio +// panel and Station Control. +export function TunerGeniusPanel({ status, onTune, onBypass, onOperate, onActivate, onClose }: { status: TGStatus; onTune: () => void; onBypass: (on: boolean) => void; onOperate: (on: boolean) => void; + onActivate: (ch: number) => void; onClose: () => void; }) { const { t } = useI18n(); const vswr = status.vswr && status.vswr > 0 ? status.vswr : undefined; + const active = status.active ?? 1; return (
@@ -68,6 +108,12 @@ export function TunerGeniusPanel({ status, onTune, onBypass, onOperate, onClose
+ {/* Channel A / B — click to activate */} +
+ onActivate(1)} t={t} /> + onActivate(2)} t={t} /> +
+ {status.message && (
{status.message} diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index 9becd1c..be06e49 100644 --- a/frontend/src/lib/i18n.tsx +++ b/frontend/src/lib/i18n.tsx @@ -333,6 +333,7 @@ const en: Dict = { 'dvkp.voiceKeyer': 'Voice keyer', 'dvkp.autoCq': 'Auto CQ', 'dvkp.autoCqHint': 'Repeat a CQ-labelled message on a timer until you stop it or play another slot', 'dvkp.gap': 'Gap', 'dvkp.notPhone': 'The voice keyer only transmits on a phone mode (SSB/AM/FM)', 'dvkp.stop': 'Stop', 'dvkp.disable': 'Disable voice keyer', 'dvkp.noMsgPre': 'No messages recorded yet. Open', 'dvkp.settingsPath': 'Settings → Audio devices & voice keyer', 'dvkp.noMsgPost': 'to record F1–F6.', 'dvkp.transmit': 'Transmit F{slot}{label} ({dur}s)', 'dvkp.empty': 'F{slot} — empty', 'dvkp.message': 'message', 'agp.portDeselect': 'Port {letter} — click to deselect', 'agp.portSelect': 'Select on port {letter}', 'agp.online': 'online', 'agp.offline': 'offline', 'agp.close': 'Close', 'agp.connecting': 'Connecting…', 'agp.noAntennas': 'No antennas configured.', 'agp.filterOnHint': 'Showing antennas for {band} only — click to show all bands', 'agp.filterOffHint': 'Showing all antennas — click to show only the current band', 'tgp.online': 'online', 'tgp.offline': 'offline', 'tgp.close': 'Close', 'tgp.connecting': 'Connecting…', 'tgp.swr': 'SWR', 'tgp.power': 'Fwd power', 'tgp.tune': 'Tune', 'tgp.tuning': 'Tuning…', 'tgp.tuneHint': 'Start an automatic tuning cycle on the active channel — key the rig into a carrier so the tuner can measure SWR', 'tgp.bypass': 'Bypass', 'tgp.bypassHint': 'Toggle global bypass — route the antenna straight through, tuner out of line', 'tgp.operate': 'Operate', 'tgp.standby': 'Standby', 'tgp.operateHint': 'Toggle Operate / Standby', + 'tgp.title': 'Tuner Genius', 'tgp.chActive': 'Channel {letter} — active', 'tgp.chSelect': 'Make channel {letter} active', 'tgp.chActiveTag': 'active', 'tgp.ant': 'Ant', 'tgp.bypassed': 'Bypassed', 'tgp.inLine': 'In line', 'flxp.ritHint': 'RIT — shifts your RECEIVE frequency only. Wheel, ± or arrow keys to adjust (Ctrl = 100 Hz). The offset is kept when you switch it off.', 'flxp.xitHint': 'XIT — shifts your TRANSMIT frequency only. Wheel, ± or arrow keys to adjust (Ctrl = 100 Hz). The offset is kept when you switch it off.', 'flxp.smartsdrRemote': 'SmartSDR remote control', 'flxp.offline': 'OFFLINE', 'flxp.waiting': 'Waiting for the FlexRadio… (set CAT to FlexRadio and connect)', 'flxp.transmit': 'Transmit', 'flxp.rfPower': 'RF Power', 'flxp.tunePwr': 'Tune Pwr', 'flxp.splitHint': 'Split: RX/TX on separate slices. ON creates a TX slice +1 kHz (CW) / +5 kHz (SSB) up, like SmartSDR.', 'flxp.sliceHint': 'Click to make this the active slice — frequency, mode, DSP and spot-clicks all follow it.', 'flxp.txSlice': 'This slice transmits', 'flxp.setTxSlice': 'Move TX to this slice (transmit here)', 'flxp.voxDly': 'VOX Dly', 'flxp.speed': 'Speed', 'flxp.pitch': 'Pitch', 'flxp.delay': 'Delay', 'flxp.receiveActive': 'Receive (active slice)', 'flxp.muted': 'Muted — click to unmute', 'flxp.mute': 'Mute RX audio', 'flxp.filter': 'Filter', 'flxp.amplifier': 'Amplifier', 'flxp.ampInLine': 'Amplifier is in line (transmitting through PA).', 'flxp.ampBypassed': 'Amplifier bypassed (standby).', 'flxp.pgConnected': 'PowerGenius connected', 'flxp.pgOffline': 'PowerGenius offline', 'flxp.fan': 'Fan', 'flxp.fanStandard': 'Standard', 'flxp.fanContest': 'Contest', 'flxp.fanBroadcast': 'Broadcast', 'flxp.fault': 'FAULT', 'flxp.meters': 'Meters', 'flxp.voltage': 'VOLTAGE', 'flxp.paTemp': 'PA TEMP', 'flxp.txFilter': 'TX filter', 'flxp.micProfile': 'Mic profile', 'flxp.noMeters': "No meters yet — waiting for the radio's UDP stream…", 'flxp.amplifierHdr': 'AMPLIFIER', 'flxp.outputPower': 'OUTPUT POWER', 'flxp.speOffline': 'SPE offline', 'flxp.acomOffline': 'ACOM offline', 'flxp.ampPick': 'Choose which amplifier this card shows', 'flxp.dspV4Hint': 'SmartSDR v4 DSP (8000/Aurora series)', 'flxp.daxHint': 'DAX as the transmit audio source (SmartSDR transmit-bar DAX button) — for WSJT-X & co', 'flxp.rnnHint': 'RNN — AI noise reduction (on/off)', 'flxp.anftHint': 'ANFT — FFT-based automatic notch filter (on/off)', 'flxp.dspNoise': 'Noise', 'flxp.dspMore': 'Show/hide advanced DSP (WNB, v4 NR/notch)', @@ -690,6 +691,7 @@ const fr: Dict = { 'dvkp.voiceKeyer': 'Manipulateur vocal', 'dvkp.autoCq': 'Auto CQ', 'dvkp.autoCqHint': 'Répète un message libellé CQ à intervalle régulier jusqu\'à l\'arrêt ou la lecture d\'un autre slot', 'dvkp.gap': 'Intervalle', 'dvkp.notPhone': 'Le manipulateur vocal n\'émet qu\'en mode phonie (SSB/AM/FM)', 'dvkp.stop': 'Stop', 'dvkp.disable': 'Désactiver le manipulateur vocal', 'dvkp.noMsgPre': 'Aucun message enregistré. Ouvre', 'dvkp.settingsPath': 'Réglages → Périphériques audio & manipulateur vocal', 'dvkp.noMsgPost': 'pour enregistrer F1–F6.', 'dvkp.transmit': 'Émettre F{slot}{label} ({dur}s)', 'dvkp.empty': 'F{slot} — vide', 'dvkp.message': 'message', 'agp.portDeselect': 'Port {letter} — clic pour désélectionner', 'agp.portSelect': 'Sélectionner sur le port {letter}', 'agp.online': 'en ligne', 'agp.offline': 'hors ligne', 'agp.close': 'Fermer', 'agp.connecting': 'Connexion…', 'agp.noAntennas': 'Aucune antenne configurée.', 'agp.filterOnHint': 'Antennes du {band} uniquement — clic pour afficher toutes les bandes', 'agp.filterOffHint': 'Toutes les antennes affichées — clic pour n’afficher que la bande courante', 'tgp.online': 'en ligne', 'tgp.offline': 'hors ligne', 'tgp.close': 'Fermer', 'tgp.connecting': 'Connexion…', 'tgp.swr': 'ROS', 'tgp.power': 'Puiss. directe', 'tgp.tune': 'Accord', 'tgp.tuning': 'Accord…', 'tgp.tuneHint': "Lancer un cycle d'accord automatique sur le canal actif — passe la radio en porteuse pour que le coupleur mesure le ROS", 'tgp.bypass': 'Bypass', 'tgp.bypassHint': "Basculer le bypass global — antenne en direct, coupleur hors ligne", 'tgp.operate': 'Operate', 'tgp.standby': 'Standby', 'tgp.operateHint': 'Basculer Operate / Standby', + 'tgp.title': 'Tuner Genius', 'tgp.chActive': 'Canal {letter} — actif', 'tgp.chSelect': 'Activer le canal {letter}', 'tgp.chActiveTag': 'actif', 'tgp.ant': 'Ant', 'tgp.bypassed': 'Bypass', 'tgp.inLine': 'En ligne', 'flxp.ritHint': "RIT — décale uniquement ta fréquence de RÉCEPTION. Molette, ± ou flèches pour ajuster (Ctrl = 100 Hz). Le décalage est conservé quand tu l'éteins.", 'flxp.xitHint': "XIT — décale uniquement ta fréquence d'ÉMISSION. Molette, ± ou flèches pour ajuster (Ctrl = 100 Hz). Le décalage est conservé quand tu l'éteins.", 'flxp.smartsdrRemote': 'Contrôle à distance SmartSDR', 'flxp.offline': 'HORS LIGNE', 'flxp.waiting': 'En attente du FlexRadio… (règle le CAT sur FlexRadio et connecte)', 'flxp.transmit': 'Émission', 'flxp.rfPower': 'Puissance RF', 'flxp.tunePwr': 'Puiss. TUNE', 'flxp.splitHint': 'Split : RX/TX sur des slices séparées. ON crée une slice TX +1 kHz (CW) / +5 kHz (SSB) au-dessus, comme SmartSDR.', 'flxp.sliceHint': 'Cliquer pour rendre cette slice active — fréquence, mode, DSP et clics de spot la suivent tous.', 'flxp.txSlice': 'Cette slice émet', 'flxp.setTxSlice': 'Déplacer le TX sur cette slice (émettre ici)', 'flxp.voxDly': 'Délai VOX', 'flxp.speed': 'Vitesse', 'flxp.pitch': 'Tonalité', 'flxp.delay': 'Délai', 'flxp.receiveActive': 'Réception (slice active)', 'flxp.muted': 'Coupé — clic pour rétablir', 'flxp.mute': "Couper l'audio RX", 'flxp.filter': 'Filtre', 'flxp.amplifier': 'Amplificateur', 'flxp.ampInLine': 'Amplificateur en ligne (émission via le PA).', 'flxp.ampBypassed': 'Amplificateur en bypass (standby).', 'flxp.pgConnected': 'PowerGenius connecté', 'flxp.pgOffline': 'PowerGenius hors ligne', 'flxp.fan': 'Ventilo', 'flxp.fanStandard': 'Standard', 'flxp.fanContest': 'Contest', 'flxp.fanBroadcast': 'Diffusion', 'flxp.fault': 'DÉFAUT', 'flxp.meters': 'Mesures', 'flxp.voltage': 'TENSION', 'flxp.paTemp': 'TEMP PA', 'flxp.txFilter': 'Filtre TX', 'flxp.micProfile': 'Profil micro', 'flxp.noMeters': 'Aucune mesure — en attente du flux UDP de la radio…', 'flxp.amplifierHdr': 'AMPLIFICATEUR', 'flxp.outputPower': 'PUISSANCE DE SORTIE', 'flxp.speOffline': 'SPE hors ligne', 'flxp.acomOffline': 'ACOM hors ligne', 'flxp.ampPick': 'Choisir quel amplificateur cette carte affiche', 'flxp.dspV4Hint': 'DSP SmartSDR v4 (séries 8000/Aurora)', 'flxp.daxHint': "DAX comme source audio d'émission (bouton DAX du bandeau transmit de SmartSDR) — pour WSJT-X & co", 'flxp.rnnHint': 'RNN — réduction de bruit par IA (on/off)', 'flxp.anftHint': 'ANFT — filtre notch automatique FFT (on/off)', 'flxp.dspNoise': 'Bruit', 'flxp.dspMore': 'Afficher/masquer le DSP avancé (WNB, NR/notch v4)', diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index 2d59b98..6066803 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -929,6 +929,8 @@ export function TestStationDevice(arg1:main.StationDevice):Promise; +export function TunerGeniusActivate(arg1:number):Promise; + export function TunerGeniusAutotune():Promise; export function TunerGeniusSetBypass(arg1:boolean):Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 661ecf3..6dcf631 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -1810,6 +1810,10 @@ export function TestUltrabeam(arg1) { return window['go']['main']['App']['TestUltrabeam'](arg1); } +export function TunerGeniusActivate(arg1) { + return window['go']['main']['App']['TunerGeniusActivate'](arg1); +} + export function TunerGeniusAutotune() { return window['go']['main']['App']['TunerGeniusAutotune'](); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 1c9bc5d..5f46564 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -4430,6 +4430,32 @@ export namespace spe { export namespace tunergenius { + export class Channel { + ptt: boolean; + band: number; + mode: number; + mode_str: string; + flex: string; + freq_mhz: number; + bypass: boolean; + antenna: number; + + static createFrom(source: any = {}) { + return new Channel(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.ptt = source["ptt"]; + this.band = source["band"]; + this.mode = source["mode"]; + this.mode_str = source["mode_str"]; + this.flex = source["flex"]; + this.freq_mhz = source["freq_mhz"]; + this.bypass = source["bypass"]; + this.antenna = source["antenna"]; + } + } export class Status { connected: boolean; host?: string; @@ -4438,13 +4464,18 @@ export namespace tunergenius { fwd_w: number; swr_db: number; vswr: number; - freq_mhz: number; operate: boolean; bypass: boolean; tuning: boolean; active: number; - antenna: number; three_way: boolean; + a: Channel; + b: Channel; + relay_c1: number; + relay_l: number; + relay_c2: number; + freq_mhz: number; + antenna: number; message?: string; static createFrom(source: any = {}) { @@ -4460,15 +4491,38 @@ export namespace tunergenius { this.fwd_w = source["fwd_w"]; this.swr_db = source["swr_db"]; this.vswr = source["vswr"]; - this.freq_mhz = source["freq_mhz"]; this.operate = source["operate"]; this.bypass = source["bypass"]; this.tuning = source["tuning"]; this.active = source["active"]; - this.antenna = source["antenna"]; this.three_way = source["three_way"]; + this.a = this.convertValues(source["a"], Channel); + this.b = this.convertValues(source["b"], Channel); + this.relay_c1 = source["relay_c1"]; + this.relay_l = source["relay_l"]; + this.relay_c2 = source["relay_c2"]; + this.freq_mhz = source["freq_mhz"]; + this.antenna = source["antenna"]; this.message = source["message"]; } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } } } diff --git a/internal/tunergenius/tunergenius.go b/internal/tunergenius/tunergenius.go index bb72203..7b7d604 100644 --- a/internal/tunergenius/tunergenius.go +++ b/internal/tunergenius/tunergenius.go @@ -35,6 +35,21 @@ const ( reconnectDelay = 2 * time.Second ) +// Channel is the live state of one of the tuner's two RF channels (A / B). The +// Tuner Genius XL is a dual (SO2R) coupler, so each channel tracks its own +// source, band, frequency and antenna — mirroring the two rows the native 4O3A +// app shows. +type Channel struct { + PTT bool `json:"ptt"` // this channel is keyed + Band int `json:"band"` // band as reported by the device (0 = unknown) + Mode int `json:"mode"` // 0=RF Sense 1=FLEX 2=CAT 3=P2B 4=BCD + ModeStr string `json:"mode_str"` // human-readable mode/source + Flex string `json:"flex"` // bound Flex radio nickname (FLEX mode) + FreqMHz float64 `json:"freq_mhz"` // current frequency + Bypass bool `json:"bypass"` // this channel bypassed + Antenna int `json:"antenna"` // antenna in use (3WAY / SO2R-with-AG; 0 = n/a) +} + // Status is the snapshot the UI renders. Power/SWR come from the device's // "status" reply; the booleans mirror the tuner's operating state. type Status struct { @@ -42,22 +57,50 @@ type Status struct { Host string `json:"host,omitempty"` LastError string `json:"last_error,omitempty"` - FwdDbm float64 `json:"fwd_dbm"` // forward power [dBm], as reported - FwdW float64 `json:"fwd_w"` // forward power [W], derived from dBm - SwrDb float64 `json:"swr_db"` // return loss [dB] as reported (negative = good match) - Vswr float64 `json:"vswr"` // VSWR ratio, derived from swr_db (1.0 = perfect) - FreqMHz float64 `json:"freq_mhz"` // active channel frequency + FwdDbm float64 `json:"fwd_dbm"` // forward power [dBm], as reported + FwdW float64 `json:"fwd_w"` // forward power [W], derived from dBm + SwrDb float64 `json:"swr_db"` // return loss [dB] as reported (negative = good match) + Vswr float64 `json:"vswr"` // VSWR ratio, derived from swr_db (1.0 = perfect) Operate bool `json:"operate"` // state == OPERATE (1) vs STANDBY (0) Bypass bool `json:"bypass"` // device global bypass engaged Tuning bool `json:"tuning"` // autotune in progress Active int `json:"active"` // active channel (1 = A, 2 = B) - Antenna int `json:"antenna"` // antenna in use (3WAY / SO2R-with-AG) ThreeWay bool `json:"three_way"` // 3-way (vs SO2R) hardware variant + A Channel `json:"a"` // channel A + B Channel `json:"b"` // channel B + + RelayC1 int `json:"relay_c1"` // tuner network position (0–255) + RelayL int `json:"relay_l"` + RelayC2 int `json:"relay_c2"` + + // FreqMHz / Antenna mirror the ACTIVE channel, kept for the compact docked + // widget that shows a single readout. + FreqMHz float64 `json:"freq_mhz"` + Antenna int `json:"antenna"` + Message string `json:"message,omitempty"` // last M| warning/info (empty = cleared) } +// modeName maps the device's numeric mode/source to a label. +func modeName(m int) string { + switch m { + case 0: + return "RF Sense" + case 1: + return "Flex" + case 2: + return "CAT" + case 3: + return "P2B" + case 4: + return "BCD" + default: + return "" + } +} + type Client struct { host string port int @@ -174,9 +217,17 @@ func (c *Client) pollLoop() { 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 + fresh := false + if c.needConnect() { + if err := c.ensureConnected(); err != nil { + c.setStatus(func(s *Status) { s.Connected = false; s.LastError = "dial: " + err.Error() }) + continue + } + fresh = true + } + // One-shot on a fresh link: learn the hardware variant (3-way vs SO2R). + if fresh { + _, _ = c.command("info") } if _, err := c.command("status"); err != nil { c.dropConn() @@ -186,6 +237,14 @@ func (c *Client) pollLoop() { } } +// needConnect reports whether the TCP link is currently down (so the poll loop +// knows a fresh connect + one-shot info query is needed). +func (c *Client) needConnect() bool { + c.mu.Lock() + defer c.mu.Unlock() + return c.conn == nil +} + func (c *Client) ensureConnected() error { c.mu.Lock() defer c.mu.Unlock() @@ -308,6 +367,15 @@ func (c *Client) parse(resp string) { default: return } + // "info …" carries the hardware variant (3way=1 on the 3-way model, absent on + // SO2R) — parsed once so the UI knows whether channels are A/B or antennas. + if strings.HasPrefix(data, "info") { + tw := strings.Contains(data, "3way=1") + c.statusMu.Lock() + c.status.ThreeWay = tw + c.statusMu.Unlock() + return + } // Only the "status …" payload carries the live state we render. if !strings.HasPrefix(data, "status") { return @@ -319,8 +387,8 @@ func (c *Client) parse(resp string) { c.applyStatus(data) } -// applyStatus maps the "status k=v …" fields onto the snapshot. Per-channel -// fields (A/B) are folded down to the active channel for the single-radio UI. +// applyStatus maps the "status k=v …" fields onto the snapshot, filling both +// channels (A/B) plus the global power/SWR and operating state. func (c *Client) applyStatus(data string) { kv := map[string]string{} for _, tok := range strings.Fields(data) { @@ -338,6 +406,9 @@ func (c *Client) applyStatus(data string) { c.status.Operate = kv["state"] == "1" c.status.Bypass = kv["bypass"] == "1" c.status.Tuning = kv["tuning"] == "1" + c.status.RelayC1 = atoiDefault(kv["relayC1"], 0) + c.status.RelayL = atoiDefault(kv["relayL"], 0) + c.status.RelayC2 = atoiDefault(kv["relayC2"], 0) if v, ok := parseFloat(kv["fwd"]); ok { c.status.FwdDbm = v @@ -347,15 +418,34 @@ func (c *Client) applyStatus(data string) { c.status.SwrDb = v c.status.Vswr = returnLossToVswr(v) } - // Active-channel frequency + antenna (suffix A for ch1, B for ch2). - suffix := "A" + + c.status.A = channelFrom(kv, "A") + c.status.B = channelFrom(kv, "B") + + // Mirror the active channel into the flat fields the compact widget uses. + act := c.status.A if active == 2 { - suffix = "B" + act = c.status.B } - if v, ok := parseFloat(kv["freq"+suffix]); ok { - c.status.FreqMHz = v + c.status.FreqMHz = act.FreqMHz + c.status.Antenna = act.Antenna +} + +// channelFrom extracts one channel's fields (suffix "A" or "B") from the parsed +// status map. +func channelFrom(kv map[string]string, suffix string) Channel { + mode := atoiDefault(kv["mode"+suffix], 0) + freq, _ := parseFloat(kv["freq"+suffix]) + return Channel{ + PTT: kv["ptt"+suffix] == "1", + Band: atoiDefault(kv["band"+suffix], 0), + Mode: mode, + ModeStr: modeName(mode), + Flex: strings.TrimSpace(kv["flex"+suffix]), + FreqMHz: freq, + Bypass: kv["bypass"+suffix] == "1", + Antenna: atoiDefault(kv["ant"+suffix], 0), } - c.status.Antenna = atoiDefault(kv["ant"+suffix], 0) } func boolNum(on bool) string {