diff --git a/app.go b/app.go index 08b3543..416eedd 100644 --- a/app.go +++ b/app.go @@ -12689,6 +12689,27 @@ func (a *App) consumeUDPEvents() { if a.ctx == nil { continue } + // 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), + }) + } switch { case ev.DecodeCall != "": // Remember the grid before anything else: a CQ is the one message that @@ -12696,6 +12717,37 @@ 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, + "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 // after the configured duration. De-duped per call in the Flex backend. @@ -18321,6 +18373,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/changelog.json b/changelog.json index 9f8bbae..71080b0 100644 --- a/changelog.json +++ b/changelog.json @@ -4,11 +4,15 @@ "date": "", "en": [ "Club Log uploads are now identified as OpsLog. They were credited to another station's application key, which took the blame for them.", - "ADIF import: \"fill my station fields\" now fills the station callsign too, on records that carry none." + "ADIF import: \"fill my station fields\" now fills the station callsign too, on records that carry none.", + "A new FT decodes tab: every decode from WSJT-X, JTDX or MSHV, grouped by transmit period, with what is new and what you are sending.", + "Clicking a decode asks WSJT-X or MSHV to call that station, exactly as double-clicking the line in their own window does." ], "fr": [ "Les envois Club Log s’identifient désormais comme OpsLog. Ils étaient attribués à la clé applicative d’une autre station, qui en portait la responsabilité.", - "Import ADIF : « remplir mes champs station » renseigne aussi l’indicatif de station, sur les enregistrements qui n’en portent pas." + "Import ADIF : « remplir mes champs station » renseigne aussi l’indicatif de station, sur les enregistrements qui n’en portent pas.", + "Un onglet Decodes FT : tous les décodes de WSJT-X, JTDX ou MSHV, groupés par période, avec ce qui est nouveau et ce que tu émets.", + "Cliquer un décode demande à WSJT-X ou MSHV d’appeler la station, exactement comme un double-clic dans leur propre fenêtre." ] }, { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index abac865..658980e 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'; @@ -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 @@ -1641,12 +1656,12 @@ export default function App() { // map ("map1"), the locator street map ("map2"), the cluster grid or the // worked-before grid. Per-profile (stored via SetUIPref → profile-prefixed), // so it's loaded async on mount and re-read on profile:changed below. - type MainPaneKind = 'map1' | 'map2' | 'cluster' | 'worked' | 'flex' | 'recent' | 'icom' | 'yaesu' | 'netcontrol'; + type MainPaneKind = 'map1' | 'map2' | 'cluster' | 'worked' | 'flex' | 'recent' | 'icom' | 'yaesu' | 'netcontrol' | 'decodes'; const [mapZoomSignal, setMapZoomSignal] = useState(0); // bump → world map auto-zooms now const [mainPaneLeft, setMainPaneLeft] = useState('map1'); const [mainPaneRight, setMainPaneRight] = useState('map2'); const loadMainPanes = useCallback(async () => { - const valid = (v: string): v is MainPaneKind => v === 'map1' || v === 'map2' || v === 'cluster' || v === 'worked' || v === 'flex' || v === 'recent' || v === 'icom' || v === 'yaesu' || v === 'netcontrol'; + const valid = (v: string): v is MainPaneKind => v === 'map1' || v === 'map2' || v === 'cluster' || v === 'worked' || v === 'flex' || v === 'recent' || v === 'icom' || v === 'yaesu' || v === 'netcontrol' || v === 'decodes'; const [l, r] = await Promise.all([ GetUIPref('mainPaneLeft').catch(() => ''), GetUIPref('mainPaneRight').catch(() => ''), @@ -1685,6 +1700,10 @@ export default function App() { // a stale closure. const spotsRef = useRef(spots); useEffect(() => { spotsRef.current = spots; }, [spots]); + // The decoded stations, for the same reason: the status refresh and the cache + // prune below both need them, and neither may re-subscribe every time a decode + // lands. Filled by an effect next to the `decodes` state further down. + const decodesRef = useRef([]); // Bound the status cache. Keyed per call|band|mode, it otherwise kept an entry // for every station ever seen — under an RBN firehose (thousands of unique // calls/hour) that grew without limit to gigabytes. Prune it back to the live @@ -1696,10 +1715,16 @@ export default function App() { const keys = Object.keys(prev); if (keys.length <= SPOTS_CAP * 2) return prev; const live = new Set(spots.map((x) => spotStatusKey(x.dx_call, x.band ?? '', x.comment ?? '', x.freq_hz))); + // Decoded stations count as live too. They share this cache, and pruning + // to the cluster spots alone would evict every one of them — on a busy + // band the decodes are what push the cache past the cap in the first + // place, so the panel would blank its own badges the moment it filled up. + for (const d of decodesRef.current) live.add(`${d.call}|${d.band ?? ''}|${(d.mode ?? '').toUpperCase()}`); const pruned: typeof prev = {}; for (const k of keys) if (live.has(k)) pruned[k] = prev[k]; return pruned; }); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [spots]); // Re-fetch the status of every SHOWN spot and OVERWRITE the cache (merge, never // clear). Overwriting keeps the other NEW badges on screen until their fresh @@ -1708,7 +1733,6 @@ export default function App() { // count (it scans the logbook once), so this is as cheap as the poll already is. const refreshSpotStatuses = useCallback(async () => { const cur = spotsRef.current; - if (!cur.length) return; const queries: { call: string; band: string; mode: string; pota_ref: string; spotter: string }[] = []; const seen = new Set(); for (const s of cur) { @@ -1717,6 +1741,19 @@ export default function App() { seen.add(k); queries.push({ call: s.dx_call, band: s.band ?? '', mode: inferSpotMode(s.comment ?? '', s.freq_hz), pota_ref: (s as any).pota_ref ?? '', spotter: s.spotter ?? '' }); } + // The decoded stations as well. Their verdict is resolved once when the + // decode arrives and then cached for ever, so a station worked five minutes + // ago went on wearing its NEW SLOT badge for the rest of the half hour it + // stays in the list — reported on an EY35S already in the log. Deduplicated + // by call+band+mode, so half an hour of a busy band is a few hundred + // queries, and the backend answers a whole batch with one pass of the log. + for (const d of decodesRef.current) { + const mode = (d.mode ?? '').toUpperCase(); + const k = `${d.call}|${d.band ?? ''}|${mode}`; + if (seen.has(k)) continue; + seen.add(k); + queries.push({ call: d.call, band: d.band ?? '', mode, pota_ref: '', spotter: '' }); + } if (!queries.length) return; try { const res = await ClusterSpotStatuses(queries as any); @@ -1749,7 +1786,8 @@ export default function App() { const spotsDirtyRef = useRef(false); useEffect(() => { const vis = mainPaneLeft === 'cluster' || mainPaneRight === 'cluster' - || activeTab === 'cluster' || activeTab === 'bandmap' || showBandMap; + || mainPaneLeft === 'decodes' || mainPaneRight === 'decodes' + || activeTab === 'cluster' || activeTab === 'bandmap' || activeTab === 'decodes' || showBandMap; if (vis && !spotsVisibleRef.current && spotsDirtyRef.current) { spotsDirtyRef.current = false; void refreshSpotStatuses(); @@ -2018,6 +2056,24 @@ 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([]); + // 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); + useEffect(() => { decodesRef.current = decodes; }, [decodes]); + // 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 +3066,89 @@ 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_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]; + 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 +4312,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 +4362,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; @@ -5306,6 +5447,34 @@ export default function App() { ); + // The FT decodes panel, built in ONE place: it is offered both as a tab and as + // a Main-view pane, and two copies of this call would be two sets of props to + // keep in step. + const renderDecodesPanel = () => ( + { + 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))); + }} + /> + ); + // Render one Main-view pane. The two sides (mainPaneLeft/Right) each pick from // the same four choices, configured per-profile in Settings → Main view. const renderMainPane = (kind: MainPaneKind) => { @@ -5324,6 +5493,14 @@ export default function App() { ); case 'map2': return ; + case 'decodes': + // Same panel as the tab, in a pane. It brings its own filter bar and + // column header, so it needs no frame of its own here. + return ( +
+ {renderDecodesPanel()} +
+ ); case 'cluster': return (
@@ -6596,6 +6773,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 +7240,12 @@ export default function App() { )} + {decodesTabOpen && ( + + {renderDecodesPanel()} + + )} + {stationTabOpen && ( ; + 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 nothing better is known. Fifteen +// seconds is FT8, the overwhelming majority of what arrives here; a wrong guess +// only mis-groups, it never loses a decode. +const DEFAULT_TR = 15; + +// MODE_TR is the authority on slot length, ahead of what the sender reports. +// +// Status carries the T/R period as a whole number of seconds, so FT4 arrives as +// 7 or 8 depending on which way the sender rounded — and a period that is out by +// half a second walks across the real boundary until decodes land in the wrong +// slot entirely. The mode name gives the exact figure, and the halving sequence +// is the whole family: 15, 7.5, 3.75. +const MODE_TR: Record = { + FT8: 15, + FT4: 7.5, + FT2: 3.75, + JT65: 60, + JT9: 60, + JS8: 15, +}; + +// trSeconds picks the slot length for a decode: the mode's own figure when we +// know it, the sender's rounded one otherwise, and FT8 as the last resort. +function trSeconds(mode?: string, reported?: number): number { + const m = MODE_TR[(mode ?? '').toUpperCase()]; + if (m) return m; + if (reported && reported > 0) return reported; + return DEFAULT_TR; +} + +// ROW is the column template, shared by the header and every row so the two can +// never drift. Full width and left-aligned — an earlier pass centred it inside a +// maximum width, which on a wide screen opened a huge dead margin down the left +// before the first callsign. +// +// 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-[64px_50px_44px_56px_50px_44px_minmax(240px,1fr)_140px_186px] 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 — +// the thing that turns rows of text into a table you can follow across. +const CELL = 'flex items-center min-w-0 px-2 border-r border-border/30'; +const CELL_LAST = 'flex items-center min-w-0 px-2 gap-1 overflow-hidden'; + +// The "new" badges. Every one of these is a REASON TO CALL, which is why they +// get a column of their own rather than a coloured edge: a stripe says something +// is special, a badge says what, and the operator is deciding whether to break +// off what they are doing. +// +// Colours match the cluster list and the band map — the same fact must not be +// amber in one panel and green in the next. +const ENTITY_BADGE: Record = { + 'new': { label: 'dec.stNew', cls: 'bg-success text-success-foreground' }, + 'new-band': { label: 'dec.stBand', cls: 'bg-warning text-warning-foreground' }, + 'new-mode': { label: 'dec.stMode', cls: 'bg-info text-info-foreground' }, + 'new-slot': { label: 'dec.stSlot', cls: 'bg-caution text-caution-foreground' }, + 'new-call': { label: 'dec.stCall', cls: 'bg-muted text-muted-foreground' }, +}; + +// The orthogonal ones: a station already worked for its entity can still be a +// new grid, a new prefix or a park never logged. +// +// The colour comes from markerColour, the table the cluster list and the band +// map read — so a new park is the same green in all three. Applied inline +// because those are categorical --chart-* variables, which the theme exposes as +// CSS custom properties and not as Tailwind colour utilities; every other place +// that paints with them does the same. +const EXTRA_BADGES: { key: keyof StatusEntry; marker: SpotMarkerKey; label: string }[] = [ + { key: 'new_pota', marker: 'new_pota', label: 'dec.bgPota' }, + { key: 'new_grid', marker: 'new_grid', label: 'dec.bgGrid' }, + { key: 'new_pfx', marker: 'new_pfx', label: 'dec.bgPfx' }, + { key: 'new_county', marker: 'new_county', label: 'dec.bgCounty' }, +]; + +// periodStartMs floors an instant to its slot, in MILLISECONDS. +// +// Milliseconds, not seconds, because FT4's slot is seven and a half of them and +// FT2's three and three quarters: flooring to whole seconds put two different +// FT4 periods in one bucket and split others down the middle. +// +// The instant is the decode's OWN timestamp, never its 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 periodStartMs(atMs: number, trSec: number): number { + const p = Math.max(0.5, trSec) * 1000; + return Math.floor(atMs / p) * p; +} + +// periodLabel names a slot. Sub-second slots get a decimal, or two FT4 periods +// inside the same second would print the same heading twice. +function periodLabel(ms: number, trSec: number): string { + const base = new Date(ms).toISOString().slice(11, 19); + if (Number.isInteger(trSec)) return base; + const tenths = Math.round((ms % 1000) / 100); + return tenths ? `${base}.${tenths}` : base; +} + +// renderMsg prints the decoded line with its leading CQ picked out. +// +// There used to be a separate green "CQ" badge in front of the message, which +// read "CQ CQ PE1NAO JO32" — the badge and the message's own first word saying +// the same thing twice. Highlighting the word already in the line keeps the +// scannability and drops the stutter. +function renderMsg(msg: string, me: string, calling: string) { + if (!msg) return null; + // Split on whitespace and colour the tokens that matter, rather than the + // whole line: an operator scanning a slot is looking for their own call in + // the first position (someone answering) and for the station being called. + const parts = msg.split(/(s+)/); + return ( + <> + {parts.map((tok, i) => { + if (/^s+$/.test(tok)) return tok; + const bare = tok.replace(/[<>]/g, '').toUpperCase(); + if (i === 0 && /^CQ$/i.test(tok)) return {tok.toUpperCase()}; + if (me && bare === me) return {tok}; + if (calling && bare === calling) return {tok}; + return {tok}; + })} + + ); +} + +// PeriodClock shows where the current T/R slot is, against the UTC clock. +// +// Slots are anchored to UTC, not to when OpsLog started or when the last decode +// landed, so this is computed from the wall clock and nothing else — which also +// means it keeps running when the band is dead and there is nothing to group. +// +// It is the one moving thing on the panel, and it answers the question an +// operator actually has between overs: how long until the next batch. +function PeriodClock({ trSec, mode }: { trSec: number; mode?: string }) { + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + // 100 ms: smooth enough for a bar that fills in three and three quarter + // seconds at the fastest, cheap enough to leave running. + const id = window.setInterval(() => setNow(Date.now()), 100); + return () => window.clearInterval(id); + }, []); + + const p = Math.max(0.5, trSec) * 1000; + const into = now % p; + const left = (p - into) / 1000; + const pct = (into / p) * 100; + // The last fifth of a slot is when a decode is imminent and an operator + // deciding whether to answer has run out of time to think. + const closing = left <= trSec / 5; + + return ( + + + + + + + {left.toFixed(1)} + + + {mode ? `${mode} ${trSec}s` : `${trSec}s`} + + + ); +} + +// hhmmssCompact is the per-row time, HHMMSS with no separators. +// +// Every row carries it. A decode belongs to a period, and the section heading +// names that period — but once a slot runs past a screenful, the heading is +// somewhere above and the instant is no longer readable where the decode is. +function hhmmssCompact(at: string): string { + const ms = Date.parse(at); + if (!Number.isFinite(ms)) return ''; + return new Date(ms).toISOString().slice(11, 19).replace(/:/g, ''); +} + +// snrTone colours the report by readability rather than as 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/70'; +} + +export function DecodesPanel({ decodes, txMsgs, txState, spotStatus, onCall, myCall }: Props) { + const { t } = useI18n(); + const [cqOnly, setCqOnly] = 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(''); + const [minSnr, setMinSnr] = useState(''); + const [search, setSearch] = useState(''); + + const statusOf = (d: Decode): StatusEntry | undefined => + spotStatus[`${d.call}|${d.band ?? ''}|${(d.mode ?? '').toUpperCase()}`]; + + // The mode currently on the air, for the slot clock. The newest decode knows + // best; between overs the transmit state still does. + const liveMode = decodes.length ? decodes[decodes.length - 1].mode : txState?.mode; + const liveTr = trSeconds(liveMode, decodes.length ? decodes[decodes.length - 1].tr_period : undefined); + + // Who I am calling, and what an answer to me looks like. + // + // These are the two lines on the screen that are not about the band but about + // the QSO in progress, and they are what an operator is actually watching for + // — the rest is context. A reply is addressed to us by name: the decoded line + // opens with our callsign, sometimes bracketed when the sender compressed a + // non-standard call. + const me = (myCall ?? '').toUpperCase(); + const calling = (txState?.dx_call ?? '').toUpperCase(); + const answersMe = (msg?: string): boolean => { + if (!me || !msg) return false; + const first = msg.trim().split(/\s+/)[0]?.replace(/[<>]/g, '').toUpperCase(); + return !!first && first === me; + }; + + // The choices are built from what is actually on the feed, and a selector with + // nothing to choose is HIDDEN. One MSHV is one band and one mode, so those two + // dropdowns were pure furniture for most operators; they appear the day a + // second instance puts a second band on the link, which is the only day they + // mean anything. + const { bands, modes, conts, instances } = useMemo(() => { + const b = new Set(), m = new Set(), c = new Set(), i = new Set(); + for (const d of decodes) { + if (d.band) b.add(d.band); + if (d.mode) m.add(d.mode); + if (d.instance) i.add(d.instance); + const ct = statusOf(d)?.continent; + if (ct) c.add(ct); + } + return { bands: [...b].sort(), modes: [...m].sort(), conts: [...c].sort(), instances: [...i].sort() }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [decodes, spotStatus]); + + 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 (lotwOnly && !e?.lotw) 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, 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. + const groups = useMemo(() => { + const by = new Map(); + for (const d of filtered) { + const at = Date.parse(d.at); + if (!Number.isFinite(at)) continue; + const k = periodStartMs(at, trSeconds(d.mode, d.tr_period)); + let g = by.get(k); + if (!g) { g = { decodes: [], tx: [] }; by.set(k, g); } + g.decodes.push(d); + } + for (const m of txMsgs) { + const at = Date.parse(m.at); + if (!Number.isFinite(at)) continue; + const k = periodStartMs(at, trSeconds(m.mode, undefined)); + // 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]) + .map(([start, g]) => ({ + start, + // The slot length this period was cut with, so its heading is labelled + // the same way it was grouped. + tr: trSeconds(g.decodes[0]?.mode ?? g.tx[0]?.mode, g.decodes[0]?.tr_period), + 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); setLotwOnly(false); setBandSel(''); + setModeSel(''); setContSel(''); setMinSnr(''); setSearch(''); + setCats(new Set()); + try { localStorage.setItem(CAT_KEY, '[]'); } catch { /* not worth failing over */ } + }; + 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( + 'h-8 px-3 rounded-full border text-sm font-medium transition-colors', + on + ? tone === 'success' + ? 'border-success bg-success text-success-foreground' + : 'border-primary bg-primary text-primary-foreground' + : 'border-border text-muted-foreground hover:bg-muted hover:text-foreground', + ); + + return ( +
+ {/* ── Filter bar ─────────────────────────────────────────────── */} +
+ + + {t('dec.title')} + + + {/* The slot clock. Taken from the newest decode's mode, falling back to + what the transmit state reports, so it is right the moment anything + is heard and keeps running when the band goes quiet. */} + + + + + + + {/* 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 && ( + + )} + {modes.length > 1 && ( + + )} + {conts.length > 1 && ( + + )} + + + +
+ + setSearch(e.target.value)} + placeholder={t('dec.searchPh')} + className="h-8 w-56 rounded-lg border border-border bg-background pl-8 pr-2 text-sm" + /> +
+ + {anyFilter && ( + + )} + + + {/* Which receivers are feeding this. Only with more than one — with a + single MSHV it is a label stating the obvious. */} + {instances.length > 1 && ( + + {t('dec.instances', { n: instances.length })} + + )} + + {t('dec.count', { shown: filtered.length, total: decodes.length })} + +
+ + {/* ── What I am sending, and to whom ─────────────────────────── */} + {/* + Its own strip rather than a line in the list: it is the one thing on this + screen that is about the operator and not about the band, and while a + period scrolls away this stays put. It appears as soon as a Status + arrives, so it says who is being called even on a sender that never + reports its transmit text. + */} + {txState && (txState.msg || txState.dx_call) && ( +
+ + {txState.transmitting && } + {txState.transmitting ? t('dec.txNow') : t('dec.txIdle')} + + {txState.msg + ? {txState.msg} + : {t('dec.txUnknown')}} + {txState.dx_call && ( + + {t('dec.working')} + {txState.dx_call} + + )} + + {txState.band && {txState.band}} + {txState.instance && instances.length > 1 && ( + {txState.instance} + )} +
+ )} + + {/* ── Column header ──────────────────────────────────────────── */} +
+
+ {t('dec.colTime')} + {t('dec.colSnr')} + {t('dec.colDt')} + {t('dec.colFreq')} + {t('dec.colBand')} + {t('dec.colMode')} + {t('dec.colMsg')} + {t('dec.colCountry')} + {t('dec.colStatus')} +
+
+ + {/* ── Periods ────────────────────────────────────────────────── */} +
+ {groups.length === 0 && ( +
+

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

+
+ )} + + {groups.map((g, gi) => ( +
+ {/* Period header — sticky, so the slot being read is always named. */} +
+
+ + {periodLabel(g.start, g.tr)} + + + {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 entity = st ? ENTITY_BADGE[st] : undefined; + const extras = EXTRA_BADGES.filter((b) => !!e?.[b.key]); + const mine = !!me && d.call === me; + const hot = !!entity || extras.length > 0; + // Someone answering us outranks everything else on the screen. + const replying = answersMe(d.msg); + // The station we are calling, so it can be picked out of a slot + // holding thirty others. + const worked = !!calling && d.call === calling; + return ( + + ); + })} +
+ ))} +
+
+ ); +} diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index 5944a06..c414b7c 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -1029,7 +1029,7 @@ function RelayAutoPanel() { // panes show, independently: the great-circle map, the locator street map, the // cluster grid or the worked-before grid. Per-profile (stored via SetUIPref, // which is profile-prefixed). Self-contained so it owns its async-loaded state. -const MAIN_PANE_VALUES = ['map1', 'map2', 'cluster', 'worked', 'recent', 'netcontrol']; +const MAIN_PANE_VALUES = ['map1', 'map2', 'cluster', 'worked', 'recent', 'netcontrol', 'decodes']; function MainViewPanes({ onChanged, flexAvailable, icomAvailable, yaesuAvailable }: { onChanged?: (side: 'left' | 'right', value: string) => void; flexAvailable?: boolean; icomAvailable?: boolean; yaesuAvailable?: boolean }) { const { t } = useI18n(); const [left, setLeft] = useState('map1'); diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index bfebadd..5c209aa 100644 --- a/frontend/src/lib/i18n.tsx +++ b/frontend/src/lib/i18n.tsx @@ -105,7 +105,7 @@ const en: Dict = { 'settings.leftPane': 'Left pane', 'settings.rightPane': 'Right pane', 'settings.pane.map1': 'Map — great-circle + beam', 'settings.pane.map2': 'Map — locator (street)', 'settings.pane.cluster': 'Cluster spots', 'settings.pane.worked': 'Worked before', - 'settings.pane.recent': 'Recent QSOs', 'settings.pane.netcontrol': 'Net control', + 'settings.pane.recent': 'Recent QSOs', 'settings.pane.netcontrol': 'Net control', 'settings.pane.decodes': 'FT decodes', 'settings.pane.flex': 'Flex Console', 'settings.pane.icom': 'Icom Console', 'settings.pane.yaesu': 'Yaesu Console', 'yaesu.notConnected': 'Yaesu CAT not connected', 'yaesu.meters': 'Meters', 'yaesu.bandMode': 'Band & mode', 'yaesu.receive': 'Receive', 'yaesu.noiseFilter': 'Noise & filter', 'yaesu.transmit': 'Transmit', 'yaesu.refresh': 'Refresh', 'yaesu.tuneHint': 'Start an antenna-tuner cycle', 'yaesu.sToRst': 'Click to fill the RST sent', 'yaesu.sidebandHint': 'Click to select this mode; click again to switch sideband (U/L)', 'yaesu.splitUpHint': 'Transmit this far above the receive frequency, and turn split on', 'yaesu.txOn': 'TX', 'yaesu.cw': 'CW', 'yaesu.breakInHint': 'Break-in: the rig switches to receive between characters', 'yaesu.zinHint': 'Zero-in: retune so the station you hear lands on your CW pitch', 'theme.auto': 'Auto (system)', 'theme.light-warm': 'Warm light', 'theme.light-cool': 'Cool light', 'theme.light-sage': 'Sage light', 'theme.light-nordic': 'Nordic light', 'theme.sahara': 'Sahara', 'theme.dim-slate': 'Dim slate', 'theme.dark-warm': 'Warm dark', @@ -124,6 +124,23 @@ 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.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.lotwOnly': 'LoTW only', 'dec.instances': '{n} receivers', + 'dec.catsHint': 'Show only these — none selected shows the whole band', + 'dec.colDt': 'DT', 'dec.colDtTitle': 'Seconds into the slot the transmission started', + 'dec.colFreq': 'Freq', 'dec.colFreqTitle': 'Audio offset inside the passband (Hz)', + 'dec.txNow': 'Transmitting', 'dec.txIdle': 'Transmit', 'dec.working': 'calling', 'dec.toYou': 'to you', + 'dec.txUnknown': 'this application does not report its transmit text', + 'dec.colTime': 'Time', 'dec.colSnr': 'SNR', 'dec.colMsg': 'Message', 'dec.colCountry': 'Country', 'dec.colBand': 'Band', 'dec.colMode': 'Mode', 'dec.colStatus': 'Status', 'dec.wkd': 'Wkd', + 'dec.bgPota': 'POTA', 'dec.bgGrid': 'GRID', 'dec.bgPfx': 'PFX', 'dec.bgCounty': 'CTY', + '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', @@ -562,7 +579,7 @@ const fr: Dict = { 'settings.leftPane': 'Volet gauche', 'settings.rightPane': 'Volet droit', 'settings.pane.map1': 'Carte — orthodromie + faisceau', 'settings.pane.map2': 'Carte — locator (rue)', 'settings.pane.cluster': 'Spots cluster', 'settings.pane.worked': 'Déjà contactés', - 'settings.pane.recent': 'QSO récents', 'settings.pane.netcontrol': 'Gestion de net', + 'settings.pane.recent': 'QSO récents', 'settings.pane.netcontrol': 'Gestion de net', 'settings.pane.decodes': 'Decodes FT', 'settings.pane.flex': 'Flex Console', 'settings.pane.icom': 'Icom Console', 'settings.pane.yaesu': 'Yaesu Console', 'yaesu.notConnected': 'CAT Yaesu non connecté', 'yaesu.meters': 'Mesures', 'yaesu.bandMode': 'Bande et mode', 'yaesu.receive': 'Réception', 'yaesu.noiseFilter': 'Bruit et filtre', 'yaesu.transmit': 'Émission', 'yaesu.refresh': 'Actualiser', 'yaesu.tuneHint': "Lancer un cycle d'accord d'antenne", 'yaesu.sToRst': 'Cliquer pour remplir le RST envoyé', 'yaesu.sidebandHint': 'Cliquer pour choisir ce mode ; recliquer pour changer de bande latérale (U/L)', 'yaesu.splitUpHint': "Émettre à cette distance au-dessus de la fréquence de réception, et activer le split", 'yaesu.txOn': 'TX', 'yaesu.cw': 'CW', 'yaesu.breakInHint': "Break-in : la radio repasse en réception entre les caractères", 'yaesu.zinHint': "Zéro-in : réaccorde pour que la station entendue tombe sur votre note CW", 'theme.auto': 'Auto (système)', 'theme.light-warm': 'Clair chaud', 'theme.light-cool': 'Clair froid', 'theme.light-sage': 'Clair sauge', 'theme.light-nordic': 'Clair nordique', 'theme.sahara': 'Sahara', 'theme.dim-slate': 'Ardoise tamisé', 'theme.dark-warm': 'Sombre chaud', @@ -580,6 +597,23 @@ 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.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.lotwOnly': 'LoTW seulement', 'dec.instances': '{n} recepteurs', + 'dec.catsHint': 'Ne montrer que ceux-ci — aucun selectionne affiche toute la bande', + 'dec.colDt': 'DT', 'dec.colDtTitle': 'Secondes ecoulees dans le creneau au debut de l emission', + 'dec.colFreq': 'Freq', 'dec.colFreqTitle': 'Decalage audio dans la bande passante (Hz)', + 'dec.txNow': 'En emission', 'dec.txIdle': 'Emission', 'dec.working': 'appelle', 'dec.toYou': 'pour toi', + 'dec.txUnknown': 'ce logiciel ne communique pas son texte d emission', + 'dec.colTime': 'Heure', 'dec.colSnr': 'SNR', 'dec.colMsg': 'Message', 'dec.colCountry': 'Pays', 'dec.colBand': 'Bande', 'dec.colMode': 'Mode', 'dec.colStatus': 'Statut', 'dec.wkd': 'Fait', + 'dec.bgPota': 'POTA', 'dec.bgGrid': 'LOC', 'dec.bgPfx': 'PFX', 'dec.bgCounty': 'CTY', + '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/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index 5e82173..75654aa 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -53,6 +53,8 @@ export function AmpPower(arg1:string,arg2:boolean):Promise; export function AmpPowerLevel(arg1:string,arg2:string):Promise; +export function AnswerDecode(arg1:string,arg2:number,arg3:number,arg4:number,arg5:number,arg6:string,arg7:string,arg8:boolean):Promise; + export function AntGeniusActivate(arg1:number,arg2:number):Promise; export function AntGeniusDeselect(arg1:number):Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 0889b2f..1bdd581 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -46,6 +46,10 @@ export function AmpPowerLevel(arg1, arg2) { return window['go']['main']['App']['AmpPowerLevel'](arg1, arg2); } +export function AnswerDecode(arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8) { + return window['go']['main']['App']['AnswerDecode'](arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8); +} + export function AntGeniusActivate(arg1, arg2) { return window['go']['main']['App']['AntGeniusActivate'](arg1, arg2); } 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..1631242 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,36 @@ 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 + // The three fields below are shown in the panel AND replayed verbatim when + // answering the station — WSJT-X matches a Reply against its own decode list, + // so every one has to go back exactly as it came. + DecodeDT float64 // seconds into the slot the transmission started + DecodeAudioHz int64 // audio offset inside the passband + DecodeMs uint32 // the decode's raw ms-since-midnight, as sent + DecodeLowConf bool + // ProgramID is the sending application's own id ("WSJT-X", "MSHV", or + // "WSJT-X - 2" for a second instance started with --rig-name). It is what + // tells two receivers apart on one multicast group — and it is the address a + // Reply message would have to be sent back to, so it is carried even though + // nothing replies yet. + ProgramID string + + // 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 +177,13 @@ 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 + // lastFrom is the address each program's packets arrive from — where a Reply + // has to be sent. See SendReply. + lastFrom map[string]*net.UDPAddr + 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 @@ -351,6 +407,18 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) { if !ok { return } + // Where this application's packets come from, so a Reply can be sent back + // to it. Per PROGRAM, not per listener: two receivers share one multicast + // group, and a reply must reach the one that heard the station — and it + // must go to the sender's own address, never to the group. + if w.ProgramID != "" && remote != nil { + s.mu.Lock() + if s.lastFrom == nil { + s.lastFrom = map[string]*net.UDPAddr{} + } + s.lastFrom[w.ProgramID] = remote + s.mu.Unlock() + } // 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 { @@ -359,11 +427,31 @@ 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, and to whom. Carried on every Status, + // so the consumer sees it change as the QSO progresses. The program id + // travels with it because a second receiver has a transmit state of + // its own. + ev.TxMessage = w.TxMessage + ev.Transmitting = w.Transmitting + ev.DECall = w.DECall + ev.ProgramID = w.ProgramID + } 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 +465,16 @@ 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 + ev.ProgramID = w.ProgramID + ev.DecodeDT = w.DeltaTime + ev.DecodeAudioHz = w.DeltaFreqHz + ev.DecodeMs = w.DecodeMsSinceMidnight + ev.DecodeLowConf = w.LowConfidence break } // Only a logged QSO is worth a line — WSJT-X/MSHV send a Status packet @@ -501,7 +599,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..a953464 100644 --- a/internal/integrations/udp/wsjt.go +++ b/internal/integrations/udp/wsjt.go @@ -55,6 +55,38 @@ 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 + // DeltaTime is how far into the slot the transmission started, in seconds — + // WSJT-X's "DT" column. Read and discarded before; kept now because it is + // shown, and because a Reply has to replay the decode field for field. + DeltaTime float64 + // 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 +198,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 +289,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 @@ -229,6 +302,7 @@ func ParseWSJT(pkt []byte) (WSJTEvent, bool, error) { if err := binary.Read(r, binary.BigEndian, &dt); err != nil { // delta_time return WSJTEvent{}, false, err } + ev.DeltaTime = dt if err := binary.Read(r, binary.BigEndian, &df); err != nil { // delta_frequency return WSJTEvent{}, false, err } @@ -240,6 +314,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 +330,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: diff --git a/internal/integrations/udp/wsjtreply.go b/internal/integrations/udp/wsjtreply.go new file mode 100644 index 0000000..d028f00 --- /dev/null +++ b/internal/integrations/udp/wsjtreply.go @@ -0,0 +1,129 @@ +package udp + +import ( + "bytes" + "encoding/binary" + "fmt" + "net" + "strings" + + "hamlog/internal/applog" +) + +// WSJT-X Reply (message type 4) — "answer this station". +// +// It is the same thing as double-clicking the line in WSJT-X's own Band +// Activity window: the application looks the decode up in its list, sets its +// transmit frequency to the caller's, fills the DX call and starts the exchange. +// Which is why OpsLog cannot do this 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 the decoding application, and this is the +// only way to hand it over. +// +// The payload REPLAYS the decode being answered, and WSJT-X matches it against +// what it decoded. Every field has to come back exactly as it went out — which +// is why the parser now keeps the time, the delta time, the audio offset and the +// message text rather than only what the panadapter needed. +// +// Reply type 4 +// id utf8 the target application's own id +// time quint32 ms since midnight, from the decode +// snr qint32 +// delta_time double seconds +// delta_frequency quint32 audio offset in the passband, Hz +// mode utf8 +// message utf8 +// low_confidence bool +// modifiers quint8 keyboard modifiers (0 = a plain click) +const wsjtMsgReply = 4 + +// Reply is one "call this station" request, rebuilt from a decode. +type Reply struct { + ProgramID string // which application to talk to ("WSJT-X", "MSHV", "WSJT-X - 2") + MsSinceMidnig uint32 + SNR int32 + DeltaTime float64 + DeltaFreqHz uint32 + Mode string + Message string + LowConfidence bool +} + +// writeQString writes a Qt QString/QUtf8: a big-endian int32 length then the +// bytes. An EMPTY string is length 0, not the -1 that means null — WSJT-X reads +// a null where it expects text as a malformed packet and drops the whole reply. +func writeQString(b *bytes.Buffer, s string) { + _ = binary.Write(b, binary.BigEndian, int32(len(s))) + b.WriteString(s) +} + +// EncodeReply builds the datagram. +func EncodeReply(r Reply) []byte { + var b bytes.Buffer + _ = binary.Write(&b, binary.BigEndian, uint32(wsjtMagic)) + _ = binary.Write(&b, binary.BigEndian, uint32(2)) // schema 2 — the one every current sender speaks + _ = binary.Write(&b, binary.BigEndian, uint32(wsjtMsgReply)) + writeQString(&b, r.ProgramID) + _ = binary.Write(&b, binary.BigEndian, r.MsSinceMidnig) + _ = binary.Write(&b, binary.BigEndian, r.SNR) + _ = binary.Write(&b, binary.BigEndian, r.DeltaTime) + _ = binary.Write(&b, binary.BigEndian, r.DeltaFreqHz) + writeQString(&b, r.Mode) + writeQString(&b, r.Message) + var low uint8 + if r.LowConfidence { + low = 1 + } + _ = binary.Write(&b, binary.BigEndian, low) + _ = binary.Write(&b, binary.BigEndian, uint8(0)) // modifiers: a plain click + return b.Bytes() +} + +// SendReply hands a Reply to the application that produced the decode. +// +// Routed by PROGRAM ID, not by listener: two receivers can share one multicast +// group, and answering a station heard on the 6 m instance by talking to the +// 20 m one would start a call on the wrong band. The id is what tells them +// apart, and the address the reply goes to is the one that instance's packets +// actually arrive from — a multicast listener must answer back to the sender, +// not to the group. +func (m *Manager) SendReply(r Reply) error { + if strings.TrimSpace(r.ProgramID) == "" { + return fmt.Errorf("no application id — cannot tell which receiver to answer with") + } + m.mu.Lock() + servers := make([]*Server, 0, len(m.inbound)) + for _, s := range m.inbound { + servers = append(servers, s) + } + m.mu.Unlock() + + for _, s := range servers { + conn, addr := s.replyTarget(r.ProgramID) + if conn == nil || addr == nil { + continue + } + pkt := EncodeReply(r) + if _, err := conn.WriteToUDP(pkt, addr); err != nil { + return fmt.Errorf("send reply to %s at %s: %w", r.ProgramID, addr, err) + } + applog.Printf("udp: reply sent to %s at %s — %q", r.ProgramID, addr, r.Message) + return nil + } + return fmt.Errorf("no packet has arrived from %q yet — nothing to answer to", r.ProgramID) +} + +// replyTarget returns this listener's socket and the address the given program +// last sent from, or nils when it has never been heard here. +func (s *Server) replyTarget(programID string) (*net.UDPConn, *net.UDPAddr) { + s.mu.Lock() + defer s.mu.Unlock() + if s.conn == nil || s.lastFrom == nil { + return nil, nil + } + addr, ok := s.lastFrom[programID] + if !ok { + return nil, nil + } + return s.conn, addr +}