chore: release v0.21.3

This commit is contained in:
2026-07-26 16:57:19 +02:00
parent 4fd70f6a9d
commit 91b5af1c7b
46 changed files with 2491 additions and 355 deletions
+73 -10
View File
@@ -22,7 +22,7 @@ import {
RefreshCtyDat, DownloadAllReferenceLists,
RotatorGoTo, RotatorStop, GetRotatorHeading,
GetDBConnectionInfo, GetLogbookRevision,
GetUltrabeamStatus, SetUltrabeamDirection,
GetUltrabeamStatus, SetUltrabeamDirection, UILog,
GetAntGeniusStatus, GetAntGeniusSettings, AntGeniusActivate,
GetTunerGeniusStatus, GetTunerGeniusSettings, TunerGeniusAutotune, TunerGeniusSetBypass, TunerGeniusSetOperate, TunerGeniusActivate,
GetScpStatus, ScpLookup,
@@ -249,6 +249,13 @@ function FreqWheelDisplay({ mhz, onNudge, className, placeholder = '—.——
function shortCatError(err?: string): string {
if (!err) return '';
const e = err.toLowerCase();
// Checked BEFORE the not-found cases: a privilege mismatch used to be condensed
// into "OmniRig not found", which is actively misleading — OmniRig is running,
// visibly, and the operator goes looking for a driver or COM-port fault. The
// full explanation is in the tooltip and the log.
if (e.includes('privilege level') || e.includes('elevation') || e.includes('élévation')) {
return 'OmniRig: run as admin?';
}
if (e.includes('not registered') || e.includes('not available')) return 'OmniRig not found';
if (e.includes('not connected')) return 'not connected';
if (e.includes('coinitialize')) return 'COM error';
@@ -1452,6 +1459,17 @@ export default function App() {
// entries synchronously (the two run on separate debounce timers).
const wbRef = useRef<WB | null>(null);
useEffect(() => { wbRef.current = wb; }, [wb]);
// Which callsign wbRef.current actually belongs to. Worked-before is replaced
// only when its query RESOLVES, so between two calls the ref still holds the
// previous station — and the backfill below would happily copy that station's
// name, QTH and grid onto the new one.
const wbCallRef = useRef('');
// A backfill the lookup asked for before the history had arrived. Parked here
// and replayed by runWorkedBefore: with no QRZ/HamQTH configured the lookup
// answers from local cty.dat almost instantly, while the history query — a
// round trip to a possibly remote MySQL logbook — is still in flight, so the
// backfill found nothing and nothing ever ran it again.
const pendingBackfillRef = useRef<{ call: string; r?: any } | null>(null);
const [wbBusy, setWbBusy] = useState(false);
// Per-award columns for the Recent QSOs / Worked-before grids: load the award
@@ -2886,9 +2904,26 @@ export default function App() {
async function runWorkedBefore(call: string, dxccHint: number = 0) {
setWbBusy(true);
try { setWb(await WorkedBefore(call, dxccHint)); }
catch { setWb(null); }
finally { setWbBusy(false); }
try {
const w = await WorkedBefore(call, dxccHint);
setWb(w);
// Mirrored synchronously rather than through the effect above: a backfill
// parked by the lookup has to read this on the very next line, not a
// render later.
wbRef.current = w;
wbCallRef.current = call;
// The lookup finished before this history did and parked its backfill —
// run it now that we can answer "who did we work last?".
const p = pendingBackfillRef.current;
if (p && p.call === call) {
pendingBackfillRef.current = null;
fillFromLastQso(p.r, call);
}
} catch {
setWb(null);
wbRef.current = null;
wbCallRef.current = '';
} finally { setWbBusy(false); }
}
// fillFromLastQso enriches the entry from the LAST QSO we logged with this call
// when the live lookup came up short — the callsign isn't on QRZ/HamQTH, or no
@@ -2896,11 +2931,32 @@ export default function App() {
// ONLY the fields the provider left empty and the operator hasn't edited, so a
// real QRZ/HamQTH hit is never overridden. `r` is the provider result (omitted
// on a lookup error, where every provider field counts as empty).
function fillFromLastQso(r?: any) {
function fillFromLastQso(r: any, call: string) {
// Only ever fill from THIS callsign's history. If worked-before hasn't
// resolved for it yet, park the request instead of reading whatever the ref
// happens to hold — that would be the previously entered station.
if (wbCallRef.current !== call) {
pendingBackfillRef.current = { call, r };
UILog(`backfill ${call}: history not in yet (have "${wbCallRef.current}") — parked`).catch(() => {});
return;
}
// Parked backfills can land late; drop it if the operator has moved on.
if (call !== callsignValRef.current.trim().toUpperCase()) {
UILog(`backfill ${call}: abandoned, entry now holds "${callsignValRef.current}"`).catch(() => {});
return;
}
const last: any = wbRef.current?.entries?.[0]; // entries are qso_date DESC → most recent
if (!last) return;
if (!last) {
UILog(`backfill ${call}: no prior QSO in the log (count=${wbRef.current?.count ?? 0}, entries=${wbRef.current?.entries?.length ?? 0})`).catch(() => {});
return;
}
const ue = userEditedRef.current;
const empty = (v: any) => (v ?? '') === '';
// One line saying what we found and what blocked each field, so this can be
// diagnosed from another operator's log instead of guessed at.
UILog(`backfill ${call}: last QSO ${last.qso_date ?? '?'} name="${last.name ?? ''}" qth="${last.qth ?? ''}" grid="${last.grid ?? ''}"`
+ ` | provider name="${r?.name ?? ''}" grid="${r?.grid ?? ''}" src=${r?.source ?? 'none'}`
+ ` | edited=[${[...ue].join(',')}]`).catch(() => {});
if (!ue.has('name') && empty(r?.name) && last.name) setName(last.name);
if (!ue.has('qth') && empty(r?.qth) && last.qth) setQth(last.qth);
if (!ue.has('country') && empty(r?.country) && last.country) setCountry(last.country);
@@ -2985,7 +3041,7 @@ export default function App() {
}));
// Backfill anything the provider didn't supply from the last time we worked
// this call (call not found on QRZ/HamQTH, or lookup off → cty.dat only).
fillFromLastQso(r);
fillFromLastQso(r, call);
if (r.dxcc && r.dxcc > 0) runWorkedBefore(call, r.dxcc);
// The DX location is now known (grid set above) — force the world map to
// auto-zoom right away, so it doesn't lag behind the resolved QSO.
@@ -3006,7 +3062,7 @@ export default function App() {
setLookupResult(null);
setLookupError(String(e?.message ?? e));
// Lookup failed outright — still borrow from the last logged QSO.
fillFromLastQso();
fillFromLastQso(undefined, call);
}
} finally {
// Only clear the spinner if we're still the current lookup — a newer one
@@ -3021,6 +3077,9 @@ export default function App() {
const call = value.trim().toUpperCase();
if (call.length < 3) {
setLookupResult(null); setWb(null);
// Drop the history and any backfill waiting on it, so clearing the field
// can't let a late one repopulate the next callsign typed.
wbRef.current = null; wbCallRef.current = ''; pendingBackfillRef.current = null;
if (lastLookedUpRef.current !== '') resetAutoFill();
return;
}
@@ -4082,7 +4141,9 @@ export default function App() {
<div className="h-full w-full min-h-0 flex flex-col bg-card border border-border rounded-lg overflow-hidden">
<WorkedBeforeGrid wb={wbWithAwards as any} awardCols={awardCols} busy={wbBusy} currentCall={callsign} onRowDoubleClicked={(q) => openEdit(q.id as number)}
onUpdateFromCty={bulkUpdateFromCty} onUpdateFromQRZ={bulkUpdateFromQRZ} onUpdateFromClublog={bulkUpdateFromClublog}
onSendTo={bulkSendTo} onSendRecording={bulkSendRecording} onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)} onDelete={(ids) => setDeletingIds(ids)} />
onSendTo={bulkSendTo} onSendRecording={bulkSendRecording} onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)}
onBulkEdit={openBulkEdit} onExportSelected={exportSelectedADIF} onExportSelectedFields={exportSelectedFields}
onExportCabrilloSelected={exportSelectedCabrillo} onDelete={(ids) => setDeletingIds(ids)} />
</div>
);
case 'flex':
@@ -5491,7 +5552,9 @@ export default function App() {
<TabsContent value="worked" className="mt-0 flex flex-col min-h-0 flex-1">
<WorkedBeforeGrid wb={wbWithAwards as any} awardCols={awardCols} busy={wbBusy} currentCall={callsign} onRowDoubleClicked={(q) => openEdit(q.id as number)}
onUpdateFromCty={bulkUpdateFromCty} onUpdateFromQRZ={bulkUpdateFromQRZ} onUpdateFromClublog={bulkUpdateFromClublog} onSendTo={bulkSendTo} onSendRecording={bulkSendRecording}
onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)} onDelete={(ids) => setDeletingIds(ids)} />
onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)}
onBulkEdit={openBulkEdit} onExportSelected={exportSelectedADIF} onExportSelectedFields={exportSelectedFields}
onExportCabrilloSelected={exportSelectedCabrillo} onDelete={(ids) => setDeletingIds(ids)} />
</TabsContent>
{/* Opened on demand from Tools QSL Manager; closable via the
+23 -7
View File
@@ -70,6 +70,13 @@ export function AlertsModal({ onClose, bands, modes, countries }: {
const { t } = useI18n();
const [rules, setRules] = useState<Rule[]>([]);
const [draft, setDraft] = useState<Rule | null>(null);
// Raw text of the callsigns box, kept separately from draft.calls. The list is
// normalised (trim + drop empties) on the way into the rule, so binding the
// textarea straight to calls.join('\n') round-trips every keystroke through
// that normalisation — a trailing newline or space is stripped before React
// re-renders, and Enter/Space appear to do nothing. Editing the raw text and
// deriving the list from it keeps typing intact.
const [callsText, setCallsText] = useState('');
const [tab, setTab] = useState('def'); // active editor tab (reset to Definition on new/select)
const [emailTo, setEmailTo] = useState('');
const [err, setErr] = useState('');
@@ -80,6 +87,12 @@ export function AlertsModal({ onClose, bands, modes, countries }: {
}, []);
useEffect(() => { refresh(); GetAlertEmailTo().then((v) => setEmailTo(v || '')).catch(() => {}); }, [refresh]);
// loadDraft opens a rule in the editor — always through here, so the raw
// callsigns text is re-seeded from whatever rule is now being edited.
const loadDraft = (r: Rule | null) => {
setDraft(r);
setCallsText((r?.calls ?? []).join('\n'));
};
const patch = (p: Partial<Rule>) => setDraft((d) => (d ? alerts.Rule.createFrom({ ...d, ...p }) : d));
const toggleIn = (key: keyof Rule, v: string) => setDraft((d) => {
if (!d) return d;
@@ -92,14 +105,14 @@ export function AlertsModal({ onClose, bands, modes, countries }: {
async function save() {
if (!draft) return;
if (!draft.name.trim()) { setErr(t('altm.giveName')); return; }
try { const saved = await SaveAlertRule(draft); await refresh(); setDraft(saved as Rule); setErr(''); }
try { const saved = await SaveAlertRule(draft); await refresh(); loadDraft(saved as Rule); setErr(''); }
catch (e: any) { setErr(String(e?.message ?? e)); }
}
async function del() {
if (!draft) return;
if (!draft.id) { setDraft(null); return; }
if (!draft.id) { loadDraft(null); return; }
if (!window.confirm(t('altm.deleteConfirm', { name: draft.name }))) return;
try { await DeleteAlertRule(draft.id); setDraft(null); await refresh(); }
try { await DeleteAlertRule(draft.id); loadDraft(null); await refresh(); }
catch (e: any) { setErr(String(e?.message ?? e)); }
}
@@ -116,12 +129,12 @@ export function AlertsModal({ onClose, bands, modes, countries }: {
<div className="w-56 shrink-0 flex flex-col border border-border rounded-md">
<div className="flex items-center gap-1 px-2 py-1.5 border-b border-border/60">
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground flex-1">{t('altm.rules')}</span>
<Button variant="ghost" size="sm" className="h-6 px-1.5" onClick={() => { setDraft(emptyRule()); setTab('def'); }}><Plus className="size-3.5" /></Button>
<Button variant="ghost" size="sm" className="h-6 px-1.5" onClick={() => { loadDraft(emptyRule()); setTab('def'); }}><Plus className="size-3.5" /></Button>
</div>
<div className="flex-1 overflow-y-auto p-1">
{rules.length === 0 && <div className="text-[11px] text-muted-foreground px-2 py-4 text-center">{t('altm.noRules')}</div>}
{rules.map((r) => (
<button key={r.id} onClick={() => { setDraft(alerts.Rule.createFrom(r)); setTab('def'); }}
<button key={r.id} onClick={() => { loadDraft(alerts.Rule.createFrom(r)); setTab('def'); }}
className={cn('w-full text-left px-2 py-1.5 rounded text-xs flex items-center gap-1.5',
draft?.id === r.id ? 'bg-accent text-accent-foreground font-semibold' : 'hover:bg-muted/60')}>
<span className={cn('size-1.5 rounded-full shrink-0', r.enabled ? 'bg-success' : 'bg-muted-foreground/40')} />
@@ -187,8 +200,11 @@ export function AlertsModal({ onClose, bands, modes, countries }: {
<Label className="text-xs">{t('altm.callsigns')}</Label>
<textarea className="w-full h-52 rounded-md border border-border bg-background p-2 text-xs font-mono resize-none"
placeholder={'DL1ABC\nIW3*\n*/P'}
value={(draft.calls ?? []).join('\n')}
onChange={(e) => patch({ calls: e.target.value.split('\n').map((x) => x.trim()).filter(Boolean) })} />
value={callsText}
onChange={(e) => {
setCallsText(e.target.value);
patch({ calls: e.target.value.split('\n').map((x) => x.trim()).filter(Boolean) });
}} />
</div>
<div className="space-y-1">
<Label className="text-xs">{t('altm.countries')}</Label>
+15 -5
View File
@@ -377,7 +377,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
<div className="grid grid-cols-[220px_1fr] min-h-0 overflow-hidden">
{/* Left: award list */}
<div className="border-r flex flex-col min-h-0">
<div className="border-r flex flex-col min-w-0 min-h-0">
<div className="p-2 border-b">
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" />
@@ -422,8 +422,14 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
</Button>
</div>
{/* Right: tabbed editor for selected award */}
<div className="flex flex-col min-h-0 overflow-hidden">
{/* Right: tabbed editor for selected award.
min-w-0 is load-bearing: this sits in a `1fr` grid track, and a 1fr
track has min-width:auto — it refuses to shrink below its content's
intrinsic width, so a wide row (the band/mode chip lists, a long
translated label) grew the track and pushed the whole editor out
past the dialog instead of scrolling inside it. Same reason on the
Tabs below: a flex item defaults to min-width:auto too. */}
<div className="flex flex-col min-w-0 min-h-0 overflow-hidden">
{err && <div onClick={() => setErr('')} title={t('awed.clickToDismiss')} className="mx-4 mt-3 text-xs text-destructive bg-destructive/10 border border-destructive/30 rounded px-3 py-1.5 whitespace-pre-line break-all cursor-pointer">{err}</div>}
{/* A fix shipped for an award this operator has customised. We did NOT
apply it — that would destroy their work — so we offer it, and say
@@ -459,7 +465,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
{!cur ? (
<div className="flex-1 grid place-items-center text-sm text-muted-foreground">{t('awed.selectOrCreate')}</div>
) : (
<Tabs defaultValue="info" className="flex flex-col min-h-0 overflow-hidden">
<Tabs defaultValue="info" className="flex flex-col min-w-0 min-h-0 overflow-hidden">
<TabsList className="px-3 justify-start">
<TabsTrigger value="info">{t('awed.tabInfo')}</TabsTrigger>
<TabsTrigger value="type">{t('awed.tabType')}</TabsTrigger>
@@ -729,7 +735,11 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
</div>
</div>
<DialogFooter className="px-5 py-3 border-t !flex-row">
{/* Eight buttons on one unwrappable row fitted in English and overflowed
in French, where the labels are half again as long. Wrap instead of
widening the dialog; gap-2 replaces the base sm:space-x-2, whose
margin-left approach leaves no vertical gap once a row wraps. */}
<DialogFooter className="px-5 py-3 border-t !flex-row flex-wrap sm:space-x-0 gap-2">
<Button variant="ghost" onClick={reset}><RotateCcw className="size-3.5 mr-1" /> {t('awed.resetDefaults')}</Button>
<Button variant="outline" onClick={exportAwards} title={t('awed.exportTitle')}>
<Download className="size-3.5 mr-1" /> {t('awed.export')}
@@ -34,6 +34,8 @@ const FIELDS: FieldDef[] = [
// My station / operator
{ id: 'station_callsign', label: 'bulk.fStationCall', group: 'My station', kind: 'text', upper: true },
{ id: 'operator', label: 'bulk.fOperator', group: 'My station', kind: 'text', upper: true },
// No promoted column: written into extras_json (see qso.bulkEditableExtras).
{ id: 'owner_callsign', label: 'bulk.fOwnerCallsign', group: 'My station', kind: 'text', upper: true },
{ id: 'my_grid', label: 'bulk.fMyGrid', group: 'My station', kind: 'text', upper: true },
{ id: 'my_antenna', label: 'bulk.fMyAntenna', group: 'My station', kind: 'text' },
{ id: 'my_rig', label: 'bulk.fMyRig', group: 'My station', kind: 'text' },
+31 -6
View File
@@ -73,6 +73,24 @@ function attOptions(model?: string): { v: string; l: string }[] {
return [OFF, { v: '20', l: '20dB' }]; // IC-7300 / IC-705 / IC-7100 / default
}
// bandOfHz names the amateur band a frequency falls in, so the band row can show
// where the rig actually is. The buttons only ever SENT a frequency and carried
// no active state at all, so nothing was highlighted whatever the rig reported.
// Edges are the ITU/IARU band limits, wide enough to cover regional differences —
// out-of-band (transverter IF, general coverage RX) matches nothing, as it should.
function bandOfHz(hz?: number): string {
if (!hz || hz <= 0) return '';
const mhz = hz / 1_000_000;
const bands: [string, number, number][] = [
['160', 1.8, 2.0], ['80', 3.5, 4.0], ['60', 5.25, 5.45], ['40', 7.0, 7.3],
['30', 10.1, 10.15], ['20', 14.0, 14.35], ['17', 18.068, 18.168],
['15', 21.0, 21.45], ['12', 24.89, 24.99], ['10', 28.0, 29.7],
['6', 50.0, 54.0], ['4', 70.0, 70.5], ['2', 144.0, 148.0], ['70', 430.0, 450.0],
];
for (const [name, lo, hi] of bands) if (mhz >= lo && mhz <= hi) return name;
return '';
}
// fmtVFO renders a Hz frequency the way an Icom front panel does:
// MHz "." 3-digit-kHz "." 2-digit-(10 Hz). 21032000 → "21.032.00".
function fmtVFO(hz?: number): string {
@@ -731,12 +749,19 @@ export function IcomPanel({ onReportRST, isNetwork = false }: { onReportRST?: (r
{/* Band buttons + antenna selection. */}
<Card icon={Antenna} title={t('icmp.bandsAntenna')} accent="#0891b2">
<div className="grid grid-cols-5 gap-1.5">
{BANDS.map((b) => (
<button key={b.l} type="button" onClick={() => SetCATFrequency(b.hz).catch(() => {})}
className="px-1 py-1.5 rounded-md text-[11px] font-bold border border-border bg-card text-foreground hover:bg-muted transition-colors">
{b.l}
</button>
))}
{BANDS.map((b) => {
const here = bandOfHz(mainHz) === b.l;
return (
<button key={b.l} type="button" onClick={() => SetCATFrequency(b.hz).catch(() => {})}
title={here ? t('icmp.bandCurrent', { b: b.l }) : undefined}
className={cn('px-1 py-1.5 rounded-md text-[11px] font-bold border transition-colors',
here
? 'border-primary bg-primary text-primary-foreground shadow-[0_0_8px] shadow-primary/40'
: 'border-border bg-card text-foreground hover:bg-muted')}>
{b.l}
</button>
);
})}
</div>
<Row label={t('icmp.antenna')}>
<Segmented value={String(st.antenna)} options={[{ v: '1', l: 'ANT1' }, { v: '2', l: 'ANT2' }]}
+66 -53
View File
@@ -4528,6 +4528,16 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
}
function AudioPanel() {
// Names the CAT backend the operator has configured, so the PTT dropdown can
// say "CAT — Icom CI-V (USB)" rather than the flatly wrong "CAT (OmniRig)".
const catBackendLabel = ({
omnirig: t('cat.optOmnirig'),
flex: t('cat.optFlex'),
icom: t('cat.optIcom'),
'icom-net': t('cat.optIcomNet'),
tci: t('cat.optTci'),
} as Record<string, string>)[catCfg.backend] ?? '';
const deviceSelect = (
field: keyof AudioSettings,
devices: AudioDev[],
@@ -4539,9 +4549,9 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
>
<SelectTrigger className="h-8"><SelectValue placeholder={placeholder} /></SelectTrigger>
<SelectContent>
<SelectItem value="_"> none / system default </SelectItem>
<SelectItem value="_">{t('aud.noneDefault')}</SelectItem>
{devices.map((d) => (
<SelectItem key={d.id} value={d.id}>{d.name}{d.default ? ' (default)' : ''}</SelectItem>
<SelectItem key={d.id} value={d.id}>{d.name}{d.default ? ' ' + t('aud.defaultTag') : ''}</SelectItem>
))}
</SelectContent>
</Select>
@@ -4552,24 +4562,24 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<SectionHeader
title={t('hw.audioVoice')}/>
<Button variant="outline" size="sm" className="h-7 text-[11px] shrink-0" onClick={reloadAudioDevices}>
Refresh devices
{t('aud.refreshDevices')}
</Button>
</div>
<div className="space-y-3 max-w-2xl">
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
<Label className="text-sm">From Radio (RX in)</Label>
{deviceSelect('from_radio', audioInputs, 'Rig audio output → soundcard input')}
<Label className="text-sm">To Radio (TX out)</Label>
{deviceSelect('to_radio', audioOutputs, 'Soundcard output → rig mic/data in')}
<Label className="text-sm">Recording mic</Label>
{deviceSelect('recording_device', audioInputs, 'Your microphone (record DVK messages)')}
<Label className="text-sm">Listening (preview)</Label>
{deviceSelect('listening_device', audioOutputs, 'Local speakers for preview')}
<Label className="text-sm">{t('aud.fromRadio')}</Label>
{deviceSelect('from_radio', audioInputs, t('aud.phFromRadio'))}
<Label className="text-sm">{t('aud.toRadio')}</Label>
{deviceSelect('to_radio', audioOutputs, t('aud.phToRadio'))}
<Label className="text-sm">{t('aud.recMic')}</Label>
{deviceSelect('recording_device', audioInputs, t('aud.phRecMic'))}
<Label className="text-sm">{t('aud.listening')}</Label>
{deviceSelect('listening_device', audioOutputs, t('aud.phListening'))}
</div>
<p className="text-[11px] text-muted-foreground">
<strong>From Radio</strong> = what you receive (used by the QSO recorder).{' '}
<strong>To Radio</strong> = where voice-keyer messages are transmitted.
<strong>{t('aud.fromRadioShort')}</strong> {t('aud.explainFrom')}{' '}
<strong>{t('aud.toRadioShort')}</strong> {t('aud.explainTo')}
</p>
<div className="flex items-center gap-3">
<Button
@@ -4578,14 +4588,12 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
className="h-8"
onClick={toggleMonitor}
disabled={!monitorOn && !audioCfg.from_radio}
title="Hear the rig's RX audio (From Radio) through your Listening device"
title={t('aud.monitorTitle')}
>
{monitorOn ? '■ Stop listening' : '▶ Listen to radio'}
{monitorOn ? t('aud.stopListening') : t('aud.listenRadio')}
</Button>
<span className="text-[11px] text-muted-foreground">
{monitorOn
? 'RX monitor running — From Radio → Listening device.'
: 'Live-monitor the rig here (USB codec now; network audio later).'}
{monitorOn ? t('aud.monitorOn') : t('aud.monitorHint')}
</span>
</div>
<div className="flex items-center gap-3">
@@ -4595,101 +4603,106 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
className="h-8"
onClick={toggleTX}
disabled={!txOn && !audioCfg.to_radio}
title="Key PTT and pipe your live mic into the rig (To Radio device)"
title={t('aud.txTitle')}
>
{txOn ? '■ Stop talking (TX)' : '🎙 Talk to radio (TX)'}
{txOn ? t('aud.stopTalk') : t('aud.talkRadio')}
</Button>
<span className="text-[11px] text-muted-foreground">
{txOn
? 'TRANSMITTING — mic → To Radio, PTT keyed. Click to stop.'
: 'Live mic → rig with PTT (USB now; network TX later).'}
{txOn ? t('aud.txOn') : t('aud.txHint')}
</span>
</div>
</div>
<div className="border-t border-border/60 pt-3 space-y-3 max-w-2xl">
<h4 className="text-sm font-semibold text-foreground">QSO recorder</h4>
<h4 className="text-sm font-semibold text-foreground">{t('aud.recorder')}</h4>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={audioCfg.qso_record} onCheckedChange={(c) => setAudioField({ qso_record: !!c })} />
Record every QSO to an audio file (From Radio + your mic)
{t('aud.recordEvery')}
</label>
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
<Label className="text-sm">Recordings folder</Label>
<Label className="text-sm">{t('aud.recFolder')}</Label>
<div className="flex gap-2">
<Input value={audioCfg.qso_dir} onChange={(e) => setAudioField({ qso_dir: e.target.value })}
placeholder="C:\…\OpsLog\Recordings" className="h-8 font-mono text-xs" />
<Button variant="outline" size="sm" className="h-8 shrink-0"
onClick={() => PickAudioFolder().then((d) => { if (d) setAudioField({ qso_dir: d }); }).catch(() => {})}>
Browse
{t('aud.browse')}
</Button>
</div>
<Label className="text-sm">Pre-roll (seconds)</Label>
<Label className="text-sm">{t('aud.preroll')}</Label>
<Input type="number" min={0} max={60} value={audioCfg.preroll_seconds}
onChange={(e) => setAudioField({ preroll_seconds: Math.max(0, Math.min(60, parseInt(e.target.value, 10) || 0)) })}
className="h-8 w-24 font-mono" />
<Label className="text-sm">File format</Label>
<Label className="text-sm">{t('aud.format')}</Label>
<Select value={audioCfg.format} onValueChange={(v) => setAudioField({ format: v as any })}>
<SelectTrigger className="h-8 w-40"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="wav">WAV (lossless, larger)</SelectItem>
<SelectItem value="mp3">MP3 (compressed, small)</SelectItem>
<SelectItem value="wav">{t('aud.wav')}</SelectItem>
<SelectItem value="mp3">{t('aud.mp3')}</SelectItem>
</SelectContent>
</Select>
<Label className="text-sm">From Radio level</Label>
<Label className="text-sm">{t('aud.fromLevel')}</Label>
<div className="flex items-center gap-2">
<input type="range" min={10} max={300} step={5} value={audioCfg.from_gain}
onChange={(e) => setAudioField({ from_gain: parseInt(e.target.value, 10) })} className="w-48 accent-primary" />
<span className="font-mono text-xs w-12 text-right">{audioCfg.from_gain}%</span>
</div>
<Label className="text-sm">Mic level</Label>
<Label className="text-sm">{t('aud.micLevel')}</Label>
<div className="flex items-center gap-2">
<input type="range" min={10} max={300} step={5} value={audioCfg.mic_gain}
onChange={(e) => setAudioField({ mic_gain: parseInt(e.target.value, 10) })} className="w-48 accent-primary" />
<span className="font-mono text-xs w-12 text-right">{audioCfg.mic_gain}%</span>
</div>
</div>
<p className="text-xs text-muted-foreground">If your voice is louder than the station, lower Mic level.</p>
<p className="text-xs text-muted-foreground">{t('aud.levelHint')}</p>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={emailCfg.auto_send} onCheckedChange={(c) => setEmailField({ auto_send: !!c })} />
Auto-send the recording to the station by e-mail when I log a QSO
{t('aud.autoSend')}
</label>
</div>
<div className="border-t border-border/60 pt-3 space-y-2 max-w-2xl">
<h4 className="text-sm font-semibold text-foreground">Voice keyer messages (F1F6)</h4>
<h4 className="text-sm font-semibold text-foreground">{t('aud.dvkTitle')}</h4>
<div className="rounded-md border border-border/60 p-2.5 space-y-2">
<div className="grid grid-cols-[120px_1fr] gap-2 items-center">
<Label className="text-sm">PTT method</Label>
<Label className="text-sm">{t('aud.pttMethod')}</Label>
<div className="flex gap-2 items-center">
<Select value={audioCfg.ptt_method} onValueChange={(v) => setAudioField({ ptt_method: v as any })}>
<SelectTrigger className="h-8 w-44"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="none">None (VOX)</SelectItem>
<SelectItem value="cat">CAT (OmniRig)</SelectItem>
<SelectItem value="rts">Serial RTS</SelectItem>
<SelectItem value="dtr">Serial DTR</SelectItem>
<SelectItem value="none">{t('aud.pttNone')}</SelectItem>
{/* This has ALWAYS driven whichever CAT backend is active —
it calls the generic manager, and every backend (OmniRig,
FlexRadio, Icom CI-V over USB and over the network, TCI)
implements SetPTT. The label said "CAT (OmniRig)", so
operators on a CI-V rig concluded there was no CAT PTT for
them and reached for RTS/DTR or VOX instead. Name the
backend actually configured. */}
<SelectItem value="cat">{t('aud.pttCat')}{catBackendLabel ? `${catBackendLabel}` : ''}</SelectItem>
<SelectItem value="rts">{t('aud.pttRts')}</SelectItem>
<SelectItem value="dtr">{t('aud.pttDtr')}</SelectItem>
</SelectContent>
</Select>
{audioCfg.ptt_method !== 'none' && (
<Button variant="outline" size="sm" className="h-8" onClick={() => { setDvkErr(''); TestPTT(audioCfg as any).catch((e: any) => setDvkErr('PTT test: ' + String(e?.message ?? e))); }}>
Test PTT
<Button variant="outline" size="sm" className="h-8" onClick={() => { setDvkErr(''); TestPTT(audioCfg as any).catch((e: any) => setDvkErr(t('aud.errPttTest') + String(e?.message ?? e))); }}>
{t('aud.testPtt')}
</Button>
)}
</div>
{(audioCfg.ptt_method === 'rts' || audioCfg.ptt_method === 'dtr') && (
<>
<Label className="text-sm">PTT COM port</Label>
<Label className="text-sm">{t('aud.pttPort')}</Label>
<div className="flex gap-2 items-center">
<Select value={audioCfg.ptt_port || '_'} onValueChange={(v) => setAudioField({ ptt_port: v === '_' ? '' : v })}>
<SelectTrigger className="h-8 w-44"><SelectValue placeholder="Pick a COM port" /></SelectTrigger>
<SelectTrigger className="h-8 w-44"><SelectValue placeholder={t('aud.pickPort')} /></SelectTrigger>
<SelectContent>
<SelectItem value="_"> select </SelectItem>
<SelectItem value="_">{t('aud.selectPort')}</SelectItem>
{wkPorts.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
</SelectContent>
</Select>
<Button variant="ghost" size="sm" className="h-8 text-[11px]"
onClick={() => ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {})}>
Refresh
{t('aud.refresh')}
</Button>
</div>
</>
@@ -4706,7 +4719,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<span className="w-7 font-mono text-xs font-bold text-muted-foreground">F{m.slot}</span>
<Input
className="h-8 flex-1"
placeholder={`Message ${m.slot} label (CQ, report, 73…)`}
placeholder={t('aud.msgPlaceholder', { n: m.slot })}
value={m.label}
onChange={(e) => setDvkMsgs((ms) => ms.map((x) => x.slot === m.slot ? { ...x, label: e.target.value } : x))}
onBlur={(e) => SetDVKLabel(m.slot, e.target.value).catch(() => {})}
@@ -4722,21 +4735,21 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
e.preventDefault();
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
setDvkErr('');
DVKStartRecord(m.slot).catch((err) => setDvkErr('Record: ' + String(err?.message ?? err)));
DVKStartRecord(m.slot).catch((err) => setDvkErr(t('aud.errRecord') + String(err?.message ?? err)));
}}
onPointerUp={() => {
DVKStopRecord().then(reloadDvk).catch((err) => setDvkErr('Save: ' + String(err?.message ?? err)));
DVKStopRecord().then(reloadDvk).catch((err) => setDvkErr(t('aud.errSave') + String(err?.message ?? err)));
}}
>
{recHere ? '● Recording…' : '● Hold to rec'}
{recHere ? t('aud.recordingNow') : t('aud.holdRec')}
</Button>
<Button
type="button"
variant="outline" size="sm" className="h-8 w-20 shrink-0"
disabled={!m.has_audio || dvkStat.recording}
onClick={() => (dvkStat.playing ? DVKStop() : DVKPreview(m.slot).catch((err) => setDvkErr('Play: ' + String(err?.message ?? err))))}
onClick={() => (dvkStat.playing ? DVKStop() : DVKPreview(m.slot).catch((err) => setDvkErr(t('aud.errPlay') + String(err?.message ?? err))))}
>
{dvkStat.playing ? '■ Stop' : '▶ Play'}
{dvkStat.playing ? t('aud.stop') : t('aud.play')}
</Button>
</div>
);
@@ -415,7 +415,11 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
);
};
const widgets: { id: string; node: React.ReactNode }[] = [];
// `wide` cards span the whole dashboard instead of sitting in one masonry
// column. The amplifier and tuner carry meter rows and a channel selector that
// are unreadable squeezed into a ~430px column — they are the same cards the
// FlexRadio panel shows full-width, and they need that room here too.
const widgets: { id: string; node: React.ReactNode; wide?: boolean }[] = [];
if (rot.enabled) {
widgets.push({ id: 'rotator', node: <RotatorWidget hd={rot} refetch={pollRot} centerLat={centerLat} centerLon={centerLon} bearing={bearing} t={t} /> });
}
@@ -423,9 +427,9 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
widgets.push({ id: 'antenna', node: <MotorAntennaWidget ant={ant} refetch={pollAnt} t={t} /> });
}
// One card per configured amplifier (identical to the Flex panel's card).
for (const amp of amps) widgets.push({ id: `amp:${amp.id}`, node: <AmpCard amp={amp} flex={flexState} t={t} /> });
for (const amp of amps) widgets.push({ id: `amp:${amp.id}`, node: <AmpCard amp={amp} flex={flexState} t={t} />, wide: true });
// Tuner Genius XL card (identical to the Flex panel's).
if (tgEnabled) widgets.push({ id: 'tuner', node: <TunerCard status={tg} t={t} /> });
if (tgEnabled) widgets.push({ id: 'tuner', node: <TunerCard status={tg} t={t} />, wide: true });
for (const dev of devices) widgets.push({ id: dev.id, node: deviceCard(dev) });
const rank = (id: string) => { const i = order.indexOf(id); return i < 0 ? 1e6 : i; };
@@ -470,7 +474,12 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
<div style={{ columnWidth: '430px', columnGap: '1rem', columnFill: 'balance',
...(cols !== 'auto' ? { maxWidth: `${Number(cols) * 446}px` } : {}) }}>
{ordered.map((w) => (
// column-span:all lifts a wide card out of the columns and across the
// full container, while keeping it in document order — so drag-reorder
// still works between wide and normal cards. Capped so it stays a card
// and not a banner on an ultra-wide window.
<div key={w.id} className="flex items-stretch break-inside-avoid mb-4"
style={w.wide ? { columnSpan: 'all', maxWidth: '900px' } : undefined}
onDragOver={(e) => { if (dragId.current) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; } }}
onDrop={(e) => { if (dragId.current) { e.preventDefault(); onDrop(w.id); } }}>
<div draggable
+41 -5
View File
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useRef, useState } from 'react';
import { Gauge, Radio, ChevronDown } from 'lucide-react';
import { cn } from '@/lib/utils';
import { MeterBar } from '@/components/MeterBar';
@@ -66,9 +66,42 @@ function ChannelButton({ letter, ch, active, ptt, threeWay, onSelect, t }: {
}
export function TunerCard({ status, t }: { status: TGStatus; t: (k: string, v?: any) => string }) {
// Peak-hold, exactly as AmpCard does it. Both cards read the same transmitter,
// but the tuner is sampled by a 400 ms TCP poll: on SSB that lands in the gaps
// between syllables as often as on a peak, so the raw reading collapses to zero
// several times a second mid-transmission and reads as a dropped link. The
// amplifier looked steady next to it only because it already smoothed this way.
// Hold the highest value seen, decaying after 2 s so it follows a real drop.
const peak = useRef<Record<string, { v: number; t: number }>>({});
const peakHold = (key: string, val: number) => {
const now = Date.now();
const p = peak.current[key];
if (!p || val >= p.v || now - p.t > 2000) { peak.current[key] = { v: val, t: now }; return val; }
return p.v;
};
const connected = !!status.connected;
const vswr = status.vswr && status.vswr > 0 ? status.vswr : undefined;
const fwdW = status.fwd_w && status.fwd_w >= 1 ? status.fwd_w : 0;
const rawVswr = status.vswr && status.vswr > 0 ? status.vswr : undefined;
const rawFwdW = status.fwd_w && status.fwd_w >= 1 ? status.fwd_w : 0;
const fwdW = peakHold('fwd', rawFwdW);
// SWR needs RF to mean anything: sampled on receive the device reports a
// meaningless 1.00, which would wipe the figure the operator actually wants —
// the one measured while transmitting. So show the live value during TX, keep it
// briefly afterwards so it can be read, then let it go.
//
// That expiry is the point: the first version of this held the last TX value
// with no timeout at all, so the meter sat frozen on the previous transmission
// indefinitely — reading 1.39:1 with the radio plainly in RX and 0 W forward.
// Same 2 s window as the power peak above, so the two meters clear together.
const swrHold = useRef<{ v: number; t: number } | null>(null);
if (rawFwdW >= 1 && rawVswr) swrHold.current = { v: rawVswr, t: Date.now() };
const heldSwr = swrHold.current;
const vswr = rawFwdW >= 1
? rawVswr
: heldSwr && Date.now() - heldSwr.t < 2000
? heldSwr.v
: undefined;
const active = status.active ?? 1;
const a: TGChannel = status.a ?? {};
const b: TGChannel = status.b ?? {};
@@ -115,8 +148,11 @@ export function TunerCard({ status, t }: { status: TGStatus; t: (k: string, v?:
onSelect={() => TunerGeniusActivate(2).catch(() => {})} t={t} />
</div>
{/* PWR + SWR meters — same grid as the Flex/amp meters so they match size. */}
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{/* PWR + SWR meters, each taking half the card. The amp card's grid is
3-wide because it shows three meters; copying it here left the tuner's
two meters in the first two of three columns, with a third of the card
empty to the right of SWR. */}
<div className="grid grid-cols-2 gap-2">
<MeterBar label={t('tgp.power')} value={fwdW} unit="W" lo={0} hi={2000}
display={fwdW >= 1 ? `${Math.round(fwdW)} W` : '—'}
segColor={(f) => (f > 0.9 ? '#dc2626' : f > 0.75 ? '#f59e0b' : '#ea580c')} />
+14 -1
View File
@@ -35,6 +35,10 @@ type Props = {
onSendTo?: (service: string, ids: number[]) => void;
onSendRecording?: (ids: number[]) => void;
onSendEQSL?: (ids: number[]) => void;
onBulkEdit?: (ids: number[]) => void;
onExportSelected?: (ids: number[]) => void;
onExportSelectedFields?: (ids: number[]) => void;
onExportCabrilloSelected?: (ids: number[]) => void;
onDelete?: (ids: number[]) => void;
// One column per defined award (cell = the reference this QSO counts for).
awardCols?: { code: string; name: string }[];
@@ -50,7 +54,7 @@ function fmtDate(s: any): string {
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`;
}
export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onDelete, awardCols }: Props) {
export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFields, onExportCabrilloSelected, onDelete, awardCols }: Props) {
const { t } = useI18n();
const gridRef = useRef<any>(null);
const [pickerOpen, setPickerOpen] = useState(false);
@@ -247,6 +251,11 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
</div>
</div>
{/* Same menu as Recent QSOs, minus the two "filtered" exports: there they
mean "the whole logbook under the active column filters", but this grid
is a per-callsign view rather than a filter over the log, so the entry
would quietly export everything — not what it would appear to do here.
The selection-based exports and bulk edit apply unchanged. */}
<QSOContextMenu
menu={menu}
onClose={() => setMenu(null)}
@@ -256,6 +265,10 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
onSendTo={onSendTo}
onSendRecording={onSendRecording}
onSendEQSL={onSendEQSL}
onBulkEdit={onBulkEdit}
onExportSelected={onExportSelected}
onExportSelectedFields={onExportSelectedFields}
onExportCabrilloSelected={onExportCabrilloSelected}
onDelete={onDelete}
/>
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1,6 +1,6 @@
// Single source of truth for the app version shown in the UI (header + About).
// Bump this on a release (the release script updates it alongside telemetry.go).
export const APP_VERSION = '0.21.2';
export const APP_VERSION = '0.21.3';
// Author / credits, shown in Help -> About.
export const APP_AUTHOR = 'F4BPO';
+2
View File
@@ -946,6 +946,8 @@ export function TunerGeniusSetBypass(arg1:boolean):Promise<void>;
export function TunerGeniusSetOperate(arg1:boolean):Promise<void>;
export function UILog(arg1:string):Promise<void>;
export function ULSStatus():Promise<main.ULSStatusResult>;
export function UltrabeamRetract():Promise<void>;
+4
View File
@@ -1842,6 +1842,10 @@ export function TunerGeniusSetOperate(arg1) {
return window['go']['main']['App']['TunerGeniusSetOperate'](arg1);
}
export function UILog(arg1) {
return window['go']['main']['App']['UILog'](arg1);
}
export function ULSStatus() {
return window['go']['main']['App']['ULSStatus']();
}