feat: remote_call UDP accepts <FREQ>/<MODE> from DXHunter and tunes the active CAT backend

This commit is contained in:
2026-07-29 17:49:29 +02:00
parent ccceab9d02
commit a036120871
2 changed files with 54 additions and 5 deletions
+16 -1
View File
@@ -10844,9 +10844,24 @@ func (a *App) consumeUDPEvents() {
"service": string(ev.Service),
"source": ev.Source,
})
case ev.DXCall != "" && ev.Service == udp.ServiceRemoteCall:
case (ev.DXCall != "" || ev.TuneFreqHz > 0) && ev.Service == udp.ServiceRemoteCall:
// CAT tune riding along with the callsign (DXHunter spot click:
// "<CALLSIGN>VP5G<FREQ>10.136<MODE>FT8"). 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{
+36 -2
View File
@@ -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: "<FREQ>10.136" (MHz) and "<MODE>FT8". Both accept
// an optional closing tag for proper-XML senders.
var (
remoteFreqRe = regexp.MustCompile(`(?i)<FREQ>\s*([0-9]+(?:\.[0-9]+)?)`)
remoteModeRe = regexp.MustCompile(`(?i)<MODE>\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
// "<CALLSIGN>VP5G<FREQ>10.136<MODE>FT8"). 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)
// "<CALLSIGN>F4XYZ<CALLSIGN>" (DXHunter-style tags)
// "<CALLSIGN>F4XYZ</CALLSIGN>" (proper XML)
// "<CALLSIGN>VP5G<FREQ>10.136<MODE>FT8" (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: <FREQ>MHz and <MODE>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
// <CALLSIGN>...<CALLSIGN> and <CALLSIGN>...</CALLSIGN>.
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 {