diff --git a/app.go b/app.go index 72cf78b..c3bd037 100644 --- a/app.go +++ b/app.go @@ -89,6 +89,8 @@ const ( keyCATFlexHost = "cat.flex.host" // FlexRadio IP (native backend) keyCATFlexPort = "cat.flex.port" // FlexRadio TCP port (default 4992) keyCATFlexSpots = "cat.flex.spots" // push cluster spots to the panadapter + keyCATFlexDecodeSpots = "cat.flex.decode_spots" // push WSJT-X decodes (heard stations) to the panadapter + keyCATFlexDecodeSecs = "cat.flex.decode_secs" // decode spot display duration (seconds) before auto-removal keyCATPollMs = "cat.poll_ms" keyCATDelayMs = "cat.delay_ms" // pause between commands keyCATDigitalDefault = "cat.digital_default" // mode to use when CAT reports DATA @@ -277,6 +279,8 @@ type CATSettings struct { 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 + FlexDecodeSpots bool `json:"flex_decode_spots"` // push WSJT-X decodes (heard stations) to the panadapter + FlexDecodeSecs int `json:"flex_decode_secs"` // decode spot display duration (s) before removal (default 120) 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) @@ -453,6 +457,8 @@ type App struct { dbBackend string // "sqlite" | "mysql" — the logbook backend actually opened at startup dbBackendErr string // non-empty when a configured MySQL backend failed and we fell back to SQLite catFlexSpots bool // push cluster spots to the FlexRadio panadapter + catFlexDecodeSpots bool // push WSJT-X decodes (heard stations) to the panadapter + catFlexDecodeSecs int // decode spot display duration (seconds) liveActMu sync.Mutex // guards the entry-strip activity reported for live status liveFreqHz int64 // last freq/band/mode the UI reported (fallback when CAT is off) liveBand string @@ -4460,7 +4466,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, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATIcomNetHost, keyCATIcomNetUser, keyCATIcomNetPass, keyCATIcomNetAudio, keyCATTCIHost, keyCATTCIPort, keyCATTCISpots, keyCATPollMs, keyCATDelayMs, keyCATDigitalDefault) + m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATFlexHost, keyCATFlexPort, keyCATFlexSpots, keyCATFlexDecodeSpots, keyCATFlexDecodeSecs, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATIcomNetHost, keyCATIcomNetUser, keyCATIcomNetPass, keyCATIcomNetAudio, keyCATTCIHost, keyCATTCIPort, keyCATTCISpots, keyCATPollMs, keyCATDelayMs, keyCATDigitalDefault) if err != nil { return CATSettings{}, err } @@ -4471,6 +4477,8 @@ func (a *App) GetCATSettings() (CATSettings, error) { FlexHost: m[keyCATFlexHost], FlexPort: 4992, FlexSpots: m[keyCATFlexSpots] == "1", + FlexDecodeSpots: m[keyCATFlexDecodeSpots] == "1", + FlexDecodeSecs: 120, IcomPort: m[keyCATIcomPort], IcomBaud: 115200, IcomAddr: 0x98, // IC-7610 default @@ -4488,6 +4496,9 @@ func (a *App) GetCATSettings() (CATSettings, error) { if n, _ := strconv.Atoi(m[keyCATFlexPort]); n > 0 && n <= 65535 { out.FlexPort = n } + if n, _ := strconv.Atoi(m[keyCATFlexDecodeSecs]); n >= 10 && n <= 3600 { + out.FlexDecodeSecs = n + } if n, _ := strconv.Atoi(m[keyCATTCIPort]); n > 0 && n <= 65535 { out.TCIPort = n } @@ -4549,6 +4560,13 @@ func (a *App) SaveCATSettings(s CATSettings) error { if s.FlexSpots { flexSpots = "1" } + flexDecodeSpots := "0" + if s.FlexDecodeSpots { + flexDecodeSpots = "1" + } + if s.FlexDecodeSecs < 10 || s.FlexDecodeSecs > 3600 { + s.FlexDecodeSecs = 120 + } tciSpots := "0" if s.TCISpots { tciSpots = "1" @@ -4567,6 +4585,8 @@ func (a *App) SaveCATSettings(s CATSettings) error { keyCATFlexHost: strings.TrimSpace(s.FlexHost), keyCATFlexPort: strconv.Itoa(s.FlexPort), keyCATFlexSpots: flexSpots, + keyCATFlexDecodeSpots: flexDecodeSpots, + keyCATFlexDecodeSecs: strconv.Itoa(s.FlexDecodeSecs), keyCATIcomPort: strings.TrimSpace(s.IcomPort), keyCATIcomBaud: strconv.Itoa(s.IcomBaud), keyCATIcomAddr: strconv.Itoa(s.IcomAddr), @@ -7855,6 +7875,24 @@ func (a *App) consumeUDPEvents() { continue } switch { + case ev.DecodeCall != "": + // A WSJT-X decode (heard station). Render it on the FlexRadio + // panadapter when the option is on; green + SNR comment, auto-expiring + // after the configured duration. De-duped per call in the Flex backend. + if a.catFlexDecodeSpots && a.cat != nil { + secs := a.catFlexDecodeSecs + if secs <= 0 { + secs = 120 + } + a.cat.SendSpot(cat.SpotInfo{ + FreqHz: ev.DecodeFreqHz, + Callsign: ev.DecodeCall, + Mode: ev.Mode, + Color: "#FF34C759", // green — distinct from cluster orange + Comment: fmt.Sprintf("%s %+ddB", ev.Mode, ev.DecodeSNR), + LifetimeSec: secs, + }) + } case ev.LoggedADIF != "": applog.Printf("udp: emit udp:logged_qso (%d bytes ADIF)\n", len(ev.LoggedADIF)) wruntime.EventsEmit(a.ctx, "udp:logged_qso", map[string]any{ @@ -8966,6 +9004,8 @@ func (a *App) reloadCAT() { a.cat.SetPollInterval(time.Duration(s.PollMs) * time.Millisecond) a.cat.SetCommandDelay(time.Duration(s.DelayMs) * time.Millisecond) a.catFlexSpots = s.Enabled && ((s.Backend == "flex" && s.FlexSpots) || (s.Backend == "tci" && s.TCISpots)) + a.catFlexDecodeSpots = s.Enabled && s.Backend == "flex" && s.FlexDecodeSpots + a.catFlexDecodeSecs = s.FlexDecodeSecs if !s.Enabled { a.cat.Stop() return diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index f8dace8..c121dc8 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -815,7 +815,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan const [bandDraft, setBandDraft] = useState(''); const [modeDraft, setModeDraft] = useState(''); const [catCfg, setCatCfg] = useState({ - enabled: false, backend: 'omnirig', omnirig_rig: 1, flex_host: '', flex_port: 4992, flex_spots: false, + enabled: false, backend: 'omnirig', omnirig_rig: 1, flex_host: '', flex_port: 4992, flex_spots: false, flex_decode_spots: false, flex_decode_secs: 120, icom_port: '', icom_baud: 115200, icom_addr: 0x98, icom_net_host: '', icom_net_user: '', icom_net_pass: '', icom_net_audio: false, tci_host: '', tci_port: 40001, tci_spots: false, poll_ms: 250, delay_ms: 0, digital_default: 'FT8', @@ -2055,6 +2055,19 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan setCatCfg((s) => ({ ...s, flex_spots: !!c }))} /> {t('cat.flexSpots')} {t('cat.flexSpotsHint')} + + {catCfg.flex_decode_spots && ( +
+ + setCatCfg((s) => ({ ...s, flex_decode_secs: parseInt(e.target.value) || 120 }))} /> + {t('cat.flexDecodeSecsHint')} +
+ )} )} {catCfg.backend === 'icom' && ( diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index f149a82..75deeba 100644 --- a/frontend/src/lib/i18n.tsx +++ b/frontend/src/lib/i18n.tsx @@ -139,7 +139,7 @@ const en: Dict = { 'cat.icomNetHint': "Connects to the rig's built-in LAN server directly — no RS-BA1 or Remote Utility needed (close them first). Use the Network User1 ID/Password set in the rig's Network menu. A rig in standby is powered on automatically.", 'cat.icomNetAudio': 'Stream RX audio over the network (experimental)', 'cat.icomNetAudioHint': 'Play the rig’s received audio through your Listening device (Settings → Audio) over the 50003 stream. Experimental — the audio framing is pending on-rig verification; leave off if control misbehaves.', - 'cat.omnirigRig': 'OmniRig rig slot', 'cat.flexIp': 'FlexRadio IP', 'cat.port': 'Port', 'cat.flexSpots': 'Show cluster spots on the panadapter', 'cat.flexSpotsHint': "(spots from OpsLog's DX cluster appear on the radio, auto-expire after 30 min)", + 'cat.omnirigRig': 'OmniRig rig slot', 'cat.flexIp': 'FlexRadio IP', 'cat.port': 'Port', 'cat.flexSpots': 'Show cluster spots on the panadapter', 'cat.flexSpotsHint': "(spots from OpsLog's DX cluster appear on the radio, auto-expire after 30 min)", 'cat.flexDecodeSpots': 'Show WSJT-X decodes on the panadapter', 'cat.flexDecodeSpotsHint': '(heard FT8/FT4 stations from your WSJT-X/JTDX UDP feed, one spot per call)', 'cat.flexDecodeSecs': 'Display for', 'cat.flexDecodeSecsHint': 'seconds before a station is removed', 'cat.icomPort': 'Icom CI-V port', 'cat.selectCom': 'Select COM port', 'cat.noPorts': 'No ports found', 'cat.baud': 'Baud rate', 'cat.icomModel': 'Rig model', 'cat.icomModelOther': 'Other (custom address)', 'cat.civAddr': 'CI-V address (hex)', 'cat.civHint': 'Pick your model to set the CI-V address automatically (or choose "Other" and type it). Set "CI-V USB Echo Back" OFF and CI-V baud to match on the rig.', 'cat.tciHost': 'TCI host', 'cat.tciHint': 'Enable the TCI server in ExpertSDR2/EESDR (Options → TCI). Default port 40001. Use 127.0.0.1 when OpsLog runs on the same PC.', 'cat.tciSpots': 'Show cluster spots on the panorama', 'cat.tciSpotsHint': "(spots from OpsLog's DX cluster appear on the SDR panadapter)", 'cat.pollMs': 'Poll interval (ms)', 'cat.delayMs': 'CAT delay (ms)', 'cat.digitalDefault': 'Default digital mode (when rig reports DIG)', 'cat.modeBeforeFreq': 'Set mode before frequency', 'cat.modeBeforeFreqHint': '(older rigs that drop the mode after a band change)', @@ -338,7 +338,7 @@ const fr: Dict = { 'cat.icomNetHint': "Se connecte directement au serveur LAN intégré du poste — sans RS-BA1 ni Remote Utility (ferme-les d'abord). Utilise l'ID/mot de passe Network User1 configurés dans le menu Network du poste. Un poste en veille est allumé automatiquement.", 'cat.icomNetAudio': 'Diffuser l’audio RX par le réseau (expérimental)', 'cat.icomNetAudioHint': 'Écoute l’audio reçu du poste sur ton périphérique d’écoute (Réglages → Audio) via le flux 50003. Expérimental — le format audio reste à vérifier sur le poste ; laisse désactivé si le contrôle se comporte mal.', - 'cat.omnirigRig': 'Slot OmniRig', 'cat.flexIp': 'IP FlexRadio', 'cat.port': 'Port', 'cat.flexSpots': 'Afficher les spots cluster sur le panadapter', 'cat.flexSpotsHint': "(les spots du cluster DX d'OpsLog apparaissent sur la radio, expirent après 30 min)", + 'cat.omnirigRig': 'Slot OmniRig', 'cat.flexIp': 'IP FlexRadio', 'cat.port': 'Port', 'cat.flexSpots': 'Afficher les spots cluster sur le panadapter', 'cat.flexSpotsHint': "(les spots du cluster DX d'OpsLog apparaissent sur la radio, expirent après 30 min)", 'cat.flexDecodeSpots': 'Afficher les décodes WSJT-X sur le panadapter', 'cat.flexDecodeSpotsHint': '(stations FT8/FT4 entendues via ton flux UDP WSJT-X/JTDX, un spot par station)', 'cat.flexDecodeSecs': 'Affichage pendant', 'cat.flexDecodeSecsHint': 'secondes avant retrait d\'une station', 'cat.icomPort': 'Port CI-V Icom', 'cat.selectCom': 'Choisir un port COM', 'cat.noPorts': 'Aucun port trouvé', 'cat.baud': 'Débit (baud)', 'cat.icomModel': 'Modèle de poste', 'cat.icomModelOther': 'Autre (adresse perso)', 'cat.civAddr': 'Adresse CI-V (hex)', 'cat.civHint': 'Choisis ton modèle pour fixer l’adresse CI-V automatiquement (ou « Autre » et saisis-la). Mets « CI-V USB Echo Back » sur OFF et fais correspondre le débit CI-V sur le poste.', 'cat.tciHost': 'Hôte TCI', 'cat.tciHint': 'Active le serveur TCI dans ExpertSDR2/EESDR (Options → TCI). Port par défaut 40001. Utilise 127.0.0.1 si OpsLog tourne sur le même PC.', 'cat.tciSpots': 'Afficher les spots cluster sur le panorama', 'cat.tciSpotsHint': "(les spots du cluster DX d'OpsLog apparaissent sur le panadapter SDR)", 'cat.pollMs': 'Intervalle de poll (ms)', 'cat.delayMs': 'Délai CAT (ms)', 'cat.digitalDefault': 'Mode numérique par défaut (quand le poste indique DIG)', 'cat.modeBeforeFreq': 'Régler le mode avant la fréquence', 'cat.modeBeforeFreqHint': '(anciens postes qui perdent le mode après un changement de bande)', diff --git a/frontend/src/version.ts b/frontend/src/version.ts index bd17e89..955f6c3 100644 --- a/frontend/src/version.ts +++ b/frontend/src/version.ts @@ -1,6 +1,6 @@ // Single source of truth for the app version shown in the UI (header + About). // Bump this on a release (the release script updates it alongside telemetry.go). -export const APP_VERSION = '0.19.3'; +export const APP_VERSION = '0.19.4'; // Author / credits, shown in Help -> About. export const APP_AUTHOR = 'F4BPO'; diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index f9c5527..e53f553 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -1378,6 +1378,8 @@ export namespace main { flex_host: string; flex_port: number; flex_spots: boolean; + flex_decode_spots: boolean; + flex_decode_secs: number; icom_port: string; icom_baud: number; icom_addr: number; @@ -1404,6 +1406,8 @@ export namespace main { this.flex_host = source["flex_host"]; this.flex_port = source["flex_port"]; this.flex_spots = source["flex_spots"]; + this.flex_decode_spots = source["flex_decode_spots"]; + this.flex_decode_secs = source["flex_decode_secs"]; this.icom_port = source["icom_port"]; this.icom_baud = source["icom_baud"]; this.icom_addr = source["icom_addr"]; diff --git a/internal/cat/cat.go b/internal/cat/cat.go index 976aa43..9aa8eaa 100644 --- a/internal/cat/cat.go +++ b/internal/cat/cat.go @@ -205,11 +205,12 @@ func (m *Manager) SetPTT(on bool) error { // the backend picks a default when it's empty. (Status-based colouring can be // driven later by setting Color per spot.) type SpotInfo struct { - FreqHz int64 - Callsign string - Mode string - Color string - Comment string + FreqHz int64 + Callsign string + Mode string + Color string + Comment string + LifetimeSec int // panadapter display seconds before auto-removal (0 = backend default) } // Spotter is an OPTIONAL backend capability: show cluster spots on the radio diff --git a/internal/cat/flex.go b/internal/cat/flex.go index 5bb6352..b4c898a 100644 --- a/internal/cat/flex.go +++ b/internal/cat/flex.go @@ -60,6 +60,7 @@ type Flex struct { pendingSpot map[int]string // seq → callsign, awaiting the spot index in the R response pendingSplit map[int]bool // seq → awaiting the new TX slice's index (split create) spotCall map[int]string // spot index → callsign (to fill the call on a panadapter click) + spotByCall map[string]int // callsign → live spot index, so re-spotting a call replaces its old spot (WSJT decodes re-fire every cycle) sentCmds map[int]string // seq → command text, so an R error names the command // OnSpotClick is called (off the reader goroutine's hot path) when the user @@ -157,7 +158,7 @@ func NewFlex(host string, port int, spotsEnabled bool) *Flex { return &Flex{ host: strings.TrimSpace(host), port: port, slices: map[int]*flexSlice{}, spotsEnabled: spotsEnabled, - spotIdx: map[int]bool{}, pendingSpot: map[int]string{}, spotCall: map[int]string{}, pendingSplit: map[int]bool{}, + spotIdx: map[int]bool{}, pendingSpot: map[int]string{}, spotCall: map[int]string{}, spotByCall: map[string]int{}, pendingSplit: map[int]bool{}, meterMeta: map[int]meterInfo{}, meterVal: map[int]float64{}, meterSub: map[int]bool{}, sentCmds: map[int]string{}, txSetAt: map[string]time.Time{}, } @@ -352,6 +353,7 @@ func (f *Flex) reader(conn net.Conn) { if idx, e := strconv.Atoi(strings.TrimSpace(parts[2])); e == nil { f.spotCall[idx] = call f.spotIdx[idx] = true + f.spotByCall[strings.ToUpper(call)] = idx } } // A successful "slice create" for split returns the new slice's index; @@ -1071,8 +1073,23 @@ func (f *Flex) SendSpot(s SpotInfo) error { if color == "" { color = "#FFFFA500" // opaque orange default } - cmd := fmt.Sprintf("spot add rx_freq=%.6f callsign=%s color=%s source=OpsLog lifetime_seconds=1800 trigger_action=Tune timestamp=%d", - float64(s.FreqHz)/1e6, call, color, time.Now().Unix()) + life := s.LifetimeSec + if life <= 0 { + life = 1800 // default 30 min (cluster spots) + } + // De-dupe by callsign: WSJT decodes re-fire every cycle, so a station already + // spotted gets its previous spot removed first — one live spot per call, + // refreshed, instead of a pile that all expire independently. + f.mu.Lock() + if old, ok := f.spotByCall[strings.ToUpper(s.Callsign)]; ok { + delete(f.spotByCall, strings.ToUpper(s.Callsign)) + delete(f.spotCall, old) + delete(f.spotIdx, old) + f.send(fmt.Sprintf("spot remove %d", old)) + } + f.mu.Unlock() + cmd := fmt.Sprintf("spot add rx_freq=%.6f callsign=%s color=%s source=OpsLog lifetime_seconds=%d trigger_action=Tune timestamp=%d", + float64(s.FreqHz)/1e6, call, color, life, time.Now().Unix()) if m := flexEncode(s.Mode); m != "" { cmd += " mode=" + m } @@ -1118,6 +1135,7 @@ func (f *Flex) ClearSpots() error { f.mu.Lock() f.spotIdx = map[int]bool{} f.spotCall = map[int]string{} + f.spotByCall = map[string]int{} connected := f.conn != nil f.mu.Unlock() if !connected { diff --git a/internal/integrations/udp/server.go b/internal/integrations/udp/server.go index e6a3167..5fbfe91 100644 --- a/internal/integrations/udp/server.go +++ b/internal/integrations/udp/server.go @@ -43,9 +43,15 @@ type Event struct { DXCall string // ServiceWSJT (Status) or ServiceRemoteCall DXGrid string // ServiceWSJT (Status) - Mode string // ServiceWSJT (Status) + Mode string // ServiceWSJT (Status/Decode) FreqHz int64 // ServiceWSJT (Status) LoggedADIF string // ServiceWSJT (LoggedADIF), ServiceADIF or ServiceN1MM + + // A WSJT-X Decode (heard station) to render on the panadapter. + DecodeCall string // transmitting (DE) callsign + DecodeFreqHz int64 // RF frequency (dial + audio offset) + DecodeSNR int // reported SNR (dB) + DecodeCQ bool // the decode was a CQ } // Server is a single inbound UDP listener. @@ -57,6 +63,8 @@ type Server struct { done chan struct{} stopped bool mu sync.Mutex + + lastDialHz int64 // WSJT: dial freq from the last Status, added to Decode offsets } func newServer(cfg Config, out chan<- Event) *Server { @@ -175,9 +183,29 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) { return } if !ok { - applog.Printf("udp: [%s] WSJT msg type ignored\n", s.cfg.Name) return } + // Status carries the current dial frequency; remember it so Decode audio + // offsets can be turned into RF frequencies for the panadapter. + if w.FreqHz > 0 && !w.IsDecode { + s.mu.Lock() + s.lastDialHz = w.FreqHz + s.mu.Unlock() + } + if w.IsDecode { + s.mu.Lock() + dial := s.lastDialHz + s.mu.Unlock() + if dial <= 0 { + return // no dial freq yet (Status not seen) → can't place the spot + } + ev.DecodeCall = w.DecodeCall + ev.DecodeFreqHz = dial + w.DeltaFreqHz + ev.DecodeSNR = w.SNR + ev.DecodeCQ = w.IsCQ + ev.Mode = w.Mode + break + } applog.Printf("udp: [%s] WSJT decoded: prog=%q dx_call=%q grid=%q mode=%q freq=%d adif_len=%d\n", s.cfg.Name, w.ProgramID, w.DXCall, w.DXGrid, w.Mode, w.FreqHz, len(w.LoggedADIF)) ev.DXCall = w.DXCall @@ -241,7 +269,7 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) { return } // Empty events are useless; skip. - if ev.DXCall == "" && ev.LoggedADIF == "" { + if ev.DXCall == "" && ev.LoggedADIF == "" && ev.DecodeCall == "" { return } select { diff --git a/internal/integrations/udp/wsjt.go b/internal/integrations/udp/wsjt.go index 09f9acb..6bce9ee 100644 --- a/internal/integrations/udp/wsjt.go +++ b/internal/integrations/udp/wsjt.go @@ -37,18 +37,27 @@ const ( ) // WSJTEvent is the parsed, typed result of decoding a single packet. -// One of (DXCall, LoggedADIF) is non-empty depending on the message. +// One of (DXCall, LoggedADIF, DecodeCall) is non-empty depending on the message. type WSJTEvent struct { - DXCall string // current "DX Call" field in the WSJT app - DXGrid string // optional grid for that call + DXCall string // current "DX Call" field in the WSJT app (Status) + DXGrid string // optional grid for that call (Status) Mode string // FT8 / FT4 / … - FreqHz int64 // current dial freq when available + FreqHz int64 // current dial freq when available (Status) LoggedADIF string // full ADIF text when message is LoggedADIF ProgramID string // "WSJT-X" / "JTDX" / "MSHV" — for diagnostics / dedup + + // Decode (type 2): the transmitting station heard on the band. FreqHz is NOT + // set here (Decode carries only the audio offset); the caller adds the last + // known dial frequency (from Status) to DeltaFreqHz to get the RF frequency. + IsDecode bool + DecodeCall string // the sender (DE) callsign extracted from the message text + DeltaFreqHz int64 // audio offset within the passband (Hz) + SNR int // reported signal-to-noise (dB) + IsCQ bool // the decode was a CQ call } // ParseWSJT decodes one UDP packet. Returns ok=false for messages we -// don't care about (heartbeat, decode lines, clears, etc.). +// don't care about (heartbeat, clears, etc.). func ParseWSJT(pkt []byte) (WSJTEvent, bool, error) { if len(pkt) < 12 { return WSJTEvent{}, false, fmt.Errorf("packet too short") @@ -143,6 +152,56 @@ func ParseWSJT(pkt []byte) (WSJTEvent, bool, error) { ev.DXGrid = strings.ToUpper(strings.TrimSpace(dxGrid)) return ev, true, nil + case wsjtMsgDecode: + // Decode payload (v2): + // bool is_new + // quint32 time (ms since midnight) + // qint32 snr + // double delta_time (seconds) + // quint32 delta_frequency (Hz, audio offset in the passband) + // QUtf8 mode + // QUtf8 message (the decoded text, e.g. "CQ K1ABC FN42") + // bool low_confidence + // bool off_air + var b uint8 + if err := binary.Read(r, binary.BigEndian, &b); err != nil { // is_new + return WSJTEvent{}, false, err + } + var t32, df uint32 + var snr int32 + if err := binary.Read(r, binary.BigEndian, &t32); err != nil { // time + return WSJTEvent{}, false, err + } + if err := binary.Read(r, binary.BigEndian, &snr); err != nil { + return WSJTEvent{}, false, err + } + var dt float64 + if err := binary.Read(r, binary.BigEndian, &dt); err != nil { // delta_time + return WSJTEvent{}, false, err + } + if err := binary.Read(r, binary.BigEndian, &df); err != nil { // delta_frequency + return WSJTEvent{}, false, err + } + mode, err := readQString(r) + if err != nil { + return WSJTEvent{}, false, err + } + msg, err := readQString(r) + if err != nil { + return WSJTEvent{}, false, err + } + call, isCQ := wsjtSender(msg) + if call == "" { + return WSJTEvent{}, false, nil // free-text / telemetry / unparseable → ignore + } + ev.IsDecode = true + ev.DecodeCall = call + ev.IsCQ = isCQ + ev.DeltaFreqHz = int64(df) + ev.SNR = int(snr) + ev.Mode = strings.ToUpper(strings.TrimSpace(mode)) + return ev, true, nil + case wsjtMsgLoggedADIF: // Payload: a single QString containing the ADIF record. adif, err := readQString(r) @@ -155,6 +214,64 @@ func ParseWSJT(pkt []byte) (WSJTEvent, bool, error) { return WSJTEvent{}, false, nil } +// wsjtSender extracts the transmitting (DE) callsign from a WSJT-X message and +// whether it was a CQ. Grammar: +// +// CQ [modifier] [grid] → de_call, isCQ=true +// [report|…] → de_call, isCQ=false +// +// Returns "" for free-text / telemetry / hashed-call messages we can't resolve. +func wsjtSender(message string) (call string, isCQ bool) { + f := strings.Fields(strings.ToUpper(strings.TrimSpace(message))) + if len(f) == 0 { + return "", false + } + if f[0] == "CQ" { + // Skip an optional modifier after CQ (DX / a region like NA / a zone like + // 020) — it never looks like a callsign (no letter+digit mix). + idx := 1 + if len(f) > 2 && !looksLikeCall(f[1]) { + idx = 2 + } + if idx < len(f) && looksLikeCall(f[idx]) { + return f[idx], true + } + return "", true + } + // Standard exchange: the DE (sender) call is the second token. + if len(f) >= 2 && looksLikeCall(f[1]) { + return f[1], false + } + return "", false +} + +// looksLikeCall is a loose callsign test: 3–12 chars of A–Z/0–9//, with at least +// one letter AND one digit. Rejects the fixed exchange tokens (RR73/RRR/73) that +// would otherwise pass. +func looksLikeCall(s string) bool { + switch s { + case "RR73", "RRR", "73": + return false + } + if len(s) < 3 || len(s) > 12 { + return false + } + hasL, hasD := false, false + for i := 0; i < len(s); i++ { + c := s[i] + switch { + case c >= 'A' && c <= 'Z': + hasL = true + case c >= '0' && c <= '9': + hasD = true + case c == '/': + default: + return false + } + } + return hasL && hasD +} + // readQString reads a Qt QString as written by QDataStream: an int32 byte // length (or -1 for null) followed by the UTF-8 bytes. func readQString(r *bytes.Reader) (string, error) { diff --git a/internal/integrations/udp/wsjt_sender_test.go b/internal/integrations/udp/wsjt_sender_test.go new file mode 100644 index 0000000..bb9d4dd --- /dev/null +++ b/internal/integrations/udp/wsjt_sender_test.go @@ -0,0 +1,32 @@ +package udp + +import "testing" + +func TestWSJTSender(t *testing.T) { + cases := []struct { + msg string + wantCall string + wantCQ bool + }{ + {"CQ K1ABC FN42", "K1ABC", true}, + {"CQ DX W2XYZ EM12", "W2XYZ", true}, // modifier "DX" skipped + {"CQ NA VE3ABC FN03", "VE3ABC", true}, // region modifier skipped + {"CQ 020 JA1XYZ PM95", "JA1XYZ", true},// zone modifier skipped + {"W2XYZ K1ABC -10", "K1ABC", false}, // exchange → sender is 2nd call + {"W2XYZ K1ABC R-10", "K1ABC", false}, + {"W2XYZ K1ABC RR73", "K1ABC", false}, + {"F4BPO K1ABC/P 73", "K1ABC/P", false},// portable call kept + {"CQ F/DL1ABC JO31", "F/DL1ABC", true},// compound prefix + // Non-callsign / free text → no sender. + {"TNX 73 GL", "", false}, + {"K1ABC RR73", "", false}, // only one call + a token → 2nd token not a call + {"", "", false}, + {"CQ CQ CQ", "", true}, // CQ but no resolvable call + } + for _, c := range cases { + call, cq := wsjtSender(c.msg) + if call != c.wantCall || cq != c.wantCQ { + t.Errorf("wsjtSender(%q) = (%q,%v), want (%q,%v)", c.msg, call, cq, c.wantCall, c.wantCQ) + } + } +} diff --git a/telemetry.go b/telemetry.go index 73f62ed..42ddd81 100644 --- a/telemetry.go +++ b/telemetry.go @@ -21,7 +21,7 @@ import ( const ( // appVersion is stamped on every heartbeat (and could feed the About box). - appVersion = "0.19.3" + appVersion = "0.19.4" // posthogHost is the PostHog ingestion endpoint. EU cloud by default; change // to https://us.i.posthog.com for a US project.