diff --git a/app.go b/app.go index e33a4f6..bddd281 100644 --- a/app.go +++ b/app.go @@ -57,6 +57,7 @@ import ( "hamlog/internal/solar" "hamlog/internal/spe" "hamlog/internal/steppir" + "hamlog/internal/tunergenius" "hamlog/internal/uls" "hamlog/internal/ultrabeam" "hamlog/internal/winkeyer" @@ -190,6 +191,12 @@ const ( keyAntGeniusHost = "antgenius.host" keyAntGeniusPassword = "antgenius.password" // remote/AUTH password (blank on LAN) + // Tuner Genius XL (4O3A) — Hardware → Tuner Genius. TCP port is fixed at 9010 + // on the device, so only the IP + optional remote code are configurable. + keyTunerGeniusEnabled = "tunergenius.enabled" + keyTunerGeniusHost = "tunergenius.host" + keyTunerGeniusPassword = "tunergenius.password" // remote/AUTH code (blank on LAN) + // Amplifier control — Hardware → Amplifier (PowerGenius XL over TCP; SPE Expert // over USB serial or an RS232-to-Ethernet bridge). Keys keep the pgxl.* prefix // for backward compatibility with existing saved settings. @@ -475,6 +482,7 @@ type App struct { motorMoveCmdNs atomic.Int64 // unixnano of the last commanded antenna move (grace window) motorInhibited atomic.Bool // TX currently inhibited by the motor-antenna watcher antgenius *antgenius.Client // Antenna Genius (4O3A) switch (TCP); nil when disabled + tunergenius *tunergenius.Client // Tuner Genius XL (4O3A) ATU (TCP); nil when disabled pgxl *powergenius.Client // PowerGenius XL (4O3A) amp fan control (TCP); nil when disabled spe *spe.Client // legacy pointer: FIRST enabled SPE amp (kept for the pre-multi bindings) acom *acom.Client // legacy pointer: FIRST enabled ACOM amp @@ -1139,6 +1147,8 @@ func (a *App) startup(ctx context.Context) { a.startUltrabeam() // Antenna Genius switch: connect in the background if enabled. a.startAntGenius() + // Tuner Genius XL ATU: connect in the background if enabled. + a.startTunerGenius() // PowerGenius XL amp fan control: connect in the background if enabled. a.startAmps() @@ -12913,6 +12923,96 @@ func (a *App) AntGeniusDeselect(port int) error { return a.antgenius.Activate(port, 0) } +// ── Tuner Genius XL (4O3A) ATU control (TCP, fixed port 9010) ──────────────── + +// TunerGeniusSettings is the JSON shape for the Hardware → Tuner Genius panel. +// The TCP port is fixed at 9010 on the device, so only the IP is configurable. +type TunerGeniusSettings struct { + Enabled bool `json:"enabled"` + Host string `json:"host"` + Password string `json:"password"` // remote-access code; leave blank on LAN (no AUTH) +} + +// GetTunerGeniusSettings returns the persisted Tuner Genius config. +func (a *App) GetTunerGeniusSettings() (TunerGeniusSettings, error) { + out := TunerGeniusSettings{} + if a.settings == nil { + return out, fmt.Errorf("db not initialized") + } + m, err := a.settings.GetMany(a.ctx, keyTunerGeniusEnabled, keyTunerGeniusHost, keyTunerGeniusPassword) + if err != nil { + return out, err + } + out.Enabled = m[keyTunerGeniusEnabled] == "1" + out.Host = m[keyTunerGeniusHost] + out.Password = m[keyTunerGeniusPassword] + return out, nil +} + +// SaveTunerGeniusSettings persists the config and (re)starts or stops the client. +func (a *App) SaveTunerGeniusSettings(s TunerGeniusSettings) error { + if a.settings == nil { + return fmt.Errorf("db not initialized") + } + for k, v := range map[string]string{ + keyTunerGeniusEnabled: boolStr(s.Enabled), + keyTunerGeniusHost: strings.TrimSpace(s.Host), + keyTunerGeniusPassword: s.Password, + } { + if err := a.settings.Set(a.ctx, k, v); err != nil { + return err + } + } + a.startTunerGenius() + return nil +} + +// startTunerGenius stops any existing client and starts a fresh one if enabled. +func (a *App) startTunerGenius() { + if a.tunergenius != nil { + go a.tunergenius.Stop() // background teardown so saving Settings doesn't block + a.tunergenius = nil + } + s, err := a.GetTunerGeniusSettings() + if err != nil || !s.Enabled || strings.TrimSpace(s.Host) == "" { + return + } + a.tunergenius = tunergenius.New(s.Host, tunergenius.DefaultPort, s.Password) + _ = a.tunergenius.Start() +} + +// GetTunerGeniusStatus returns the ATU's current state for the UI poll. +func (a *App) GetTunerGeniusStatus() tunergenius.Status { + if a.tunergenius == nil { + return tunergenius.Status{} + } + return a.tunergenius.GetStatus() +} + +// TunerGeniusAutotune starts an automatic tuning cycle on the active channel. +func (a *App) TunerGeniusAutotune() error { + if a.tunergenius == nil { + return fmt.Errorf("Tuner Genius not connected — enable it in Settings → Tuner Genius") + } + return a.tunergenius.Autotune() +} + +// TunerGeniusSetBypass engages (true) or clears (false) the global bypass. +func (a *App) TunerGeniusSetBypass(on bool) error { + if a.tunergenius == nil { + return fmt.Errorf("Tuner Genius not connected") + } + return a.tunergenius.SetBypass(on) +} + +// TunerGeniusSetOperate puts the tuner in OPERATE (true) or STANDBY (false). +func (a *App) TunerGeniusSetOperate(on bool) error { + if a.tunergenius == nil { + return fmt.Errorf("Tuner Genius not connected") + } + return a.tunergenius.SetOperate(on) +} + // ── 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 114bcc8..5474ad8 100644 --- a/changelog.json +++ b/changelog.json @@ -3,6 +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.", "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.", @@ -11,6 +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.", "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 ff7081f..2e6a4bd 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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({ connected: false, port_a: 0, port_b: 0, antennas: [] }); const [agEnabled, setAgEnabled] = useState(false); + const [tgStatus, setTgStatus] = useState({ 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 && } )} + {tgEnabled && ( + + )} {chatAvailable && ( + + + {!status.connected ? ( +
+
{t('tgp.connecting')}
+ {status.last_error &&
{status.last_error}
} +
+ ) : ( +
+ {/* SWR + forward power readouts */} +
+
+
{t('tgp.swr')}
+
+ {vswr ? `${vswr.toFixed(2)}:1` : '—'} +
+
+
+
{t('tgp.power')}
+
+ {status.fwd_w && status.fwd_w >= 1 ? `${Math.round(status.fwd_w)} W` : '—'} +
+
+
+ + {status.message && ( +
+ {status.message} +
+ )} + + {/* Tune */} + + + {/* Bypass + Operate toggles */} +
+ + +
+
+ )} + + ); +} diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index 031e8a8..9becd1c 100644 --- a/frontend/src/lib/i18n.tsx +++ b/frontend/src/lib/i18n.tsx @@ -139,7 +139,7 @@ const en: Dict = { 'uscty.backfillDone': '{s} US QSOs scanned · {c} counties, {g} grids filled.', 'station.title': 'Station Control', 'station.rotator': 'Rotator', 'station.rotatorNoRead': 'No heading read', 'station.pattern': 'Pattern', 'station.bi': 'Bi', 'station.retract': 'Retract elements', 'station.moving': 'MOVING', 'station.elements': 'Elements (mm)', 'station.read': 'Read', 'station.readLengths': 'Read current element lengths from the controller', 'station.noLengths': 'Lengths unknown — click Read to fetch them from the controller.', 'station.element': 'Element', 'station.reflector': 'Reflector', 'station.driven': 'Driven', 'station.director': 'Dir', 'station.set': 'Set', 'station.elementsHint': 'Each press lengthens/shortens the element by 2 mm (like the physical console). Verify which element responds on your antenna.', 'station.setExactLen': 'Click to type the exact current length (fixes the baseline if the auto-read is off).', 'station.atMax': 'Controller refused — the element is likely at its maximum length for this band, so it can\'t extend further.', 'station.go': 'Go', 'station.stop': 'Stop', 'station.dragHint': 'Drag the grip handle on the left of a panel to move it. Pick a column count to lay them out in a grid.', 'station.dragMove': 'Drag to move this panel', 'station.colsAuto': 'Auto', 'station.addDevice': 'Add device', 'station.editDevice': 'Edit device', 'station.empty': 'No relay boards yet. Add a WebSwitch 1216H or a KMTronic 8-relay board to control your station power and accessories.', 'station.online': 'Online', 'station.offline': 'Offline', 'station.edit': 'Edit', 'station.delete': 'Delete', 'station.relay': 'Relay', 'station.on': 'ON', 'station.off': 'OFF', 'station.type': 'Device type', 'station.name': 'Name', 'station.host': 'Host / IP', 'station.user': 'Username', 'station.pass': 'Password', 'station.optional': 'optional', 'station.ftdiSerial': 'FTDI serial number', 'station.detect': 'Detect', 'station.ftdiHint': 'The Denkovi board is driven via FTDI bit-bang (not the COM port). Pick its serial (e.g. DAE0006K). Needs the FTDI D2XX driver installed.', 'station.channels': 'Relays', 'station.comPort': 'COM port', 'station.noPorts': 'No ports found', 'station.usbRelayHint': 'Cheap USB-serial relay boards (CH340/LCUS) using the A0 command protocol. If yours does not switch, tell me its model / command set.', 'station.labels': 'Relay labels', 'station.cancel': 'Cancel', 'station.save': 'Save', 'station.test': 'Test connection', 'station.testOk': 'Connected — {n} relays', 'station.testFail': 'Not connected', 'station.detectNone': 'No FTDI board found — check the cable and that the D2XX driver is installed.', 'station.detectFound': '{n} board(s) detected.', 'sec.awards': 'Awards', 'sec.cat': 'CAT interface', 'sec.rotator': 'Rotator', 'sec.winkeyer': 'CW Keyer', - 'sec.antenna': 'Ultrabeam / Steppir', 'sec.antgenius': 'Antenna Genius', 'sec.pgxl': 'Amplifier', 'sec.flex': 'FlexRadio', 'sec.audio': 'Audio devices', + 'sec.antenna': 'Ultrabeam / Steppir', 'sec.antgenius': 'Antenna Genius', 'sec.tunergenius': 'Tuner Genius', 'sec.pgxl': 'Amplifier', 'sec.flex': 'FlexRadio', 'sec.audio': 'Audio devices', // CW Keyer settings panel 'wk.enable': 'Enable CW keyer (shows the keyer panel)', 'wk.engine': 'Keyer engine', 'wk.escClears': 'ESC clears the callsign too (otherwise ESC only stops transmission)', @@ -252,6 +252,7 @@ const en: Dict = { 'autostart.hint': 'Launch external programs (WSJT-X, JTAlert, rotator control…) when OpsLog starts. A program already running is not started again. Saved per profile.', 'cat.hint': "Reads the rig's frequency / band / mode and pushes them into the entry strip in real time. Use OmniRig (free, any rig) or — for FlexRadio — the native SmartSDR API (no OmniRig needed, real-time, no second-click mode bug).", 'ag2.hint': 'OpsLog talks to the 4O3A Antenna Genius switch over TCP (GSCP protocol). The port is fixed at 9007, so only the device IP is needed. A docked widget then lets you switch antennas per port (A/B).', 'ag2.password': 'Remote password', 'ag2.passwordPh': 'blank on LAN', 'ag2.passwordHint': 'Only needed when reaching the device remotely — it then announces "AG AUTH" and rejects commands until you log in. Leave blank on the local network.', + 'tg2.hint': 'OpsLog talks to the 4O3A Tuner Genius XL over TCP (port fixed at 9010), so only the device IP is needed. A docked widget then shows SWR and forward power and offers Tune, Bypass and Operate/Standby. Control it directly (not through the radio) so OpsLog uses just one of the box\'s connection slots.', 'tg2.enable': 'Enable Tuner Genius control', 'tg2.portHint': 'The TCP port is fixed at 9010 on the device.', 'tg2.password': 'Remote code', 'tg2.passwordPh': 'blank on LAN', 'tg2.passwordHint': 'Only needed when reaching the device remotely — it then announces "AUTH" and rejects commands until you log in. Leave blank on the local network.', 'rot.enable': 'Enable rotator control', 'rot.testOkRG': 'Connected — the Rotator Genius accepted the command (moving to 0°).', 'rot.type': 'Rotator type', 'rot.rotatorNum': 'Rotator #', 'rot.rgHint': 'Talks directly to a 4O3A Rotator Genius over TCP (default port 9006) — no PstRotator needed. Rotator # selects which of the two rotators to drive.', 'rot.arcoHint': "Talks directly to a microHAM ARCO — no PstRotator needed. LAN: set Config → LAN → CONTROL PROTOCOL to 'Yaesu GS-232A' on the ARCO and enter the same TCP port here (up to 4 parallel connections). USB: set USB CONTROL PROTOCOL to 'Yaesu GS-232A' and pick the ARCO's COM port here (baud rate doesn't matter on USB).", 'rot.hint': "OpsLog sends UDP commands to PstRotator. Enable PstRotator's UDP listener (Setup → Communication → UDP) before testing.", 'extsvc.hint': 'Upload logged QSOs to online logbooks. Each service uploads automatically on a new QSO when enabled; timing is per-service (immediate, or a 1–2 min delay so a mis-logged QSO can still be fixed first).', 'hw.motorTxInhibit': 'Inhibit transmission while the antenna is moving', 'hw.motorTxInhibitHint': 'Blocks the FlexRadio from transmitting while the elements move (needs FlexRadio in API mode + this antenna enabled). No effect with other radios.', 'hw.motorAntenna': 'Ultrabeam / Steppir', 'hw.motorEnable': 'Enable antenna control', 'hw.motorType': 'Antenna type', 'hw.motorTransport': 'Connection', 'hw.motorTcp': 'Network (TCP)', 'hw.motorSerial': 'Serial (COM)', 'hw.motorCom': 'Serial port', 'hw.motorBaud': 'Baud', 'hw.steppirHint': 'SteppIR controllers are RS-232 serial (the DATA OUT DB9 port). Serial = a USB↔RS-232 (FTDI) adapter, shown as a COM port. Network = a serial-to-Ethernet bridge (as for the Ultrabeam).', 'hw.steppirRange': 'Tunable range', 'hw.steppirRangeHint': "The SteppIR's frequency coverage. On a band outside this range (e.g. 30 m on a 20 m–6 m SteppIR) OpsLog won't try to tune the antenna and won't inhibit transmission. Default 13–54 MHz (20 m–6 m); widen the low edge (e.g. 6) for a 40 m-equipped SteppIR.", 'hw.ultrabeam': 'Antenna (Ultrabeam)', 'hw.audioVoice': 'Audio devices & voice keyer', @@ -331,6 +332,7 @@ const en: Dict = { 'wkp.autoCallHint': 'Click a CQ macro (one whose text contains CQ) to resend it on a loop — message, gap, repeat — until you send another macro (e.g. a report), press Stop, or hit ESC. Non-CQ macros send once.', 'wkp.autoCall': 'Auto-call', 'wkp.gap': 'gap', 'wkp.gapHint': 'Seconds to wait after the message before resending', 'wkp.loopHint': 'click a CQ macro to loop it', 'wkp.macroN': 'Macro {n}', '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', '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)', @@ -510,7 +512,7 @@ const fr: Dict = { 'uscty.backfillDone': '{s} QSO US analysés · {c} comtés, {g} locators remplis.', 'station.title': 'Contrôle station', 'station.rotator': 'Rotator', 'station.rotatorNoRead': 'Azimut non lu', 'station.pattern': 'Diagramme', 'station.bi': 'Bi', 'station.retract': 'Rétracter les éléments', 'station.moving': 'EN MOUVEMENT', 'station.elements': 'Éléments (mm)', 'station.read': 'Lire', 'station.readLengths': 'Lire les longueurs actuelles depuis le contrôleur', 'station.noLengths': 'Longueurs inconnues — clique sur Lire pour les récupérer depuis le contrôleur.', 'station.element': 'Élément', 'station.reflector': 'Réflecteur', 'station.driven': 'Radiateur', 'station.director': 'Dir', 'station.set': 'Régler', 'station.elementsHint': "Chaque appui allonge/raccourcit l'élément de 2 mm (comme le pupitre). Vérifie quel élément répond sur ton antenne.", 'station.setExactLen': "Clique pour taper la longueur actuelle exacte (recale la base si la lecture auto est fausse).", 'station.atMax': "Refusé par le contrôleur — l'élément est probablement en butée (longueur max pour cette bande), il ne peut plus s'allonger.", 'station.go': 'Aller', 'station.stop': 'Stop', 'station.dragHint': 'Glisse la poignée à gauche d un panneau pour le déplacer. Choisis un nombre de colonnes pour la disposition.', 'station.dragMove': 'Glisser pour déplacer ce panneau', 'station.colsAuto': 'Auto', 'station.addDevice': 'Ajouter un appareil', 'station.editDevice': "Modifier l'appareil", 'station.empty': "Aucune carte relais. Ajoute un WebSwitch 1216H ou une carte KMTronic 8 relais pour piloter l'alimentation et les accessoires de ta station.", 'station.online': 'En ligne', 'station.offline': 'Hors ligne', 'station.edit': 'Modifier', 'station.delete': 'Supprimer', 'station.relay': 'Relais', 'station.on': 'ON', 'station.off': 'OFF', 'station.type': "Type d'appareil", 'station.name': 'Nom', 'station.host': 'Hôte / IP', 'station.user': "Nom d'utilisateur", 'station.pass': 'Mot de passe', 'station.optional': 'optionnel', 'station.ftdiSerial': 'Numéro de série FTDI', 'station.detect': 'Détecter', 'station.ftdiHint': "La carte Denkovi se pilote en FTDI bit-bang (pas via le port COM). Choisis son numéro de série (ex. DAE0006K). Nécessite le driver FTDI D2XX installé.", 'station.channels': 'Relais', 'station.comPort': 'Port COM', 'station.noPorts': 'Aucun port', 'station.usbRelayHint': "Cartes USB-série bon marché (CH340/LCUS) protocole A0. Si la tienne ne commute pas, donne-moi le modèle / jeu de commandes.", 'station.labels': 'Libellés des relais', 'station.cancel': 'Annuler', 'station.save': 'Enregistrer', 'station.test': 'Tester la connexion', 'station.testOk': 'Connecté — {n} relais', 'station.testFail': 'Non connecté', 'station.detectNone': 'Aucune carte FTDI trouvée — vérifie le câble et que le driver D2XX est installé.', 'station.detectFound': '{n} carte(s) détectée(s).', 'sec.awards': 'Diplômes', 'sec.cat': 'Interface CAT', 'sec.rotator': 'Rotator', 'sec.winkeyer': 'Manipulateur CW', - 'sec.antenna': 'Antenne motorisée', 'sec.antgenius': 'Antenna Genius', 'sec.pgxl': 'Amplificateur', 'sec.flex': 'FlexRadio', 'sec.audio': 'Périphériques audio', + 'sec.antenna': 'Antenne motorisée', 'sec.antgenius': 'Antenna Genius', 'sec.tunergenius': 'Tuner Genius', 'sec.pgxl': 'Amplificateur', 'sec.flex': 'FlexRadio', 'sec.audio': 'Périphériques audio', // Panneau Manipulateur CW 'wk.enable': 'Activer le manipulateur CW (affiche le panneau)', 'wk.engine': 'Moteur du manipulateur', 'wk.escClears': "ÉCHAP efface aussi l'indicatif (sinon ÉCHAP arrête seulement la transmission)", @@ -614,6 +616,7 @@ const fr: Dict = { 'autostart.hint': "Lance des programmes externes (WSJT-X, JTAlert, contrôle rotator…) au démarrage d'OpsLog. Un programme déjà lancé n'est pas relancé. Enregistré par profil.", 'cat.hint': "Lit la fréquence / bande / mode du poste et les injecte dans le bandeau de saisie en temps réel. Utilise OmniRig (gratuit, tout poste) ou — pour FlexRadio — l'API native SmartSDR (sans OmniRig, temps réel, sans le bug du mode au 2ᵉ clic).", 'ag2.hint': "OpsLog dialogue avec le switch 4O3A Antenna Genius en TCP (protocole GSCP). Le port est fixé à 9007, seule l'IP de l'appareil est nécessaire. Un widget ancré permet ensuite de commuter les antennes par port (A/B).", 'ag2.password': 'Mot de passe distant', 'ag2.passwordPh': 'vide en LAN', 'ag2.passwordHint': "Nécessaire seulement à distance — l'appareil annonce alors « AG AUTH » et refuse les commandes tant qu'on n'est pas identifié. Laisse vide sur le réseau local.", + 'tg2.hint': "OpsLog dialogue avec le 4O3A Tuner Genius XL en TCP (port fixé à 9010), seule l'IP de l'appareil est nécessaire. Un widget ancré affiche le ROS et la puissance directe et propose Accord, Bypass et Operate/Standby. Pilotage direct (pas via la radio) pour n'utiliser qu'une des connexions de la boîte.", 'tg2.enable': "Activer le contrôle du Tuner Genius", 'tg2.portHint': "Le port TCP est fixé à 9010 sur l'appareil.", 'tg2.password': 'Code distant', 'tg2.passwordPh': 'vide en LAN', 'tg2.passwordHint': "Nécessaire seulement à distance — l'appareil annonce alors « AUTH » et refuse les commandes tant qu'on n'est pas identifié. Laisse vide sur le réseau local.", 'rot.enable': 'Activer le contrôle du rotator', 'rot.testOkRG': 'Connecté — le Rotator Genius a accepté la commande (rotation vers 0°).', 'rot.type': 'Type de rotator', 'rot.rotatorNum': 'Rotator n°', 'rot.rgHint': 'Parle directement à un Rotator Genius 4O3A en TCP (port 9006 par défaut) — sans PstRotator. Le n° choisit lequel des deux rotators du boîtier piloter.', 'rot.arcoHint': "Parle directement à un microHAM ARCO — sans PstRotator. Réseau : régler Config → LAN → CONTROL PROTOCOL sur « Yaesu GS-232A » sur l'ARCO et saisir ici le même port TCP (jusqu'à 4 connexions en parallèle). USB : régler USB CONTROL PROTOCOL sur « Yaesu GS-232A » et choisir ici le port COM de l'ARCO (la vitesse est sans importance en USB).", 'rot.hint': "OpsLog envoie des commandes UDP à PstRotator. Active l'écouteur UDP de PstRotator (Setup → Communication → UDP) avant de tester.", 'extsvc.hint': "Envoie les QSO enregistrés vers des carnets en ligne. Chaque service upload automatiquement à chaque nouveau QSO si activé ; le délai est propre à chaque service (immédiat, ou 1–2 min pour corriger un QSO mal saisi avant).", 'hw.motorTxInhibit': "Inhiber la transmission pendant que l'antenne bouge", 'hw.motorTxInhibitHint': "Empêche le FlexRadio d'émettre pendant que les éléments bougent (nécessite le FlexRadio en API + cette antenne activée). Sans effet avec les autres radios.", 'hw.motorAntenna': 'Antenne motorisée', 'hw.motorEnable': "Activer le contrôle de l'antenne", 'hw.motorType': "Type d'antenne", 'hw.motorTransport': 'Connexion', 'hw.motorTcp': 'Réseau (TCP)', 'hw.motorSerial': 'Série (COM)', 'hw.motorCom': 'Port série', 'hw.motorBaud': 'Débit', 'hw.steppirHint': "Les contrôleurs SteppIR sont en RS-232 série (port DB9 « DATA OUT »). Série = un adaptateur USB↔RS-232 (FTDI), vu comme un port COM. Réseau = un pont série-Ethernet (comme pour l'Ultrabeam).", 'hw.steppirRange': 'Plage accordable', 'hw.steppirRangeHint': "La couverture en fréquence de la SteppIR. Sur une bande hors de cette plage (p. ex. 30 m avec une SteppIR 20 m-6 m), OpsLog n'essaie pas d'accorder l'antenne et n'inhibe pas l'émission. Défaut 13-54 MHz (20 m-6 m) ; abaisse la borne basse (p. ex. 6) pour une SteppIR équipée 40 m.", 'hw.ultrabeam': 'Antenne (Ultrabeam)', 'hw.audioVoice': 'Périphériques audio & manipulateur vocal', @@ -686,6 +689,7 @@ const fr: Dict = { 'wkp.autoCallHint': "Clique une macro CQ (dont le texte contient CQ) pour la réémettre en boucle — message, pause, répétition — jusqu'à envoyer une autre macro (ex. un report), appuyer sur Stop ou ESC. Les macros non-CQ ne sont émises qu'une fois.", 'wkp.autoCall': 'Appel auto', 'wkp.gap': 'pause', 'wkp.gapHint': 'Secondes à attendre après le message avant de réémettre', 'wkp.loopHint': 'clique une macro CQ pour la boucler', 'wkp.macroN': 'Macro {n}', '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', '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 bb09577..2d59b98 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -14,6 +14,7 @@ import {extsvc} from '../models'; import {powergenius} from '../models'; import {spe} from '../models'; import {solar} from '../models'; +import {tunergenius} from '../models'; import {winkeyer} from '../models'; import {alerts} from '../models'; import {audio} from '../models'; @@ -474,6 +475,10 @@ export function GetStationStatus():Promise>; export function GetTelemetryEnabled():Promise; +export function GetTunerGeniusSettings():Promise; + +export function GetTunerGeniusStatus():Promise; + export function GetUIPref(arg1:string):Promise; export function GetUltrabeamSettings():Promise; @@ -842,6 +847,8 @@ export function SaveStationDevices(arg1:Array):Promise export function SaveStationSettings(arg1:main.StationSettings):Promise; +export function SaveTunerGeniusSettings(arg1:main.TunerGeniusSettings):Promise; + export function SaveUDPIntegration(arg1:udp.Config):Promise; export function SaveUltrabeamSettings(arg1:main.UltrabeamSettings):Promise; @@ -922,6 +929,12 @@ export function TestStationDevice(arg1:main.StationDevice):Promise; +export function TunerGeniusAutotune():Promise; + +export function TunerGeniusSetBypass(arg1:boolean):Promise; + +export function TunerGeniusSetOperate(arg1:boolean):Promise; + export function ULSStatus():Promise; export function UltrabeamRetract():Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index a62b30d..661ecf3 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -902,6 +902,14 @@ export function GetTelemetryEnabled() { return window['go']['main']['App']['GetTelemetryEnabled'](); } +export function GetTunerGeniusSettings() { + return window['go']['main']['App']['GetTunerGeniusSettings'](); +} + +export function GetTunerGeniusStatus() { + return window['go']['main']['App']['GetTunerGeniusStatus'](); +} + export function GetUIPref(arg1) { return window['go']['main']['App']['GetUIPref'](arg1); } @@ -1638,6 +1646,10 @@ export function SaveStationSettings(arg1) { return window['go']['main']['App']['SaveStationSettings'](arg1); } +export function SaveTunerGeniusSettings(arg1) { + return window['go']['main']['App']['SaveTunerGeniusSettings'](arg1); +} + export function SaveUDPIntegration(arg1) { return window['go']['main']['App']['SaveUDPIntegration'](arg1); } @@ -1798,6 +1810,18 @@ export function TestUltrabeam(arg1) { return window['go']['main']['App']['TestUltrabeam'](arg1); } +export function TunerGeniusAutotune() { + return window['go']['main']['App']['TunerGeniusAutotune'](); +} + +export function TunerGeniusSetBypass(arg1) { + return window['go']['main']['App']['TunerGeniusSetBypass'](arg1); +} + +export function TunerGeniusSetOperate(arg1) { + return window['go']['main']['App']['TunerGeniusSetOperate'](arg1); +} + export function ULSStatus() { return window['go']['main']['App']['ULSStatus'](); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index e43b1dd..1c9bc5d 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -2939,6 +2939,22 @@ export namespace main { this.error = source["error"]; } } + export class TunerGeniusSettings { + enabled: boolean; + host: string; + password: string; + + static createFrom(source: any = {}) { + return new TunerGeniusSettings(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.enabled = source["enabled"]; + this.host = source["host"]; + this.password = source["password"]; + } + } export class ULSStatusResult { count: number; updated_at: string; @@ -4412,6 +4428,51 @@ export namespace spe { } +export namespace tunergenius { + + export class Status { + 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; + + 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.fwd_dbm = source["fwd_dbm"]; + 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.message = source["message"]; + } + } + +} + export namespace udp { export class Config { diff --git a/internal/tunergenius/tunergenius.go b/internal/tunergenius/tunergenius.go new file mode 100644 index 0000000..bb72203 --- /dev/null +++ b/internal/tunergenius/tunergenius.go @@ -0,0 +1,403 @@ +// Package tunergenius drives a 4O3A Tuner Genius XL over its TCP text API +// (fixed port 9010 — the same port the device also uses for UDP discovery +// broadcasts). It's the same "Genius Series" line protocol as the PowerGenius +// XL / Antenna Genius: on connect the device sends a banner ("V1.1.8" or +// "V1.1.8 AUTH" when reached from outside the LAN); commands are +// "C|\n" and replies are "R||" (code 0 = OK). +// The status reply is pushed back as "S|status ", and unsolicited +// "M|" info/warning lines can arrive at any time. +// +// Protocol reference: 4O3A "TUNER GENIUS XL — PROTOCOL DESCRIPTION". +package tunergenius + +import ( + "bufio" + "fmt" + "math" + "net" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" + + "hamlog/internal/applog" +) + +const ( + // DefaultPort is fixed on the device (both the TCP control channel and the + // UDP discovery broadcast use 9010). + DefaultPort = 9010 + + dialTimeout = 5 * time.Second + ioTimeout = 3 * time.Second + pollEvery = 1500 * time.Millisecond + reconnectDelay = 2 * time.Second +) + +// 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 { + Connected bool `json:"connected"` + 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 + + 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 + + Message string `json:"message,omitempty"` // last M| warning/info (empty = cleared) +} + +type Client struct { + host string + port int + password string // remote-access code; sent as "auth " when the banner announces AUTH + + mu sync.Mutex // serialises command send/recv on the connection + conn net.Conn + reader *bufio.Reader + + statusMu sync.RWMutex + status Status + lastRaw string // last raw status payload — logged on change to map fields against real hardware + + cmdID atomic.Int64 + stop chan struct{} + running bool +} + +func New(host string, port int, password string) *Client { + if port <= 0 || port > 65535 { + port = DefaultPort + } + return &Client{ + host: host, + port: port, + password: strings.TrimSpace(password), + 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() +} + +// SetOperate puts the tuner in OPERATE (1) or STANDBY (0). +func (c *Client) SetOperate(on bool) error { + if _, err := c.command("operate set=" + boolNum(on)); err != nil { + return err + } + c.setStatus(func(s *Status) { s.Operate = on }) // optimistic; the poll confirms + return nil +} + +// SetBypass engages (1) or clears (0) the device's global bypass (antenna +// connected straight through, tuner out of line). +func (c *Client) SetBypass(on bool) error { + if _, err := c.command("bypass set=" + boolNum(on)); err != nil { + return err + } + c.setStatus(func(s *Status) { s.Bypass = on }) + return nil +} + +// Autotune starts an automatic tuning cycle on the active channel. The rig must +// be keyed into a carrier for the tuner to measure SWR — the device asserts its +// own PTT OUT if "tune PTT" is enabled in its setup. +func (c *Client) Autotune() error { + if _, err := c.command("autotune"); err != nil { + return err + } + c.setStatus(func(s *Status) { s.Tuning = true }) // optimistic until the poll clears it + return nil +} + +// Activate selects the active channel. On SO2R hardware ch is 1 (A) or 2 (B); +// on the 3-way variant it selects the antenna (1/2/3). +func (c *Client) Activate(ch int) error { + if ch < 1 { + return fmt.Errorf("tunergenius: invalid channel %d", ch) + } + key := "ch" + if c.GetStatus().ThreeWay { + key = "ant" + } + _, err := c.command(fmt.Sprintf("activate %s=%d", key, ch)) + 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) + // Banner: "V1.1.8" (LAN) or "V1.1.8 AUTH" (remote → authentication required). + _ = conn.SetReadDeadline(time.Now().Add(ioTimeout)) + banner, _ := c.reader.ReadString('\n') + banner = strings.TrimSpace(banner) + applog.Printf("tunergenius: connected %s → %s, banner=%q", conn.LocalAddr(), conn.RemoteAddr(), banner) + if strings.Contains(banner, "AUTH") { + if c.password == "" { + applog.Printf("tunergenius: device requires AUTH but no remote code set (Settings → Tuner Genius)") + } else if err := c.authLocked(); err != nil { + c.conn.Close() + c.conn, c.reader = nil, nil + return err + } + } + c.setStatus(func(s *Status) { s.Connected = true; s.LastError = ""; s.Host = c.host }) + return nil +} + +// authLocked sends "auth " and checks the reply. Must be called with c.mu +// held (during ensureConnected). Note the device replies R|0|... for BOTH +// success ("auth OK") and failure ("Unauthorized"), so the message text — not +// the response code — decides. +func (c *Client) authLocked() error { + id := c.cmdID.Add(1) + _ = c.conn.SetWriteDeadline(time.Now().Add(ioTimeout)) + if _, err := fmt.Fprintf(c.conn, "C%d|auth %s\n", id, c.password); 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) + if strings.Contains(strings.ToLower(line), "unauthorized") { + return fmt.Errorf("tunergenius: authentication failed — check the remote code") + } + applog.Printf("tunergenius: authenticated") + return nil +} + +// command sends "C|\n" and returns the matching reply line, updating +// the status snapshot from whatever status/message lines arrive. Unsolicited +// "M|" info lines that precede the reply are consumed (they update Message). +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("tunergenius: 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 + } + // Read until the command's reply (R…/S…); consume async M| lines along the way. + for i := 0; i < 8; i++ { + _ = c.conn.SetReadDeadline(time.Now().Add(ioTimeout)) + line, err := c.reader.ReadString('\n') + if err != nil { + return "", err + } + line = strings.TrimSpace(line) + if line == "" { + continue + } + c.parse(line) + if line[0] == 'R' || line[0] == 'S' { + return line, nil + } + } + return "", fmt.Errorf("tunergenius: no reply to %q", cmd) +} + +func (c *Client) dropConn() { + c.mu.Lock() + if c.conn != nil { + c.conn.Close() + c.conn = nil + c.reader = nil + } + c.mu.Unlock() +} + +// parse handles the three line shapes: "R||", "S|status …" +// and "M|". +func (c *Client) parse(resp string) { + // Async info/warning: "M|" (empty message = cleared). + if strings.HasPrefix(resp, "M|") { + msg := strings.TrimSpace(strings.TrimPrefix(resp, "M|")) + c.setStatus(func(s *Status) { s.Message = msg }) + return + } + 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 + } + // Only the "status …" payload carries the live state we render. + if !strings.HasPrefix(data, "status") { + return + } + if data != c.lastRaw { + c.lastRaw = data + applog.Printf("tunergenius: status raw=%q", data) + } + 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. +func (c *Client) applyStatus(data string) { + kv := map[string]string{} + for _, tok := range strings.Fields(data) { + if p := strings.SplitN(tok, "=", 2); len(p) == 2 { + kv[p[0]] = p[1] + } + } + active := atoiDefault(kv["active"], 1) + + c.statusMu.Lock() + defer c.statusMu.Unlock() + c.status.Connected = true + c.status.LastError = "" + c.status.Active = active + c.status.Operate = kv["state"] == "1" + c.status.Bypass = kv["bypass"] == "1" + c.status.Tuning = kv["tuning"] == "1" + + if v, ok := parseFloat(kv["fwd"]); ok { + c.status.FwdDbm = v + c.status.FwdW = dbmToWatts(v) + } + if v, ok := parseFloat(kv["swr"]); ok { + c.status.SwrDb = v + c.status.Vswr = returnLossToVswr(v) + } + // Active-channel frequency + antenna (suffix A for ch1, B for ch2). + suffix := "A" + if active == 2 { + suffix = "B" + } + if v, ok := parseFloat(kv["freq"+suffix]); ok { + c.status.FreqMHz = v + } + c.status.Antenna = atoiDefault(kv["ant"+suffix], 0) +} + +func boolNum(on bool) string { + if on { + return "1" + } + return "0" +} + +func atoiDefault(s string, def int) int { + if n, err := strconv.Atoi(strings.TrimSpace(s)); err == nil { + return n + } + return def +} + +func parseFloat(s string) (float64, bool) { + if s == "" { + return 0, false + } + v, err := strconv.ParseFloat(strings.TrimSpace(s), 64) + return v, err == nil +} + +// dbmToWatts converts a power reading in dBm to watts (0 dBm = 1 mW). +func dbmToWatts(dbm float64) float64 { + return math.Pow(10, (dbm-30)/10) +} + +// returnLossToVswr converts the device's "swr" field — a return loss in dB, +// reported as a negative number (e.g. -60 = an excellent 60 dB match) — into a +// conventional VSWR ratio. A near-zero return loss (bad match) yields a large +// VSWR; a large negative one yields ~1.0. +func returnLossToVswr(swrDb float64) float64 { + rl := math.Abs(swrDb) + rho := math.Pow(10, -rl/20) // reflection coefficient magnitude + if rho >= 1 { + return 99.9 + } + vswr := (1 + rho) / (1 - rho) + if vswr > 99.9 || math.IsInf(vswr, 0) || math.IsNaN(vswr) { + return 99.9 + } + return vswr +}