refactor(decodes): real columns, spelled-out flags, bigger type

First pass on the panel from operating feedback.

Columns are a grid template shared by the header row and every data row, so
the two cannot drift and the eye has a rail to follow. It is capped at
1500 px and centred: free-flowing, a 2500 px window put the country a foot
from the callsign it belonged to and left a hole in the middle of every
line.

"New" gets a COLUMN. It was only a coloured edge before, which says
something is special without saying what — and every one of these is a
reason to break off what you are doing and call. The entity verdict is a
solid badge, the orthogonal ones (park, grid, prefix, county) are outlined
in the colours markerColour already gives the cluster list and the band
map, so a new park is the same green in all three. Applied inline because
those are categorical --chart-* custom properties, which the theme does not
expose as Tailwind colour utilities: written as border-chart-7 the badge
would simply have had no colour.

Band and mode selectors now appear only when the feed actually carries more
than one of each. One MSHV is one band and one mode, so for most operators
they were furniture; they show up the day a second instance puts a second
band on the link, which is the only day they mean anything. Same rule for
continent, and a receiver count when more than one instance is feeding.

Added a LoTW-only filter, and raised the type throughout (call and message
to 14 px, secondary to 12 px, badges to 11 px) with more room per row.

The decode payload now carries the sending application's own id. It tells
two receivers apart on one multicast group — and it is the address a
WSJT-X Reply message would have to go back to, so it is carried now rather
than requiring another trip through the parser later.
This commit is contained in:
2026-08-18 06:04:28 +02:00
parent a197d124dc
commit 4f77d51ffe
4 changed files with 215 additions and 137 deletions
+1
View File
@@ -12712,6 +12712,7 @@ func (a *App) consumeUDPEvents() {
"tr_period": ev.DecodeTRPeriod,
"off_air": ev.DecodeOffAir,
"source": ev.Source,
"instance": ev.ProgramID,
})
// A WSJT-X decode (heard station). Render it on the FlexRadio
// panadapter when the option is on; green + SNR comment, auto-expiring
+156 -92
View File
@@ -16,7 +16,7 @@ import { useMemo, useState } from 'react';
import { Radio, Search, X, Signal, ArrowUpRight } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import { activeMarkers, markerColour } from '@/lib/spotMarkers';
import { markerColour, type SpotMarkerKey } from '@/lib/spotMarkers';
export type Decode = {
call: string;
@@ -32,6 +32,7 @@ export type Decode = {
tr_period?: number;
off_air?: boolean;
source?: string;
instance?: string;
};
export type TxMsg = {
@@ -69,23 +70,43 @@ interface Props {
// arrives here; a wrong guess only mis-groups, it never loses a decode.
const DEFAULT_TR = 15;
// Status → the pill's look. The vocabulary is the cluster's, deliberately: the
// same fact must not be amber in one panel and green in the next.
const STATUS_STYLE: Record<string, string> = {
'new': 'bg-success text-success-foreground',
'new-band': 'bg-warning text-warning-foreground',
'new-mode': 'bg-info text-info-foreground',
'new-slot': 'bg-caution text-caution-foreground',
'new-call': 'bg-muted text-muted-foreground',
// ROW is the column template, shared by the header and every row so the two can
// never drift. Fixed widths for the short fields, one flexible column for the
// message — and a hard cap on the whole grid, because on a 2500 px screen a
// free-flowing row puts the country a foot away from the callsign it belongs to
// and the eye has to travel the gap on every line.
const ROW = 'grid grid-cols-[3px_112px_56px_60px_1fr_260px_170px_28px] gap-x-3 items-center';
const ROW_MAX = 'max-w-[1500px]';
// 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<string, { label: string; cls: string }> = {
'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' },
};
const STATUS_LABEL: Record<string, string> = {
'new': 'dec.stNew',
'new-band': 'dec.stBand',
'new-mode': 'dec.stMode',
'new-slot': 'dec.stSlot',
'new-call': 'dec.stCall',
};
// 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' },
];
// periodStart floors a decode's own timestamp to its slot. Its OWN timestamp,
// not arrival: a period's decodes reach us in one burst a second or two after
@@ -101,19 +122,20 @@ function periodStart(at: string, tr: number): number {
const hhmmss = (epochSec: number) =>
new Date(epochSec * 1000).toISOString().slice(11, 19);
// snrTone colours the report by readability rather than by a gradient: an
// 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';
return 'text-muted-foreground/70';
}
export function DecodesPanel({ decodes, txMsgs, spotStatus, onCall, myCall }: Props) {
const { t } = useI18n();
const [cqOnly, setCqOnly] = useState(false);
const [newOnly, setNewOnly] = useState(false);
const [lotwOnly, setLotwOnly] = useState(false);
const [bandSel, setBandSel] = useState('');
const [modeSel, setModeSel] = useState('');
const [contSel, setContSel] = useState('');
@@ -123,18 +145,21 @@ export function DecodesPanel({ decodes, txMsgs, spotStatus, onCall, myCall }: Pr
const statusOf = (d: Decode): StatusEntry | undefined =>
spotStatus[`${d.call}|${d.band ?? ''}|${(d.mode ?? '').toUpperCase()}`];
// The band / mode / continent choices are built from what is actually on the
// feed, not from a fixed list: a selector offering 160 m to an operator whose
// receivers are all on 6 m is noise.
const { bands, modes, conts } = useMemo(() => {
const b = new Set<string>(), m = new Set<string>(), c = new Set<string>();
// 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<string>(), m = new Set<string>(), c = new Set<string>(), i = new Set<string>();
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() };
return { bands: [...b].sort(), modes: [...m].sort(), conts: [...c].sort(), instances: [...i].sort() };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [decodes, spotStatus]);
@@ -150,13 +175,14 @@ export function DecodesPanel({ decodes, txMsgs, spotStatus, onCall, myCall }: Pr
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;
if (newOnly && !isNewSomething(e)) return false;
if (contSel && e?.continent !== contSel) return false;
if (q && !(d.call.includes(q) || (d.grid ?? '').toUpperCase().includes(q) || (d.msg ?? '').toUpperCase().includes(q))) return false;
return true;
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [decodes, spotStatus, cqOnly, newOnly, bandSel, modeSel, contSel, minSnr, search]);
}, [decodes, spotStatus, cqOnly, newOnly, lotwOnly, bandSel, modeSel, contSel, minSnr, search]);
// Group into periods, newest first, and drop the operator's transmissions into
// the slot they went out in.
@@ -188,78 +214,113 @@ export function DecodesPanel({ decodes, txMsgs, spotStatus, onCall, myCall }: Pr
}, [filtered, txMsgs]);
const resetFilters = () => {
setCqOnly(false); setNewOnly(false); setBandSel(''); setModeSel('');
setContSel(''); setMinSnr(''); setSearch('');
setCqOnly(false); setNewOnly(false); setLotwOnly(false); setBandSel('');
setModeSel(''); setContSel(''); setMinSnr(''); setSearch('');
};
const anyFilter = cqOnly || newOnly || !!bandSel || !!modeSel || !!contSel || !!minSnr || !!search.trim();
const anyFilter = cqOnly || newOnly || lotwOnly || !!bandSel || !!modeSel || !!contSel || !!minSnr || !!search.trim();
const sel = 'h-7 rounded-md border border-border bg-background px-2 text-xs';
const chip = (on: boolean) => cn(
'h-7 px-2.5 rounded-full border text-xs font-medium transition-colors',
on ? 'border-primary bg-primary text-primary-foreground' : 'border-border text-muted-foreground hover:bg-muted',
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 (
<div className="flex flex-col h-full min-h-0">
{/* ── Filter bar ─────────────────────────────────────────────── */}
<div className="flex flex-wrap items-center gap-1.5 px-3 py-2 border-b border-border bg-muted/30 shrink-0">
<div className="flex flex-wrap items-center gap-2 px-4 py-2.5 border-b border-border bg-muted/30 shrink-0">
<Radio className="size-4 text-primary shrink-0" />
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mr-1">
<span className="text-sm font-semibold uppercase tracking-wider text-muted-foreground mr-1">
{t('dec.title')}
</span>
<button type="button" className={chip(cqOnly)} onClick={() => setCqOnly((v) => !v)}>
<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>
{/* 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)}>
<option value="">{t('dec.allBands')}</option>
{bands.map((b) => <option key={b} value={b}>{b}</option>)}
</select>
)}
{modes.length > 1 && (
<select className={sel} value={modeSel} onChange={(e) => setModeSel(e.target.value)}>
<option value="">{t('dec.allModes')}</option>
{modes.map((m) => <option key={m} value={m}>{m}</option>)}
</select>
)}
{conts.length > 1 && (
<select className={sel} value={contSel} onChange={(e) => setContSel(e.target.value)}>
<option value="">{t('dec.allConts')}</option>
{conts.map((c) => <option key={c} value={c}>{c}</option>)}
</select>
)}
<label className="flex items-center gap-1 text-xs text-muted-foreground">
<Signal className="size-3.5" />
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
<Signal className="size-4" />
<input
type="number" placeholder="dB" value={minSnr}
onChange={(e) => setMinSnr(e.target.value)}
className="h-7 w-16 rounded-md border border-border bg-background px-1.5 text-xs tabular-nums"
className="h-8 w-20 rounded-lg border border-border bg-background px-2 text-sm tabular-nums"
title={t('dec.minSnrTitle')}
/>
</label>
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none" />
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-4 text-muted-foreground pointer-events-none" />
<input
value={search} onChange={(e) => setSearch(e.target.value)}
placeholder={t('dec.searchPh')}
className="h-7 w-44 rounded-md border border-border bg-background pl-7 pr-2 text-xs"
className="h-8 w-56 rounded-lg border border-border bg-background pl-8 pr-2 text-sm"
/>
</div>
{anyFilter && (
<button type="button" onClick={resetFilters}
className="h-7 px-2 rounded-md text-xs text-muted-foreground hover:bg-muted inline-flex items-center gap-1">
<X className="size-3" /> {t('dec.clearFilters')}
className="h-8 px-2.5 rounded-lg text-sm text-muted-foreground hover:bg-muted hover:text-foreground inline-flex items-center gap-1">
<X className="size-3.5" /> {t('dec.clearFilters')}
</button>
)}
<span className="flex-1" />
<span className="text-[11px] text-muted-foreground tabular-nums">
{/* Which receivers are feeding this. Only with more than one — with a
single MSHV it is a label stating the obvious. */}
{instances.length > 1 && (
<span className="text-xs text-muted-foreground">
{t('dec.instances', { n: instances.length })}
</span>
)}
<span className="text-sm text-muted-foreground tabular-nums">
{t('dec.count', { shown: filtered.length, total: decodes.length })}
</span>
</div>
{/* ── Column header ──────────────────────────────────────────── */}
<div className="px-4 shrink-0 border-b border-border bg-background">
<div className={cn(ROW, ROW_MAX, 'mx-auto py-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground')}>
<span />
<span>{t('dec.colCall')}</span>
<span className="text-right">{t('dec.colSnr')}</span>
<span>{t('dec.colGrid')}</span>
<span>{t('dec.colMsg')}</span>
<span>{t('dec.colFlags')}</span>
<span>{t('dec.colCountry')}</span>
<span className="text-center" title="LoTW">L</span>
</div>
</div>
{/* ── Periods ────────────────────────────────────────────────── */}
<div className="flex-1 min-h-0 overflow-y-auto">
{groups.length === 0 && (
@@ -271,105 +332,108 @@ export function DecodesPanel({ decodes, txMsgs, spotStatus, onCall, myCall }: Pr
)}
{groups.map((g, gi) => (
<section key={g.start} className="border-b border-border/60">
{/* Period header — sticky so the slot you are reading is always named. */}
<header className={cn(
'sticky top-0 z-10 flex items-center gap-2 px-3 py-1 backdrop-blur',
'bg-muted/80 border-b border-border/60',
)}>
<span className={cn('font-mono text-xs font-bold tabular-nums',
<section key={g.start}>
{/* Period header — sticky, so the slot being read is always named. */}
<header className="sticky top-0 z-10 px-4 bg-muted/95 backdrop-blur border-y border-border/60">
<div className={cn(ROW_MAX, 'mx-auto flex items-center gap-2.5 py-1.5')}>
<span className={cn('font-mono text-sm font-bold tabular-nums',
gi === 0 ? 'text-primary' : 'text-foreground')}>
{hhmmss(g.start)}
</span>
<span className="text-[11px] text-muted-foreground">
<span className="text-xs text-muted-foreground">
{t('dec.periodCount', { n: g.decodes.length })}
</span>
{gi === 0 && (
<span className="inline-flex items-center gap-1 text-[10px] font-semibold uppercase tracking-wider text-success">
<span className="inline-flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wider text-success">
<span className="size-1.5 rounded-full bg-success animate-pulse" />
{t('dec.live')}
</span>
)}
</div>
</header>
{/* 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) => (
<div key={`tx-${i}`}
className="flex items-center gap-2 px-3 py-1 bg-primary/10 border-l-2 border-primary">
<ArrowUpRight className="size-3.5 text-primary shrink-0" />
<span className="text-[10px] font-bold uppercase tracking-wider text-primary shrink-0">
<div key={`tx-${i}`} className="px-4 bg-primary/10">
<div className={cn(ROW_MAX, 'mx-auto flex items-center gap-2.5 py-1.5 border-l-2 border-primary pl-2')}>
<ArrowUpRight className="size-4 text-primary shrink-0" />
<span className="text-[11px] font-bold uppercase tracking-wider text-primary shrink-0">
{t('dec.tx')}
</span>
<span className="font-mono text-xs text-foreground truncate">{m.msg}</span>
{m.band && <span className="ml-auto text-[10px] text-muted-foreground shrink-0">{m.band}</span>}
<span className="font-mono text-sm text-foreground truncate">{m.msg}</span>
{m.band && <span className="ml-auto text-xs text-muted-foreground shrink-0">{m.band}</span>}
</div>
</div>
))}
{g.decodes.map((d, i) => {
const e = statusOf(d);
const st = e?.status && e.status !== 'worked' ? e.status : '';
const markers = activeMarkers(e as any);
const entity = st ? ENTITY_BADGE[st] : undefined;
const extras = EXTRA_BADGES.filter((b) => !!e?.[b.key]);
const mine = myCall && d.call === myCall.toUpperCase();
const hot = !!entity || extras.length > 0;
return (
<div key={`${d.call}-${d.freq_hz}-${i}`}
className={cn('px-4 transition-colors', mine ? 'bg-info/10' : 'hover:bg-muted/50')}>
<button
key={`${d.call}-${d.freq_hz}-${i}`}
type="button"
onClick={() => onCall(d)}
title={t('dec.callTitle', { call: d.call })}
className={cn(
'w-full flex items-center gap-2 px-3 py-1 text-left transition-colors',
'hover:bg-muted/60 active:scale-[0.998]',
mine && 'bg-info/10',
)}
className={cn(ROW, ROW_MAX, 'mx-auto w-full py-1.5 text-left')}
>
{/* Marker rail: one segment per orthogonal flag, same colours as
the cluster list and the band map. */}
<span className="flex flex-col w-1 h-5 rounded-full overflow-hidden shrink-0">
{markers.length === 0
? <span className="flex-1 bg-transparent" />
: markers.map((m) => (
<span key={m.key} className="flex-1" style={{ background: markerColour(m.key) }} />
))}
</span>
{/* A thin accent, kept only as a hint that the row carries a
reason — the reasons themselves are spelled out in the
flags column, where they can be read. */}
<span className={cn('h-5 rounded-full', hot ? 'bg-success' : 'bg-transparent')} />
<span className={cn('font-mono text-xs font-bold w-[92px] shrink-0 truncate',
<span className={cn('font-mono text-sm font-bold truncate',
e?.worked_call ? 'text-muted-foreground' : 'text-foreground')}>
{d.call}
</span>
<span className={cn('font-mono text-xs w-11 text-right tabular-nums shrink-0', snrTone(d.snr))}>
<span className={cn('font-mono text-sm text-right tabular-nums', snrTone(d.snr))}>
{d.snr > 0 ? `+${d.snr}` : d.snr}
</span>
<span className="font-mono text-[11px] text-muted-foreground w-14 shrink-0">
<span className="font-mono text-xs text-muted-foreground">
{d.grid ?? ''}
</span>
{d.cq && (
<span className="text-[10px] font-bold text-success shrink-0">CQ</span>
)}
<span className="font-mono text-[11px] text-muted-foreground truncate flex-1 min-w-0">
{d.msg}
<span className="font-mono text-sm truncate min-w-0">
{d.cq && <span className="font-bold text-success mr-1.5">CQ</span>}
<span className="text-muted-foreground">{d.msg}</span>
</span>
{e?.country && (
<span className="text-[11px] text-muted-foreground truncate max-w-[130px] shrink-0 hidden xl:block">
{e.country}
{/* Flags — the column this panel exists for. */}
<span className="flex items-center gap-1 overflow-hidden">
{entity && (
<span className={cn('text-[11px] font-bold uppercase tracking-wide px-1.5 py-0.5 rounded shrink-0', entity.cls)}>
{t(entity.label)}
</span>
)}
{extras.map((b) => (
<span key={b.key as string}
className="text-[11px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded border bg-transparent shrink-0"
style={{ borderColor: markerColour(b.marker), color: markerColour(b.marker) }}>
{t(b.label)}
</span>
))}
</span>
<span className="text-xs text-muted-foreground truncate">
{e?.country ?? ''}
</span>
<span className="text-center">
{e?.lotw && (
<span className="text-[9px] font-bold px-1 rounded bg-info-muted text-info-muted-foreground shrink-0"
<span className="text-[11px] font-bold px-1.5 py-0.5 rounded bg-info-muted text-info-muted-foreground"
title="LoTW">L</span>
)}
{st && (
<span className={cn('text-[9px] font-bold uppercase tracking-wide px-1.5 py-px rounded shrink-0',
STATUS_STYLE[st] ?? 'bg-muted text-muted-foreground')}>
{t(STATUS_LABEL[st] ?? 'dec.stNew')}
</span>
)}
</button>
</div>
);
})}
</section>
+6
View File
@@ -130,6 +130,9 @@ const en: Dict = {
'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.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',
'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.',
@@ -595,6 +598,9 @@ const fr: Dict = {
'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.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',
'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.',
+7
View File
@@ -127,6 +127,12 @@ 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
// 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.
@@ -439,6 +445,7 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
ev.DecodeTRPeriod = tr
ev.DecodeDial = dial
ev.DecodeOffAir = w.OffAir
ev.ProgramID = w.ProgramID
break
}
// Only a logged QSO is worth a line — WSJT-X/MSHV send a Status packet