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
+100
View File
@@ -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