diff --git a/app.go b/app.go index 924ccb7..e7d88b1 100644 --- a/app.go +++ b/app.go @@ -12668,17 +12668,24 @@ 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{ + // The operator's own transmit state, from Status. Emitted before the + // switch because a Status carries BOTH a DX call and a transmit message, + // and the switch below takes only one branch. + // + // Sent on EVERY Status, not only when there is a transmit message: MSHV + // and older JTDX builds stop before tx_message in the Status payload, and + // the panel still has to be able to say who is being called and whether + // the carrier is up. A Status always carries de_call, so that is the test. + if ev.DECall != "" || ev.TxMessage != "" { + wruntime.EventsEmit(a.ctx, "udp:tx_state", map[string]any{ "msg": ev.TxMessage, "transmitting": ev.Transmitting, "de_call": ev.DECall, + "dx_call": ev.DXCall, "mode": ev.Mode, "freq_hz": ev.FreqHz, "band": bandForHz(ev.FreqHz), + "instance": ev.ProgramID, "at": time.Now().UTC().Format(time.RFC3339), }) } @@ -12713,6 +12720,12 @@ func (a *App) consumeUDPEvents() { "off_air": ev.DecodeOffAir, "source": ev.Source, "instance": ev.ProgramID, + "dt": ev.DecodeDT, + "audio_hz": ev.DecodeAudioHz, + // Carried so a click can answer the station: WSJT-X matches a + // Reply against its own decode list, field for field. + "ms": ev.DecodeMs, + "low_conf": ev.DecodeLowConf, }) // A WSJT-X decode (heard station). Render it on the FlexRadio // panadapter when the option is on; green + SNR comment, auto-expiring @@ -18339,6 +18352,37 @@ type GridCacheStatus struct { Pending int `json:"pending"` // waiting for the next batch write } +// AnswerDecode tells the decoding application to call a station — the same +// thing as double-clicking the line in WSJT-X's own Band Activity window. +// +// This is not something OpsLog can do by tuning the radio. On FT8 the whole band +// sits inside one passband, so moving the dial changes nothing about who gets +// answered: the decision belongs to WSJT-X/MSHV, and the Reply message is the +// only way to hand it over. The panel therefore does NOT retune the rig on a +// click, which would only fight the digital application for the VFO. +// +// Every argument replays the decode as it arrived, because the target matches it +// against its own decode list and ignores anything it cannot find. +func (a *App) AnswerDecode(instance string, ms uint32, snr int, dt float64, audioHz int64, mode, msg string, lowConf bool) error { + if a.udp == nil { + return fmt.Errorf("udp not initialized") + } + err := a.udp.SendReply(udp.Reply{ + ProgramID: instance, + MsSinceMidnig: ms, + SNR: int32(snr), + DeltaTime: dt, + DeltaFreqHz: uint32(audioHz), + Mode: mode, + Message: msg, + LowConfidence: lowConf, + }) + if err != nil { + applog.Printf("udp: answer decode %q on %q failed: %v", msg, instance, err) + } + return err +} + // GetGridCacheStatus reports what the locator store holds. func (a *App) GetGridCacheStatus() GridCacheStatus { out := GridCacheStatus{Enabled: a.gridStore != nil} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 64003a4..f031997 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -94,7 +94,7 @@ import { ShutdownProgress } from '@/components/ShutdownProgress'; import { ClusterGrid } from '@/components/ClusterGrid'; import { cleanSpotter, inferSpotMode, spotModeCategory, spotStatusKey } from '@/lib/spot'; import { applySpotDisplay, readSpotDisplayOptions, spotIsWorked, SPOT_DISPLAY_OPTIONS_EXPOSED } from '@/lib/spotDisplay'; -import { GetMatrixColors, GetRotorPresets, GetRowColors, GetSpotTTLMinutes, IsNewUSCounty } from '../wailsjs/go/main/App'; +import { AnswerDecode, GetMatrixColors, GetRotorPresets, GetRowColors, GetSpotTTLMinutes, IsNewUSCounty } from '../wailsjs/go/main/App'; import { applyMatrixColors } from '@/lib/matrixColors'; import { WorkedBeforeGrid } from '@/components/WorkedBeforeGrid'; import { NetControlPanel } from '@/components/NetControlPanel'; @@ -2042,6 +2042,9 @@ export default function App() { const DECODE_KEEP_MS = 30 * 60 * 1000; const [decodes, setDecodes] = useState([]); const [txMsgs, setTxMsgs] = useState([]); + // The LIVE transmit state, replaced on every Status — what is going out now + // and to whom, which the period history cannot answer between overs. + const [txState, setTxState] = useState(null); // 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([]); @@ -3099,7 +3102,13 @@ export default function App() { // 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) => { + const unsubTx = EventsOn('udp:tx_state', (m: any) => { + // The live strip takes every Status: it has to say who is being called + // even between overs, and on a sender that never reports its transmit + // text at all. + setTxState(m as TxMsgRow); + // The period history takes only real transmissions — Status repeats + // itself once a second whether the carrier is up or not. if (!m?.transmitting || !String(m?.msg ?? '').trim()) return; setTxMsgs((arr) => { const last = arr[arr.length - 1]; @@ -7176,18 +7185,25 @@ export default function App() { 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)} + // A click ANSWERS the station: it hands the decode back to + // WSJT-X/MSHV as a Reply, which is the same thing as + // double-clicking the line in their own window. + // + // Deliberately NOT a rig tune, unlike a cluster spot. On FT8 + // the whole band is inside one passband, so moving the dial + // changes nothing about who gets answered — and it would only + // fight the digital application for the VFO. The entry is + // still filled, so the QSO can be logged here. + onCall={(d) => { + onCallsignInput(d.call, { force: true }); + AnswerDecode( + d.instance ?? '', d.ms ?? 0, d.snr, d.dt ?? 0, + d.audio_hz ?? 0, d.mode ?? '', d.msg ?? '', !!d.low_conf, + ).catch((e: any) => setError(String(e?.message ?? e))); + }} /> )} diff --git a/frontend/src/components/DecodesPanel.tsx b/frontend/src/components/DecodesPanel.tsx index 0c263aa..b73f63a 100644 --- a/frontend/src/components/DecodesPanel.tsx +++ b/frontend/src/components/DecodesPanel.tsx @@ -33,14 +33,22 @@ export type Decode = { off_air?: boolean; source?: string; instance?: string; + dt?: number; + audio_hz?: number; + // Replayed verbatim when answering the station — see AnswerDecode. + ms?: number; + low_conf?: boolean; }; export type TxMsg = { msg: string; de_call?: string; + dx_call?: string; mode?: string; band?: string; freq_hz?: number; + instance?: string; + transmitting?: boolean; at: string; }; @@ -60,11 +68,52 @@ type StatusEntry = { interface Props { decodes: Decode[]; txMsgs: TxMsg[]; + // txState is the LIVE transmit state — what is going out right now and to + // whom. Separate from txMsgs, which is the history threaded into the periods. + txState?: TxMsg | null; spotStatus: Record; onCall: (d: Decode) => void; myCall?: string; } +// The "new" categories, as toggle badges — the same idea and the same colours as +// the Chase New panel, so an operator who has learned one has learned both. +// +// All off means no filtering at all: this is a decode LOG first, and a panel +// that starts by hiding most of the band would be lying about what is on it. +type NewCat = 'dxcc' | 'band' | 'mode' | 'slot' | 'pfx' | 'grid' | 'pota' | 'cty'; + +const NEW_CATS: { key: NewCat; label: string; colour: string }[] = [ + { key: 'dxcc', label: 'dec.stNew', colour: 'var(--success)' }, + { key: 'band', label: 'dec.stBand', colour: 'var(--warning)' }, + { key: 'mode', label: 'dec.stMode', colour: 'var(--info)' }, + { key: 'slot', label: 'dec.stSlot', colour: 'var(--caution)' }, + { key: 'pota', label: 'dec.bgPota', colour: markerColour('new_pota') }, + { key: 'grid', label: 'dec.bgGrid', colour: markerColour('new_grid') }, + { key: 'pfx', label: 'dec.bgPfx', colour: markerColour('new_pfx') }, + { key: 'cty', label: 'dec.bgCounty', colour: markerColour('new_county') }, +]; + +// catsOf lists everything a decode is new for. A station can be several at once +// — a new entity that is also a new park — so this is a set, not a verdict. +function catsOf(e: StatusEntry | undefined): Set { + const out = new Set(); + if (!e) return out; + switch (e.status) { + case 'new': out.add('dxcc'); break; + case 'new-band': out.add('band'); break; + case 'new-mode': out.add('mode'); break; + case 'new-slot': out.add('slot'); break; + } + if (e.new_pota) out.add('pota'); + if (e.new_grid) out.add('grid'); + if (e.new_pfx) out.add('pfx'); + if (e.new_county) out.add('cty'); + return out; +} + +const CAT_KEY = 'opslog.decodeCats'; + // 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. @@ -78,7 +127,7 @@ const DEFAULT_TR = 15; // Message is the one elastic column, with a floor so it does not collapse; the // slack lands there rather than between two fixed columns, which is what read as // a hole in the middle of every line. -const ROW = 'grid grid-cols-[3px_120px_64px_68px_minmax(280px,1fr)_230px_180px_36px] items-stretch'; +const ROW = 'grid grid-cols-[3px_120px_58px_54px_62px_64px_minmax(240px,1fr)_222px_160px_34px] items-stretch'; // CELL draws the column rule. items-stretch above plus a right border here is // what makes the lines run unbroken from the header to the bottom of the list — @@ -156,11 +205,22 @@ function snrTone(snr: number): string { return 'text-muted-foreground/70'; } -export function DecodesPanel({ decodes, txMsgs, spotStatus, onCall, myCall }: Props) { +export function DecodesPanel({ decodes, txMsgs, txState, spotStatus, onCall, myCall }: Props) { const { t } = useI18n(); const [cqOnly, setCqOnly] = useState(false); - const [newOnly, setNewOnly] = useState(false); const [lotwOnly, setLotwOnly] = useState(false); + const [cats, setCats] = useState>(() => { + try { + const raw = JSON.parse(localStorage.getItem(CAT_KEY) || '[]'); + return new Set(Array.isArray(raw) ? raw : []); + } catch { return new Set(); } + }); + const toggleCat = (k: NewCat) => setCats((prev) => { + const next = new Set(prev); + if (next.has(k)) next.delete(k); else next.add(k); + try { localStorage.setItem(CAT_KEY, JSON.stringify([...next])); } catch { /* not worth failing over */ } + return next; + }); const [bandSel, setBandSel] = useState(''); const [modeSel, setModeSel] = useState(''); const [contSel, setContSel] = useState(''); @@ -188,9 +248,6 @@ export function DecodesPanel({ decodes, txMsgs, spotStatus, onCall, myCall }: Pr // 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); @@ -201,13 +258,20 @@ export function DecodesPanel({ decodes, txMsgs, spotStatus, onCall, myCall }: Pr if (floor != null && Number.isFinite(floor) && d.snr < floor) return false; const e = statusOf(d); if (lotwOnly && !e?.lotw) return false; - if (newOnly && !isNewSomething(e)) return false; + // Any badge lit narrows the list to the things it names; none lit shows + // the band as it is. + if (cats.size > 0) { + const have = catsOf(e); + let hit = false; + for (const c of cats) if (have.has(c)) { hit = true; break; } + if (!hit) 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, lotwOnly, bandSel, modeSel, contSel, minSnr, search]); + }, [decodes, spotStatus, cqOnly, lotwOnly, cats, bandSel, modeSel, contSel, minSnr, search]); // Group into periods, newest first, and drop the operator's transmissions into // the slot they went out in. @@ -221,10 +285,15 @@ export function DecodesPanel({ decodes, txMsgs, spotStatus, onCall, myCall }: Pr } 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); + // A transmit slot CREATES its period when there is none. + // + // This is the whole alternation, and getting it wrong hid the feature + // completely: FT8 transmits and receives in opposite slots, so the period + // you were sending in is exactly the one with no decodes in it. Dropping + // the message when its period was empty meant it never appeared at all. + let g = by.get(k); + if (!g) { g = { decodes: [], tx: [] }; by.set(k, g); } + g.tx.push(m); } return [...by.entries()] .sort((a, b) => b[0] - a[0]) @@ -239,10 +308,12 @@ export function DecodesPanel({ decodes, txMsgs, spotStatus, onCall, myCall }: Pr }, [filtered, txMsgs]); const resetFilters = () => { - setCqOnly(false); setNewOnly(false); setLotwOnly(false); setBandSel(''); + setCqOnly(false); setLotwOnly(false); setBandSel(''); setModeSel(''); setContSel(''); setMinSnr(''); setSearch(''); + setCats(new Set()); + try { localStorage.setItem(CAT_KEY, '[]'); } catch { /* not worth failing over */ } }; - const anyFilter = cqOnly || newOnly || lotwOnly || !!bandSel || !!modeSel || !!contSel || !!minSnr || !!search.trim(); + const anyFilter = cqOnly || lotwOnly || cats.size > 0 || !!bandSel || !!modeSel || !!contSel || !!minSnr || !!search.trim(); const sel = 'h-8 rounded-lg border border-border bg-background px-2 text-sm'; const chip = (on: boolean, tone = 'primary') => cn( @@ -266,13 +337,32 @@ export function DecodesPanel({ decodes, txMsgs, spotStatus, onCall, myCall }: Pr - + {/* Per-category badges, in the colours of the flags they select — the + same vocabulary as the Chase New panel. */} + + {NEW_CATS.map((c) => { + const on = cats.has(c.key); + return ( + + ); + })} + + {/* Only when there is a choice to make — see the memo above. */} {bands.length > 1 && (