diff --git a/app.go b/app.go index 7d5c006..98618e1 100644 --- a/app.go +++ b/app.go @@ -10844,9 +10844,24 @@ func (a *App) consumeUDPEvents() { "service": string(ev.Service), "source": ev.Source, }) - case ev.DXCall != "" && ev.Service == udp.ServiceRemoteCall: - applog.Printf("udp: emit udp:remote_call %q\n", ev.DXCall) - wruntime.EventsEmit(a.ctx, "udp:remote_call", ev.DXCall) + case (ev.DXCall != "" || ev.TuneFreqHz > 0) && ev.Service == udp.ServiceRemoteCall: + // CAT tune riding along with the callsign (DXHunter spot click: + // "VP5G10.136FT8"). Freq first so the rig is + // already moving while the frontend fills the entry field. + if ev.TuneFreqHz > 0 { + applog.Printf("udp: remote_call tune request %.3f MHz mode=%q\n", float64(ev.TuneFreqHz)/1e6, ev.TuneMode) + if err := a.SetCATFrequency(ev.TuneFreqHz); err != nil { + applog.Printf("udp: remote_call tune failed: %v\n", err) + } else if ev.TuneMode != "" { + if err := a.SetCATMode(ev.TuneMode); err != nil { + applog.Printf("udp: remote_call mode set failed: %v\n", err) + } + } + } + if ev.DXCall != "" { + applog.Printf("udp: emit udp:remote_call %q\n", ev.DXCall) + wruntime.EventsEmit(a.ctx, "udp:remote_call", ev.DXCall) + } case ev.DXCall != "": applog.Printf("udp: emit udp:dx_call %q (mode=%s freq=%d)\n", ev.DXCall, ev.Mode, ev.FreqHz) wruntime.EventsEmit(a.ctx, "udp:dx_call", map[string]any{ diff --git a/internal/integrations/udp/server.go b/internal/integrations/udp/server.go index 8b737b8..aa252dc 100644 --- a/internal/integrations/udp/server.go +++ b/internal/integrations/udp/server.go @@ -4,6 +4,8 @@ import ( "context" "fmt" "net" + "regexp" + "strconv" "strings" "sync" "syscall" @@ -14,6 +16,14 @@ import ( "hamlog/internal/applog" ) +// remoteFreqRe / remoteModeRe pull the optional tune request out of a +// ServiceRemoteCall packet: "10.136" (MHz) and "FT8". Both accept +// an optional closing tag for proper-XML senders. +var ( + remoteFreqRe = regexp.MustCompile(`(?i)\s*([0-9]+(?:\.[0-9]+)?)`) + remoteModeRe = regexp.MustCompile(`(?i)\s*([A-Z0-9-]+)`) +) + // reusingListenConfig builds a net.ListenConfig that sets SO_REUSEADDR // (and SO_REUSEPORT on Unix) on the underlying socket before bind. This // is the only way for two processes to share a UDP port on Windows — Go @@ -57,6 +67,13 @@ type Event struct { // Call after previously reporting one — i.e. the operator cleared the call in // the digital app. OpsLog clears its entry to match. ClearCall bool + + // TuneFreqHz / TuneMode carry an explicit "tune the radio" request embedded + // in a ServiceRemoteCall packet (DXHunter spot click sends + // "VP5G10.136FT8"). Zero/empty = no tune requested — + // the packet only fills the entry callsign, exactly as before. + TuneFreqHz int64 + TuneMode string } // Server is a single inbound UDP listener. @@ -248,9 +265,25 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) { // "CALL F4XYZ" (text prefix) // "F4XYZ" (DXHunter-style tags) // "F4XYZ" (proper XML) + // "VP5G10.136FT8" (DXHunter with CAT tune) // Strip every angle-bracket tag, normalise whitespace, take the // last non-empty token. Upper-case for downstream consistency. text := string(pkt) + // Optional tune request: MHz and str ride along with the + // callsign so a DXHunter spot click can drive OpsLog's CAT. Extract + // (and cut) them BEFORE the generic tag-stripping below, which would + // otherwise leave their values as stray tokens and corrupt the + // "last token = callsign" heuristic. + if m := remoteFreqRe.FindStringSubmatch(text); m != nil { + if mhz, err := strconv.ParseFloat(m[1], 64); err == nil && mhz > 0 { + ev.TuneFreqHz = int64(mhz * 1e6) + } + text = strings.Replace(text, m[0], " ", 1) + } + if m := remoteModeRe.FindStringSubmatch(text); m != nil { + ev.TuneMode = strings.ToUpper(m[1]) + text = strings.Replace(text, m[0], " ", 1) + } // Drop every <...> tag (open or close) — works for both // ... and .... for { @@ -286,8 +319,9 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) { return } // Empty events are useless; skip — EXCEPT a clear signal, which is meant to be - // empty (the DX Call was cleared in the digital app). - if ev.DXCall == "" && ev.LoggedADIF == "" && ev.DecodeCall == "" && !ev.ClearCall { + // empty (the DX Call was cleared in the digital app), and a tune-only + // request (freq with no callsign). + if ev.DXCall == "" && ev.LoggedADIF == "" && ev.DecodeCall == "" && !ev.ClearCall && ev.TuneFreqHz == 0 { return } select {