feat(decodes): answer a station on click, DT and Freq, badge filters
Clicking a decode now ANSWERS it. It sends WSJT-X/MSHV a Reply message (type 4), which is the same thing as double-clicking the line in their own Band Activity window: the application looks the decode up, sets its transmit frequency to the caller's and starts the exchange. It deliberately does not tune the radio, which is what it did before and why nothing happened. 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 the Reply is the only way to hand it over. Tuning would also just fight it for the VFO. The entry is still filled so the QSO can be logged here. The reply is 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. It goes to the address that instance's packets actually arrive from - a multicast listener must answer the sender, never the group. WSJT-X matches the reply against its own decode list, so the payload replays the decode field for field: time, snr, delta time, audio offset, mode and message text. Two columns added, DT and Freq - the audio offset inside the passband, not the RF frequency, which is the same for every station in the list and says nothing. Past about two seconds DT takes a warning tint: that station is drifting out of the window. The transmit strip. "You cannot see what you are sending, or who you are calling" - two separate faults. The message was only ever threaded into its period, and in FT8 you transmit in the slots you are NOT receiving in, so its period had no decodes and the whole line was dropped; a transmit slot now creates its period. And the state is a strip of its own at the top, because it is the one thing on the screen that is about the operator rather than the band. It is fed by every Status rather than only by one carrying transmit text, so it can still name the station being called on MSHV and older JTDX builds, which stop before tx_message in the Status payload. "New only" became per-category badges, in the colours and the vocabulary of the Chase New panel. None lit shows the whole band - this is a decode log first, and a panel that opened by hiding most of the traffic would be lying about what is on the air.
This commit is contained in:
@@ -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}
|
||||
|
||||
+28
-12
@@ -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<DecodeRow[]>([]);
|
||||
const [txMsgs, setTxMsgs] = useState<TxMsgRow[]>([]);
|
||||
// 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<TxMsgRow | null>(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<DecodeRow[]>([]);
|
||||
@@ -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() {
|
||||
<DecodesPanel
|
||||
decodes={decodes}
|
||||
txMsgs={txMsgs}
|
||||
txState={txState}
|
||||
spotStatus={spotStatus as any}
|
||||
myCall={station.callsign}
|
||||
// Same handler as a cluster spot click: one way to answer a
|
||||
// station, whether it came off the telnet feed or the receiver.
|
||||
onCall={(d) => 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)));
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
@@ -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<string, StatusEntry>;
|
||||
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<NewCat> {
|
||||
const out = new Set<NewCat>();
|
||||
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<Set<NewCat>>(() => {
|
||||
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
|
||||
<button type="button" className={chip(cqOnly, 'success')} onClick={() => setCqOnly((v) => !v)}>
|
||||
{t('dec.cqOnly')}
|
||||
</button>
|
||||
<button type="button" className={chip(newOnly)} onClick={() => setNewOnly((v) => !v)}>
|
||||
{t('dec.newOnly')}
|
||||
</button>
|
||||
<button type="button" className={chip(lotwOnly)} onClick={() => setLotwOnly((v) => !v)}>
|
||||
{t('dec.lotwOnly')}
|
||||
</button>
|
||||
|
||||
{/* Per-category badges, in the colours of the flags they select — the
|
||||
same vocabulary as the Chase New panel. */}
|
||||
<span className="flex items-center gap-1 pl-1 border-l border-border/60 ml-1" title={t('dec.catsHint')}>
|
||||
{NEW_CATS.map((c) => {
|
||||
const on = cats.has(c.key);
|
||||
return (
|
||||
<button
|
||||
key={c.key}
|
||||
type="button"
|
||||
onClick={() => toggleCat(c.key)}
|
||||
className={cn(
|
||||
'rounded border px-1.5 py-0.5 text-[11px] font-bold uppercase tracking-wide transition-all',
|
||||
on ? 'border-transparent' : 'border-border text-muted-foreground opacity-50 hover:opacity-100',
|
||||
)}
|
||||
style={on ? { color: c.colour, borderColor: c.colour } : undefined}
|
||||
>
|
||||
{t(c.label)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
|
||||
{/* Only when there is a choice to make — see the memo above. */}
|
||||
{bands.length > 1 && (
|
||||
<select className={sel} value={bandSel} onChange={(e) => setBandSel(e.target.value)}>
|
||||
@@ -332,12 +422,47 @@ export function DecodesPanel({ decodes, txMsgs, spotStatus, onCall, myCall }: Pr
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* ── 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) && (
|
||||
<div className={cn('flex items-center gap-3 px-3 py-2 shrink-0 border-b',
|
||||
txState.transmitting ? 'bg-primary/15 border-primary/40' : 'bg-muted/40 border-border')}>
|
||||
<span className={cn('inline-flex items-center gap-1.5 text-[11px] font-bold uppercase tracking-wider shrink-0',
|
||||
txState.transmitting ? 'text-primary' : 'text-muted-foreground')}>
|
||||
{txState.transmitting && <span className="size-2 rounded-full bg-primary animate-pulse" />}
|
||||
{txState.transmitting ? t('dec.txNow') : t('dec.txIdle')}
|
||||
</span>
|
||||
{txState.msg
|
||||
? <span className="font-mono text-base font-semibold text-foreground truncate">{txState.msg}</span>
|
||||
: <span className="text-sm text-muted-foreground italic">{t('dec.txUnknown')}</span>}
|
||||
{txState.dx_call && (
|
||||
<span className="flex items-center gap-1.5 shrink-0">
|
||||
<span className="text-xs text-muted-foreground uppercase tracking-wider">{t('dec.working')}</span>
|
||||
<span className="font-mono text-base font-bold text-warning">{txState.dx_call}</span>
|
||||
</span>
|
||||
)}
|
||||
<span className="flex-1" />
|
||||
{txState.band && <span className="text-xs text-muted-foreground shrink-0">{txState.band}</span>}
|
||||
{txState.instance && instances.length > 1 && (
|
||||
<span className="text-xs text-muted-foreground shrink-0">{txState.instance}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Column header ──────────────────────────────────────────── */}
|
||||
<div className="shrink-0 border-b border-border bg-background">
|
||||
<div className={cn(ROW, 'h-8 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground')}>
|
||||
<span />
|
||||
<span className={CELL}>{t('dec.colCall')}</span>
|
||||
<span className={cn(CELL, 'justify-end')}>{t('dec.colSnr')}</span>
|
||||
<span className={cn(CELL, 'justify-end')} title={t('dec.colDtTitle')}>{t('dec.colDt')}</span>
|
||||
<span className={cn(CELL, 'justify-end')} title={t('dec.colFreqTitle')}>{t('dec.colFreq')}</span>
|
||||
<span className={CELL}>{t('dec.colGrid')}</span>
|
||||
<span className={CELL}>{t('dec.colMsg')}</span>
|
||||
<span className={CELL}>{t('dec.colFlags')}</span>
|
||||
@@ -421,6 +546,21 @@ export function DecodesPanel({ decodes, txMsgs, spotStatus, onCall, myCall }: Pr
|
||||
{d.snr > 0 ? `+${d.snr}` : d.snr}
|
||||
</span>
|
||||
|
||||
{/* DT — how far into the slot the transmission started. Past
|
||||
about ±2 s a station is drifting out of the window, so the
|
||||
figure earns a warning tint rather than staying grey. */}
|
||||
<span className={cn(CELL, 'justify-end font-mono text-xs tabular-nums',
|
||||
Math.abs(d.dt ?? 0) > 2 ? 'text-warning' : 'text-muted-foreground')}>
|
||||
{d.dt == null ? '' : d.dt.toFixed(1)}
|
||||
</span>
|
||||
|
||||
{/* The audio offset inside the passband, which is what WSJT-X
|
||||
calls Freq — not the RF frequency, which is the same for
|
||||
every station in the list and would say nothing. */}
|
||||
<span className={cn(CELL, 'justify-end font-mono text-xs text-muted-foreground tabular-nums')}>
|
||||
{d.audio_hz ?? ''}
|
||||
</span>
|
||||
|
||||
<span className={cn(CELL, 'font-mono text-xs text-muted-foreground')}>
|
||||
{d.grid ?? ''}
|
||||
</span>
|
||||
|
||||
@@ -125,12 +125,17 @@ const en: Dict = {
|
||||
'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.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.txUnknown': 'this application does not report its transmit text',
|
||||
'dec.colCall': 'Call', 'dec.colSnr': 'SNR', 'dec.colGrid': 'Grid', 'dec.colMsg': 'Message', 'dec.colFlags': 'New', 'dec.colCountry': 'Country',
|
||||
'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',
|
||||
@@ -593,12 +598,17 @@ const fr: Dict = {
|
||||
'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.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.txUnknown': 'ce logiciel ne communique pas son texte d emission',
|
||||
'dec.colCall': 'Indicatif', 'dec.colSnr': 'SNR', 'dec.colGrid': 'Locator', 'dec.colMsg': 'Message', 'dec.colFlags': 'Nouveau', 'dec.colCountry': 'Pays',
|
||||
'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',
|
||||
|
||||
Vendored
+2
@@ -53,6 +53,8 @@ export function AmpPower(arg1:string,arg2:boolean):Promise<void>;
|
||||
|
||||
export function AmpPowerLevel(arg1:string,arg2:string):Promise<void>;
|
||||
|
||||
export function AnswerDecode(arg1:string,arg2:number,arg3:number,arg4:number,arg5:number,arg6:string,arg7:string,arg8:boolean):Promise<void>;
|
||||
|
||||
export function AntGeniusActivate(arg1:number,arg2:number):Promise<void>;
|
||||
|
||||
export function AntGeniusDeselect(arg1:number):Promise<void>;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -127,6 +127,13 @@ type Event struct {
|
||||
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
|
||||
@@ -173,6 +180,9 @@ type Server struct {
|
||||
// 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
|
||||
@@ -397,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 {
|
||||
@@ -417,11 +439,14 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
|
||||
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.
|
||||
// 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()
|
||||
@@ -446,6 +471,10 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
|
||||
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
|
||||
|
||||
@@ -55,6 +55,10 @@ 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
|
||||
@@ -298,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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user