Merge branch 'feature/ftx-decodes'
An FT decodes panel fed by the inbound UDP link: every decode from WSJT-X, JTDX or MSHV, grouped by T/R period, with the new-entity flags the cluster already computes and the operator's own transmissions threaded into the slots they went out in. Clicking a line answers the station through a WSJT-X Reply, routed to the instance that heard it. Available both as a closable tab and as a Main-view pane.
This commit is contained in:
@@ -12689,6 +12689,27 @@ func (a *App) consumeUDPEvents() {
|
|||||||
if a.ctx == nil {
|
if a.ctx == nil {
|
||||||
continue
|
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 {
|
switch {
|
||||||
case ev.DecodeCall != "":
|
case ev.DecodeCall != "":
|
||||||
// Remember the grid before anything else: a CQ is the one message that
|
// Remember the grid before anything else: a CQ is the one message that
|
||||||
@@ -12696,6 +12717,37 @@ func (a *App) consumeUDPEvents() {
|
|||||||
if ev.DecodeGrid != "" {
|
if ev.DecodeGrid != "" {
|
||||||
a.rememberDecodeGrid(ev.DecodeCall, ev.DecodeGrid, gridcache.SourceDecode)
|
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
|
// A WSJT-X decode (heard station). Render it on the FlexRadio
|
||||||
// panadapter when the option is on; green + SNR comment, auto-expiring
|
// panadapter when the option is on; green + SNR comment, auto-expiring
|
||||||
// after the configured duration. De-duped per call in the Flex backend.
|
// 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
|
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.
|
// GetGridCacheStatus reports what the locator store holds.
|
||||||
func (a *App) GetGridCacheStatus() GridCacheStatus {
|
func (a *App) GetGridCacheStatus() GridCacheStatus {
|
||||||
out := GridCacheStatus{Enabled: a.gridStore != nil}
|
out := GridCacheStatus{Enabled: a.gridStore != nil}
|
||||||
|
|||||||
+6
-2
@@ -4,11 +4,15 @@
|
|||||||
"date": "",
|
"date": "",
|
||||||
"en": [
|
"en": [
|
||||||
"Club Log uploads are now identified as OpsLog. They were credited to another station's application key, which took the blame for them.",
|
"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": [
|
"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é.",
|
"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."
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
+206
-5
@@ -94,7 +94,7 @@ import { ShutdownProgress } from '@/components/ShutdownProgress';
|
|||||||
import { ClusterGrid } from '@/components/ClusterGrid';
|
import { ClusterGrid } from '@/components/ClusterGrid';
|
||||||
import { cleanSpotter, inferSpotMode, spotModeCategory, spotStatusKey } from '@/lib/spot';
|
import { cleanSpotter, inferSpotMode, spotModeCategory, spotStatusKey } from '@/lib/spot';
|
||||||
import { applySpotDisplay, readSpotDisplayOptions, spotIsWorked, SPOT_DISPLAY_OPTIONS_EXPOSED } from '@/lib/spotDisplay';
|
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 { applyMatrixColors } from '@/lib/matrixColors';
|
||||||
import { WorkedBeforeGrid } from '@/components/WorkedBeforeGrid';
|
import { WorkedBeforeGrid } from '@/components/WorkedBeforeGrid';
|
||||||
import { NetControlPanel } from '@/components/NetControlPanel';
|
import { NetControlPanel } from '@/components/NetControlPanel';
|
||||||
@@ -108,6 +108,7 @@ import { DetailsPanel, type DetailsState } from '@/components/DetailsPanel';
|
|||||||
import { SendSpotModal, type RecentSpotQSO } from '@/components/SendSpotModal';
|
import { SendSpotModal, type RecentSpotQSO } from '@/components/SendSpotModal';
|
||||||
import { WinkeyerPanel, type WKStatus, type WKMacro } from '@/components/WinkeyerPanel';
|
import { WinkeyerPanel, type WKStatus, type WKMacro } from '@/components/WinkeyerPanel';
|
||||||
import { RotorCompass } from '@/components/RotorCompass';
|
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 { subscribeRotorHeading, pokeRotorHeading } from '@/lib/rotorHeading';
|
||||||
import { writeUiPref } from '@/lib/uiPref';
|
import { writeUiPref } from '@/lib/uiPref';
|
||||||
import { formatDateTimeUTC } from '@/lib/dateFormat';
|
import { formatDateTimeUTC } from '@/lib/dateFormat';
|
||||||
@@ -1031,6 +1032,20 @@ export default function App() {
|
|||||||
setStationTabOpen(false);
|
setStationTabOpen(false);
|
||||||
setActiveTab((t) => (t === 'station' ? 'recent' : t));
|
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
|
// Recent QSOs row cap, persisted. With AG Grid's virtual scroller
|
||||||
// huge logs render OK once loaded, but a 25k+ logbook still takes a
|
// huge logs render OK once loaded, but a 25k+ logbook still takes a
|
||||||
// couple of seconds to round-trip from SQLite at launch. Defaulting
|
// 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
|
// map ("map1"), the locator street map ("map2"), the cluster grid or the
|
||||||
// worked-before grid. Per-profile (stored via SetUIPref → profile-prefixed),
|
// worked-before grid. Per-profile (stored via SetUIPref → profile-prefixed),
|
||||||
// so it's loaded async on mount and re-read on profile:changed below.
|
// 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 [mapZoomSignal, setMapZoomSignal] = useState(0); // bump → world map auto-zooms now
|
||||||
const [mainPaneLeft, setMainPaneLeft] = useState<MainPaneKind>('map1');
|
const [mainPaneLeft, setMainPaneLeft] = useState<MainPaneKind>('map1');
|
||||||
const [mainPaneRight, setMainPaneRight] = useState<MainPaneKind>('map2');
|
const [mainPaneRight, setMainPaneRight] = useState<MainPaneKind>('map2');
|
||||||
const loadMainPanes = useCallback(async () => {
|
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([
|
const [l, r] = await Promise.all([
|
||||||
GetUIPref('mainPaneLeft').catch(() => ''),
|
GetUIPref('mainPaneLeft').catch(() => ''),
|
||||||
GetUIPref('mainPaneRight').catch(() => ''),
|
GetUIPref('mainPaneRight').catch(() => ''),
|
||||||
@@ -1685,6 +1700,10 @@ export default function App() {
|
|||||||
// a stale closure.
|
// a stale closure.
|
||||||
const spotsRef = useRef(spots);
|
const spotsRef = useRef(spots);
|
||||||
useEffect(() => { spotsRef.current = spots; }, [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<DecodeRow[]>([]);
|
||||||
// Bound the status cache. Keyed per call|band|mode, it otherwise kept an entry
|
// 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
|
// 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
|
// 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);
|
const keys = Object.keys(prev);
|
||||||
if (keys.length <= SPOTS_CAP * 2) return 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)));
|
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 = {};
|
const pruned: typeof prev = {};
|
||||||
for (const k of keys) if (live.has(k)) pruned[k] = prev[k];
|
for (const k of keys) if (live.has(k)) pruned[k] = prev[k];
|
||||||
return pruned;
|
return pruned;
|
||||||
});
|
});
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [spots]);
|
}, [spots]);
|
||||||
// Re-fetch the status of every SHOWN spot and OVERWRITE the cache (merge, never
|
// 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
|
// 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.
|
// count (it scans the logbook once), so this is as cheap as the poll already is.
|
||||||
const refreshSpotStatuses = useCallback(async () => {
|
const refreshSpotStatuses = useCallback(async () => {
|
||||||
const cur = spotsRef.current;
|
const cur = spotsRef.current;
|
||||||
if (!cur.length) return;
|
|
||||||
const queries: { call: string; band: string; mode: string; pota_ref: string; spotter: string }[] = [];
|
const queries: { call: string; band: string; mode: string; pota_ref: string; spotter: string }[] = [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
for (const s of cur) {
|
for (const s of cur) {
|
||||||
@@ -1717,6 +1741,19 @@ export default function App() {
|
|||||||
seen.add(k);
|
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 ?? '' });
|
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;
|
if (!queries.length) return;
|
||||||
try {
|
try {
|
||||||
const res = await ClusterSpotStatuses(queries as any);
|
const res = await ClusterSpotStatuses(queries as any);
|
||||||
@@ -1749,7 +1786,8 @@ export default function App() {
|
|||||||
const spotsDirtyRef = useRef(false);
|
const spotsDirtyRef = useRef(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const vis = mainPaneLeft === 'cluster' || mainPaneRight === 'cluster'
|
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) {
|
if (vis && !spotsVisibleRef.current && spotsDirtyRef.current) {
|
||||||
spotsDirtyRef.current = false;
|
spotsDirtyRef.current = false;
|
||||||
void refreshSpotStatuses();
|
void refreshSpotStatuses();
|
||||||
@@ -2018,6 +2056,24 @@ export default function App() {
|
|||||||
// settings dialog closes, which is the only place it changes.
|
// settings dialog closes, which is the only place it changes.
|
||||||
const [rowColors, setRowColors] = useState<any>(null);
|
const [rowColors, setRowColors] = useState<any>(null);
|
||||||
useEffect(() => { GetRowColors().then(setRowColors).catch(() => {}); }, [showSettings]);
|
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<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);
|
||||||
|
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<DecodeRow[]>([]);
|
||||||
|
const pendingDecodeTimer = useRef<number | undefined>(undefined);
|
||||||
|
|
||||||
// Rotor quick-turn buttons (Settings → Rotator). Same reload trigger as the
|
// Rotor quick-turn buttons (Settings → Rotator). Same reload trigger as the
|
||||||
// row colours: the settings dialog is the only place they change.
|
// row colours: the settings dialog is the only place they change.
|
||||||
const [rotorPresets, setRotorPresets] = useState<{ label: string; azimuth: number }[]>([]);
|
const [rotorPresets, setRotorPresets] = useState<{ label: string; azimuth: number }[]>([]);
|
||||||
@@ -3010,6 +3066,89 @@ export default function App() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// 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<string>();
|
||||||
|
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 ───────────────────────────────────────────
|
// ── UDP integration events ───────────────────────────────────────────
|
||||||
// Live updates from external apps (WSJT-X / JTDX / MSHV / DXHunter…).
|
// Live updates from external apps (WSJT-X / JTDX / MSHV / DXHunter…).
|
||||||
// We push the broadcast DX call into the entry field and auto-log any
|
// 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('tools.qslManager'), action: 'tools.qslmanager' },
|
||||||
{ type: 'item', label: t('stats.tab'), action: 'tools.stats' },
|
{ type: 'item', label: t('stats.tab'), action: 'tools.stats' },
|
||||||
{ type: 'item', label: t('station.title'), action: 'tools.station' },
|
{ 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: 'item', label: t('tools.qslDesigner'), action: 'tools.qsldesigner' },
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
{ type: 'item', label: (wkEnabled ? '✓ ' : '') + t('tools.winkeyer'), action: 'tools.winkeyer' },
|
{ 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.qslmanager': setQslTabOpen(true); setActiveTab('qsl'); break;
|
||||||
case 'tools.stats': setStatsTabOpen(true); setActiveTab('stats'); break;
|
case 'tools.stats': setStatsTabOpen(true); setActiveTab('stats'); break;
|
||||||
case 'tools.station': setStationTabOpen(true); setActiveTab('station'); break;
|
case 'tools.station': setStationTabOpen(true); setActiveTab('station'); break;
|
||||||
|
case 'tools.decodes': openDecodesTab(); break;
|
||||||
case 'tools.qsldesigner': setQslDesignerOpen(true); break;
|
case 'tools.qsldesigner': setQslDesignerOpen(true); break;
|
||||||
case 'tools.winkeyer': wkSetEnabled(!wkEnabled); break;
|
case 'tools.winkeyer': wkSetEnabled(!wkEnabled); break;
|
||||||
case 'tools.dvk': setDvkEnabled((v) => !v); break;
|
case 'tools.dvk': setDvkEnabled((v) => !v); break;
|
||||||
@@ -5306,6 +5447,34 @@ export default function App() {
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 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 = () => (
|
||||||
|
<DecodesPanel
|
||||||
|
decodes={decodes}
|
||||||
|
txMsgs={txMsgs}
|
||||||
|
txState={txState}
|
||||||
|
spotStatus={spotStatus as any}
|
||||||
|
myCall={station.callsign}
|
||||||
|
// 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)));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
// Render one Main-view pane. The two sides (mainPaneLeft/Right) each pick from
|
// Render one Main-view pane. The two sides (mainPaneLeft/Right) each pick from
|
||||||
// the same four choices, configured per-profile in Settings → Main view.
|
// the same four choices, configured per-profile in Settings → Main view.
|
||||||
const renderMainPane = (kind: MainPaneKind) => {
|
const renderMainPane = (kind: MainPaneKind) => {
|
||||||
@@ -5324,6 +5493,14 @@ export default function App() {
|
|||||||
);
|
);
|
||||||
case 'map2':
|
case 'map2':
|
||||||
return <LocatorMap toGrid={grid} toLabel={callsign} />;
|
return <LocatorMap toGrid={grid} toLabel={callsign} />;
|
||||||
|
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 (
|
||||||
|
<div className="h-full w-full min-h-0 flex flex-col bg-card border border-border rounded-lg overflow-hidden">
|
||||||
|
{renderDecodesPanel()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
case 'cluster':
|
case 'cluster':
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full min-h-0 flex flex-col bg-card border border-border rounded-lg overflow-hidden">
|
<div className="h-full w-full min-h-0 flex flex-col bg-card border border-border rounded-lg overflow-hidden">
|
||||||
@@ -6596,6 +6773,24 @@ export default function App() {
|
|||||||
</span>
|
</span>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
)}
|
)}
|
||||||
|
{decodesTabOpen && (
|
||||||
|
<TabsTrigger value="decodes" className="gap-1.5">
|
||||||
|
{t('dec.tab')}
|
||||||
|
{decodes.length > 0 && (
|
||||||
|
<span className="text-[10px] tabular-nums text-muted-foreground">{decodes.length}</span>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
role="button"
|
||||||
|
aria-label="Close FT decodes"
|
||||||
|
title="Close"
|
||||||
|
className="inline-flex items-center justify-center size-4 rounded hover:bg-foreground/10 text-muted-foreground hover:text-foreground"
|
||||||
|
onPointerDown={(e) => { e.stopPropagation(); }}
|
||||||
|
onClick={(e) => { e.stopPropagation(); closeDecodesTab(); }}
|
||||||
|
>
|
||||||
|
<X className="size-3" />
|
||||||
|
</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
)}
|
||||||
{stationTabOpen && (
|
{stationTabOpen && (
|
||||||
<TabsTrigger value="station" className="gap-1.5">
|
<TabsTrigger value="station" className="gap-1.5">
|
||||||
{t('station.title')}
|
{t('station.title')}
|
||||||
@@ -7045,6 +7240,12 @@ export default function App() {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{decodesTabOpen && (
|
||||||
|
<TabsContent value="decodes" className="mt-0 flex flex-col min-h-0 flex-1 data-[state=inactive]:hidden">
|
||||||
|
{renderDecodesPanel()}
|
||||||
|
</TabsContent>
|
||||||
|
)}
|
||||||
|
|
||||||
{stationTabOpen && (
|
{stationTabOpen && (
|
||||||
<TabsContent value="station" className="mt-0 flex flex-col min-h-0 flex-1 data-[state=inactive]:hidden">
|
<TabsContent value="station" className="mt-0 flex flex-col min-h-0 flex-1 data-[state=inactive]:hidden">
|
||||||
<StationControlPanel
|
<StationControlPanel
|
||||||
|
|||||||
@@ -0,0 +1,761 @@
|
|||||||
|
// DecodesPanel — every FTx decode from the inbound UDP feed, grouped by T/R period.
|
||||||
|
//
|
||||||
|
// The point of this panel, and what makes it different from the cluster list, is
|
||||||
|
// the PERIOD. FT8 is a sequence of fifteen-second slots, and an operator reads a
|
||||||
|
// band by watching them go by: who called CQ this period, who answered, what I
|
||||||
|
// was sending while they did. A flat list sorted by time loses exactly that —
|
||||||
|
// the slot boundaries are where the information is.
|
||||||
|
//
|
||||||
|
// So the list is grouped, one section per period, newest first, with the
|
||||||
|
// operator's own transmission shown INSIDE the period it went out in.
|
||||||
|
//
|
||||||
|
// Status flags (new entity / band / mode / slot / grid / prefix / POTA / county)
|
||||||
|
// come from the same resolver the cluster uses, so a call means the same thing in
|
||||||
|
// both panels rather than being judged twice by two rules.
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Radio, Search, X, Signal, ArrowUpRight, Timer } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { markerColour, type SpotMarkerKey } from '@/lib/spotMarkers';
|
||||||
|
|
||||||
|
export type Decode = {
|
||||||
|
call: string;
|
||||||
|
grid?: string;
|
||||||
|
snr: number;
|
||||||
|
freq_hz: number;
|
||||||
|
dial_hz?: number;
|
||||||
|
band?: string;
|
||||||
|
mode?: string;
|
||||||
|
msg?: string;
|
||||||
|
cq?: boolean;
|
||||||
|
at: string;
|
||||||
|
tr_period?: number;
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
type StatusEntry = {
|
||||||
|
status?: string;
|
||||||
|
country?: string;
|
||||||
|
continent?: string;
|
||||||
|
worked_call?: boolean;
|
||||||
|
worked_slot?: boolean;
|
||||||
|
new_county?: boolean;
|
||||||
|
new_pota?: boolean;
|
||||||
|
new_pfx?: boolean;
|
||||||
|
new_grid?: boolean;
|
||||||
|
lotw?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
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 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<string, number> = {
|
||||||
|
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<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' },
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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 <span key={i} className="font-bold text-success">{tok.toUpperCase()}</span>;
|
||||||
|
if (me && bare === me) return <span key={i} className="font-bold text-success">{tok}</span>;
|
||||||
|
if (calling && bare === calling) return <span key={i} className="font-bold text-danger">{tok}</span>;
|
||||||
|
return <span key={i} className="text-foreground/85">{tok}</span>;
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<span className="flex items-center gap-2 shrink-0" title={mode ? `${mode} · ${trSec}s` : `${trSec}s`}>
|
||||||
|
<Timer className={cn('size-4', closing ? 'text-warning' : 'text-muted-foreground')} />
|
||||||
|
<span className="relative h-1.5 w-24 rounded-full bg-muted overflow-hidden">
|
||||||
|
<span
|
||||||
|
className={cn('absolute inset-y-0 left-0 rounded-full transition-[width] duration-100 ease-linear',
|
||||||
|
closing ? 'bg-warning' : 'bg-primary')}
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span className={cn('font-mono text-sm tabular-nums w-10 text-right',
|
||||||
|
closing ? 'text-warning font-semibold' : 'text-muted-foreground')}>
|
||||||
|
{left.toFixed(1)}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{mode ? `${mode} ${trSec}s` : `${trSec}s`}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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<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('');
|
||||||
|
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<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(), 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<number, { decodes: Decode[]; tx: TxMsg[] }>();
|
||||||
|
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 (
|
||||||
|
<div className="flex flex-col h-full min-h-0">
|
||||||
|
{/* ── Filter bar ─────────────────────────────────────────────── */}
|
||||||
|
<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-sm font-semibold uppercase tracking-wider text-muted-foreground mr-1">
|
||||||
|
{t('dec.title')}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* 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. */}
|
||||||
|
<PeriodClock trSec={liveTr} mode={liveMode} />
|
||||||
|
<span className="w-px h-5 bg-border/60 mx-1" />
|
||||||
|
|
||||||
|
<button type="button" className={chip(cqOnly, 'success')} onClick={() => setCqOnly((v) => !v)}>
|
||||||
|
{t('dec.cqOnly')}
|
||||||
|
</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)}>
|
||||||
|
<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.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-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.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-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-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" />
|
||||||
|
{/* 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>
|
||||||
|
|
||||||
|
{/* ── 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-7 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground')}>
|
||||||
|
<span className={CELL}>{t('dec.colTime')}</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.colBand')}</span>
|
||||||
|
<span className={CELL}>{t('dec.colMode')}</span>
|
||||||
|
<span className={CELL}>{t('dec.colMsg')}</span>
|
||||||
|
<span className={CELL}>{t('dec.colCountry')}</span>
|
||||||
|
<span className={CELL_LAST}>{t('dec.colStatus')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Periods ────────────────────────────────────────────────── */}
|
||||||
|
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||||
|
{groups.length === 0 && (
|
||||||
|
<div className="h-full flex items-center justify-center px-6 text-center">
|
||||||
|
<p className="text-sm text-muted-foreground max-w-md">
|
||||||
|
{decodes.length === 0 ? t('dec.empty') : t('dec.emptyFiltered')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{groups.map((g, gi) => (
|
||||||
|
<section key={g.start}>
|
||||||
|
{/* Period header — sticky, so the slot being read is always named. */}
|
||||||
|
<header className="sticky top-0 z-10 px-3 bg-muted/95 backdrop-blur border-y border-border/60">
|
||||||
|
<div className="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')}>
|
||||||
|
{periodLabel(g.start, g.tr)}
|
||||||
|
</span>
|
||||||
|
<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.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.5 py-1.5 pl-2 pr-3 bg-primary/10 border-l-2 border-primary">
|
||||||
|
<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-sm text-foreground truncate">{m.msg}</span>
|
||||||
|
{m.band && <span className="ml-auto text-xs text-muted-foreground shrink-0">{m.band}</span>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{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 (
|
||||||
|
<button
|
||||||
|
key={`${d.call}-${d.freq_hz}-${i}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onCall(d)}
|
||||||
|
title={t('dec.callTitle', { call: d.call })}
|
||||||
|
className={cn(ROW, 'w-full text-left border-b border-border/20 transition-colors',
|
||||||
|
replying ? 'bg-success/20 hover:bg-success/25'
|
||||||
|
: worked ? 'bg-danger/15 hover:bg-danger/20'
|
||||||
|
: mine ? 'bg-info/10'
|
||||||
|
: 'hover:bg-muted/50')}
|
||||||
|
>
|
||||||
|
{/* Time on EVERY row, compact. A decode belongs to a slot, but
|
||||||
|
it also has an instant, and reading one off a section
|
||||||
|
heading three screens up is not reading it. */}
|
||||||
|
<span className={cn(CELL, 'font-mono text-[11px] tabular-nums',
|
||||||
|
replying ? 'text-success' : worked ? 'text-danger' : 'text-muted-foreground/70')}>
|
||||||
|
{hhmmssCompact(d.at)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className={cn(CELL, 'justify-end font-mono text-[13px] font-semibold tabular-nums', snrTone(d.snr))}>
|
||||||
|
{d.snr > 0 ? `+${d.snr}` : d.snr}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Past about two seconds a station is drifting out of the
|
||||||
|
window, which is worth a tint rather than a shrug. */}
|
||||||
|
<span className={cn(CELL, 'justify-end font-mono text-[11px] tabular-nums',
|
||||||
|
Math.abs(d.dt ?? 0) > 2 ? 'text-warning' : 'text-muted-foreground/70')}>
|
||||||
|
{d.dt == null ? '' : d.dt.toFixed(1)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* The audio offset in the passband — what WSJT-X calls Freq.
|
||||||
|
Not the RF frequency, which is the same for every station
|
||||||
|
here and would say nothing. */}
|
||||||
|
<span className={cn(CELL, 'justify-end font-mono text-[11px] text-muted-foreground/70 tabular-nums')}>
|
||||||
|
{d.audio_hz ?? ''}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className={CELL}>
|
||||||
|
{d.band && (
|
||||||
|
<span className="rounded px-1 py-px text-[10px] font-bold uppercase bg-info-muted text-info-muted-foreground">
|
||||||
|
{d.band}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className={cn(CELL, 'font-mono text-[10px] text-muted-foreground/70')}>
|
||||||
|
{d.mode ?? ''}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* The message carries the callsign already — which is why
|
||||||
|
there is no column repeating it. */}
|
||||||
|
<span className={cn(CELL, 'font-mono text-[13px]')}>
|
||||||
|
<span className="truncate">{renderMsg(d.msg ?? '', me, calling)}</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className={cn(CELL, 'text-[11px] text-muted-foreground')}>
|
||||||
|
<span className="truncate">{e?.country ?? ''}</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Status: everything worth acting on, in one place at the
|
||||||
|
right edge, shortest first so the eye can scan the column
|
||||||
|
rather than read it. */}
|
||||||
|
<span className={CELL_LAST}>
|
||||||
|
{e?.lotw && (
|
||||||
|
<span className="text-[10px] font-bold text-info-muted-foreground shrink-0" title="LoTW">L</span>
|
||||||
|
)}
|
||||||
|
{e?.worked_call && (
|
||||||
|
<span className="rounded px-1 py-px text-[10px] font-medium bg-muted text-muted-foreground shrink-0">
|
||||||
|
{t('dec.wkd')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{entity && (
|
||||||
|
<span className={cn('rounded px-1 py-px text-[10px] font-bold uppercase tracking-wide shrink-0', entity.cls)}>
|
||||||
|
{t(entity.label)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{extras.map((b) => (
|
||||||
|
<span key={b.key as string}
|
||||||
|
className="rounded border px-1 py-px text-[10px] font-semibold uppercase tracking-wide bg-transparent shrink-0"
|
||||||
|
style={{ borderColor: markerColour(b.marker), color: markerColour(b.marker) }}>
|
||||||
|
{t(b.label)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{replying && (
|
||||||
|
<span className="rounded px-1 py-px text-[10px] font-bold uppercase bg-success text-success-foreground shrink-0">
|
||||||
|
{t('dec.toYou')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1029,7 +1029,7 @@ function RelayAutoPanel() {
|
|||||||
// panes show, independently: the great-circle map, the locator street map, the
|
// panes show, independently: the great-circle map, the locator street map, the
|
||||||
// cluster grid or the worked-before grid. Per-profile (stored via SetUIPref,
|
// 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.
|
// 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 }) {
|
function MainViewPanes({ onChanged, flexAvailable, icomAvailable, yaesuAvailable }: { onChanged?: (side: 'left' | 'right', value: string) => void; flexAvailable?: boolean; icomAvailable?: boolean; yaesuAvailable?: boolean }) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [left, setLeft] = useState('map1');
|
const [left, setLeft] = useState('map1');
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ const en: Dict = {
|
|||||||
'settings.leftPane': 'Left pane', 'settings.rightPane': 'Right pane',
|
'settings.leftPane': 'Left pane', 'settings.rightPane': 'Right pane',
|
||||||
'settings.pane.map1': 'Map — great-circle + beam', 'settings.pane.map2': 'Map — locator (street)',
|
'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.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',
|
'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.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',
|
'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.tipCallConf': 'This callsign confirmed', 'mx.tipCallWork': 'This callsign worked (not confirmed)',
|
||||||
'mx.tipDxConf': 'Entity confirmed (other callsign)', 'mx.tipDxWork': 'Entity worked (other callsign)',
|
'mx.tipDxConf': 'Entity confirmed (other callsign)', 'mx.tipDxWork': 'Entity worked (other callsign)',
|
||||||
'mx.tipNone': 'Never worked', 'mx.tipClick': 'click to list the QSOs',
|
'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.email': 'E-mail (SMTP)', 'sec.lookup': 'Callsign Lookup',
|
||||||
'sec.bands': 'Bands', 'sec.modes': 'Modes & default RST', 'sec.cluster': 'DX Cluster',
|
'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',
|
'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.leftPane': 'Volet gauche', 'settings.rightPane': 'Volet droit',
|
||||||
'settings.pane.map1': 'Carte — orthodromie + faisceau', 'settings.pane.map2': 'Carte — locator (rue)',
|
'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.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",
|
'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.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',
|
'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.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.tipDxConf': 'Entité confirmée (autre indicatif)', 'mx.tipDxWork': 'Entité contactée (autre indicatif)',
|
||||||
'mx.tipNone': 'Jamais contacté', 'mx.tipClick': 'cliquer pour lister les QSO',
|
'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.email': 'E-mail (SMTP)', 'sec.lookup': "Recherche d'indicatif",
|
||||||
'sec.bands': 'Bandes', 'sec.modes': 'Modes & RST par défaut', 'sec.cluster': 'DX Cluster',
|
'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',
|
'sec.udp': 'Connexions', 'sec.database': 'Base de données', 'sec.autostart': 'Démarrage auto', 'sec.backup': 'Sauvegarde base', 'sec.uscounties': 'Comtés US',
|
||||||
|
|||||||
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 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 AntGeniusActivate(arg1:number,arg2:number):Promise<void>;
|
||||||
|
|
||||||
export function AntGeniusDeselect(arg1: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);
|
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) {
|
export function AntGeniusActivate(arg1, arg2) {
|
||||||
return window['go']['main']['App']['AntGeniusActivate'](arg1, arg2);
|
return window['go']['main']['App']['AntGeniusActivate'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
// Event is what a Server emits to its consumer for every parsed packet.
|
||||||
// At most one of the fields is populated per event.
|
// At most one of the fields is populated per event.
|
||||||
type Event struct {
|
type Event struct {
|
||||||
@@ -96,6 +116,36 @@ type Event struct {
|
|||||||
DecodeFreqHz int64 // RF frequency (dial + audio offset)
|
DecodeFreqHz int64 // RF frequency (dial + audio offset)
|
||||||
DecodeSNR int // reported SNR (dB)
|
DecodeSNR int // reported SNR (dB)
|
||||||
DecodeCQ bool // the decode was a CQ
|
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
|
// 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
|
// Call after previously reporting one — i.e. the operator cleared the call in
|
||||||
@@ -127,6 +177,12 @@ type Server struct {
|
|||||||
// 50.400 panadapter. WSJT-X requires --rig-name for a second instance, so the
|
// 50.400 panadapter. WSJT-X requires --rig-name for a second instance, so the
|
||||||
// id is distinct whenever there is more than one.
|
// id is distinct whenever there is more than one.
|
||||||
dialHz map[string]int64
|
dialHz map[string]int64
|
||||||
|
// 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
|
lastDX string // WSJT: last non-empty DX Call seen, to detect a clear
|
||||||
|
|
||||||
// badPkts counts datagrams this listener could not parse, so the diagnostic
|
// badPkts counts datagrams this listener could not parse, so the diagnostic
|
||||||
@@ -351,6 +407,18 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
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
|
// Status carries the current dial frequency; remember it so Decode audio
|
||||||
// offsets can be turned into RF frequencies for the panadapter.
|
// offsets can be turned into RF frequencies for the panadapter.
|
||||||
if w.FreqHz > 0 && !w.IsDecode {
|
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 = map[string]int64{}
|
||||||
}
|
}
|
||||||
s.dialHz[w.ProgramID] = w.FreqHz
|
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()
|
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 {
|
if w.IsDecode {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
dial := s.dialHz[w.ProgramID]
|
dial := s.dialHz[w.ProgramID]
|
||||||
|
tr := s.trPeriod[w.ProgramID]
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
if dial <= 0 {
|
if dial <= 0 {
|
||||||
// No Status from THIS instance yet. Guessing with another
|
// 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.DecodeSNR = w.SNR
|
||||||
ev.DecodeCQ = w.IsCQ
|
ev.DecodeCQ = w.IsCQ
|
||||||
ev.Mode = w.Mode
|
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
|
break
|
||||||
}
|
}
|
||||||
// Only a logged QSO is worth a line — WSJT-X/MSHV send a Status packet
|
// 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 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
|
// empty (the DX Call was cleared in the digital app), and a tune-only
|
||||||
// request (freq with no callsign).
|
// 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
|
return
|
||||||
}
|
}
|
||||||
select {
|
select {
|
||||||
|
|||||||
@@ -55,6 +55,38 @@ type WSJTEvent struct {
|
|||||||
DeltaFreqHz int64 // audio offset within the passband (Hz)
|
DeltaFreqHz int64 // audio offset within the passband (Hz)
|
||||||
SNR int // reported signal-to-noise (dB)
|
SNR int // reported signal-to-noise (dB)
|
||||||
IsCQ bool // the decode was a CQ call
|
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
|
// 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
|
return WSJTEvent{}, false, err
|
||||||
}
|
}
|
||||||
ev.DXCall = strings.ToUpper(strings.TrimSpace(dxCall))
|
ev.DXCall = strings.ToUpper(strings.TrimSpace(dxCall))
|
||||||
// Skip report, tx_mode (QUtf8), tx_enabled (bool), transmitting,
|
// report, tx_mode → skipped.
|
||||||
// decoding, rx_df (qint32), tx_df (qint32), de_call (QUtf8),
|
|
||||||
// de_grid (QUtf8) → then dx_grid.
|
|
||||||
for _, name := range []string{"report", "tx_mode"} {
|
for _, name := range []string{"report", "tx_mode"} {
|
||||||
if _, err := readQString(r); err != nil {
|
if _, err := readQString(r); err != nil {
|
||||||
return ev, true, fmt.Errorf("read %s: %w", name, err)
|
return ev, true, fmt.Errorf("read %s: %w", name, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 3 booleans (each 1 byte)
|
// tx_enabled, transmitting, decoding (1 byte each). The middle one is
|
||||||
for i := 0; i < 3; i++ {
|
// worth keeping: it says the carrier is up, which is what turns TxMessage
|
||||||
var b uint8
|
// from "what I would send" into "what is going out".
|
||||||
if err := binary.Read(r, binary.BigEndian, &b); err != nil {
|
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
|
return ev, true, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 2 int32
|
ev.Transmitting = transmitting != 0
|
||||||
|
// rx_df, tx_df
|
||||||
var i32 int32
|
var i32 int32
|
||||||
for i := 0; i < 2; i++ {
|
for i := 0; i < 2; i++ {
|
||||||
if err := binary.Read(r, binary.BigEndian, &i32); err != nil {
|
if err := binary.Read(r, binary.BigEndian, &i32); err != nil {
|
||||||
return ev, true, err
|
return ev, true, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// de_call, de_grid, dx_grid
|
deCall, err := readQString(r)
|
||||||
if _, err := readQString(r); err != nil {
|
if err != nil {
|
||||||
return ev, true, err
|
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
|
return ev, true, err
|
||||||
}
|
}
|
||||||
|
ev.DEGrid = strings.ToUpper(strings.TrimSpace(deGrid))
|
||||||
dxGrid, err := readQString(r)
|
dxGrid, err := readQString(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ev, true, err
|
return ev, true, err
|
||||||
}
|
}
|
||||||
ev.DXGrid = strings.ToUpper(strings.TrimSpace(dxGrid))
|
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
|
return ev, true, nil
|
||||||
|
|
||||||
case wsjtMsgDecode:
|
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
|
if err := binary.Read(r, binary.BigEndian, &b); err != nil { // is_new
|
||||||
return WSJTEvent{}, false, err
|
return WSJTEvent{}, false, err
|
||||||
}
|
}
|
||||||
|
ev.DecodeIsNew = b != 0
|
||||||
var t32, df uint32
|
var t32, df uint32
|
||||||
var snr int32
|
var snr int32
|
||||||
if err := binary.Read(r, binary.BigEndian, &t32); err != nil { // time
|
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
|
if err := binary.Read(r, binary.BigEndian, &dt); err != nil { // delta_time
|
||||||
return WSJTEvent{}, false, err
|
return WSJTEvent{}, false, err
|
||||||
}
|
}
|
||||||
|
ev.DeltaTime = dt
|
||||||
if err := binary.Read(r, binary.BigEndian, &df); err != nil { // delta_frequency
|
if err := binary.Read(r, binary.BigEndian, &df); err != nil { // delta_frequency
|
||||||
return WSJTEvent{}, false, err
|
return WSJTEvent{}, false, err
|
||||||
}
|
}
|
||||||
@@ -240,6 +314,11 @@ func ParseWSJT(pkt []byte) (WSJTEvent, bool, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return WSJTEvent{}, false, err
|
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)
|
call, isCQ, grid := wsjtSender(msg)
|
||||||
if call == "" {
|
if call == "" {
|
||||||
return WSJTEvent{}, false, nil // free-text / telemetry / unparseable → ignore
|
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.DeltaFreqHz = int64(df)
|
||||||
ev.SNR = int(snr)
|
ev.SNR = int(snr)
|
||||||
ev.Mode = strings.ToUpper(strings.TrimSpace(mode))
|
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
|
return ev, true, nil
|
||||||
|
|
||||||
case wsjtMsgLoggedADIF:
|
case wsjtMsgLoggedADIF:
|
||||||
|
|||||||
@@ -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