diff --git a/app.go b/app.go index b1ea694..07c7507 100644 --- a/app.go +++ b/app.go @@ -127,6 +127,8 @@ const ( keyUltrabeamEnabled = "ultrabeam.enabled" keyUltrabeamHost = "ultrabeam.host" keyUltrabeamPort = "ultrabeam.port" + keyUltrabeamFollow = "ultrabeam.follow" // "1" → re-tune to the rig frequency + keyUltrabeamStep = "ultrabeam.step_khz" // re-tune hysteresis: 25 | 50 | 100 kHz // WinKeyer CW keyer (serial) — Hardware → CW Keyer. keyWKEnabled = "winkeyer.enabled" @@ -349,9 +351,10 @@ type App struct { udpRepo *udp.Repo extsvc *extsvc.Manager winkeyer *winkeyer.Manager - clublog *clublog.Manager - ultrabeam *ultrabeam.Client // Ultrabeam antenna (TCP); nil when disabled - audioMgr *audio.Manager + clublog *clublog.Manager + ultrabeam *ultrabeam.Client // Ultrabeam antenna (TCP); nil when disabled + ubFollowStop chan struct{} // stops the "follow frequency" loop; nil when off + audioMgr *audio.Manager qsoRec *audio.Recorder // continuous QSO recorder (rolling pre-roll) dvkRecSlot int // slot currently being recorded (DVKStartRecord → DVKStopRecord) dvkPttKeyed bool // we keyed PTT for a voice message; unkey when it ends @@ -3969,6 +3972,7 @@ func (a *App) pttKey(cfg AudioSettings) error { return fmt.Errorf("CAT not initialized") } if err := a.cat.SetPTT(true); err != nil { + applog.Printf("ptt: CAT SetPTT failed: %v", err) return err } a.pttMu.Lock() @@ -4045,6 +4049,11 @@ func (a *App) pttUnkey() { // UI selection), so the user can test a method/port without saving first — // matching TestRotator / TestUltrabeam. func (a *App) TestPTT(cfg AudioSettings) error { + if cfg.PTTMethod == "rts" || cfg.PTTMethod == "dtr" { + applog.Printf("ptt: TestPTT method=%q port=%q", cfg.PTTMethod, cfg.PTTPort) + } else { + applog.Printf("ptt: TestPTT method=%q (CAT via OmniRig — serial port not used)", cfg.PTTMethod) + } if cfg.PTTMethod == "" || cfg.PTTMethod == "none" { return fmt.Errorf("PTT method is None (VOX) — pick CAT, RTS or DTR first") } @@ -6005,15 +6014,17 @@ type UltrabeamSettings struct { Enabled bool `json:"enabled"` Host string `json:"host"` Port int `json:"port"` + Follow bool `json:"follow"` // re-tune the antenna to the rig's frequency + StepKHz int `json:"step_khz"` // re-tune only when the freq moved this far (25/50/100) } // GetUltrabeamSettings returns the persisted Ultrabeam config with defaults. func (a *App) GetUltrabeamSettings() (UltrabeamSettings, error) { - out := UltrabeamSettings{Port: 23} + out := UltrabeamSettings{Port: 23, StepKHz: 50} if a.settings == nil { return out, fmt.Errorf("db not initialized") } - m, err := a.settings.GetMany(a.ctx, keyUltrabeamEnabled, keyUltrabeamHost, keyUltrabeamPort) + m, err := a.settings.GetMany(a.ctx, keyUltrabeamEnabled, keyUltrabeamHost, keyUltrabeamPort, keyUltrabeamFollow, keyUltrabeamStep) if err != nil { return out, err } @@ -6022,6 +6033,10 @@ func (a *App) GetUltrabeamSettings() (UltrabeamSettings, error) { if p, _ := strconv.Atoi(m[keyUltrabeamPort]); p > 0 && p <= 65535 { out.Port = p } + out.Follow = m[keyUltrabeamFollow] == "1" + if st, _ := strconv.Atoi(m[keyUltrabeamStep]); st == 25 || st == 50 || st == 100 { + out.StepKHz = st + } return out, nil } @@ -6034,10 +6049,15 @@ func (a *App) SaveUltrabeamSettings(s UltrabeamSettings) error { if s.Port <= 0 || s.Port > 65535 { s.Port = 23 } + if s.StepKHz != 25 && s.StepKHz != 50 && s.StepKHz != 100 { + s.StepKHz = 50 + } for k, v := range map[string]string{ keyUltrabeamEnabled: boolStr(s.Enabled), keyUltrabeamHost: strings.TrimSpace(s.Host), keyUltrabeamPort: strconv.Itoa(s.Port), + keyUltrabeamFollow: boolStr(s.Follow), + keyUltrabeamStep: strconv.Itoa(s.StepKHz), } { if err := a.settings.Set(a.ctx, k, v); err != nil { return err @@ -6051,6 +6071,11 @@ func (a *App) SaveUltrabeamSettings(s UltrabeamSettings) error { // antenna is enabled and configured. Safe to call repeatedly (on startup and // after a settings save). func (a *App) startUltrabeam() { + // Stop any running follow loop first. + if a.ubFollowStop != nil { + close(a.ubFollowStop) + a.ubFollowStop = nil + } if a.ultrabeam != nil { a.ultrabeam.Stop() a.ultrabeam = nil @@ -6061,6 +6086,61 @@ func (a *App) startUltrabeam() { } a.ultrabeam = ultrabeam.New(s.Host, s.Port) _ = a.ultrabeam.Start() + if s.Follow { + stop := make(chan struct{}) + a.ubFollowStop = stop + go a.ultrabeamFollowLoop(a.ultrabeam, s.StepKHz, stop) + } +} + +// ultrabeamFollowLoop re-tunes the antenna to the rig's current frequency +// whenever it drifts at least stepKHz from what the antenna is set to — so the +// elements track the band without the motors chasing every small QSY. Runs +// until stop is closed (a settings change or shutdown). +func (a *App) ultrabeamFollowLoop(c *ultrabeam.Client, stepKHz int, stop <-chan struct{}) { + if stepKHz <= 0 { + stepKHz = 50 + } + ticker := time.NewTicker(1500 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-stop: + return + case <-ticker.C: + if a.cat == nil { + continue + } + rs := a.cat.State() + if !rs.Connected || rs.FreqHz <= 0 { + continue + } + st, err := c.GetStatus() + if err != nil || st == nil || !st.Connected { + continue + } + rigKHz := int(rs.FreqHz / 1000) + // Skip frequencies outside the antenna's tunable range (other band). + if st.FreqMin > 0 && st.FreqMax > 0 { + rigMHz := rs.FreqHz / 1_000_000 + if rigMHz < int64(st.FreqMin) || rigMHz > int64(st.FreqMax) { + continue + } + } + diff := rigKHz - st.Frequency + if diff < 0 { + diff = -diff + } + if st.Frequency > 0 && diff < stepKHz { + continue // within the deadband — leave the motors alone + } + if err := c.SetFrequency(rigKHz, st.Direction); err != nil { + applog.Printf("ultrabeam: follow re-tune to %d kHz failed: %v", rigKHz, err) + } else { + applog.Printf("ultrabeam: followed rig → %d kHz (dir %d)", rigKHz, st.Direction) + } + } + } } // UltrabeamStatusInfo is the live antenna status for the UI (status bar + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3044177..cbca689 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2535,6 +2535,9 @@ export default function App() { if (m) setMode(m); } onCallsignInput(s.dx_call); + // Clicking a spot fills the call programmatically (no blur + // on the call field), so start the QSO recording here too. + if (s.dx_call.trim()) QSOAudioBegin().then(setRecording).catch(() => {}); }} /> ); @@ -2750,6 +2753,7 @@ export default function App() { if (m) setMode(m); } onCallsignInput(s.dx_call); + if (s.dx_call.trim()) QSOAudioBegin().then(setRecording).catch(() => {}); }} onClose={() => setShowBandMap(false)} /> diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index f0b66f4..f4c6ae5 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -370,8 +370,8 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) { const [rotatorTest, setRotatorTest] = useState<{ ok: boolean; msg: string } | null>(null); // Ultrabeam antenna (TCP) settings. - const [ultrabeam, setUltrabeam] = useState<{ enabled: boolean; host: string; port: number }>({ - enabled: false, host: '', port: 23, + const [ultrabeam, setUltrabeam] = useState<{ enabled: boolean; host: string; port: number; follow: boolean; step_khz: number }>({ + enabled: false, host: '', port: 23, follow: false, step_khz: 50, }); const [ubTesting, setUbTesting] = useState(false); const [ubTest, setUbTest] = useState<{ ok: boolean; msg: string } | null>(null); @@ -1554,6 +1554,26 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) { /> +
+ + {ultrabeam.follow && ( +
+ + + re-tune only when the frequency moves this far +
+ )} +