chore: release v0.27.12

This commit is contained in:
2026-09-05 19:07:21 +02:00
parent f93e1c5898
commit be889681a9
40 changed files with 4971 additions and 453 deletions
+134 -146
View File
@@ -1,7 +1,7 @@
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Activity, AlertCircle, Antenna, Bell, Check, CheckCircle2, ChevronDown, Clock, CloudOff, Compass, Database, Ear, Eraser, Flame, Gauge, Hash, Loader2, Lock,
ChevronUp, Maximize2, Minimize2, Mic, MessageSquare, Pencil, Radar, Radio, RadioTower, RefreshCw, Satellite, Send, SatelliteDish, Settings, SlidersHorizontal, SpellCheck, Square, Star, Terminal, Trash2, Unlock, X, Zap,
ChevronLeft, ChevronUp, Maximize2, Minimize2, Mic, MessageSquare, Pencil, Radar, Radio, RadioTower, RefreshCw, Satellite, Send, SatelliteDish, Settings, SlidersHorizontal, SpellCheck, Square, Star, Terminal, Trash2, Unlock, X, Zap,
} from 'lucide-react';
import {
@@ -54,6 +54,7 @@ import {
GetFlexState, FlexAmpOperate,
GetPSKReporterStatus, GetLiveOpenings, GetChaseNew,
QSLViaRepairStatus, RepairQSLVia, DismissQSLViaRepair,
GetAutoCallStatus, SetAutoCall, SetAutoCallOnly, TakeAutoCallTarget, ResetAutoCall,
} from '../wailsjs/go/main/App';
import { Combobox } from '@/components/ui/combobox';
import { applyAwardRefs, parseAwardRefs as parseManualRefs, spotRefList , withIOTARef, withRDARef } from '@/lib/awardRefs';
@@ -119,9 +120,9 @@ import { SendSpotModal, type RecentSpotQSO } from '@/components/SendSpotModal';
import { WinkeyerPanel, type WKStatus, type WKMacro } from '@/components/WinkeyerPanel';
import { RotorCompass } from '@/components/RotorCompass';
import { GridSquareMap } from '@/components/GridSquareMap';
import { loadAutoCall, shouldAutoCall, autoCallKey, type AutoCallSettings } from '@/lib/autocall';
import { loadClusterMacros, visibleClusterMacros } from '@/lib/clusterMacros';
import { DecodesPanel, type Decode as DecodeRow, type TxMsg as TxMsgRow } from '@/components/DecodesPanel';
import { PSKReporterPanel } from '@/components/PSKReporterPanel';
import { subscribeRotorHeading, pokeRotorHeading } from '@/lib/rotorHeading';
import { writeUiPref } from '@/lib/uiPref';
import { formatDateTimeUTC } from '@/lib/dateFormat';
@@ -2565,136 +2566,34 @@ export default function App() {
// ── Auto-call ──────────────────────────────────────────────────────
//
// Answers a decode without the operator clicking it. The DECISION lives in
// lib/autocall (one pure function, so the dangerous part can be read and
// argued with); this is only the plumbing that runs it and keys the radio.
//
// Re-read when Preferences closes, like every other setting edited there.
// The DECISION is in the BACKEND (internal/autocall), because it keys a
// transmitter: it has to behave the same whether this tab is open or the
// window is minimised, and every rule it applies — seven calls, three missed
// periods, the ladder — is a Go test rather than something only the air can
// check. What is left here is the switch, the readout, and handing over the
// station the operator clicks.
// The named command buttons, re-read when Preferences closes like every other
// setting edited there.
const [clusterMacros, setClusterMacros] = useState(loadClusterMacros);
useEffect(() => { if (!showSettings) setClusterMacros(loadClusterMacros()); }, [showSettings]);
const clusterMacrosShown = useMemo(() => visibleClusterMacros(clusterMacros), [clusterMacros]);
// Auto-call is withdrawn — it duplicated DXHunter, which answers decodes from
// the same shack. This flag is the single place that says so at runtime.
const AUTO_CALL_ENABLED = false;
const [autoCall, setAutoCall] = useState<AutoCallSettings>(loadAutoCall);
useEffect(() => { if (!showSettings) setAutoCall(loadAutoCall()); }, [showSettings]);
// When each callsign was last answered, so a station still calling CQ is not
// re-answered every slot while the QSO it started is still running.
const autoCalledRef = useRef<Map<string, number>>(new Map());
// Decodes are scanned once. Without this the same decode is reconsidered on
// every status refresh, and a cooldown that has just expired would fire again
// on a decode minutes old.
const autoSeenRef = useRef<Set<string>>(new Set());
// Set when a call goes out, so nothing else fires until the receiver's own
// status catches up and `busy` can be trusted again.
const autoHoldUntilRef = useRef(0);
// The station auto-call is currently working, and when it started.
//
// This is OpsLog's OWN record of "a QSO is running", and it exists because
// deriving that from the sender's Status was not enough: the moment
// WSJT-X/JTDX drops the DX call or the Enable-Tx flag between overs — which
// they do — the exchange looks finished and the next CQ gets answered,
// interleaving two and then three QSOs on one slice. A lock we set ourselves
// cannot be cleared by a flag we do not control.
//
// Released when that station's QSO is logged, when the operator halts or
// takes over by clicking a decode, and by the watchdog below.
const autoTargetRef = useRef<{ call: string; at: number } | null>(null);
// An exchange abandoned mid-way must not lock auto-call out for ever: four
// minutes covers a repeated FT8 QSO and still frees the next period soon
// enough to matter.
const AUTO_TARGET_MAX_MS = 240_000;
// Per receiver, when the carrier was last up. Feeds the stale-exchange
// backstop below.
const lastTxAtRef = useRef<Map<string, number>>(new Map());
const [autoCallStatus, setAutoCallStatus] = useState<any>({ enabled: false, target: '', calls: 0, max: 0, misses: 0, max_miss: 0, stopped: false, reason: '' });
useEffect(() => {
// AUTO-CALL IS WITHDRAWN — DXHunter already answers decodes, and two
// programs doing it from one shack key over each other. See lib/autocall.ts.
//
// Returning here rather than deleting the loop: the decision it implements
// is the delicate part, argued over and tested, and worth keeping intact.
// The guard is what matters — an operator whose stored preference still
// says "enabled" must not have their transmitter keyed by a feature they
// can no longer see, let alone switch off.
if (!AUTO_CALL_ENABLED) return;
if (!autoCall.enabled) return;
const now = Date.now();
// A QSO is in progress somewhere if ANY receiver is transmitting or still
// holding a DX call it has not finished with. Both matter: between overs the
// carrier is down but the exchange is not over, and calling someone else
// then is exactly the "it never stops" behaviour.
const busy = Object.values(txStates).some((tx) => {
if (tx?.transmitting) return true;
// A DX call still set means the exchange is not finished — but ONLY while
// the sender still intends to transmit. The watchdog stops transmission
// and leaves the DX call behind, and reading the call alone left auto-call
// waiting for a QSO that had already been given up on, for ever.
if (!tx?.dx_call?.trim()) return false;
if (tx.tx_enabled === false) return false;
// Backstop for a sender that never reports the toggle: an exchange with no
// transmission for three minutes is over, whatever the DX call still says.
// Measured from the last TIME THE CARRIER WAS UP — Status itself arrives
// every second and so can never go stale.
const last = lastTxAtRef.current.get(tx.instance ?? '');
return last === undefined || now - last < 180_000;
})
// The hold closes the gap between sending a Reply and the receiver saying
// it has acted on it — about a second. Without it the OTHER instance still
// looks idle in that window and gets a call of its own.
|| now < autoHoldUntilRef.current;
// Our own lock, evaluated after the watchdog so an abandoned exchange does
// not hold the transmitter shut.
if (autoTargetRef.current && now - autoTargetRef.current.at > AUTO_TARGET_MAX_MS) {
LogUIError('auto-call', `giving up on ${autoTargetRef.current.call} — nothing logged in four minutes`, '');
autoTargetRef.current = null;
}
const locked = busy || autoTargetRef.current !== null;
for (const d of decodes) {
const seenKey = `${d.call}|${d.ms ?? d.at}|${d.instance ?? ''}`;
if (autoSeenRef.current.has(seenKey)) continue;
// A Replay's resent history is display-only: answering a line the far
// end already dropped would fail anyway, and doing it at startup — the
// moment replays arrive — would be a transmitter firing on old news.
if ((d as any).is_new === false) { autoSeenRef.current.add(seenKey); continue; }
// Only decodes from the CURRENT period are worth answering: replying to a
// slot that has closed asks the far end to match a decode it has dropped.
if (now - Date.parse(d.at) > 30_000) { autoSeenRef.current.add(seenKey); continue; }
const e = spotStatus[`${d.call}|${d.band ?? ''}|${(d.mode ?? '').toUpperCase()}`];
// NOT marked seen until its status has resolved. Statuses land a few
// hundred milliseconds after the decode, so consuming it on first sight
// would throw away almost every decode unjudged — the effect re-runs when
// spotStatus changes, and this is what lets it look again.
if (!e) continue;
autoSeenRef.current.add(seenKey);
const verdict = shouldAutoCall(autoCall, d, e as any, {
busy: locked,
calledAt: autoCalledRef.current,
now,
myCall: station.callsign,
});
if (!verdict.call) continue;
autoCalledRef.current.set(d.call.toUpperCase(), now);
autoHoldUntilRef.current = now + 12_000; // an FT8 slot, near enough
autoTargetRef.current = { call: d.call.toUpperCase(), at: now };
// Same reason as a manual click: put the transmitter on the decode's band
// before answering, or a second slice answers on the wrong one.
FlexTXOnBand(d.band ?? '').catch(() => {});
// Logged, always: an automatic transmission with no record of WHY is the
// one thing an operator cannot argue with after the fact.
LogUIError('auto-call', `calling ${d.call}${verdict.reason}`, '');
AnswerDecode(
d.instance ?? '', d.ms ?? 0, d.snr, d.dt ?? 0,
d.audio_hz ?? 0, d.mode_raw || d.mode || '', d.msg_raw ?? d.msg ?? '', !!d.low_conf,
).catch((e2: any) => setError(String(e2?.message ?? e2)));
onCallsignInput(d.call, { force: true });
break; // one per pass: a period can hold several, and we work one station
}
// The seen-set is bounded by the same half hour the decode list keeps.
if (autoSeenRef.current.size > 20000) autoSeenRef.current.clear();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [decodes, spotStatus, autoCall, txStates]);
GetAutoCallStatus().then(setAutoCallStatus).catch(() => {});
// Pushed by the engine on every decision, and polled as well: the push is
// what makes the counter move the instant a call goes out, the poll is what
// recovers a status missed while the window was asleep.
const off = EventsOn('autocall:status', (s: any) => setAutoCallStatus(s));
const id = window.setInterval(() => { GetAutoCallStatus().then(setAutoCallStatus).catch(() => {}); }, 3000);
return () => { off(); window.clearInterval(id); };
}, []);
const toggleAutoCall = () => {
const next = !autoCallStatus?.enabled;
setAutoCallStatus((s: any) => ({ ...s, enabled: next }));
SetAutoCall(next).catch((e: any) => setError(String(e?.message ?? e)));
};
// 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[]>([]);
@@ -2846,6 +2745,14 @@ export default function App() {
const [showChaseNew, setShowChaseNew] = useState(() => localStorage.getItem('opslog.showChaseNew') !== '0');
const refreshChaseNew = useCallback(() => { GetChaseNew().then(setChaseNewOn).catch(() => {}); }, []);
useEffect(() => { refreshChaseNew(); }, [refreshChaseNew]);
// The PSK Reporter panel beside the decodes. Open/closed is remembered; the
// TARGET is whichever station is being worked, so it follows a click on a
// decode and the DX call the digital application reports, and nothing has to
// be selected twice.
const [pskPanelOpen, setPskPanelOpen] = useState(() => localStorage.getItem('opslog.pskPanel') === '1');
useEffect(() => { try { localStorage.setItem('opslog.pskPanel', pskPanelOpen ? '1' : '0'); } catch { /* private mode */ } }, [pskPanelOpen]);
const [pskTarget, setPskTarget] = useState('');
const [pskTargetMode, setPskTargetMode] = useState('');
const [showBeamOnMap, setShowBeamOnMap] = useState(() => localStorage.getItem('opslog.showBeamOnMap') !== '0');
// Compact rotor widget (Settings → Rotator): dial + SP/LP only. RotorCompass
// already draws exactly that when it is given neither presets nor onStop —
@@ -3836,11 +3743,6 @@ export default function App() {
setTxState(m as TxMsgRow);
if (m?.instance) {
setTxStates((prev) => ({ ...prev, [m.instance]: m as TxMsgRow }));
// When this receiver last actually TRANSMITTED, which is not the same as
// when it last spoke: Status arrives about once a second whether the
// carrier is up or not, so it can never say how long an exchange has
// been stalled.
if (m.transmitting) lastTxAtRef.current.set(m.instance, Date.now());
}
// The period history takes only real transmissions — Status repeats
// itself once a second whether the carrier is up or not.
@@ -3957,11 +3859,6 @@ export default function App() {
try {
await LogUDPLoggedADIF(text);
await refresh();
// The QSO auto-call started has finished — release the lock so the next
// CQ can be answered. Matched on the callsign: a QSO logged from
// somewhere else must not free a run that is still going.
const logged = /<call:d+(?::[^>]*)?>([^<s]+)/i.exec(text)?.[1]?.toUpperCase();
if (logged && autoTargetRef.current?.call === logged) autoTargetRef.current = null;
} catch (e: any) {
const msg = String(e?.message ?? e);
// A re-broadcast of an already-logged QSO (Log4OM/WSJT-X) is benign —
@@ -5182,6 +5079,8 @@ export default function App() {
{ type: 'separator' },
{ type: 'item', label: t('logview.title'), action: 'help.log' },
{ type: 'separator' },
{ type: 'item', label: t('help.discord'), action: 'help.discord' },
{ type: 'separator' },
{ type: 'item', label: t('help.donate'), action: 'help.donate', accent: true },
{ type: 'separator' },
{ type: 'item', label: t('help.about'), action: 'help.about' },
@@ -5226,7 +5125,8 @@ export default function App() {
case 'help.sendlog': sendLogToDeveloper(); break;
// Opens in the system browser, NOT the app WebView: a payment page must show
// the address bar and padlock the donor knows how to check.
case 'help.donate': BrowserOpenURL('https://www.paypal.com/donate/?hosted_button_id=PDMY7KV99K38S'); break;
case 'help.discord': BrowserOpenURL('https://discord.gg/8ZsPDmH9q2'); break;
case 'help.donate': BrowserOpenURL('https://www.paypal.com/donate/?hosted_button_id=PDMY7KV99K38S'); break;
}
}
@@ -6348,7 +6248,73 @@ export default function App() {
// The FT decodes panel, built in ONE place: it is offered both as a tab and as
// a Main-view pane, and two copies of this call would be two sets of props to
// keep in step.
// The dial the decodes are being read against. It has to be the DECODER's,
// not the rig's: a report at 14.074.850 is only "+850 Hz in his passband"
// measured from the same dial the digital application is using, and on split
// or with a transverter the rig's own frequency is a different number.
const pskDialHz = useMemo(() => {
for (const d of decodes) if (d.dial_hz && d.dial_hz > 0) return d.dial_hz;
return txState?.freq_hz ?? 0;
}, [decodes, txState?.freq_hz]);
// Stations WE are decoding that are calling the same DX — the competition at
// this end, which PSK Reporter cannot see: it carries who was heard, never
// who they were calling.
const pskCallers = useMemo(() => {
if (!pskTarget) return [] as string[];
const want = pskTarget.toUpperCase() + ' ';
const seen = new Set<string>();
for (const d of decodes) {
const msg = (d.msg ?? '').toUpperCase().trim();
if (!msg.startsWith(want)) continue;
const caller = msg.slice(want.length).trim().split(/\s+/)[0];
// "CQ" cannot be a caller, and neither can our own transmission coming
// back as a decode of ourselves.
if (caller && caller !== 'CQ' && caller !== (station.callsign ?? '').toUpperCase()) seen.add(caller);
}
return [...seen];
}, [decodes, pskTarget, station.callsign]);
// Follow the station the digital application says it is calling. A decode
// clicked here sets the target directly (see onCall); this covers the QSO
// started from the other side — WSJT-X's own double-click, or auto-call.
useEffect(() => {
const dx = (txState?.dx_call ?? '').toUpperCase().trim();
if (dx && dx !== pskTarget) {
setPskTarget(dx);
setPskTargetMode(txState?.mode ?? '');
}
}, [txState?.dx_call, txState?.mode, pskTarget]);
const renderDecodesPanel = () => (
<div className="flex h-full min-h-0">
<div className="flex-1 min-w-0 flex flex-col min-h-0">
{renderDecodesList()}
</div>
{pskPanelOpen ? (
<PSKReporterPanel
target={pskTarget}
mode={pskTargetMode}
dialHz={pskDialHz}
callers={pskCallers.length}
callerCalls={pskCallers}
onCollapse={() => setPskPanelOpen(false)}
/>
) : (
// Collapsed to a strip rather than removed: a panel with no way back is
// one an operator loses, and the button has to say what it opens.
<button type="button" onClick={() => setPskPanelOpen(true)} title={t('psk.show')}
className="w-7 shrink-0 border-l border-border bg-card hover:bg-muted flex flex-col items-center gap-2 py-2 text-muted-foreground hover:text-foreground">
<ChevronLeft className="size-4" />
<span className="text-[10px] font-semibold uppercase tracking-wider [writing-mode:vertical-rl]">
{t('psk.title')}
</span>
</button>
)}
</div>
);
const renderDecodesList = () => (
<DecodesPanel
decodes={decodes}
txMsgs={txMsgs}
@@ -6359,18 +6325,31 @@ export default function App() {
// compare with", never "the rig is on no band".
rigBand={catState.connected ? (catState.band || '') : ''}
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.
// A DOUBLE click answers the station: it hands the decode back to
// WSJT-X/MSHV as a Reply, the same thing as double-clicking the line in
// their own window. A single click only selects it — see onSelect below,
// and the cluster, where the two gestures already mean this.
//
// 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.
// One click: take the station, transmit nothing. The entry is filled and
// the panels follow it, exactly as clicking a cluster spot does — so a
// row can be inspected, looked up and read about without keying up.
onSelect={(d) => {
setPskTarget((d.call ?? '').toUpperCase());
setPskTargetMode(d.mode ?? '');
onCallsignInput(d.call, { force: true });
}}
onCall={(d) => {
// The operator has picked a station: that is now the QSO in progress, so
// auto-call must not answer someone else over the top of it.
autoTargetRef.current = { call: (d.call ?? '').toUpperCase(), at: Date.now() };
// The station being answered is also the one worth analysing: the PSK
// Reporter panel follows the click rather than asking for a second one.
setPskTarget((d.call ?? '').toUpperCase());
setPskTargetMode(d.mode ?? '');
// Auto-call adopts the station: the click chooses WHO, the watchdogs
// still decide how long it is called for.
TakeAutoCallTarget(d.call ?? '', d.band ?? '', d.mode ?? '').catch(() => {});
onCallsignInput(d.call, { force: true });
// With two slices on two bands, the Reply reaches the right INSTANCE but
// the radio still transmits on whichever slice holds the TX flag. Move
@@ -6389,7 +6368,6 @@ export default function App() {
// buffer goes too or the next flush would put back what was just cleared.
onClear={(instance) => {
if (!instance) {
autoTargetRef.current = null;
pendingDecodesRef.current = [];
setDecodes([]);
setTxMsgs([]);
@@ -6407,10 +6385,20 @@ export default function App() {
// An empty instance lets the backend fall back to whichever application
// last reported its status — the normal single-receiver case.
onHalt={(instance) => {
// Halt means stop, including whatever auto-call had started.
autoTargetRef.current = null;
// Halt means stop, including whatever auto-call had started — and it
// clears the engine's state, so a target it had given up on is not
// still sitting there when the operator switches it back on.
ResetAutoCall().catch(() => {});
HaltDecodeTx(instance, false).catch((e: any) => setError(String(e?.message ?? e)));
}}
autoCallOn={!!autoCallStatus?.enabled}
onToggleAutoCall={toggleAutoCall}
autoCall={autoCallStatus}
autoCallOnly={autoCallStatus?.only ?? ''}
onSetAutoCallOnly={(list) => {
setAutoCallStatus((st: any) => ({ ...st, only: list.toUpperCase() }));
SetAutoCallOnly(list).catch((e: any) => setError(String(e?.message ?? e)));
}}
/>
);