diff --git a/app.go b/app.go index b306f5f..7ba3353 100644 --- a/app.go +++ b/app.go @@ -12668,6 +12668,20 @@ func (a *App) consumeUDPEvents() { if a.ctx == nil { continue } + // The operator's own transmit message, from Status. Emitted before the + // switch because a Status can carry BOTH a DX call and a transmit + // message, and the switch below takes only one branch. + if ev.TxMessage != "" { + wruntime.EventsEmit(a.ctx, "udp:tx_message", map[string]any{ + "msg": ev.TxMessage, + "transmitting": ev.Transmitting, + "de_call": ev.DECall, + "mode": ev.Mode, + "freq_hz": ev.FreqHz, + "band": bandForHz(ev.FreqHz), + "at": time.Now().UTC().Format(time.RFC3339), + }) + } switch { case ev.DecodeCall != "": // Remember the grid before anything else: a CQ is the one message that @@ -12675,6 +12689,30 @@ func (a *App) consumeUDPEvents() { if ev.DecodeGrid != "" { a.rememberDecodeGrid(ev.DecodeCall, ev.DecodeGrid, gridcache.SourceDecode) } + // Hand every decode to the UI. Unconditional, and BEFORE the + // panadapter block below, which skips a call it spotted moments ago: + // that de-duplication exists to spare the radio, and applying it here + // would silently drop most of a period from the panel that is meant to + // show the period whole. + at := ev.DecodeAt + if at.IsZero() { + at = time.Now().UTC() // sender gave no timestamp — arrival will do + } + wruntime.EventsEmit(a.ctx, "udp:decode", map[string]any{ + "call": ev.DecodeCall, + "grid": ev.DecodeGrid, + "snr": ev.DecodeSNR, + "freq_hz": ev.DecodeFreqHz, + "dial_hz": ev.DecodeDial, + "band": bandForHz(ev.DecodeFreqHz), + "mode": ev.Mode, + "msg": ev.DecodeMsg, + "cq": ev.DecodeCQ, + "at": at.Format(time.RFC3339), + "tr_period": ev.DecodeTRPeriod, + "off_air": ev.DecodeOffAir, + "source": ev.Source, + }) // 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. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index abac865..64003a4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -108,6 +108,7 @@ import { DetailsPanel, type DetailsState } from '@/components/DetailsPanel'; import { SendSpotModal, type RecentSpotQSO } from '@/components/SendSpotModal'; import { WinkeyerPanel, type WKStatus, type WKMacro } from '@/components/WinkeyerPanel'; import { RotorCompass } from '@/components/RotorCompass'; +import { DecodesPanel, type Decode as DecodeRow, type TxMsg as TxMsgRow } from '@/components/DecodesPanel'; import { subscribeRotorHeading, pokeRotorHeading } from '@/lib/rotorHeading'; import { writeUiPref } from '@/lib/uiPref'; import { formatDateTimeUTC } from '@/lib/dateFormat'; @@ -1031,6 +1032,20 @@ export default function App() { setStationTabOpen(false); setActiveTab((t) => (t === 'station' ? 'recent' : t)); } + // FTx decodes — same closable-tab pattern, but its open state is REMEMBERED: + // unlike Statistics, which is consulted and closed, this one is a panel an + // operator running digital modes leaves open for the session. + const [decodesTabOpen, setDecodesTabOpen] = useState(() => localStorage.getItem('opslog.decodesTab') === '1'); + function openDecodesTab() { + setDecodesTabOpen(true); + writeUiPref('opslog.decodesTab', '1'); + setActiveTab('decodes'); + } + function closeDecodesTab() { + setDecodesTabOpen(false); + writeUiPref('opslog.decodesTab', '0'); + setActiveTab((t) => (t === 'decodes' ? 'recent' : t)); + } // Recent QSOs row cap, persisted. With AG Grid's virtual scroller // huge logs render OK once loaded, but a 25k+ logbook still takes a // couple of seconds to round-trip from SQLite at launch. Defaulting @@ -2018,6 +2033,20 @@ export default function App() { // settings dialog closes, which is the only place it changes. const [rowColors, setRowColors] = useState(null); useEffect(() => { GetRowColors().then(setRowColors).catch(() => {}); }, [showSettings]); + // ── FTx decodes from the inbound UDP feed ────────────────────────── + // + // Held in the frontend, like the cluster spots: they are a live view, not + // data, and nothing outside this panel reads them. Pruned to a rolling + // half hour — long enough to hold a whole opening, short enough that a night + // of FT8 on 20 m does not turn the list into something no filter can rescue. + const DECODE_KEEP_MS = 30 * 60 * 1000; + const [decodes, setDecodes] = useState([]); + const [txMsgs, setTxMsgs] = useState([]); + // Staged like the cluster's, so a period arriving as one burst of fifty + // packets costs one status lookup and one render, not fifty of each. + const pendingDecodesRef = useRef([]); + const pendingDecodeTimer = useRef(undefined); + // Rotor quick-turn buttons (Settings → Rotator). Same reload trigger as the // row colours: the settings dialog is the only place they change. const [rotorPresets, setRotorPresets] = useState<{ label: string; azimuth: number }[]>([]); @@ -3010,6 +3039,83 @@ export default function App() { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + // ── FTx decodes ──────────────────────────────────────────────────── + useEffect(() => { + // Resolve the new-entity / new-slot flags into the SAME map the cluster + // fills. One cache, one verdict: a call must not be "new band" in the + // decodes panel and plain worked in the cluster list two seconds later. + const flushDecodes = async () => { + pendingDecodeTimer.current = undefined; + const batch = pendingDecodesRef.current; + pendingDecodesRef.current = []; + if (batch.length === 0) return; + try { + const known = spotStatusRef.current; + const seen = new Set(); + const unknown: { call: string; band: string; mode: string; pota_ref: string; spotter: string }[] = []; + for (const d of batch) { + const k = `${d.call}|${d.band ?? ''}|${(d.mode ?? '').toUpperCase()}`; + if (seen.has(k) || known[k]) continue; + seen.add(k); + unknown.push({ call: d.call, band: d.band ?? '', mode: (d.mode ?? '').toUpperCase(), pota_ref: '', spotter: '' }); + } + if (unknown.length > 0) { + const res = await ClusterSpotStatuses(unknown as any); + setSpotStatus((prev) => { + const next = { ...prev }; + for (const r of res) { + const k = `${r.call}|${r.band ?? ''}|${(r.mode ?? '').toUpperCase()}`; + next[k] = { + status: r.status ?? '', + country: r.country, + continent: (r as any).continent, + worked_call: !!(r as any).worked_call, + worked_slot: !!(r as any).worked_slot, + new_county: !!(r as any).new_county, lotw: !!(r as any).lotw, + grid: (r as any).grid, new_grid: !!(r as any).new_grid, + county: (r as any).county, state: (r as any).state, + new_pota: !!(r as any).new_pota, + new_pfx: !!(r as any).new_pfx, pfx: (r as any).pfx, + }; + } + return next; + }); + } + } catch { /* status unresolved — the decode still shows, just unflagged */ } + setDecodes((arr) => { + const cutoff = Date.now() - DECODE_KEEP_MS; + const next = [...arr, ...batch].filter((d) => Date.parse(d.at) >= cutoff); + return next; + }); + }; + + const unsubDecode = EventsOn('udp:decode', (d: DecodeRow) => { + pendingDecodesRef.current.push(d); + if (pendingDecodeTimer.current === undefined) { + pendingDecodeTimer.current = window.setTimeout(flushDecodes, 300); + } + }); + + // The operator's own transmission. Status repeats it about once a second + // for the whole over, so it is recorded ONCE per message: the panel wants + // "I sent this in that period", not sixty copies of it. + const unsubTx = EventsOn('udp:tx_message', (m: any) => { + if (!m?.transmitting || !String(m?.msg ?? '').trim()) return; + setTxMsgs((arr) => { + const last = arr[arr.length - 1]; + if (last && last.msg === m.msg && Date.parse(m.at) - Date.parse(last.at) < 30_000) return arr; + const cutoff = Date.now() - DECODE_KEEP_MS; + return [...arr, m as TxMsgRow].filter((x) => Date.parse(x.at) >= cutoff); + }); + }); + + return () => { + unsubDecode?.(); unsubTx?.(); + if (pendingDecodeTimer.current !== undefined) window.clearTimeout(pendingDecodeTimer.current); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + // ── UDP integration events ─────────────────────────────────────────── // Live updates from external apps (WSJT-X / JTDX / MSHV / DXHunter…). // We push the broadcast DX call into the entry field and auto-log any @@ -4173,6 +4279,7 @@ export default function App() { { type: 'item', label: t('tools.qslManager'), action: 'tools.qslmanager' }, { type: 'item', label: t('stats.tab'), action: 'tools.stats' }, { type: 'item', label: t('station.title'), action: 'tools.station' }, + { type: 'item', label: t('dec.tab'), action: 'tools.decodes' }, { type: 'item', label: t('tools.qslDesigner'), action: 'tools.qsldesigner' }, { type: 'separator' }, { type: 'item', label: (wkEnabled ? '✓ ' : '') + t('tools.winkeyer'), action: 'tools.winkeyer' }, @@ -4222,6 +4329,7 @@ export default function App() { case 'tools.qslmanager': setQslTabOpen(true); setActiveTab('qsl'); break; case 'tools.stats': setStatsTabOpen(true); setActiveTab('stats'); break; case 'tools.station': setStationTabOpen(true); setActiveTab('station'); break; + case 'tools.decodes': openDecodesTab(); break; case 'tools.qsldesigner': setQslDesignerOpen(true); break; case 'tools.winkeyer': wkSetEnabled(!wkEnabled); break; case 'tools.dvk': setDvkEnabled((v) => !v); break; @@ -6596,6 +6704,24 @@ export default function App() { )} + {decodesTabOpen && ( + + {t('dec.tab')} + {decodes.length > 0 && ( + {decodes.length} + )} + { e.stopPropagation(); }} + onClick={(e) => { e.stopPropagation(); closeDecodesTab(); }} + > + + + + )} {stationTabOpen && ( {t('station.title')} @@ -7045,6 +7171,27 @@ export default function App() { )} + {decodesTabOpen && ( + + handleSpotClick({ + dx_call: d.call, + freq_hz: d.freq_hz, + freq_khz: d.freq_hz / 1000, + band: d.band, + comment: d.mode, + spotter: '', + } as any)} + /> + + )} + {stationTabOpen && ( ; + onCall: (d: Decode) => void; + myCall?: string; +} + +// DEFAULT_TR is the slot length assumed when the sender never told us its T/R +// period. Fifteen seconds is FT8, which is the overwhelming majority of what +// arrives here; a wrong guess only mis-groups, it never loses a decode. +const DEFAULT_TR = 15; + +// Status → the pill's look. The vocabulary is the cluster's, deliberately: the +// same fact must not be amber in one panel and green in the next. +const STATUS_STYLE: Record = { + 'new': 'bg-success text-success-foreground', + 'new-band': 'bg-warning text-warning-foreground', + 'new-mode': 'bg-info text-info-foreground', + 'new-slot': 'bg-caution text-caution-foreground', + 'new-call': 'bg-muted text-muted-foreground', +}; + +const STATUS_LABEL: Record = { + 'new': 'dec.stNew', + 'new-band': 'dec.stBand', + 'new-mode': 'dec.stMode', + 'new-slot': 'dec.stSlot', + 'new-call': 'dec.stCall', +}; + +// periodStart floors a decode's own timestamp to its slot. Its OWN timestamp, +// not arrival: a period's decodes reach us in one burst a second or two after +// the slot closes, so arrival time would pile a whole period into the next one. +function periodStart(at: string, tr: number): number { + const ms = Date.parse(at); + if (!Number.isFinite(ms)) return 0; + const s = Math.floor(ms / 1000); + const p = tr > 0 ? tr : DEFAULT_TR; + return Math.floor(s / p) * p; +} + +const hhmmss = (epochSec: number) => + new Date(epochSec * 1000).toISOString().slice(11, 19); + +// snrTone colours the report by readability rather than by a gradient: an +// operator scanning a period wants "workable" to jump out, and -24 dB is not +// three shades worse than -6, it is a different decision. +function snrTone(snr: number): string { + if (snr >= -5) return 'text-success'; + if (snr >= -15) return 'text-foreground'; + return 'text-muted-foreground'; +} + +export function DecodesPanel({ decodes, txMsgs, spotStatus, onCall, myCall }: Props) { + const { t } = useI18n(); + const [cqOnly, setCqOnly] = useState(false); + const [newOnly, setNewOnly] = useState(false); + const [bandSel, setBandSel] = useState(''); + const [modeSel, setModeSel] = useState(''); + const [contSel, setContSel] = useState(''); + const [minSnr, setMinSnr] = useState(''); + const [search, setSearch] = useState(''); + + const statusOf = (d: Decode): StatusEntry | undefined => + spotStatus[`${d.call}|${d.band ?? ''}|${(d.mode ?? '').toUpperCase()}`]; + + // The band / mode / continent choices are built from what is actually on the + // feed, not from a fixed list: a selector offering 160 m to an operator whose + // receivers are all on 6 m is noise. + const { bands, modes, conts } = useMemo(() => { + const b = new Set(), m = new Set(), c = new Set(); + for (const d of decodes) { + if (d.band) b.add(d.band); + if (d.mode) m.add(d.mode); + const ct = statusOf(d)?.continent; + if (ct) c.add(ct); + } + return { bands: [...b].sort(), modes: [...m].sort(), conts: [...c].sort() }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [decodes, spotStatus]); + + const isNewSomething = (e: StatusEntry | undefined): boolean => + !!e && ((!!e.status && e.status !== 'worked') || !!e.new_county || !!e.new_pota || !!e.new_pfx || !!e.new_grid); + + const filtered = useMemo(() => { + const q = search.trim().toUpperCase(); + const floor = minSnr.trim() === '' ? null : parseInt(minSnr, 10); + return decodes.filter((d) => { + if (cqOnly && !d.cq) return false; + if (bandSel && d.band !== bandSel) return false; + if (modeSel && d.mode !== modeSel) return false; + if (floor != null && Number.isFinite(floor) && d.snr < floor) return false; + const e = statusOf(d); + if (newOnly && !isNewSomething(e)) return false; + if (contSel && e?.continent !== contSel) return false; + if (q && !(d.call.includes(q) || (d.grid ?? '').toUpperCase().includes(q) || (d.msg ?? '').toUpperCase().includes(q))) return false; + return true; + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [decodes, spotStatus, cqOnly, newOnly, bandSel, modeSel, contSel, minSnr, search]); + + // Group into periods, newest first, and drop the operator's transmissions into + // the slot they went out in. + const groups = useMemo(() => { + const by = new Map(); + for (const d of filtered) { + const k = periodStart(d.at, d.tr_period ?? DEFAULT_TR); + let g = by.get(k); + if (!g) { g = { decodes: [], tx: [] }; by.set(k, g); } + g.decodes.push(d); + } + for (const m of txMsgs) { + const k = periodStart(m.at, DEFAULT_TR); + // Only into a period the list is actually showing — a transmission alone + // in an empty slot would be a section with nothing to read. + const g = by.get(k); + if (g) g.tx.push(m); + } + return [...by.entries()] + .sort((a, b) => b[0] - a[0]) + .map(([start, g]) => ({ + start, + tx: g.tx, + // Strongest first inside a period: the eye should land on what is + // workable, and time within a slot means nothing — they were all + // transmitting simultaneously. + decodes: g.decodes.sort((x, y) => y.snr - x.snr), + })); + }, [filtered, txMsgs]); + + const resetFilters = () => { + setCqOnly(false); setNewOnly(false); setBandSel(''); setModeSel(''); + setContSel(''); setMinSnr(''); setSearch(''); + }; + const anyFilter = cqOnly || newOnly || !!bandSel || !!modeSel || !!contSel || !!minSnr || !!search.trim(); + + const sel = 'h-7 rounded-md border border-border bg-background px-2 text-xs'; + const chip = (on: boolean) => cn( + 'h-7 px-2.5 rounded-full border text-xs font-medium transition-colors', + on ? 'border-primary bg-primary text-primary-foreground' : 'border-border text-muted-foreground hover:bg-muted', + ); + + return ( +
+ {/* ── Filter bar ─────────────────────────────────────────────── */} +
+ + + {t('dec.title')} + + + + + + + + + + + +
+ + setSearch(e.target.value)} + placeholder={t('dec.searchPh')} + className="h-7 w-44 rounded-md border border-border bg-background pl-7 pr-2 text-xs" + /> +
+ + {anyFilter && ( + + )} + + + + {t('dec.count', { shown: filtered.length, total: decodes.length })} + +
+ + {/* ── Periods ────────────────────────────────────────────────── */} +
+ {groups.length === 0 && ( +
+

+ {decodes.length === 0 ? t('dec.empty') : t('dec.emptyFiltered')} +

+
+ )} + + {groups.map((g, gi) => ( +
+ {/* Period header — sticky so the slot you are reading is always named. */} +
+ + {hhmmss(g.start)} + + + {t('dec.periodCount', { n: g.decodes.length })} + + {gi === 0 && ( + + + {t('dec.live')} + + )} +
+ + {/* The operator's own transmission, at the top of its slot: it is what + the stations below were answering (or ignoring). */} + {g.tx.map((m, i) => ( +
+ + + {t('dec.tx')} + + {m.msg} + {m.band && {m.band}} +
+ ))} + + {g.decodes.map((d, i) => { + const e = statusOf(d); + const st = e?.status && e.status !== 'worked' ? e.status : ''; + const markers = activeMarkers(e as any); + const mine = myCall && d.call === myCall.toUpperCase(); + return ( + + ); + })} +
+ ))} +
+
+ ); +} diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index 062b7b6..9a37af5 100644 --- a/frontend/src/lib/i18n.tsx +++ b/frontend/src/lib/i18n.tsx @@ -124,6 +124,15 @@ const en: Dict = { 'mx.tipCallConf': 'This callsign confirmed', 'mx.tipCallWork': 'This callsign worked (not confirmed)', 'mx.tipDxConf': 'Entity confirmed (other callsign)', 'mx.tipDxWork': 'Entity worked (other callsign)', 'mx.tipNone': 'Never worked', 'mx.tipClick': 'click to list the QSOs', + // FTx decodes panel (Tools -> FT decodes) + 'dec.tab': 'FT decodes', 'dec.title': 'Decodes', 'dec.cqOnly': 'CQ only', 'dec.newOnly': 'New only', + 'dec.allBands': 'All bands', 'dec.allModes': 'All modes', 'dec.allConts': 'All continents', + 'dec.minSnrTitle': 'Hide anything weaker than this SNR', 'dec.searchPh': 'Call, grid or message', + 'dec.clearFilters': 'Clear', 'dec.live': 'live', 'dec.tx': 'TX', + 'dec.count': '{shown} of {total}', 'dec.periodCount': '{n} decodes', 'dec.callTitle': 'Call {call} — fills the entry and tunes the rig', + 'dec.stNew': 'NEW', 'dec.stBand': 'BAND', 'dec.stMode': 'MODE', 'dec.stSlot': 'SLOT', 'dec.stCall': 'CALL', + 'dec.empty': 'Nothing decoded yet. Decodes arrive from WSJT-X, JTDX or MSHV over the inbound UDP link (Settings -> UDP).', + 'dec.emptyFiltered': 'No decode matches these filters.', 'sec.email': 'E-mail (SMTP)', 'sec.lookup': 'Callsign Lookup', 'sec.bands': 'Bands', 'sec.modes': 'Modes & default RST', 'sec.cluster': 'DX Cluster', 'sec.udp': 'Connections', 'sec.database': 'Database', 'sec.autostart': 'Autostart', 'sec.backup': 'Database backup', 'sec.uscounties': 'US Counties', @@ -580,6 +589,15 @@ const fr: Dict = { 'mx.tipCallConf': 'Cet indicatif est confirmé', 'mx.tipCallWork': 'Cet indicatif est contacté (non confirmé)', 'mx.tipDxConf': 'Entité confirmée (autre indicatif)', 'mx.tipDxWork': 'Entité contactée (autre indicatif)', 'mx.tipNone': 'Jamais contacté', 'mx.tipClick': 'cliquer pour lister les QSO', + // Panneau des decodes FTx (Outils -> Decodes FT) + 'dec.tab': 'Decodes FT', 'dec.title': 'Decodes', 'dec.cqOnly': 'CQ seulement', 'dec.newOnly': 'Nouveaux seulement', + 'dec.allBands': 'Toutes bandes', 'dec.allModes': 'Tous modes', 'dec.allConts': 'Tous continents', + 'dec.minSnrTitle': 'Masquer tout ce qui est plus faible que ce rapport', 'dec.searchPh': 'Indicatif, locator ou message', + 'dec.clearFilters': 'Effacer', 'dec.live': 'en direct', 'dec.tx': 'TX', + 'dec.count': '{shown} sur {total}', 'dec.periodCount': '{n} decodes', 'dec.callTitle': 'Appeler {call} — remplit la saisie et accorde le poste', + 'dec.stNew': 'NOUV', 'dec.stBand': 'BANDE', 'dec.stMode': 'MODE', 'dec.stSlot': 'SLOT', 'dec.stCall': 'IND', + 'dec.empty': "Aucun decode pour l'instant. Ils arrivent de WSJT-X, JTDX ou MSHV par le lien UDP entrant (Reglages -> UDP).", + 'dec.emptyFiltered': 'Aucun decode ne correspond a ces filtres.', 'sec.email': 'E-mail (SMTP)', 'sec.lookup': "Recherche d'indicatif", 'sec.bands': 'Bandes', 'sec.modes': 'Modes & RST par défaut', 'sec.cluster': 'DX Cluster', 'sec.udp': 'Connexions', 'sec.database': 'Base de données', 'sec.autostart': 'Démarrage auto', 'sec.backup': 'Sauvegarde base', 'sec.uscounties': 'Comtés US', diff --git a/internal/integrations/udp/decodetime_test.go b/internal/integrations/udp/decodetime_test.go new file mode 100644 index 0000000..a05014e --- /dev/null +++ b/internal/integrations/udp/decodetime_test.go @@ -0,0 +1,52 @@ +package udp + +import ( + "testing" + "time" +) + +// WSJT-X stamps a decode with a time of DAY and no date, so the date has to come +// from our own clock — and around midnight the two disagree. A decode stamped +// 23:59:58 that reaches us at 00:00:01 would be dated the NEW day, putting it +// almost 24 hours in the future: it would sort to the top of the decodes panel +// and stay there for the rest of the session, and its period would never line up +// with the ones around it. +func TestDecodeTimeCrossesMidnight(t *testing.T) { + const ms = 1000 + sec := func(h, m, s int) uint32 { return uint32((h*3600 + m*60 + s) * ms) } + + got := decodeTime(sec(23, 59, 58)) + now := time.Now().UTC() + // Whatever the clock says, a decode must never land in the future beyond the + // slack of a single period, nor more than a day in the past. + if d := got.Sub(now); d > time.Minute { + t.Errorf("decode at 23:59:58 resolved to %s, %s in the FUTURE", got.Format(time.RFC3339), d) + } + if d := now.Sub(got); d > 24*time.Hour { + t.Errorf("decode at 23:59:58 resolved to %s, %s in the past", got.Format(time.RFC3339), d) + } + + // And the ordinary case: a stamp close to now stays on today. + near := decodeTime(sec(now.Hour(), now.Minute(), now.Second())) + if diff := near.Sub(now); diff > 2*time.Second || diff < -2*time.Second { + t.Errorf("a decode stamped at the current time resolved to %s (%s off)", near.Format(time.RFC3339), diff) + } +} + +// The whole point of the timestamp is grouping, so two decodes from the same +// fifteen-second slot must floor to the same period however far apart in the +// slot they were heard. +func TestDecodesInOneSlotShareAPeriod(t *testing.T) { + const ms = 1000 + at := func(h, m, s int) time.Time { return decodeTime(uint32((h*3600 + m*60 + s) * ms)) } + floor := func(x time.Time) int64 { return x.Unix() / 15 * 15 } + + a, b := at(12, 30, 0), at(12, 30, 14) + if floor(a) != floor(b) { + t.Errorf("12:30:00 and 12:30:14 fell in different periods (%d vs %d)", floor(a), floor(b)) + } + c := at(12, 30, 15) + if floor(a) == floor(c) { + t.Error("12:30:00 and 12:30:15 shared a period — the slot boundary was not honoured") + } +} diff --git a/internal/integrations/udp/server.go b/internal/integrations/udp/server.go index cdd38ca..fbdfa66 100644 --- a/internal/integrations/udp/server.go +++ b/internal/integrations/udp/server.go @@ -77,6 +77,26 @@ func reusingListenConfig() net.ListenConfig { } } +// decodeTime turns WSJT-X's milliseconds-since-midnight into a UTC instant. +// +// The sender gives a time of DAY with no date, so the date comes from our own +// clock — and the two can straddle midnight: a decode stamped 23:59:58 that +// reaches us at 00:00:01 would otherwise be dated a day late and sort to the top +// of the list for the rest of the session. More than half a day apart is read as +// the wrong side of midnight and moved. +func decodeTime(msSinceMidnight uint32) time.Time { + now := time.Now().UTC() + midnight := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) + at := midnight.Add(time.Duration(msSinceMidnight) * time.Millisecond) + switch { + case at.Sub(now) > 12*time.Hour: + at = at.AddDate(0, 0, -1) // stamped late yesterday, arrived after midnight + case now.Sub(at) > 12*time.Hour: + at = at.AddDate(0, 0, 1) // stamped just after midnight, our clock still on the old day + } + return at +} + // Event is what a Server emits to its consumer for every parsed packet. // At most one of the fields is populated per event. type Event struct { @@ -96,6 +116,23 @@ type Event struct { DecodeFreqHz int64 // RF frequency (dial + audio offset) DecodeSNR int // reported SNR (dB) DecodeCQ bool // the decode was a CQ + DecodeMsg string // the decoded line as printed ("CQ K1ABC FN42") + // DecodeAt is the decode's own UTC timestamp, rebuilt from the sender's + // milliseconds-since-midnight. It is what groups decodes into T/R periods: + // a period's worth arrives in one burst, so arrival time would put them all + // in whichever slot the burst happened to land in. + DecodeAt time.Time + // DecodeTRPeriod is the transmit/receive period in seconds, from the last + // Status of the same program (15 = FT8). 0 when the sender never said. + DecodeTRPeriod int + DecodeDial int64 // dial frequency the decode was heard on, for the band + DecodeOffAir bool // decoded from a file rather than off the air + + // TxMessage is what the operator's digital app is sending, with Transmitting + // true while the carrier is actually up. From Status, so ~1 Hz. + TxMessage string + Transmitting bool + DECall string // the operator's own call, as the digital app knows it // ClearCall is set when a WSJT/JTDX/MSHV Status message reports an EMPTY DX // Call after previously reporting one — i.e. the operator cleared the call in @@ -127,7 +164,10 @@ type Server struct { // 50.400 panadapter. WSJT-X requires --rig-name for a second instance, so the // id is distinct whenever there is more than one. dialHz map[string]int64 - lastDX string // WSJT: last non-empty DX Call seen, to detect a clear + // trPeriod is the T/R period (seconds) from each program's last Status — + // what tells a decode which slot it belongs to. + trPeriod map[string]int + lastDX string // WSJT: last non-empty DX Call seen, to detect a clear // badPkts counts datagrams this listener could not parse, so the diagnostic // dump below stays bounded. A misconfigured port is not a one-off: the @@ -359,11 +399,28 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) { s.dialHz = map[string]int64{} } s.dialHz[w.ProgramID] = w.FreqHz + // The T/R period travels with Status, and a decode has to be told + // which slot it belongs to — so it is remembered per program the + // same way the dial is. + if w.TRPeriod > 0 { + if s.trPeriod == nil { + s.trPeriod = map[string]int{} + } + s.trPeriod[w.ProgramID] = w.TRPeriod + } s.mu.Unlock() } + if !w.IsDecode && (w.TxMessage != "" || w.DECall != "") { + // What the operator is sending. Carried on every Status, so the + // consumer sees it change as the QSO progresses. + ev.TxMessage = w.TxMessage + ev.Transmitting = w.Transmitting + ev.DECall = w.DECall + } if w.IsDecode { s.mu.Lock() dial := s.dialHz[w.ProgramID] + tr := s.trPeriod[w.ProgramID] s.mu.Unlock() if dial <= 0 { // No Status from THIS instance yet. Guessing with another @@ -377,6 +434,11 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) { ev.DecodeSNR = w.SNR ev.DecodeCQ = w.IsCQ ev.Mode = w.Mode + ev.DecodeMsg = w.DecodeMsg + ev.DecodeAt = decodeTime(w.DecodeMsSinceMidnight) + ev.DecodeTRPeriod = tr + ev.DecodeDial = dial + ev.DecodeOffAir = w.OffAir break } // Only a logged QSO is worth a line — WSJT-X/MSHV send a Status packet @@ -501,7 +563,10 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) { // Empty events are useless; skip — EXCEPT a clear signal, which is meant to be // 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 { + // TxMessage rides on Status, which also carries the DX call — but a Status + // with an empty DX call and a live transmit message (calling CQ) used to be + // dropped here, and that is exactly the message the decodes panel needs. + if ev.DXCall == "" && ev.LoggedADIF == "" && ev.DecodeCall == "" && ev.TxMessage == "" && !ev.ClearCall && ev.TuneFreqHz == 0 { return } select { diff --git a/internal/integrations/udp/wsjt.go b/internal/integrations/udp/wsjt.go index beda735..b4069dc 100644 --- a/internal/integrations/udp/wsjt.go +++ b/internal/integrations/udp/wsjt.go @@ -55,6 +55,34 @@ type WSJTEvent struct { DeltaFreqHz int64 // audio offset within the passband (Hz) SNR int // reported signal-to-noise (dB) IsCQ bool // the decode was a CQ call + // DecodeMsg is the decoded text as WSJT-X printed it ("CQ K1ABC FN42", + // "F4BPO K1ABC -07"). Kept whole rather than only its parsed pieces: the + // exchange is what tells an operator where a station is in a QSO, and no set + // of extracted fields says "R-09" the way the line itself does. + DecodeMsg string + // DecodeMsSinceMidnight is the decode's own timestamp, in milliseconds since + // 00:00 UTC, as the sender reported it. It is what groups decodes into T/R + // PERIODS — arrival time cannot, since a whole period's decodes land in one + // burst and a slow link shifts the lot into the next slot. + DecodeMsSinceMidnight uint32 + DecodeIsNew bool // sender's "is_new": first time this line was decoded + LowConfidence bool // sender is unsure of the decode + OffAir bool // decoded from a file, not off the air + + // ---- Status extras ---- + + // TxMessage is what the operator is sending right now ("CQ F4BPO JN18"), + // with Transmitting saying whether the carrier is actually up. Both come + // from Status, so they arrive about once a second. + TxMessage string + Transmitting bool + DECall string // the operator's own callsign, as the digital app knows it + DEGrid string // and their square + // TRPeriod is the transmit/receive period in seconds (15 for FT8, 7 or 8 for + // FT4 depending on the sender's rounding). The authority on how long a slot + // is — better than inferring it from the mode name, which says nothing about + // a custom period. + TRPeriod int } // maxFwdHeader bounds how far into a packet the WSJT-X magic may sit behind a @@ -166,40 +194,80 @@ func ParseWSJT(pkt []byte) (WSJTEvent, bool, error) { return WSJTEvent{}, false, err } ev.DXCall = strings.ToUpper(strings.TrimSpace(dxCall)) - // Skip report, tx_mode (QUtf8), tx_enabled (bool), transmitting, - // decoding, rx_df (qint32), tx_df (qint32), de_call (QUtf8), - // de_grid (QUtf8) → then dx_grid. + // report, tx_mode → skipped. for _, name := range []string{"report", "tx_mode"} { if _, err := readQString(r); err != nil { return ev, true, fmt.Errorf("read %s: %w", name, err) } } - // 3 booleans (each 1 byte) - for i := 0; i < 3; i++ { - var b uint8 - if err := binary.Read(r, binary.BigEndian, &b); err != nil { + // tx_enabled, transmitting, decoding (1 byte each). The middle one is + // worth keeping: it says the carrier is up, which is what turns TxMessage + // from "what I would send" into "what is going out". + var txEnabled, transmitting, decoding uint8 + for _, p := range []*uint8{&txEnabled, &transmitting, &decoding} { + if err := binary.Read(r, binary.BigEndian, p); err != nil { return ev, true, err } } - // 2 int32 + ev.Transmitting = transmitting != 0 + // rx_df, tx_df var i32 int32 for i := 0; i < 2; i++ { if err := binary.Read(r, binary.BigEndian, &i32); err != nil { return ev, true, err } } - // de_call, de_grid, dx_grid - if _, err := readQString(r); err != nil { + deCall, err := readQString(r) + if err != nil { return ev, true, err } - if _, err := readQString(r); err != nil { + ev.DECall = strings.ToUpper(strings.TrimSpace(deCall)) + deGrid, err := readQString(r) + if err != nil { return ev, true, err } + ev.DEGrid = strings.ToUpper(strings.TrimSpace(deGrid)) dxGrid, err := readQString(r) if err != nil { return ev, true, err } ev.DXGrid = strings.ToUpper(strings.TrimSpace(dxGrid)) + + // Everything past here was APPENDED to the schema over successive + // releases, and JTDX and MSHV each stop at their own point. A short + // packet is therefore normal, not an error: read as far as the sender + // went and keep what we got. That is why the tail below swallows its + // errors instead of reporting them — the fields already parsed are good. + var b uint8 + if binary.Read(r, binary.BigEndian, &b) != nil { // tx_watchdog + return ev, true, nil + } + if _, err := readQString(r); err != nil { // sub_mode + return ev, true, nil + } + if binary.Read(r, binary.BigEndian, &b) != nil { // fast_mode + return ev, true, nil + } + if binary.Read(r, binary.BigEndian, &b) != nil { // special_operation_mode + return ev, true, nil + } + var u32 uint32 + if binary.Read(r, binary.BigEndian, &u32) != nil { // frequency_tolerance + return ev, true, nil + } + if binary.Read(r, binary.BigEndian, &u32) != nil { // tr_period (seconds) + return ev, true, nil + } + // 0xFFFFFFFF is WSJT-X's "not applicable" for the quint32 fields. + if u32 > 0 && u32 < 3600 { + ev.TRPeriod = int(u32) + } + if _, err := readQString(r); err != nil { // configuration_name + return ev, true, nil + } + if txMsg, err := readQString(r); err == nil { + ev.TxMessage = strings.TrimSpace(txMsg) + } return ev, true, nil case wsjtMsgDecode: @@ -217,6 +285,7 @@ func ParseWSJT(pkt []byte) (WSJTEvent, bool, error) { if err := binary.Read(r, binary.BigEndian, &b); err != nil { // is_new return WSJTEvent{}, false, err } + ev.DecodeIsNew = b != 0 var t32, df uint32 var snr int32 if err := binary.Read(r, binary.BigEndian, &t32); err != nil { // time @@ -240,6 +309,11 @@ func ParseWSJT(pkt []byte) (WSJTEvent, bool, error) { if err != nil { return WSJTEvent{}, false, err } + // low_confidence and off_air were appended later; absent on older senders. + var lowConf, offAir uint8 + _ = binary.Read(r, binary.BigEndian, &lowConf) + _ = binary.Read(r, binary.BigEndian, &offAir) + call, isCQ, grid := wsjtSender(msg) if call == "" { return WSJTEvent{}, false, nil // free-text / telemetry / unparseable → ignore @@ -251,6 +325,10 @@ func ParseWSJT(pkt []byte) (WSJTEvent, bool, error) { ev.DeltaFreqHz = int64(df) ev.SNR = int(snr) ev.Mode = strings.ToUpper(strings.TrimSpace(mode)) + ev.DecodeMsg = strings.TrimSpace(msg) + ev.DecodeMsSinceMidnight = t32 + ev.LowConfidence = lowConf != 0 + ev.OffAir = offAir != 0 return ev, true, nil case wsjtMsgLoggedADIF: