feat: Support for Antenna Genius

This commit is contained in:
2026-06-21 20:15:30 +02:00
parent 8b7c42ec9b
commit b302d4d87b
14 changed files with 2315 additions and 6 deletions
+213 -2
View File
@@ -40,6 +40,7 @@ import (
"hamlog/internal/qso"
"hamlog/internal/rotator/pst"
"hamlog/internal/settings"
"hamlog/internal/antgenius"
"hamlog/internal/ultrabeam"
"hamlog/internal/winkeyer"
@@ -83,6 +84,9 @@ const (
keyCATPollMs = "cat.poll_ms"
keyCATDelayMs = "cat.delay_ms" // pause between commands
keyCATDigitalDefault = "cat.digital_default" // mode to use when CAT reports DATA
keyCATIcomPort = "cat.icom.port" // Icom USB CI-V serial port (e.g. COM5)
keyCATIcomBaud = "cat.icom.baud" // Icom CI-V baud (default 115200)
keyCATIcomAddr = "cat.icom.addr" // Icom CI-V address, decimal (IC-7610 = 152 / 0x98)
// Audio (Digital Voice Keyer + QSO recorder). Machine-local hardware, so
// global (not per-profile) like CAT/rotator. Device fields store the
@@ -137,6 +141,11 @@ const (
keyUltrabeamFollow = "ultrabeam.follow" // "1" → re-tune to the rig frequency
keyUltrabeamStep = "ultrabeam.step_khz" // re-tune hysteresis: 25 | 50 | 100 kHz
// Antenna Genius (4O3A) antenna switch — Hardware → Antenna Genius. TCP
// port is fixed at 9007, so only the IP is configurable.
keyAntGeniusEnabled = "antgenius.enabled"
keyAntGeniusHost = "antgenius.host"
// WinKeyer CW keyer (serial) — Hardware → CW Keyer.
keyWKEnabled = "winkeyer.enabled"
keyWKPort = "winkeyer.port"
@@ -241,11 +250,14 @@ type QSLDefaults struct {
// individual key/value pairs to keep the settings table flat.
type CATSettings struct {
Enabled bool `json:"enabled"`
Backend string `json:"backend"` // "omnirig" | "flex"
Backend string `json:"backend"` // "omnirig" | "flex" | "icom"
OmniRigNum int `json:"omnirig_rig"` // 1 or 2 (OmniRig "Rig1"/"Rig2" slot)
FlexHost string `json:"flex_host"` // FlexRadio IP (native backend)
FlexPort int `json:"flex_port"` // FlexRadio TCP port (default 4992)
FlexSpots bool `json:"flex_spots"` // push cluster spots to the panadapter
IcomPort string `json:"icom_port"` // Icom USB CI-V serial port (e.g. COM5)
IcomBaud int `json:"icom_baud"` // Icom CI-V baud (default 115200)
IcomAddr int `json:"icom_addr"` // Icom CI-V address, decimal (IC-7610 = 152)
PollMs int `json:"poll_ms"` // poll interval in ms (default 250)
DelayMs int `json:"delay_ms"` // pause between commands (default 0)
DigitalDefault string `json:"digital_default"` // when CAT says DATA, surface this mode (FT8/FT4/RTTY/…)
@@ -377,6 +389,7 @@ type App struct {
clublog *clublog.Manager
ultrabeam *ultrabeam.Client // Ultrabeam antenna (TCP); nil when disabled
ubFollowStop chan struct{} // stops the "follow frequency" loop; nil when off
antgenius *antgenius.Client // Antenna Genius (4O3A) switch (TCP); nil when disabled
audioMgr *audio.Manager
qsoRec *audio.Recorder // continuous QSO recorder (rolling pre-roll)
cwMu sync.Mutex // guards the CW decoder lifecycle
@@ -809,6 +822,8 @@ func (a *App) startup(ctx context.Context) {
// Ultrabeam antenna: connect in the background if enabled.
a.startUltrabeam()
// Antenna Genius switch: connect in the background if enabled.
a.startAntGenius()
// Autostart: launch the active profile's configured external programs that
// aren't already running (WSJT-X, JTAlert, rotator control, …). Background
@@ -3786,7 +3801,7 @@ func (a *App) GetCATSettings() (CATSettings, error) {
if a.settings == nil {
return CATSettings{Backend: "omnirig", OmniRigNum: 1, PollMs: 250}, fmt.Errorf("db not initialized")
}
m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATFlexHost, keyCATFlexPort, keyCATFlexSpots, keyCATPollMs, keyCATDelayMs, keyCATDigitalDefault)
m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATFlexHost, keyCATFlexPort, keyCATFlexSpots, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATPollMs, keyCATDelayMs, keyCATDigitalDefault)
if err != nil {
return CATSettings{}, err
}
@@ -3797,6 +3812,9 @@ func (a *App) GetCATSettings() (CATSettings, error) {
FlexHost: m[keyCATFlexHost],
FlexPort: 4992,
FlexSpots: m[keyCATFlexSpots] == "1",
IcomPort: m[keyCATIcomPort],
IcomBaud: 115200,
IcomAddr: 0x98, // IC-7610 default
PollMs: 250,
DelayMs: 0,
DigitalDefault: m[keyCATDigitalDefault],
@@ -3804,6 +3822,12 @@ func (a *App) GetCATSettings() (CATSettings, error) {
if n, _ := strconv.Atoi(m[keyCATFlexPort]); n > 0 && n <= 65535 {
out.FlexPort = n
}
if n, _ := strconv.Atoi(m[keyCATIcomBaud]); n > 0 {
out.IcomBaud = n
}
if n, _ := strconv.Atoi(m[keyCATIcomAddr]); n > 0 && n <= 0xFF {
out.IcomAddr = n
}
if out.Backend == "" {
out.Backend = "omnirig"
}
@@ -3836,6 +3860,12 @@ func (a *App) SaveCATSettings(s CATSettings) error {
if s.FlexPort <= 0 || s.FlexPort > 65535 {
s.FlexPort = 4992
}
if s.IcomBaud <= 0 {
s.IcomBaud = 115200
}
if s.IcomAddr <= 0 || s.IcomAddr > 0xFF {
s.IcomAddr = 0x98
}
if s.PollMs < 50 || s.PollMs > 2000 {
s.PollMs = 250
}
@@ -3860,6 +3890,9 @@ func (a *App) SaveCATSettings(s CATSettings) error {
keyCATFlexHost: strings.TrimSpace(s.FlexHost),
keyCATFlexPort: strconv.Itoa(s.FlexPort),
keyCATFlexSpots: flexSpots,
keyCATIcomPort: strings.TrimSpace(s.IcomPort),
keyCATIcomBaud: strconv.Itoa(s.IcomBaud),
keyCATIcomAddr: strconv.Itoa(s.IcomAddr),
keyCATPollMs: strconv.Itoa(s.PollMs),
keyCATDelayMs: strconv.Itoa(s.DelayMs),
keyCATDigitalDefault: strings.ToUpper(strings.TrimSpace(s.DigitalDefault)),
@@ -6769,6 +6802,100 @@ func (a *App) GetFlexState() cat.FlexTXState {
return st
}
// ── Icom CI-V control panel (receive DSP) ──────────────────────────────────
func (a *App) GetIcomState() cat.IcomTXState {
if a.cat == nil {
return cat.IcomTXState{}
}
st, _ := a.cat.IcomState()
return st
}
func (a *App) IcomRefresh() error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.RefreshIcom() })
}
func (a *App) IcomSetAFGain(p int) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetAFGain(p) })
}
func (a *App) IcomSetRFGain(p int) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetRFGain(p) })
}
func (a *App) IcomSetNB(on bool) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetNB(on) })
}
func (a *App) IcomSetNBLevel(p int) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetNBLevel(p) })
}
func (a *App) IcomSetNR(on bool) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetNR(on) })
}
func (a *App) IcomSetNRLevel(p int) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetNRLevel(p) })
}
func (a *App) IcomSetANF(on bool) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetANF(on) })
}
func (a *App) IcomSetAGC(mode string) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetAGC(mode) })
}
func (a *App) IcomSetPreamp(n int) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetPreamp(n) })
}
func (a *App) IcomSetAtt(db int) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetAtt(db) })
}
func (a *App) IcomSetFilter(n int) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetIcomFilter(n) })
}
func (a *App) FlexSetPower(p int) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
@@ -7053,6 +7180,10 @@ func (a *App) reloadCAT() {
}
}
a.cat.Start(fb)
case "icom":
// Native Icom CI-V over the radio's USB serial port (local control).
// Same civ protocol a future network backend will reuse for remote.
a.cat.Start(cat.NewIcomSerial(s.IcomPort, s.IcomBaud, s.IcomAddr, s.DigitalDefault))
default:
// Unknown backend → stop and emit a dummy state so the UI shows it.
a.cat.Stop()
@@ -7712,6 +7843,86 @@ func (a *App) TestUltrabeam(s UltrabeamSettings) error {
return fmt.Errorf("no response from %s:%d", s.Host, s.Port)
}
// ── Antenna Genius (4O3A) antenna switch (TCP, port fixed 9007) ─────────────
// AntGeniusSettings is the JSON shape for the Hardware → Antenna Genius panel.
// The TCP port is fixed at 9007 on the device, so only the IP is configurable.
type AntGeniusSettings struct {
Enabled bool `json:"enabled"`
Host string `json:"host"`
}
// GetAntGeniusSettings returns the persisted Antenna Genius config.
func (a *App) GetAntGeniusSettings() (AntGeniusSettings, error) {
out := AntGeniusSettings{}
if a.settings == nil {
return out, fmt.Errorf("db not initialized")
}
m, err := a.settings.GetMany(a.ctx, keyAntGeniusEnabled, keyAntGeniusHost)
if err != nil {
return out, err
}
out.Enabled = m[keyAntGeniusEnabled] == "1"
out.Host = m[keyAntGeniusHost]
return out, nil
}
// SaveAntGeniusSettings persists the config and (re)starts or stops the client.
func (a *App) SaveAntGeniusSettings(s AntGeniusSettings) error {
if a.settings == nil {
return fmt.Errorf("db not initialized")
}
for k, v := range map[string]string{
keyAntGeniusEnabled: boolStr(s.Enabled),
keyAntGeniusHost: strings.TrimSpace(s.Host),
} {
if err := a.settings.Set(a.ctx, k, v); err != nil {
return err
}
}
a.startAntGenius()
return nil
}
// startAntGenius stops any existing client and starts a fresh one if enabled.
func (a *App) startAntGenius() {
if a.antgenius != nil {
a.antgenius.Stop()
a.antgenius = nil
}
s, err := a.GetAntGeniusSettings()
if err != nil || !s.Enabled || strings.TrimSpace(s.Host) == "" {
return
}
a.antgenius = antgenius.New(s.Host, 9007)
_ = a.antgenius.Start()
}
// GetAntGeniusStatus returns the switch's current state for the UI poll
// (connection, active antenna per port, and the configured antenna list).
func (a *App) GetAntGeniusStatus() antgenius.Status {
if a.antgenius == nil {
return antgenius.Status{}
}
return a.antgenius.GetStatus()
}
// AntGeniusActivate selects an antenna on a port (1 = A, 2 = B).
func (a *App) AntGeniusActivate(port, antenna int) error {
if a.antgenius == nil {
return fmt.Errorf("Antenna Genius not connected — enable it in Settings → Antenna Genius")
}
return a.antgenius.Activate(port, antenna)
}
// AntGeniusDeselect clears the active antenna on a port (sets it to "None").
func (a *App) AntGeniusDeselect(port int) error {
if a.antgenius == nil {
return fmt.Errorf("Antenna Genius not connected")
}
return a.antgenius.Activate(port, 0)
}
// --- WinKeyer (CW keyer) bindings ---
// WKMacro is one CW message slot (F1…): a short label + the macro text, which