= {
validated: 'bg-success text-success-foreground',
confirmed: 'bg-warning text-warning-foreground',
@@ -112,7 +125,7 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged, onPaperQSL }: {
const [refSearch, setRefSearch] = useState('');
const [editing, setEditing] = useState(false);
const [view, setView] = useState<'grid' | 'list' | 'stats'>('grid');
- const [refFilter, setRefFilter] = useState<'all' | 'worked' | 'notworked' | 'worked_notconf'>('all');
+ const [refFilter, setRefFilter] = useState<'all' | 'worked' | 'notworked' | 'worked_notconf' | 'slots_notconf'>('all');
// Mode filter, stacked ON TOP of the status one. "Worked on CW but not
// confirmed" is two questions at once, and answering only one of them is what
// sends an operator to a spreadsheet.
@@ -304,6 +317,10 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged, onPaperQSL }: {
if (refFilter === 'worked' && !r.worked) return false;
if (refFilter === 'notworked' && r.worked) return false;
if (refFilter === 'worked_notconf' && !(r.worked && !r.confirmed)) return false;
+ // Worked-not-confirmed by SLOT, not by reference: an entity confirmed on
+ // 20 m still has a 15 m contact waiting for its card, and every filter
+ // above answers "no" for it because the entity itself is confirmed.
+ if (refFilter === 'slots_notconf' && slotsToConfirm(r, gridBands) === 0) return false;
if (modeFilter !== 'all' && refFilter !== 'notworked') {
// A reference never worked has no mode, so "not worked" plus a mode is
// a contradiction: the mode filter stands aside rather than emptying
@@ -339,7 +356,14 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged, onPaperQSL }: {
}
return a.ref.localeCompare(b.ref, undefined, { numeric: true }) * dir;
});
- }, [current, refSearch, refFilter, modeFilter, refSort, refSortDir]);
+ }, [current, refSearch, refFilter, modeFilter, refSort, refSortDir, gridBands]);
+
+ // The gap itself, over whatever the other filters left on screen: the number
+ // of cells an operator would have to turn green to close it.
+ const slotGap = useMemo(
+ () => filteredRefs.reduce((n, r) => n + slotsToConfirm(r, gridBands), 0),
+ [filteredRefs, gridBands],
+ );
// The group column earns its width only when the list actually carries one
// (DXCC prefixes, POTA locations); most custom lists have none. For DXCC the
@@ -468,7 +492,7 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged, onPaperQSL }: {
setRefSearch(e.target.value)} />
- {([['all', t('awp.filterAll')], ['worked', t('awp.filterWkd')], ['notworked', t('awp.filterNotWkd')], ['worked_notconf', t('awp.filterWkdNotCfmd')]] as const).map(([k, label]) => (
+ {([['all', t('awp.filterAll')], ['worked', t('awp.filterWkd')], ['notworked', t('awp.filterNotWkd')], ['worked_notconf', t('awp.filterWkdNotCfmd')], ['slots_notconf', t('awp.filterSlotsNotCfmd')]] as const).map(([k, label]) => (
setRefFilter(k)}
className={cn('px-2 py-1', refFilter === k ? 'bg-accent font-medium' : 'hover:bg-accent/50 text-muted-foreground')}>
{label}
@@ -484,6 +508,11 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged, onPaperQSL }: {
))}
{filteredRefs.length} {t('awp.refs')}
+ {slotGap > 0 && (
+
+ · {slotGap} {t('awp.slotGap')}
+
+ )}
{/* Only for an award scoped to a DXCC entity. "In this award's
scope but with no reference" needs a scope to be in: on a
worldwide reference award — POTA, SOTA, IOTA, WWFF — every
@@ -604,7 +633,14 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged, onPaperQSL }: {
{s === 'none' ? : (
setCell({ ref: r.ref, band: b, name: r.name })}
>{CELL_LABEL[s]}
diff --git a/frontend/src/components/BandSlotGrid.tsx b/frontend/src/components/BandSlotGrid.tsx
index ce7fa9e..b98855e 100644
--- a/frontend/src/components/BandSlotGrid.tsx
+++ b/frontend/src/components/BandSlotGrid.tsx
@@ -81,23 +81,44 @@ const STATUS_CLASSES: Record = {
// i18n keys the Appearance panel's colour pickers use, so the two can never
// disagree about which green is which. swatch = the background class (or a
// special ring marker for the current-entry cell).
-const LEGEND: { swatch: string; ring?: boolean; label: string }[] = [
+const LEGEND: { swatch: string; ring?: boolean; mark?: string; label: string }[] = [
{ swatch: 'bg-mx-call-conf', label: 'mx.callConf' },
{ swatch: 'bg-mx-call-work', label: 'mx.callWork' },
{ swatch: 'bg-mx-dx-conf', label: 'mx.dxConf' },
{ swatch: 'bg-mx-dx-work', label: 'mx.dxWork' },
{ swatch: 'bg-mx-none', label: 'mx.none' },
{ swatch: 'bg-mx-none', ring: true, label: 'mx.current' },
+ { swatch: 'bg-mx-none', mark: 'w', label: 'mx.markWork' },
+ { swatch: 'bg-mx-none', mark: 'c', label: 'mx.markConf' },
];
-function cellTitle(t: (k: string) => string, band: string, cls: string, status: string, current: boolean): string {
+// CallMark — "this callsign has already been worked on this slot".
+//
+// Drawn the same way on every cell, whatever colour the entity status gave it:
+// the operator learns one shape and reads it without first working out what the
+// background means. Only the fill changes, and only with the callsign's own
+// state (worked / confirmed) — never with the entity's. The ring is the theme
+// background, which is what keeps the dot legible over all five cell colours.
+function CallMark({ state = 'w' }: { state?: string }) {
+ return (
+
+ );
+}
+
+function cellTitle(t: (k: string) => string, band: string, cls: string, status: string, current: boolean, call = ''): string {
const desc =
status === 'call_c' ? t('mx.tipCallConf') :
status === 'call_w' ? t('mx.tipCallWork') :
status === 'dxcc_c' ? t('mx.tipDxConf') :
status === 'dxcc_w' ? t('mx.tipDxWork') :
t('mx.tipNone');
- return `${band} ${cls}: ${desc}${current ? ' — ' + t('mx.current') : ''}`;
+ const mine = call === 'c' ? t('mx.tipThisCallConf') : call === 'w' ? t('mx.tipThisCall') : '';
+ return `${band} ${cls}: ${desc}${mine ? ' — ' + mine : ''}${current ? ' — ' + t('mx.current') : ''}`;
}
export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCall = true, lat, lon, forCall, onEditQso }: Props) {
@@ -126,6 +147,16 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
return m;
}, [wb]);
+ // Worked-with-this-callsign, per cell — carried separately by the backend
+ // because collapsing it into the status is what hid it.
+ const callMap = useMemo(() => {
+ const m = new Map();
+ for (const s of wb?.band_status ?? []) {
+ if ((s as any).call) m.set(`${s.band}|${s.class}`, (s as any).call);
+ }
+ return m;
+ }, [wb]);
+
// "Newness" of the current band+mode entry, for the award/DX-chase badges.
// Derived straight from the entity's real band_status (all bands it was
// worked on — not just the operator's configured column list).
@@ -308,21 +339,29 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
{cols.map((b) => {
const st = statusMap.get(`${b.tag}|${cls}`) ?? '';
+ // The same cell's other answer: worked with THIS callsign
+ // here. The status above is the entity's, and a confirmed
+ // entity outranks a worked call — so chasing a DXpedition,
+ // the cell could say "confirmed" about a contact made years
+ // ago and nothing about the one made this morning.
+ const mine = callMap.get(`${b.tag}|${cls}`) ?? '';
const isCurrent = hasCall && b.tag === currentBand && classCurrent;
return (
setSlot({ band: b.tag, cls }) : undefined}
className={cn(
- 'w-[28px] h-[24px] rounded transition-colors p-0',
+ 'relative w-[28px] h-[24px] rounded transition-colors p-0',
st ? STATUS_CLASSES[st] : 'bg-mx-none',
// Only a filled cell has anything to show — an empty one
// stays inert rather than opening a "no QSOs" dialog.
st && 'cursor-pointer hover:brightness-110',
isCurrent && 'ring-2 ring-mx-cur ring-inset',
)}
- />
+ >
+ {mine ? : null}
+
);
})}
@@ -337,11 +376,13 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
+ >
+ {l.mark ? : null}
+
{t(l.label)}
))}
diff --git a/frontend/src/components/ClusterGrid.tsx b/frontend/src/components/ClusterGrid.tsx
index fec3aaf..10a143c 100644
--- a/frontend/src/components/ClusterGrid.tsx
+++ b/frontend/src/components/ClusterGrid.tsx
@@ -45,6 +45,7 @@ export type ClusterSpot = {
raw: string;
repeats?: number;
pota_ref?: string;
+ sota_ref?: string;
pota_name?: string;
};
@@ -336,6 +337,16 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
: { color: 'var(--success)' }) as any,
tooltipValueGetter: (p: any) => (p.data?.pota_name ? t('clg2.tipPota', { name: p.data.pota_name }) : undefined),
},
+ {
+ // SOTA sits next to POTA and reads the same way. The reference comes from the
+ // spot's comment, so it is present on the SOTA feeds and empty elsewhere —
+ // which is why the column is off by default rather than an empty column for
+ // everyone who does not watch summits.
+ group: 'Spot', label: t('clg2.c.sota'), colId: 'sota',
+ headerName: t('clg2.c.sota'), field: 'sota_ref' as any, width: 100, cellClass: 'font-mono',
+ defaultVisible: false,
+ cellStyle: () => ({ color: 'var(--success)' }) as any,
+ },
{
group: 'Spot', label: t('clg2.c.freq'), colId: 'freq',
headerName: t('clg2.c.freq'), field: 'freq_khz' as any, width: 95, type: 'rightAligned', cellClass: 'font-mono',
diff --git a/frontend/src/components/IcomPanel.tsx b/frontend/src/components/IcomPanel.tsx
index bdcd6f4..c4066b8 100644
--- a/frontend/src/components/IcomPanel.tsx
+++ b/frontend/src/components/IcomPanel.tsx
@@ -292,6 +292,9 @@ function ScopePanadapter() {
const wfRef = useRef(null); // waterfall
const peakRef = useRef(160); // running amplitude ceiling for auto-scale
const holdRef = useRef([]); // per-bin peak-hold line
+ // Some radios control their scope over CI-V but never stream it (IC-7851).
+ // Saying so beats a black rectangle, which reads as a bug in OpsLog.
+ const [unsupported, setUnsupported] = useState(false);
const spanRef = useRef({ low: 0, high: 0 }); // latest sweep edges, for click-to-tune
const vfoRef = useRef(0); // latest VFO frequency, for wheel-tune
const centerRef = useRef(0); // scope centre we last set (for pan ◀/▶)
@@ -333,6 +336,7 @@ function ScopePanadapter() {
if (!alive) return;
try {
const sw = await IcomScopeData();
+ if (sw?.unsupported) setUnsupported(true);
if (sw && sw.seq !== lastSeq && sw.amp && sw.amp.length) {
lastSeq = sw.seq;
setFixed(sw.fixed);
@@ -543,7 +547,10 @@ function ScopePanadapter() {
- {on && (
+ {on && unsupported && (
+ {t('icmp.scopeNoStream')}
+ )}
+ {on && !unsupported && (
{ GetLoTWDownloadAllCalls().then((v: boolean) => setLotwAllCalls(!!v)).catch(() => {}); }, []);
// Download date window: 'last' = incremental since last pull, 'date' = from a
// chosen date, 'all' = everything.
const [sinceMode, setSinceMode] = useState<'last' | 'date' | 'all'>('last');
@@ -434,6 +437,18 @@ export function QSLManagerPanel({ onEditQSO, actions, paperRequest }: {
{paperBusy ? : }
{t('qslm.search')}
+ {/* The station's QRZ page, one click away: writing a card means
+ reading the address, the manager and whether they even want
+ paper, and all three are on that page. */}
+ {
+ const c = paperCall.trim().toUpperCase();
+ if (c) OpenExternalURL(`https://www.qrz.com/db/${c}`).catch(() => {});
+ }}>
+
+ QRZ
+
{t('qslm.paperHint')}
>
) : (
@@ -736,6 +751,12 @@ export function QSLManagerPanel({ onEditQSO, actions, paperRequest }: {
setAddNotFound(!!c)} />
{t('qslm.addNotFound')}
+ {service === 'lotw' && (
+
+ { setLotwAllCalls(!!c); SetLoTWDownloadAllCalls(!!c); }} />
+ {t('qslm.lotwAllCalls')}
+
+ )}
>)}
diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx
index a39c6b8..09d73f5 100644
--- a/frontend/src/lib/i18n.tsx
+++ b/frontend/src/lib/i18n.tsx
@@ -127,6 +127,15 @@ const en: Dict = {
'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.tipNone': 'Never worked', 'mx.tipClick': 'click to list the QSOs',
+ 'icmp.scopeNoStream': 'This radio does not send its scope over CI-V — its own screen still works.',
+ 'qslm.qrzTitle': 'Open this callsign on QRZ.com',
+ 'qslm.lotwAllCalls': 'All my callsigns',
+ 'qslm.lotwAllCallsTitle': "Download the confirmations of every callsign on the LoTW account, not just this profile's. A QSO made as F4BPO/P or TM2Q is confirmed at LoTW but never reaches an F4BPO profile without this.",
+ 'awp.filterSlotsNotCfmd': 'Slots to confirm', 'awp.slotGap': 'slots to confirm',
+ 'awp.slotGapTip': 'Band-slots worked and not yet confirmed — the difference between the worked and confirmed totals above.',
+ 'mx.markWork': 'This callsign worked', 'mx.markConf': 'This callsign confirmed',
+ 'mx.tipThisCall': 'already worked with this callsign',
+ 'mx.tipThisCallConf': 'already confirmed with this callsign',
// 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', 'toast.qsoLogged': 'QSO logged', 'dec.contsHint': 'Continents: click to keep one or several', 'dec.allConts': 'All continents',
@@ -427,7 +436,7 @@ const en: Dict = {
'tgp.title': 'Tuner Genius', 'tgp.chActive': 'Channel {letter} — active', 'tgp.chSelect': 'Make channel {letter} active', 'tgp.chActiveTag': 'active', 'tgp.ant': 'Ant', 'tgp.bypassed': 'Bypassed', 'tgp.inLine': 'In line',
'flxp.ritHint': 'RIT — shifts your RECEIVE frequency only. Wheel, ± or arrow keys to adjust (Ctrl = 100 Hz). The offset is kept when you switch it off.', 'flxp.xitHint': 'XIT — shifts your TRANSMIT frequency only. Wheel, ± or arrow keys to adjust (Ctrl = 100 Hz). The offset is kept when you switch it off.',
'flxp.smartsdrRemote': 'SmartSDR remote control', 'flxp.offline': 'OFFLINE', 'flxp.waiting': 'Waiting for the FlexRadio… (set CAT to FlexRadio and connect)', 'flxp.transmit': 'Transmit', 'flxp.rfPower': 'RF Power', 'flxp.tunePwr': 'Tune Pwr', 'flxp.rstChaseHint': 'Chase the pile-up: when the CW skimmer marks a report ({m}) on the panadapter, move the TRANSMIT slice there — that is where the DX was listening a second ago. The receive slice never moves. Right-click to change the marker text.', 'flxp.rstChaseMarkerHint': 'The text the skimmer writes for a report — whatever SDC is set to send (599, 5NN…). Several can be given, separated by commas; add the old-report marker to chase those too.', 'flxp.rstChaseOffset': 'off', 'k3.console': 'Elecraft Console', 'k3.waiting': 'Waiting for the radio… (set CAT to Elecraft or Kenwood and connect)', 'k3.rfGain': 'RF gain', 'k3.micGain': 'Mic', 'k3.squelch': 'Squelch', 'k3.filter': 'Filter', 'k3.antenna': 'Antenna', 'k3.clear': 'CLEAR', 'k3.keySpeed': 'Keyer', 'k3.meters': 'Meters', 'k3.levels': 'Levels', 'k3.receive': 'Receive', 'k3.power': 'Power', 'k3.volume': 'Volume', 'k3.refreshHint': 'Re-read the settings from the radio — for when a knob was turned on the front panel.', 'k3.sMeterHint': 'Click to use this reading as the report sent. Raw value from the rig: {raw}.', 'k3.atuHint': 'Put the ATU in line or bypass it (a hold of the K3 ATU switch).', 'k3.tuneHint': 'Start an ATU tuning cycle (K3: a tap of the ATU TUNE button). The exact command sent is written to the log.', 'k3.provisional': 'Meter scaling is provisional: it has not yet been confirmed against a real K3, and the raw readings are written to the log so it can be.', 'flxp.splitHint': 'Split: RX/TX on separate slices. ON creates a TX slice +1 kHz (CW) / +5 kHz (SSB) up, like SmartSDR.', 'flxp.sliceHint': 'Click to make this the active slice — frequency, mode, DSP and spot-clicks all follow it.', 'flxp.txSlice': 'This slice transmits', 'flxp.setTxSlice': 'Move TX to this slice (transmit here)', 'flxp.voxDly': 'VOX Dly', 'flxp.speed': 'Speed', 'flxp.pitch': 'Pitch', 'flxp.delay': 'Delay',
- 'flxp.receiveActive': 'Receive (active slice)', 'flxp.muted': 'Muted — click to unmute', 'flxp.mute': 'Mute RX audio', 'flxp.filter': 'Filter', 'flxp.amplifier': 'Amplifier', 'flxp.atuTune': 'TUNE', 'flxp.atuTuneHint': 'Start a tuning cycle on the built-in ATU. The radio keys a carrier itself to measure the match.', 'flxp.atuBypass': 'BYPASS', 'flxp.atuBypassHint': 'Take the ATU out of line (straight through).', 'flxp.atuMem': 'MEM', 'flxp.atuMemHint': 'Reuse the stored tuning solution for this frequency instead of tuning again.', 'flxp.atuIdle': 'not tuned', 'flxp.atuTuning': 'tuning…', 'flxp.atuOk': 'tuned', 'flxp.atuFail': 'TUNE FAILED', 'flxp.atuBypassed': 'bypassed', 'flxp.atuAborted': 'aborted', 'flxp.ampInLine': 'Amplifier is in line (transmitting through PA).', 'flxp.ampBypassed': 'Amplifier bypassed (standby).', 'flxp.pgConnected': 'PowerGenius connected', 'flxp.pgOffline': 'PowerGenius offline', 'flxp.fan': 'Fan', 'flxp.fanStandard': 'Standard', 'flxp.fanContest': 'Contest', 'flxp.fanBroadcast': 'Broadcast', 'flxp.fault': 'FAULT', 'flxp.meters': 'Meters', 'flxp.voltage': 'VOLTAGE', 'flxp.paTemp': 'PA TEMP', 'flxp.txFilter': 'TX filter', 'flxp.micProfile': 'Mic profile', 'flxp.noMeters': "No meters yet — waiting for the radio's UDP stream…", 'flxp.amplifierHdr': 'AMPLIFIER', 'flxp.outputPower': 'OUTPUT POWER', 'flxp.speOffline': 'SPE offline', 'flxp.acomOffline': 'Acom offline', 'flxp.kpaTuning': 'TUNING', 'flxp.kpaClearsFault': 'OPERATE also clears the current fault (except temperature, which clears as it cools)', 'flxp.ampPick': 'Choose which amplifier this card shows', 'flxp.dspV4Hint': 'SmartSDR v4 DSP (8000/Aurora series)', 'flxp.daxHint': 'DAX as the transmit audio source (SmartSDR transmit-bar DAX button) — for WSJT-X & co', 'flxp.rnnHint': 'RNN — AI noise reduction (on/off)', 'flxp.anftHint': 'ANFT — FFT-based automatic notch filter (on/off)', 'flxp.dspNoise': 'Noise', 'flxp.dspMore': 'Show/hide advanced DSP (WNB, v4 NR/notch)',
+ 'flxp.receiveActive': 'Receive (active slice)', 'flxp.muted': 'Muted — click to unmute', 'flxp.mute': 'Mute RX audio', 'flxp.filter': 'Filter', 'flxp.amplifier': 'Amplifier', 'flxp.atuTune': 'TUNE', 'flxp.atuTuneHint': 'Start a tuning cycle on the built-in ATU. The radio keys a carrier itself to measure the match.', 'flxp.atuBypass': 'BYPASS', 'flxp.atuBypassHint': 'Take the ATU out of line (straight through).', 'flxp.atuMem': 'MEM', 'flxp.atuMemHint': 'Reuse the stored tuning solution for this frequency instead of tuning again.', 'flxp.atuIdle': 'not tuned', 'flxp.atuTuning': 'tuning…', 'flxp.atuOk': 'tuned', 'flxp.atuFail': 'TUNE FAILED', 'flxp.atuBypassed': 'bypassed', 'flxp.atuAborted': 'aborted', 'flxp.ampInLine': 'Amplifier is in line (transmitting through PA).', 'flxp.ampBypassed': 'Amplifier bypassed (standby).', 'flxp.pgConnected': 'PowerGenius connected', 'flxp.pgOffline': 'PowerGenius offline', 'flxp.fan': 'Fan', 'flxp.fanStandard': 'Standard', 'flxp.fanContest': 'Contest', 'flxp.fanBroadcast': 'Broadcast', 'flxp.fault': 'FAULT', 'flxp.meters': 'Meters', 'flxp.voltage': 'VOLTAGE', 'flxp.paTemp': 'PA TEMP', 'flxp.txFilter': 'TX filter', 'flxp.micProfile': 'Mic profile', 'flxp.noMeters': "No meters yet — waiting for the radio's UDP stream…", 'flxp.amplifierHdr': 'AMPLIFIER', 'flxp.outputPower': 'OUTPUT POWER', 'flxp.speOffline': 'SPE offline', 'flxp.acomOffline': 'Acom offline', 'flxp.kpaOffline': 'KPA offline', 'flxp.kpaTuning': 'TUNING', 'flxp.kpaClearsFault': 'OPERATE also clears the current fault (except temperature, which clears as it cools)', 'flxp.ampPick': 'Choose which amplifier this card shows', 'flxp.dspV4Hint': 'SmartSDR v4 DSP (8000/Aurora series)', 'flxp.daxHint': 'DAX as the transmit audio source (SmartSDR transmit-bar DAX button) — for WSJT-X & co', 'flxp.rnnHint': 'RNN — AI noise reduction (on/off)', 'flxp.anftHint': 'ANFT — FFT-based automatic notch filter (on/off)', 'flxp.dspNoise': 'Noise', 'flxp.dspMore': 'Show/hide advanced DSP (WNB, v4 NR/notch)',
'icmp.spectrum': 'Spectrum', 'icmp.scopeFixed': 'Fixed — double-click / wheel to tune', 'icmp.scopeCenter': 'Center — follows VFO', 'icmp.scopeOff': 'Scope off', 'icmp.scopePanDown': 'Shift scope −50 kHz', 'icmp.scopePanUp': 'Shift scope +50 kHz', 'icmp.scopeCenterVfo': 'Center scope on the current frequency (±50 kHz)', 'icmp.notConnected': "Icom not connected. Enable the Icom CI-V backend in Settings → CAT and connect the radio's USB port.", 'icmp.refresh': 'Refresh', 'icmp.meters': 'Meters', 'icmp.transmit': 'Transmit', 'icmp.power': 'Power', 'icmp.mic': 'Mic', 'icmp.receive': 'Receive', 'icmp.preamp': 'Preamp', 'icmp.filter': 'Filter', 'icmp.noiseNotch': 'Noise / Notch', 'icmp.autoNotch': 'Auto notch filter', 'icmp.apf': 'Audio peak filter (CW)', 'icmp.clarifiers': 'RIT / ΔTX', 'icmp.ritHint': 'Wheel or ± to shift · Ctrl+←/→ shifts RIT when active', 'icmp.bandsAntenna': 'Bands & Antenna', 'icmp.bandCurrent': 'The rig is on {b} m', 'icmp.antenna': 'Antenna', 'icmp.passband': 'Passband / Notch', 'icmp.pbtCenter': 'Center PBT', 'icmp.manualNotch': 'Manual notch — MN on, then set position', 'icmp.squelch': 'Squelch', 'icmp.powerOnHint': 'Power the radio ON (boots ~15 s)', 'icmp.powerOffHint': 'Power the radio OFF', 'icmp.powerOffConfirm': 'Switch the radio OFF?',
'rst.clickToFill': 'Click to set RST tx from the signal',
'qrz.openTitle': 'Open {call} on QRZ.com',
@@ -487,7 +496,7 @@ const en: Dict = {
'wbg.awardTip': '{name} — reference this QSO counts for', 'wbg.typeCall': 'Type a callsign in the entry strip to see prior contacts.', 'wbg.checking': 'checking…', 'wbg.new': 'NEW', 'wbg.noPriorPre': 'No prior QSO with ', 'wbg.noPriorPost': '.', 'wbg.workedBefore': 'Worked before', 'wbg.first': 'First:', 'wbg.last': 'Last:', 'wbg.dxcc': 'DXCC:', 'wbg.entityQsos': '{n} entity QSOs', 'wbg.clearFiltersTitle': 'Clear all column filters', 'wbg.clearFilters': 'Clear filters', 'wbg.columns': 'Columns', 'wbg.olderQsos': '+ {n} older QSOs (not shown — capped for performance)', 'wbg.pickerTitle': 'Worked-before columns', 'wbg.pickerDesc': 'Pick the columns you want visible in the Worked-before table.', 'wbg.allGroups': 'All groups:', 'wbg.all': 'all', 'wbg.none': 'none', 'wbg.grpAwards': 'Awards', 'wbg.resetDefaults': 'Reset to defaults', 'wbg.done': 'Done',
'chn.title': 'Chase new', 'chn.close': 'Hide the panel', 'chn.filterHint': 'Show or hide this kind', 'chn.allFiltered': 'Everything heard is filtered out — turn a category back on above.', 'chn.toggle': 'Chase new', 'chn.count': '{n} heard', 'chn.loading': 'Waiting for the feed…', 'chn.empty': 'Nothing new being decoded near you right now.', 'chn.digitalOnly': 'PSK Reporter — digital modes only, heard within ~300 km of you.', 'chn.option': 'Chase new (PSK Reporter)', 'chn.optionHelp': 'Lists stations being decoded near you that are new against your log — new entity, band, mode, slot, prefix or square. Digital modes only.', 'chn.show': 'Chase new panel',
'clg2.allFiltered': '{n} spots received, none shown — your filters are hiding them all.', 'clg2.activeFilters': 'Active:', 'clg2.clearAllFilters': 'Clear every filter', 'clg2.fBandLock': 'band locked to the rig', 'clg2.fBands': 'bands {list}', 'clg2.fModeLock': 'mode locked to the rig', 'clg2.fModes': 'modes {list}', 'clg2.fStatus': 'status chips', 'clg2.fHideWorked': 'hide worked', 'clg2.fLotwOnly': 'LoTW users only', 'clg2.fSpotterCont': 'spotter continent', 'clg2.fSource': 'one source node', 'clg2.fSearch': 'search “{q}”',
- 'clg2.c.time': 'Time', 'clg2.c.call': 'Call', 'clg2.c.status': 'Status', 'clg2.c.pota': 'POTA', 'clg2.c.freq': 'Freq', 'clg2.c.band': 'Band', 'clg2.c.mode': 'Mode', 'clg2.c.pfx': 'Pfx', 'clg2.c.cqz': 'CQ Zone', 'clg2.h.cqz': 'CQZ', 'clg2.c.ituz': 'ITU Zone', 'clg2.h.ituz': 'ITU', 'clg2.c.distance_km': 'Distance (km)', 'clg2.h.distance_km': 'Dist km', 'clg2.c.sp_deg': 'Short path (°)', 'clg2.h.sp_deg': 'SP°', 'clg2.c.lp_deg': 'Long path (°)', 'clg2.h.lp_deg': 'LP°', 'clg2.c.country': 'Country', 'clg2.c.continent': 'Continent', 'clg2.h.continent': 'Cont', 'clg2.c.spotter': 'Spotter', 'clg2.c.source': 'Source', 'clg2.c.locator': 'Spotter locator', 'clg2.h.locator': 'Spotter loc', 'clg2.c.county': 'US County', 'clg2.tipNewCounty': 'NEW COUNTY — never worked', 'clg2.tipNewPfx': 'NEW PREFIX — this WPX prefix has never been worked', 'clg2.c.comment': 'Comment', 'clg2.c.received_at': 'Received at', 'clg2.h.received_at': 'Received UTC', 'clg2.c.raw': 'Raw', 'clg2.newDxcc': 'NEW DXCC', 'clg2.newBandMode': 'NEW B+M', 'clg2.newBand': 'NEW BAND', 'clg2.newMode': 'NEW MODE', 'clg2.newSlot': 'NEW SLOT', 'clg2.newCall': 'NEW CALL', 'clg2.wkdCall': 'WKD CALL', 'clg2.newCounty': 'NEW CTY', 'clg2.newGridUnconf': 'GRID?', 'clg2.newGrid': 'NEW GRID', 'clg2.c.grid': 'Grid', 'clg2.tipNewGrid': 'NEW GRID — this square has never been worked (grid heard in a CQ on the UDP link)', 'clg2.newPfx': "NEW PFX", 'clg2.newPota': 'NEW POTA', 'clg2.tipNewDxcc': 'NEW DXCC: {country}', 'clg2.tipWorkedCall': 'Already worked this call', 'clg2.tipNewBandMode': 'NEW BAND AND NEW MODE for this entity — neither has been worked with it', 'clg2.tipNewBand': 'NEW BAND for this entity', 'clg2.tipNewSlotBand': 'NEW SLOT (mode not yet worked on this band)', 'clg2.tipNewMode': 'NEW MODE (this mode never worked on this entity)', 'clg2.tipNewSlot': 'NEW SLOT (this band+mode not yet worked)', 'clg2.tipNewCall': 'NEW CALL — this callsign has never been worked on this band and mode (the entity has)', 'clg2.tipPota': 'POTA — {name}', 'clg2.grpSpot': 'Spot', 'clg2.grpGeo': 'Geo', 'clg2.clearFiltersTitle': 'Clear all column filters', 'clg2.clearFilters': 'Clear filters', 'clg2.newSpots': '{n} new spots — click to resume', 'clg2.columns': 'Columns', 'clg2.pickerTitle': 'Cluster columns', 'clg2.pickerDesc': 'Pick the columns you want visible in the Cluster table.', 'clg2.allGroups': 'All groups:', 'clg2.all': 'all', 'clg2.none': 'none', 'clg2.resetDefaults': 'Reset to defaults', 'clg2.done': 'Done',
+ 'clg2.c.time': 'Time', 'clg2.c.call': 'Call', 'clg2.c.status': 'Status', 'clg2.c.pota': 'POTA', 'clg2.c.sota': 'SOTA', 'clg2.c.freq': 'Freq', 'clg2.c.band': 'Band', 'clg2.c.mode': 'Mode', 'clg2.c.pfx': 'Pfx', 'clg2.c.cqz': 'CQ Zone', 'clg2.h.cqz': 'CQZ', 'clg2.c.ituz': 'ITU Zone', 'clg2.h.ituz': 'ITU', 'clg2.c.distance_km': 'Distance (km)', 'clg2.h.distance_km': 'Dist km', 'clg2.c.sp_deg': 'Short path (°)', 'clg2.h.sp_deg': 'SP°', 'clg2.c.lp_deg': 'Long path (°)', 'clg2.h.lp_deg': 'LP°', 'clg2.c.country': 'Country', 'clg2.c.continent': 'Continent', 'clg2.h.continent': 'Cont', 'clg2.c.spotter': 'Spotter', 'clg2.c.source': 'Source', 'clg2.c.locator': 'Spotter locator', 'clg2.h.locator': 'Spotter loc', 'clg2.c.county': 'US County', 'clg2.tipNewCounty': 'NEW COUNTY — never worked', 'clg2.tipNewPfx': 'NEW PREFIX — this WPX prefix has never been worked', 'clg2.c.comment': 'Comment', 'clg2.c.received_at': 'Received at', 'clg2.h.received_at': 'Received UTC', 'clg2.c.raw': 'Raw', 'clg2.newDxcc': 'NEW DXCC', 'clg2.newBandMode': 'NEW B+M', 'clg2.newBand': 'NEW BAND', 'clg2.newMode': 'NEW MODE', 'clg2.newSlot': 'NEW SLOT', 'clg2.newCall': 'NEW CALL', 'clg2.wkdCall': 'WKD CALL', 'clg2.newCounty': 'NEW CTY', 'clg2.newGridUnconf': 'GRID?', 'clg2.newGrid': 'NEW GRID', 'clg2.c.grid': 'Grid', 'clg2.tipNewGrid': 'NEW GRID — this square has never been worked (grid heard in a CQ on the UDP link)', 'clg2.newPfx': "NEW PFX", 'clg2.newPota': 'NEW POTA', 'clg2.tipNewDxcc': 'NEW DXCC: {country}', 'clg2.tipWorkedCall': 'Already worked this call', 'clg2.tipNewBandMode': 'NEW BAND AND NEW MODE for this entity — neither has been worked with it', 'clg2.tipNewBand': 'NEW BAND for this entity', 'clg2.tipNewSlotBand': 'NEW SLOT (mode not yet worked on this band)', 'clg2.tipNewMode': 'NEW MODE (this mode never worked on this entity)', 'clg2.tipNewSlot': 'NEW SLOT (this band+mode not yet worked)', 'clg2.tipNewCall': 'NEW CALL — this callsign has never been worked on this band and mode (the entity has)', 'clg2.tipPota': 'POTA — {name}', 'clg2.grpSpot': 'Spot', 'clg2.grpGeo': 'Geo', 'clg2.clearFiltersTitle': 'Clear all column filters', 'clg2.clearFilters': 'Clear filters', 'clg2.newSpots': '{n} new spots — click to resume', 'clg2.columns': 'Columns', 'clg2.pickerTitle': 'Cluster columns', 'clg2.pickerDesc': 'Pick the columns you want visible in the Cluster table.', 'clg2.allGroups': 'All groups:', 'clg2.all': 'all', 'clg2.none': 'none', 'clg2.resetDefaults': 'Reset to defaults', 'clg2.done': 'Done',
// Audio devices & voice keyer (Preferences → Audio devices).
'aud.refreshDevices': 'Refresh devices', 'aud.fromRadio': 'From Radio (RX in)', 'aud.toRadio': 'To Radio (TX out)', 'aud.recMic': 'Recording mic', 'aud.listening': 'Listening (preview)',
'aud.phFromRadio': 'Rig audio output → soundcard input', 'aud.phToRadio': 'Soundcard output → rig mic/data in', 'aud.phRecMic': 'Your microphone (record voice-keyer messages)', 'aud.phListening': 'Local speakers for preview',
@@ -613,6 +622,15 @@ const fr: Dict = {
'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.tipNone': 'Jamais contacté', 'mx.tipClick': 'cliquer pour lister les QSO',
+ 'icmp.scopeNoStream': "Cette radio n'envoie pas son scope en CI-V — son propre écran fonctionne toujours.",
+ 'qslm.qrzTitle': 'Ouvrir cet indicatif sur QRZ.com',
+ 'qslm.lotwAllCalls': 'Tous mes indicatifs',
+ 'qslm.lotwAllCallsTitle': "Télécharger les confirmations de tous les indicatifs du compte LoTW, pas seulement celui du profil. Un QSO fait en F4BPO/P ou TM2Q est confirmé chez LoTW mais n'atteint jamais un profil F4BPO sans cette option.",
+ 'awp.filterSlotsNotCfmd': 'Slots à confirmer', 'awp.slotGap': 'slots à confirmer',
+ 'awp.slotGapTip': "Créneaux bande contactés et pas encore confirmés — l'écart entre les totaux contactés et confirmés ci-dessus.",
+ 'mx.markWork': 'Cet indicatif contacté', 'mx.markConf': 'Cet indicatif confirmé',
+ 'mx.tipThisCall': 'déjà contacté avec cet indicatif',
+ 'mx.tipThisCallConf': 'déjà confirmé avec cet indicatif',
// 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', 'toast.qsoLogged': 'QSO enregistré', 'dec.contsHint': 'Continents : clique pour en garder un ou plusieurs', 'dec.allConts': 'Tous continents',
@@ -899,7 +917,7 @@ const fr: Dict = {
'tgp.title': 'Tuner Genius', 'tgp.chActive': 'Canal {letter} — actif', 'tgp.chSelect': 'Activer le canal {letter}', 'tgp.chActiveTag': 'actif', 'tgp.ant': 'Ant', 'tgp.bypassed': 'Bypass', 'tgp.inLine': 'En ligne',
'flxp.ritHint': "RIT — décale uniquement ta fréquence de RÉCEPTION. Molette, ± ou flèches pour ajuster (Ctrl = 100 Hz). Le décalage est conservé quand tu l'éteins.", 'flxp.xitHint': "XIT — décale uniquement ta fréquence d'ÉMISSION. Molette, ± ou flèches pour ajuster (Ctrl = 100 Hz). Le décalage est conservé quand tu l'éteins.",
'flxp.smartsdrRemote': 'Contrôle à distance SmartSDR', 'flxp.offline': 'HORS LIGNE', 'flxp.waiting': 'En attente du FlexRadio… (règle le CAT sur FlexRadio et connecte)', 'flxp.transmit': 'Émission', 'flxp.rfPower': 'Puissance RF', 'flxp.tunePwr': 'Puiss. TUNE', 'flxp.rstChaseHint': "Chasser le pile-up : quand le skimmer CW marque un report ({m}) sur le panadapter, déplacer la slice d'ÉMISSION dessus — c'est là que le DX écoutait il y a une seconde. La slice de réception ne bouge jamais. Clic droit pour changer le texte du marqueur.", 'flxp.rstChaseMarkerHint': "Le texte que le skimmer écrit pour un report — ce que SDC est réglé à envoyer (599, 5NN…). On peut en mettre plusieurs, séparés par des virgules ; ajoute le marqueur des reports anciens pour les chasser aussi.", 'flxp.rstChaseOffset': 'off', 'k3.console': 'Console Elecraft', 'k3.waiting': 'En attente de la radio… (règle le CAT sur Elecraft ou Kenwood et connecte)', 'k3.rfGain': 'Gain HF', 'k3.micGain': 'Micro', 'k3.squelch': 'Squelch', 'k3.filter': 'Filtre', 'k3.antenna': 'Antenne', 'k3.clear': 'EFFACER', 'k3.keySpeed': 'Manip', 'k3.meters': 'Mesures', 'k3.levels': 'Niveaux', 'k3.receive': 'Réception', 'k3.power': 'Puissance', 'k3.volume': 'Volume', 'k3.refreshHint': "Relire les réglages depuis la radio — quand un bouton a été tourné en façade.", 'k3.sMeterHint': 'Cliquer pour utiliser cette lecture comme report envoyé. Valeur brute de la radio : {raw}.', 'k3.atuHint': "Mettre la boîte d'accord en ligne ou la contourner (maintien de la touche ATU du K3).", 'k3.tuneHint': "Lancer un cycle d'accord de l'ATU (K3 : appui sur la touche ATU TUNE). La commande exacte envoyée est écrite dans le journal.", 'k3.provisional': "L'échelle des mesures est provisoire : elle n'a pas encore été confirmée sur un vrai K3, et les valeurs brutes sont écrites dans le journal pour qu'elle puisse l'être.", 'flxp.splitHint': 'Split : RX/TX sur des slices séparées. ON crée une slice TX +1 kHz (CW) / +5 kHz (SSB) au-dessus, comme SmartSDR.', 'flxp.sliceHint': 'Cliquer pour rendre cette slice active — fréquence, mode, DSP et clics de spot la suivent tous.', 'flxp.txSlice': 'Cette slice émet', 'flxp.setTxSlice': 'Déplacer le TX sur cette slice (émettre ici)', 'flxp.voxDly': 'Délai VOX', 'flxp.speed': 'Vitesse', 'flxp.pitch': 'Tonalité', 'flxp.delay': 'Délai',
- 'flxp.receiveActive': 'Réception (slice active)', 'flxp.muted': 'Coupé — clic pour rétablir', 'flxp.mute': "Couper l'audio RX", 'flxp.filter': 'Filtre', 'flxp.amplifier': 'Amplificateur', 'flxp.atuTune': 'ACCORD', 'flxp.atuTuneHint': "Lance un cycle d'accord sur le coupleur intégré. La radio émet elle-même une porteuse pour mesurer l'adaptation.", 'flxp.atuBypass': 'BYPASS', 'flxp.atuBypassHint': 'Sort le coupleur de la ligne (passage direct).', 'flxp.atuMem': 'MEM', 'flxp.atuMemHint': "Réutilise l'accord mémorisé pour cette fréquence au lieu de refaire un cycle.", 'flxp.atuIdle': 'non accordé', 'flxp.atuTuning': 'accord en cours…', 'flxp.atuOk': 'accordé', 'flxp.atuFail': 'ÉCHEC ACCORD', 'flxp.atuBypassed': 'contourné', 'flxp.atuAborted': 'interrompu', 'flxp.ampInLine': 'Amplificateur en ligne (émission via le PA).', 'flxp.ampBypassed': 'Amplificateur en bypass (standby).', 'flxp.pgConnected': 'PowerGenius connecté', 'flxp.pgOffline': 'PowerGenius hors ligne', 'flxp.fan': 'Ventilo', 'flxp.fanStandard': 'Standard', 'flxp.fanContest': 'Contest', 'flxp.fanBroadcast': 'Diffusion', 'flxp.fault': 'DÉFAUT', 'flxp.meters': 'Mesures', 'flxp.voltage': 'TENSION', 'flxp.paTemp': 'TEMP PA', 'flxp.txFilter': 'Filtre TX', 'flxp.micProfile': 'Profil micro', 'flxp.noMeters': 'Aucune mesure — en attente du flux UDP de la radio…', 'flxp.amplifierHdr': 'AMPLIFICATEUR', 'flxp.outputPower': 'PUISSANCE DE SORTIE', 'flxp.speOffline': 'SPE hors ligne', 'flxp.acomOffline': 'Acom hors ligne', 'flxp.kpaTuning': 'ACCORD', 'flxp.kpaClearsFault': "OPERATE efface aussi le défaut courant (sauf la température, qui s'efface en refroidissant)", 'flxp.ampPick': 'Choisir quel amplificateur cette carte affiche', 'flxp.dspV4Hint': 'DSP SmartSDR v4 (séries 8000/Aurora)', 'flxp.daxHint': "DAX comme source audio d'émission (bouton DAX du bandeau transmit de SmartSDR) — pour WSJT-X & co", 'flxp.rnnHint': 'RNN — réduction de bruit par IA (on/off)', 'flxp.anftHint': 'ANFT — filtre notch automatique FFT (on/off)', 'flxp.dspNoise': 'Bruit', 'flxp.dspMore': 'Afficher/masquer le DSP avancé (WNB, NR/notch v4)',
+ 'flxp.receiveActive': 'Réception (slice active)', 'flxp.muted': 'Coupé — clic pour rétablir', 'flxp.mute': "Couper l'audio RX", 'flxp.filter': 'Filtre', 'flxp.amplifier': 'Amplificateur', 'flxp.atuTune': 'ACCORD', 'flxp.atuTuneHint': "Lance un cycle d'accord sur le coupleur intégré. La radio émet elle-même une porteuse pour mesurer l'adaptation.", 'flxp.atuBypass': 'BYPASS', 'flxp.atuBypassHint': 'Sort le coupleur de la ligne (passage direct).', 'flxp.atuMem': 'MEM', 'flxp.atuMemHint': "Réutilise l'accord mémorisé pour cette fréquence au lieu de refaire un cycle.", 'flxp.atuIdle': 'non accordé', 'flxp.atuTuning': 'accord en cours…', 'flxp.atuOk': 'accordé', 'flxp.atuFail': 'ÉCHEC ACCORD', 'flxp.atuBypassed': 'contourné', 'flxp.atuAborted': 'interrompu', 'flxp.ampInLine': 'Amplificateur en ligne (émission via le PA).', 'flxp.ampBypassed': 'Amplificateur en bypass (standby).', 'flxp.pgConnected': 'PowerGenius connecté', 'flxp.pgOffline': 'PowerGenius hors ligne', 'flxp.fan': 'Ventilo', 'flxp.fanStandard': 'Standard', 'flxp.fanContest': 'Contest', 'flxp.fanBroadcast': 'Diffusion', 'flxp.fault': 'DÉFAUT', 'flxp.meters': 'Mesures', 'flxp.voltage': 'TENSION', 'flxp.paTemp': 'TEMP PA', 'flxp.txFilter': 'Filtre TX', 'flxp.micProfile': 'Profil micro', 'flxp.noMeters': 'Aucune mesure — en attente du flux UDP de la radio…', 'flxp.amplifierHdr': 'AMPLIFICATEUR', 'flxp.outputPower': 'PUISSANCE DE SORTIE', 'flxp.speOffline': 'SPE hors ligne', 'flxp.acomOffline': 'Acom hors ligne', 'flxp.kpaOffline': 'KPA hors ligne', 'flxp.kpaTuning': 'ACCORD', 'flxp.kpaClearsFault': "OPERATE efface aussi le défaut courant (sauf la température, qui s'efface en refroidissant)", 'flxp.ampPick': 'Choisir quel amplificateur cette carte affiche', 'flxp.dspV4Hint': 'DSP SmartSDR v4 (séries 8000/Aurora)', 'flxp.daxHint': "DAX comme source audio d'émission (bouton DAX du bandeau transmit de SmartSDR) — pour WSJT-X & co", 'flxp.rnnHint': 'RNN — réduction de bruit par IA (on/off)', 'flxp.anftHint': 'ANFT — filtre notch automatique FFT (on/off)', 'flxp.dspNoise': 'Bruit', 'flxp.dspMore': 'Afficher/masquer le DSP avancé (WNB, NR/notch v4)',
'icmp.spectrum': 'Spectre', 'icmp.scopeFixed': 'Fixe — double-clic / molette pour accorder', 'icmp.scopeCenter': 'Centré — suit le VFO', 'icmp.scopeOff': 'Scope éteint', 'icmp.scopePanDown': 'Décaler le scope −50 kHz', 'icmp.scopePanUp': 'Décaler le scope +50 kHz', 'icmp.scopeCenterVfo': 'Centrer le scope sur la fréquence actuelle (±50 kHz)', 'icmp.notConnected': 'Icom non connecté. Active le backend CI-V Icom dans Réglages → CAT et connecte le port USB de la radio.', 'icmp.refresh': 'Rafraîchir', 'icmp.meters': 'Mesures', 'icmp.transmit': 'Émission', 'icmp.power': 'Puissance', 'icmp.mic': 'Micro', 'icmp.receive': 'Réception', 'icmp.preamp': 'Préampli', 'icmp.filter': 'Filtre', 'icmp.noiseNotch': 'Bruit / Notch', 'icmp.autoNotch': 'Filtre notch auto', 'icmp.apf': 'Filtre de pic audio (CW)', 'icmp.clarifiers': 'RIT / ΔTX', 'icmp.ritHint': 'Molette ou ± pour décaler · Ctrl+←/→ décale le RIT si actif', 'icmp.bandsAntenna': 'Bandes & Antenne', 'icmp.bandCurrent': 'Le poste est sur {b} m', 'icmp.antenna': 'Antenne', 'icmp.passband': 'Passe-bande / Notch', 'icmp.pbtCenter': 'Centrer PBT', 'icmp.manualNotch': 'Notch manuel — active MN, puis règle la position', 'icmp.squelch': 'Squelch', 'icmp.powerOnHint': 'Allumer la radio (démarre en ~15 s)', 'icmp.powerOffHint': 'Éteindre la radio', 'icmp.powerOffConfirm': 'Éteindre la radio ?',
'rst.clickToFill': 'Clic pour remplir le RST tx depuis le signal',
'qrz.openTitle': 'Ouvrir {call} sur QRZ.com',
@@ -955,7 +973,7 @@ const fr: Dict = {
'wbg.awardTip': '{name} — référence comptée pour ce QSO', 'wbg.typeCall': 'Saisissez un indicatif dans la barre pour voir les contacts précédents.', 'wbg.checking': 'vérification…', 'wbg.new': 'NOUVEAU', 'wbg.noPriorPre': 'Aucun QSO précédent avec ', 'wbg.noPriorPost': '.', 'wbg.workedBefore': 'Déjà contacté', 'wbg.first': 'Premier :', 'wbg.last': 'Dernier :', 'wbg.dxcc': 'DXCC :', 'wbg.entityQsos': '{n} QSO avec cette entité', 'wbg.clearFiltersTitle': 'Effacer tous les filtres de colonne', 'wbg.clearFilters': 'Effacer les filtres', 'wbg.columns': 'Colonnes', 'wbg.olderQsos': '+ {n} QSO plus anciens (non affichés — limités pour la performance)', 'wbg.pickerTitle': 'Colonnes « Déjà contacté »', 'wbg.pickerDesc': 'Choisissez les colonnes à afficher dans le tableau « Déjà contacté ».', 'wbg.allGroups': 'Tous les groupes :', 'wbg.all': 'tout', 'wbg.none': 'aucun', 'wbg.grpAwards': 'Diplômes', 'wbg.resetDefaults': 'Réinitialiser', 'wbg.done': 'Terminé',
'chn.title': 'Chasse au nouveau', 'chn.close': 'Masquer le panneau', 'chn.filterHint': 'Afficher ou masquer ce type', 'chn.allFiltered': 'Tout ce qui est entendu est filtré — réactivez une catégorie ci-dessus.', 'chn.toggle': 'Chasse au nouveau', 'chn.count': '{n} entendus', 'chn.loading': 'En attente du flux…', 'chn.empty': 'Rien de nouveau décodé près de vous pour le moment.', 'chn.digitalOnly': 'PSK Reporter — modes numériques uniquement, entendus à moins de ~300 km.', 'chn.option': 'Chasse au nouveau (PSK Reporter)', 'chn.optionHelp': 'Liste les stations décodées près de chez vous qui sont nouvelles par rapport à votre log — entité, bande, mode, créneau, préfixe ou carré. Modes numériques uniquement.', 'chn.show': 'Panneau chasse au nouveau',
'clg2.allFiltered': '{n} spots reçus, aucun affiché — vos filtres les masquent tous.', 'clg2.activeFilters': 'Actifs :', 'clg2.clearAllFilters': 'Effacer tous les filtres', 'clg2.fBandLock': 'bande verrouillée sur la radio', 'clg2.fBands': 'bandes {list}', 'clg2.fModeLock': 'mode verrouillé sur la radio', 'clg2.fModes': 'modes {list}', 'clg2.fStatus': 'pastilles de statut', 'clg2.fHideWorked': 'masquer les contactés', 'clg2.fLotwOnly': 'utilisateurs LoTW uniquement', 'clg2.fSpotterCont': 'continent du spotteur', 'clg2.fSource': 'un seul nœud source', 'clg2.fSearch': 'recherche « {q} »',
- 'clg2.c.time': 'Heure', 'clg2.c.call': 'Indicatif', 'clg2.c.status': 'Statut', 'clg2.c.pota': 'POTA', 'clg2.c.freq': 'Fréq', 'clg2.c.band': 'Bande', 'clg2.c.mode': 'Mode', 'clg2.c.pfx': 'Préf.', 'clg2.c.cqz': 'Zone CQ', 'clg2.h.cqz': 'CQZ', 'clg2.c.ituz': 'Zone ITU', 'clg2.h.ituz': 'ITU', 'clg2.c.distance_km': 'Distance (km)', 'clg2.h.distance_km': 'Dist km', 'clg2.c.sp_deg': 'Chemin court (°)', 'clg2.h.sp_deg': 'CC°', 'clg2.c.lp_deg': 'Chemin long (°)', 'clg2.h.lp_deg': 'CL°', 'clg2.c.country': 'Pays', 'clg2.c.continent': 'Continent', 'clg2.h.continent': 'Cont', 'clg2.c.spotter': 'Spotter', 'clg2.c.source': 'Source', 'clg2.c.locator': 'Locator du spotter', 'clg2.h.locator': 'Loc spotter', 'clg2.c.county': 'Comté US', 'clg2.tipNewCounty': 'NOUVEAU COMTÉ — jamais contacté', 'clg2.tipNewPfx': "NOUVEAU PRÉFIXE — ce préfixe WPX n'a jamais été contacté", 'clg2.c.comment': 'Commentaire', 'clg2.c.received_at': 'Reçu le', 'clg2.h.received_at': 'Reçu UTC', 'clg2.c.raw': 'Brut', 'clg2.newDxcc': 'NOUV DXCC', 'clg2.newBandMode': 'NOUV B+M', 'clg2.newBand': 'NOUV BANDE', 'clg2.newMode': 'NOUV MODE', 'clg2.newSlot': 'NOUV SLOT', 'clg2.newCall': 'CALL NEUF', 'clg2.wkdCall': 'DÉJÀ QSO', 'clg2.newCounty': 'NOUV CTY', 'clg2.newGridUnconf': 'GRID?', 'clg2.newGrid': 'NOUV GRID', 'clg2.c.grid': 'Grid', 'clg2.tipNewGrid': 'NOUVEAU GRID — ce carré n a jamais été contacté (grid entendu dans un CQ sur le lien UDP)', 'clg2.newPfx': "NOUVEAU PFX", 'clg2.newPota': 'NOUV POTA', 'clg2.tipNewDxcc': 'NOUVEAU DXCC : {country}', 'clg2.tipWorkedCall': 'Indicatif déjà contacté', 'clg2.tipNewBandMode': 'NOUVELLE BANDE ET NOUVEAU MODE pour cette entité — aucun des deux n’a été fait avec elle', 'clg2.tipNewBand': 'NOUVELLE BANDE pour cette entité', 'clg2.tipNewSlotBand': 'NOUVEAU SLOT (mode pas encore contacté sur cette bande)', 'clg2.tipNewMode': 'NOUVEAU MODE (ce mode jamais contacté sur cette entité)', 'clg2.tipNewSlot': 'NOUVEAU SLOT (cette bande+mode pas encore contactée)', 'clg2.tipNewCall': "CALL NEUF — cet indicatif n a jamais été contacté sur cette bande et ce mode (l entité, si)", 'clg2.tipPota': 'POTA — {name}', 'clg2.grpSpot': 'Spot', 'clg2.grpGeo': 'Géo', 'clg2.clearFiltersTitle': 'Effacer tous les filtres de colonne', 'clg2.clearFilters': 'Effacer les filtres', 'clg2.newSpots': '{n} nouveaux spots — cliquer pour reprendre', 'clg2.columns': 'Colonnes', 'clg2.pickerTitle': 'Colonnes du cluster', 'clg2.pickerDesc': 'Choisissez les colonnes à afficher dans le tableau du cluster.', 'clg2.allGroups': 'Tous les groupes :', 'clg2.all': 'tout', 'clg2.none': 'aucun', 'clg2.resetDefaults': 'Réinitialiser', 'clg2.done': 'Terminé',
+ 'clg2.c.time': 'Heure', 'clg2.c.call': 'Indicatif', 'clg2.c.status': 'Statut', 'clg2.c.pota': 'POTA', 'clg2.c.sota': 'SOTA', 'clg2.c.freq': 'Fréq', 'clg2.c.band': 'Bande', 'clg2.c.mode': 'Mode', 'clg2.c.pfx': 'Préf.', 'clg2.c.cqz': 'Zone CQ', 'clg2.h.cqz': 'CQZ', 'clg2.c.ituz': 'Zone ITU', 'clg2.h.ituz': 'ITU', 'clg2.c.distance_km': 'Distance (km)', 'clg2.h.distance_km': 'Dist km', 'clg2.c.sp_deg': 'Chemin court (°)', 'clg2.h.sp_deg': 'CC°', 'clg2.c.lp_deg': 'Chemin long (°)', 'clg2.h.lp_deg': 'CL°', 'clg2.c.country': 'Pays', 'clg2.c.continent': 'Continent', 'clg2.h.continent': 'Cont', 'clg2.c.spotter': 'Spotter', 'clg2.c.source': 'Source', 'clg2.c.locator': 'Locator du spotter', 'clg2.h.locator': 'Loc spotter', 'clg2.c.county': 'Comté US', 'clg2.tipNewCounty': 'NOUVEAU COMTÉ — jamais contacté', 'clg2.tipNewPfx': "NOUVEAU PRÉFIXE — ce préfixe WPX n'a jamais été contacté", 'clg2.c.comment': 'Commentaire', 'clg2.c.received_at': 'Reçu le', 'clg2.h.received_at': 'Reçu UTC', 'clg2.c.raw': 'Brut', 'clg2.newDxcc': 'NOUV DXCC', 'clg2.newBandMode': 'NOUV B+M', 'clg2.newBand': 'NOUV BANDE', 'clg2.newMode': 'NOUV MODE', 'clg2.newSlot': 'NOUV SLOT', 'clg2.newCall': 'CALL NEUF', 'clg2.wkdCall': 'DÉJÀ QSO', 'clg2.newCounty': 'NOUV CTY', 'clg2.newGridUnconf': 'GRID?', 'clg2.newGrid': 'NOUV GRID', 'clg2.c.grid': 'Grid', 'clg2.tipNewGrid': 'NOUVEAU GRID — ce carré n a jamais été contacté (grid entendu dans un CQ sur le lien UDP)', 'clg2.newPfx': "NOUVEAU PFX", 'clg2.newPota': 'NOUV POTA', 'clg2.tipNewDxcc': 'NOUVEAU DXCC : {country}', 'clg2.tipWorkedCall': 'Indicatif déjà contacté', 'clg2.tipNewBandMode': 'NOUVELLE BANDE ET NOUVEAU MODE pour cette entité — aucun des deux n’a été fait avec elle', 'clg2.tipNewBand': 'NOUVELLE BANDE pour cette entité', 'clg2.tipNewSlotBand': 'NOUVEAU SLOT (mode pas encore contacté sur cette bande)', 'clg2.tipNewMode': 'NOUVEAU MODE (ce mode jamais contacté sur cette entité)', 'clg2.tipNewSlot': 'NOUVEAU SLOT (cette bande+mode pas encore contactée)', 'clg2.tipNewCall': "CALL NEUF — cet indicatif n a jamais été contacté sur cette bande et ce mode (l entité, si)", 'clg2.tipPota': 'POTA — {name}', 'clg2.grpSpot': 'Spot', 'clg2.grpGeo': 'Géo', 'clg2.clearFiltersTitle': 'Effacer tous les filtres de colonne', 'clg2.clearFilters': 'Effacer les filtres', 'clg2.newSpots': '{n} nouveaux spots — cliquer pour reprendre', 'clg2.columns': 'Colonnes', 'clg2.pickerTitle': 'Colonnes du cluster', 'clg2.pickerDesc': 'Choisissez les colonnes à afficher dans le tableau du cluster.', 'clg2.allGroups': 'Tous les groupes :', 'clg2.all': 'tout', 'clg2.none': 'aucun', 'clg2.resetDefaults': 'Réinitialiser', 'clg2.done': 'Terminé',
// Périphériques audio et manipulateur vocal (Préférences → Périphériques audio).
'aud.refreshDevices': 'Actualiser les périphériques', 'aud.fromRadio': 'Depuis la radio (entrée RX)', 'aud.toRadio': 'Vers la radio (sortie TX)', 'aud.recMic': "Micro d'enregistrement", 'aud.listening': 'Écoute (pré-écoute)',
'aud.phFromRadio': 'Sortie audio du poste → entrée carte son', 'aud.phToRadio': 'Sortie carte son → entrée micro/data du poste', 'aud.phRecMic': 'Votre microphone (enregistrer les messages vocaux)', 'aud.phListening': 'Haut-parleurs locaux pour la pré-écoute',
diff --git a/frontend/src/lib/matrixColors.ts b/frontend/src/lib/matrixColors.ts
index cc9e813..83fdabd 100644
--- a/frontend/src/lib/matrixColors.ts
+++ b/frontend/src/lib/matrixColors.ts
@@ -14,9 +14,11 @@ export type MatrixColors = {
entity_worked: string;
not_worked: string;
current_entry: string;
+ mark_worked: string;
+ mark_confirmed: string;
};
-// The six settings fields and the CSS custom property each one drives. Also the
+// The settings fields and the CSS custom property each one drives. Also the
// display order — the same order the legend under the matrix reads in, so the
// settings panel and the grid can never disagree about which green is which.
export const MATRIX_VARS: { key: keyof Omit; cssVar: string; label: string }[] = [
@@ -26,12 +28,15 @@ export const MATRIX_VARS: { key: keyof Omit; cssVar: st
{ key: 'entity_worked', cssVar: '--mx-dx-work', label: 'mx.dxWork' },
{ key: 'not_worked', cssVar: '--mx-none', label: 'mx.none' },
{ key: 'current_entry', cssVar: '--mx-cur', label: 'mx.current' },
+ { key: 'mark_worked', cssVar: '--mx-mark-work', label: 'mx.markWork' },
+ { key: 'mark_confirmed', cssVar: '--mx-mark-conf', label: 'mx.markConf' },
];
export const emptyMatrixColors = (): MatrixColors => ({
enabled: false,
call_confirmed: '', call_worked: '', entity_confirmed: '',
entity_worked: '', not_worked: '', current_entry: '',
+ mark_worked: '', mark_confirmed: '',
});
// applyMatrixColors stamps (or clears) the overrides on . Safe to call as
diff --git a/frontend/src/style.css b/frontend/src/style.css
index 5d9aba7..8ec69dd 100644
--- a/frontend/src/style.css
+++ b/frontend/src/style.css
@@ -91,6 +91,11 @@
be recoloured on its own without dragging every other warning in the app
with it (Appearance → matrix colours). */
--mx-cur: var(--warning);
+ /* The "worked with this callsign" dot. Declared ONCE, like --mx-cur: it is
+ drawn over every one of the five cell colours, so it follows the theme's own
+ foreground/background pair rather than a per-theme colour of its own. */
+ --mx-mark-work: var(--foreground);
+ --mx-mark-conf: var(--foreground);
--scrollbar-thumb: #b8a880;
--scrollbar-thumb-hover: #968455;
@@ -981,6 +986,8 @@
--color-mx-dx-work: var(--mx-dx-work);
--color-mx-none: var(--mx-none);
--color-mx-cur: var(--mx-cur);
+ --color-mx-mark-work: var(--mx-mark-work);
+ --color-mx-mark-conf: var(--mx-mark-conf);
--radius: 0.5rem;
diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts
index 8b334c4..a30b0d0 100644
--- a/frontend/wailsjs/go/main/App.d.ts
+++ b/frontend/wailsjs/go/main/App.d.ts
@@ -503,6 +503,8 @@ export function GetLiveOpenings():Promise>;
export function GetLiveStations():Promise>;
+export function GetLoTWDownloadAllCalls():Promise;
+
export function GetLoTWUsersStatus():Promise;
export function GetLogFilePath():Promise;
@@ -1157,6 +1159,8 @@ export function SetKenwoodXIT(arg1:boolean):Promise;
export function SetLinkedAmps(arg1:Array):Promise;
+export function SetLoTWDownloadAllCalls(arg1:boolean):Promise;
+
export function SetMotorFollow(arg1:boolean,arg2:number,arg3:string):Promise;
export function SetOpsLogQSLReceived(arg1:number,arg2:boolean):Promise;
diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js
index cb8c126..7bc35c4 100644
--- a/frontend/wailsjs/go/main/App.js
+++ b/frontend/wailsjs/go/main/App.js
@@ -946,6 +946,10 @@ export function GetLiveStations() {
return window['go']['main']['App']['GetLiveStations']();
}
+export function GetLoTWDownloadAllCalls() {
+ return window['go']['main']['App']['GetLoTWDownloadAllCalls']();
+}
+
export function GetLoTWUsersStatus() {
return window['go']['main']['App']['GetLoTWUsersStatus']();
}
@@ -2254,6 +2258,10 @@ export function SetLinkedAmps(arg1) {
return window['go']['main']['App']['SetLinkedAmps'](arg1);
}
+export function SetLoTWDownloadAllCalls(arg1) {
+ return window['go']['main']['App']['SetLoTWDownloadAllCalls'](arg1);
+}
+
export function SetMotorFollow(arg1, arg2, arg3) {
return window['go']['main']['App']['SetMotorFollow'](arg1, arg2, arg3);
}
diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts
index 4a5bd09..0dc7623 100644
--- a/frontend/wailsjs/go/models.ts
+++ b/frontend/wailsjs/go/models.ts
@@ -1196,6 +1196,7 @@ export namespace cat {
low_hz: number;
high_hz: number;
fixed: boolean;
+ unsupported: boolean;
static createFrom(source: any = {}) {
return new ScopeSweep(source);
@@ -1208,6 +1209,7 @@ export namespace cat {
this.low_hz = source["low_hz"];
this.high_hz = source["high_hz"];
this.fixed = source["fixed"];
+ this.unsupported = source["unsupported"];
}
}
export class TCIPanelState {
@@ -3066,6 +3068,8 @@ export namespace main {
entity_worked: string;
not_worked: string;
current_entry: string;
+ mark_worked: string;
+ mark_confirmed: string;
static createFrom(source: any = {}) {
return new MatrixColors(source);
@@ -3080,6 +3084,8 @@ export namespace main {
this.entity_worked = source["entity_worked"];
this.not_worked = source["not_worked"];
this.current_entry = source["current_entry"];
+ this.mark_worked = source["mark_worked"];
+ this.mark_confirmed = source["mark_confirmed"];
}
}
@@ -5027,6 +5033,7 @@ export namespace qso {
band: string;
class: string;
status: string;
+ call?: string;
static createFrom(source: any = {}) {
return new BandStatus(source);
@@ -5037,6 +5044,7 @@ export namespace qso {
this.band = source["band"];
this.class = source["class"];
this.status = source["status"];
+ this.call = source["call"];
}
}
export class Bucket {
diff --git a/internal/cat/cat.go b/internal/cat/cat.go
index 8aaf708..1dbb693 100644
--- a/internal/cat/cat.go
+++ b/internal/cat/cat.go
@@ -674,6 +674,12 @@ type ScopeSweep struct {
LowHz int64 `json:"low_hz"` // left edge frequency (0 when unknown)
HighHz int64 `json:"high_hz"` // right edge frequency (0 when unknown)
Fixed bool `json:"fixed"` // true = fixed-span mode, false = center-on-VFO
+ // Unsupported: this radio refuses the waveform-output command, so there will
+ // never be a sweep. The IC-7851 does — its last firmware is from 2016, older
+ // than the CI-V waveform stream — while still answering the scope's other
+ // commands. Reported so the panadapter can say so instead of showing a black
+ // rectangle that looks like a bug in OpsLog.
+ Unsupported bool `json:"unsupported"`
}
// IcomState returns the current Icom DSP state, or (zero, false) when the active
diff --git a/internal/cat/icomserial.go b/internal/cat/icomserial.go
index d44a604..3a37c1c 100644
--- a/internal/cat/icomserial.go
+++ b/internal/cat/icomserial.go
@@ -93,15 +93,18 @@ type IcomSerial struct {
// leading main/sub selector byte (IC-7610/9700). scopeAmp is the latest
// reassembled sweep; scopeMu guards it (written by the scope goroutine, read
// via ScopeData from the binding goroutine).
- dualScope bool
- scopeMu sync.Mutex
- scopeAmp []byte
- scopeLow int64 // spectrum left-edge frequency (from the sweep's header frame)
- scopeHigh int64 // spectrum right-edge frequency
- scopeSeq int
- scopeOn bool
- scopeFixed bool // true = fixed-span mode (tracked optimistically)
- scopeSeen bool // logged the first sweep's structure once (on-rig verification)
+ dualScope bool
+ // Set when the rig rejects the waveform-output command in both shapes: it has
+ // no stream to give, and asking again on every enable is noise.
+ scopeUnsupported bool
+ scopeMu sync.Mutex
+ scopeAmp []byte
+ scopeLow int64 // spectrum left-edge frequency (from the sweep's header frame)
+ scopeHigh int64 // spectrum right-edge frequency
+ scopeSeq int
+ scopeOn bool
+ scopeFixed bool // true = fixed-span mode (tracked optimistically)
+ scopeSeen bool // logged the first sweep's structure once (on-rig verification)
curFreq int64 // last frequency read (for sideband choice)
curModeByte byte // last raw Icom mode byte (for filter re-send)
@@ -848,6 +851,19 @@ func (b *IcomSerial) scopeLoop(spec chan civ.Decoded, done chan struct{}) {
loggedCfg[f.Data[0]] = true
applog.Printf("icom scope cfg 0x%02X: data=[% X]", f.Data[0], f.Data)
}
+ // The rig just told us its own layout: a mode/span/edge answer of
+ // three bytes or more carries the main/sub selector, one of two
+ // bytes does not. Worth reading, because the SET commands take the
+ // same shape and several firmwares answer a wrong-shaped set with
+ // silence rather than a rejection — which is not something the
+ // retry in execScope can act on.
+ if f.Data[0] == civ.SubScopeMode && len(f.Data) >= 2 {
+ if sel := len(f.Data) >= 3; sel != b.dualScope {
+ applog.Printf("icom scope: the rig answers 0x%02X with %d bytes — using the %s form",
+ f.Data[0], len(f.Data)-1, map[bool]string{true: "27 xx 00 …", false: "27 xx …"}[sel])
+ b.dualScope = sel
+ }
+ }
continue
}
if rawN < 24 {
@@ -982,6 +998,43 @@ func (b *IcomSerial) assembleSweep(regions map[byte][]byte, total byte) {
}
}
+// execScope sends a 0x27 SET and, if the rig rejects it, sends it once more in
+// the other shape — with or without the leading main/sub selector byte — and
+// remembers which one this rig speaks.
+//
+// The shape used to be decided from the CI-V address, which meant every new
+// model was a blank scope until someone reported it: the IC-7851 (0x8E) rejects
+// "27 11 01" outright and wants "27 11 00 01", exactly as the IC-7610 does not.
+// A rejection is a cheap and unambiguous answer, so ask the rig instead of
+// keeping a list. Only the SET commands need this — the waveform parser already
+// detects the selector per frame.
+func (b *IcomSerial) execScope(what string, sub byte, args ...byte) error {
+ try := func(sel bool) error {
+ p := []byte{civ.CmdScope, sub}
+ if sel {
+ p = append(p, 0x00) // main scope
+ }
+ return b.exec(append(p, args...)...)
+ }
+ err := try(b.dualScope)
+ // Only a REJECTION means "wrong shape". A timeout says nothing (several
+ // firmwares simply don't ack a 0x27 set), and retrying it in the other shape
+ // would flip a working rig onto the wrong one.
+ if err == nil || !strings.Contains(err.Error(), "rejected") {
+ return err
+ }
+ err2 := try(!b.dualScope)
+ applog.Printf("icom scope: %s rejected in the %s form — the other form gave: %v",
+ what, map[bool]string{true: "27 xx 00 …", false: "27 xx …"}[b.dualScope], err2)
+ if err2 == nil {
+ b.dualScope = !b.dualScope
+ applog.Printf("icom scope: %s rejected — this rig wants the %s form (selector=%v)",
+ what, map[bool]string{true: "27 xx 00 …", false: "27 xx …"}[b.dualScope], b.dualScope)
+ return nil
+ }
+ return err
+}
+
// SetScope enables or disables the spectrum scope. Two commands are needed and
// RS-BA1 sends both: 0x27 0x10 turns the scope DISPLAY on (without it the rig
// streams nothing — the case when we're remote and can't touch the front panel),
@@ -1000,15 +1053,25 @@ func (b *IcomSerial) SetScope(on bool) error {
// radio, and closing OpsLog (SetScope(false)) blanking a local IC-7300's
// screen is exactly the regression this avoids. Some firmwares don't ack a
// 0x27 set; a timeout isn't fatal, so log and continue.
- if err := b.exec(civ.CmdScope, civ.SubScopeOnOff, 0x01); err != nil {
+ if err := b.execScope("display on", civ.SubScopeOnOff, 0x01); err != nil {
applog.Printf("icom scope: display on ack: %v", err)
}
}
// Waveform data OUTPUT over CI-V: enabled with the scope, and — crucially —
// the ONLY thing we switch off on disable, so the radio's own scope display is
// left exactly as the operator had it.
- if err := b.exec(civ.CmdScope, civ.SubScopeOn, boolByte(on)); err != nil {
+ if err := b.execScope("data output", civ.SubScopeOn, boolByte(on)); err != nil {
applog.Printf("icom scope: output on=%v ack: %v", on, err)
+ // Rejected in both shapes = the command does not exist on this rig, which
+ // is a permanent answer and not a bad guess on our part. Remember it: the
+ // panel can then say so, and we stop asking a radio that has already
+ // said no.
+ if strings.Contains(err.Error(), "rejected") {
+ applog.Printf("icom scope: %s does not stream its scope over CI-V — control commands only", b.model)
+ b.scopeMu.Lock()
+ b.scopeUnsupported = true
+ b.scopeMu.Unlock()
+ }
}
b.scopeMu.Lock()
b.scopeOn = on
@@ -1041,13 +1104,7 @@ func (b *IcomSerial) scopeReadCfg() {
// makes the scope follow the VFO, so tuning pans the view left/right.
func (b *IcomSerial) SetScopeMode(fixed bool) error {
mode := boolByte(fixed) // 0 = center, 1 = fixed (verify on rig via the cfg log)
- var payload []byte
- if b.dualScope {
- payload = []byte{civ.CmdScope, civ.SubScopeMode, 0x00, mode}
- } else {
- payload = []byte{civ.CmdScope, civ.SubScopeMode, mode}
- }
- if err := b.exec(payload...); err != nil {
+ if err := b.execScope("set mode", civ.SubScopeMode, mode); err != nil {
applog.Printf("icom scope: set mode fixed=%v ack: %v", fixed, err)
}
b.scopeMu.Lock()
@@ -1093,13 +1150,8 @@ func (b *IcomSerial) SetScopeEdges(low, high int64) error {
if rangeID == 0 {
return fmt.Errorf("icom scope: freq out of range")
}
- if b.dualScope {
- _ = b.exec(civ.CmdScope, civ.SubScopeMode, 0x00, 0x01) // fixed mode (main)
- _ = b.exec(civ.CmdScope, civ.SubScopeEdge, 0x00, 0x01) // activate edge set 1
- } else {
- _ = b.exec(civ.CmdScope, civ.SubScopeMode, 0x01)
- _ = b.exec(civ.CmdScope, civ.SubScopeEdge, 0x01)
- }
+ _ = b.execScope("fixed mode", civ.SubScopeMode, 0x01)
+ _ = b.execScope("edge set 1", civ.SubScopeEdge, 0x01)
payload := append([]byte{civ.CmdScope, civ.SubScopeFixEdge, rangeID, 0x01}, civ.FreqToBCD(low)...)
payload = append(payload, civ.FreqToBCD(high)...)
b.scopeMu.Lock()
@@ -1263,7 +1315,8 @@ func (b *IcomSerial) ScopeData() ScopeSweep {
for i, v := range b.scopeAmp {
amp[i] = int(v)
}
- return ScopeSweep{Amp: amp, Seq: b.scopeSeq, LowHz: b.scopeLow, HighHz: b.scopeHigh, Fixed: b.scopeFixed}
+ return ScopeSweep{Amp: amp, Seq: b.scopeSeq, LowHz: b.scopeLow, HighHz: b.scopeHigh, Fixed: b.scopeFixed,
+ Unsupported: b.scopeUnsupported}
}
// exec sends a set command and waits for the rig's OK (FB) / NG (FA) ack.
diff --git a/internal/cluster/cluster.go b/internal/cluster/cluster.go
index d180c59..e5d85c5 100644
--- a/internal/cluster/cluster.go
+++ b/internal/cluster/cluster.go
@@ -44,30 +44,30 @@ type ServerConfig struct {
// is emitted to the UI, so the table never has empty country cells
// flickering in for a few hundred ms.
type Spot struct {
- SourceID int64 `json:"source_id"` // ID of the cluster server this came from
- SourceName string `json:"source_name"` // display name (handy in the UI when multiple servers)
- Spotter string `json:"spotter"` // DE field
+ SourceID int64 `json:"source_id"` // ID of the cluster server this came from
+ SourceName string `json:"source_name"` // display name (handy in the UI when multiple servers)
+ Spotter string `json:"spotter"` // DE field
// SpotterContinent belongs to the SPOT, not to the DX station: one call is
// spotted by dozens of skimmers on every continent within a minute. It is
// resolved per spot at ingest for exactly that reason — see the note on the
// spotter-continent filter in App.tsx.
- SpotterContinent string `json:"spotter_continent,omitempty"`
- DXCall string `json:"dx_call"` // the DX station heard
- FreqKHz float64 `json:"freq_khz"`
- FreqHz int64 `json:"freq_hz"`
- Band string `json:"band,omitempty"`
- Comment string `json:"comment,omitempty"`
- Locator string `json:"locator,omitempty"` // spotter grid (optional)
- TimeUTC string `json:"time_utc,omitempty"`
- Country string `json:"country,omitempty"` // DXCC entity name (cty.dat)
- Continent string `json:"continent,omitempty"` // 2-letter continent
- CQZone int `json:"cqz,omitempty"` // DXCC entity CQ zone
- ITUZone int `json:"ituz,omitempty"` // DXCC entity ITU zone
- DistanceKm int `json:"distance_km,omitempty"` // great-circle km from operator's grid
- ShortPath int `json:"sp_deg,omitempty"` // azimuth (deg) short path from operator
- LongPath int `json:"lp_deg,omitempty"` // azimuth (deg) long path = SP + 180 mod 360
- ReceivedAt time.Time `json:"received_at"`
- Raw string `json:"raw"`
+ SpotterContinent string `json:"spotter_continent,omitempty"`
+ DXCall string `json:"dx_call"` // the DX station heard
+ FreqKHz float64 `json:"freq_khz"`
+ FreqHz int64 `json:"freq_hz"`
+ Band string `json:"band,omitempty"`
+ Comment string `json:"comment,omitempty"`
+ Locator string `json:"locator,omitempty"` // spotter grid (optional)
+ TimeUTC string `json:"time_utc,omitempty"`
+ Country string `json:"country,omitempty"` // DXCC entity name (cty.dat)
+ Continent string `json:"continent,omitempty"` // 2-letter continent
+ CQZone int `json:"cqz,omitempty"` // DXCC entity CQ zone
+ ITUZone int `json:"ituz,omitempty"` // DXCC entity ITU zone
+ DistanceKm int `json:"distance_km,omitempty"` // great-circle km from operator's grid
+ ShortPath int `json:"sp_deg,omitempty"` // azimuth (deg) short path from operator
+ LongPath int `json:"lp_deg,omitempty"` // azimuth (deg) long path = SP + 180 mod 360
+ ReceivedAt time.Time `json:"received_at"`
+ Raw string `json:"raw"`
// Historical marks a spot recovered from a SH/DX table rather than heard live.
// It belongs in the grid, but must NOT fire alerts or reach the panadapter:
// replaying 100 past spots would spam both, and a station spotted three hours
@@ -75,6 +75,10 @@ type Spot struct {
Historical bool `json:"historical,omitempty"`
POTARef string `json:"pota_ref,omitempty"` // park id if this station is activating (api.pota.app)
POTAName string `json:"pota_name,omitempty"` // park name
+ // SOTARef comes from the COMMENT, not from an API: the SOTA clusters put the
+ // summit in the text of the spot they send ("W9/WI-001"), and there is no
+ // per-callsign endpoint to ask the way POTA has one.
+ SOTARef string `json:"sota_ref,omitempty"`
}
// State enumerates the per-server lifecycle.
diff --git a/internal/cluster/cluster_test.go b/internal/cluster/cluster_test.go
index a74b650..9b91174 100644
--- a/internal/cluster/cluster_test.go
+++ b/internal/cluster/cluster_test.go
@@ -110,7 +110,7 @@ func TestParseShowDX(t *testing.T) {
// chatter turned into fake spots would be worse than no parser at all.
func TestParseShowDXRejectsNoise(t *testing.T) {
noise := []string{
- "DX de F5ABC: 14195.0 EA8DHH CQ DX 1234Z", // the broadcast form: spotRE owns it
+ "DX de F5ABC: 14195.0 EA8DHH CQ DX 1234Z", // the broadcast form: spotRE owns it
"Hello and welcome to the DXSpider cluster",
"WWV de VE7CC <18Z> : SFI=110, A=16, K=2",
"F4BPO de GB7DXC 12-Jul-2026 2130Z dxspider >",
diff --git a/internal/cluster/sotaref.go b/internal/cluster/sotaref.go
new file mode 100644
index 0000000..18da707
--- /dev/null
+++ b/internal/cluster/sotaref.go
@@ -0,0 +1,21 @@
+package cluster
+
+import "regexp"
+
+// sotaRefRe matches a SOTA summit reference inside a spot comment.
+//
+// The shape is association/region-NNN — "W9/WI-001", "DM/BM-063", "VK3/VC-014",
+// "F/AM-123" — and the association may carry digits. Anchored on both sides so
+// a callsign like DL/SP9DPM/P can never be read as one, and deliberately
+// narrower than "anything with a slash and a dash": POTA (US-4475) and WWFF
+// (DLFF-0001) refs share the comment field and must not be caught here.
+var sotaRefRe = regexp.MustCompile(`\b([A-Z0-9]{1,4}(?:/[A-Z0-9]{1,4})?/[A-Z]{2}-[0-9]{3})\b`)
+
+// SOTARefFrom returns the first SOTA reference in a spot comment, or "".
+func SOTARefFrom(comment string) string {
+ m := sotaRefRe.FindStringSubmatch(comment)
+ if m == nil {
+ return ""
+ }
+ return m[1]
+}
diff --git a/internal/cluster/sotaref_test.go b/internal/cluster/sotaref_test.go
new file mode 100644
index 0000000..a2e0d58
--- /dev/null
+++ b/internal/cluster/sotaref_test.go
@@ -0,0 +1,25 @@
+package cluster
+
+import "testing"
+
+func TestSOTARefFrom(t *testing.T) {
+ // Left column: real comments seen on the SOTA cluster feed.
+ cases := []struct{ in, want string }{
+ {"W9/WI-001", "W9/WI-001"},
+ {"DM/BM-063", "DM/BM-063"},
+ {"W7Y/TT-122", "W7Y/TT-122"},
+ {"VK3/VC-014 s2s", "VK3/VC-014"},
+ {"[SOTA] F/AM-123 cq", "F/AM-123"},
+ {"", ""},
+ // The other reference schemes that share this field.
+ {"POTA US-4475", ""},
+ {"WWFF DLFF-0001", ""},
+ // A portable callsign is not a summit.
+ {"DL/SP9DPM/P calling", ""},
+ }
+ for _, c := range cases {
+ if got := SOTARefFrom(c.in); got != c.want {
+ t.Errorf("SOTARefFrom(%q) = %q, want %q", c.in, got, c.want)
+ }
+ }
+}
diff --git a/internal/extsvc/lotw.go b/internal/extsvc/lotw.go
index 6243f31..0f36780 100644
--- a/internal/extsvc/lotw.go
+++ b/internal/extsvc/lotw.go
@@ -41,23 +41,36 @@ func DownloadLoTWConfirmations(ctx context.Context, client *http.Client, cfg Ser
if c := strings.TrimSpace(ownCall); c != "" {
q.Set("qso_owncall", c) // restrict to this station callsign
}
- if s := strings.TrimSpace(since); s != "" {
- q.Set("qso_qslsince", s)
+ // qso_qslsince is ALWAYS sent, even for "everything".
+ //
+ // Left out, LoTW does not answer "all confirmations" — it answers with a
+ // handful of recent ones, which arrives as a 200 and a valid ADIF and reads
+ // as a successful download of a nearly empty account. Asking from a date
+ // older than the service itself is the only way to mean "all".
+ sinceDate := strings.TrimSpace(since)
+ if sinceDate == "" {
+ sinceDate = "1945-11-15" // older than any QSO LoTW will accept
}
+ q.Set("qso_qslsince", sinceDate)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, lotwReportURL+"?"+q.Encode(), nil)
if err != nil {
return "", fmt.Errorf("lotw: build request: %w", err)
}
if client == nil {
- client = &http.Client{Timeout: 120 * time.Second}
+ // A full account is tens of megabytes and LoTW builds it slowly — several
+ // minutes for a log of 30 000 QSOs, all of it before the first byte. The
+ // old two-minute limit turned that into "context deadline exceeded while
+ // reading body", which reads as a network fault rather than as "ask for
+ // less at a time".
+ client = &http.Client{Timeout: 20 * time.Minute}
}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("lotw: request failed: %w", err)
}
defer resp.Body.Close()
- body, err := io.ReadAll(io.LimitReader(resp.Body, 32*1024*1024))
+ body, err := io.ReadAll(io.LimitReader(resp.Body, 256*1024*1024))
if err != nil {
return "", fmt.Errorf("lotw: read response: %w", err)
}
diff --git a/internal/qso/qso.go b/internal/qso/qso.go
index 906f042..361e786 100644
--- a/internal/qso/qso.go
+++ b/internal/qso/qso.go
@@ -1901,10 +1901,24 @@ type WorkedBefore struct {
}
// BandStatus is one cell in the worked-before grid.
+//
+// Status is the single highest thing true of the cell, which is what colours
+// it. Call is the SAME cell's answer to a different question — "have I worked
+// THIS callsign here" — kept separately because the two are asked at the same
+// moment and one was hiding the other.
+//
+// Chasing an expedition, an operator needs both: whether the slot is still
+// missing for the entity (does this fill a DXCC hole) and whether this
+// expedition has already been worked on it (would this be a dupe). A confirmed
+// entity outranks a worked callsign in Status — correctly, for awards — so a
+// slot worked with the DX yesterday can read "entity confirmed" and say nothing
+// at all about yesterday.
type BandStatus struct {
Band string `json:"band"` // ADIF lowercase band, e.g. "20m"
Class string `json:"class"` // "PH" | "CW" | "DIG"
Status string `json:"status"` // "call_c" | "call_w" | "dxcc_c" | "dxcc_w"
+ // Call is "", "w" (worked with this callsign) or "c" (confirmed with it).
+ Call string `json:"call,omitempty"`
}
// Band-status codes, lowest first. The ORDER is the rule: a cell shows the
@@ -2222,13 +2236,17 @@ func (r *Repo) WorkedBefore(ctx context.Context, callsign string, dxccHint int,
// The grid answers "what do I still need on this band and mode", and for
// that question confirmation is the axis that matters: a confirmed entity
// needs nothing, whoever else was worked afterwards.
+ // The two per-callsign columns use the SAME predicate as the callsign count
+ // above, portable variants included: a cell that counts RI1FJL/1 in "worked
+ // with this call" and a header that does not would be two answers to one
+ // question.
// Filter NULL/empty band+mode rows — they'd create a NULL group key
// that Scan into *string can't handle and would error out the whole
// WorkedBefore call, blanking the matrix in the UI.
statusRows, err := r.db.QueryContext(ctx, `
SELECT band, mode,
- MAX(CASE WHEN callsign = ? THEN 1 ELSE 0 END),
- MAX(CASE WHEN callsign = ?
+ MAX(CASE WHEN `+pred+` THEN 1 ELSE 0 END),
+ MAX(CASE WHEN `+pred+`
AND (lotw_rcvd IN `+ConfirmedValues+` OR qsl_rcvd IN `+ConfirmedValues+` OR eqsl_rcvd IN `+ConfirmedValues+`)
THEN 1 ELSE 0 END),
MAX(CASE WHEN lotw_rcvd IN `+ConfirmedValues+` OR qsl_rcvd IN `+ConfirmedValues+` OR eqsl_rcvd IN `+ConfirmedValues+`
@@ -2237,12 +2255,14 @@ func (r *Repo) WorkedBefore(ctx context.Context, callsign string, dxccHint int,
WHERE dxcc = ?
AND band IS NOT NULL AND band != ''
AND mode IS NOT NULL AND mode != ''
- GROUP BY band, mode`, wb.Callsign, wb.Callsign, dxcc)
+ GROUP BY band, mode`, append(append(append([]any{}, predArgs...), predArgs...), dxcc)...)
if err != nil {
return wb, fmt.Errorf("band status: %w", err)
}
type cellKey struct{ band, class string }
best := map[cellKey]int{}
+ // The call's own answer per cell, independent of the ladder above.
+ callByCell := map[cellKey]string{}
for statusRows.Next() {
var band, mode string
var callW, callC, dxccConfirmed int
@@ -2255,12 +2275,21 @@ func (r *Repo) WorkedBefore(ctx context.Context, callsign string, dxccHint int,
if cur, ok := best[k]; !ok || code > cur {
best[k] = code
}
+ // Confirmed beats worked here too, and neither is ever erased by the
+ // entity: this is only ever about the callsign.
+ switch {
+ case callC == 1:
+ callByCell[k] = "c"
+ case callW == 1 && callByCell[k] == "":
+ callByCell[k] = "w"
+ }
}
statusRows.Close()
codeStr := bandStatusNames
for k, code := range best {
wb.BandStatus = append(wb.BandStatus, BandStatus{
Band: k.band, Class: k.class, Status: codeStr[code],
+ Call: callByCell[k],
})
}
return wb, nil
@@ -2955,6 +2984,33 @@ func DedupeKey(callsign, qsoDateMinute, band, mode string) string {
return strings.ToUpper(callsign) + "|" + qsoDateMinute + "|" + strings.ToLower(band) + "|" + strings.ToUpper(mode)
}
+// StationCallsigns lists the distinct station callsigns the logbook was worked
+// under, upper-cased and without the blanks.
+//
+// Used to decide whether a downloaded confirmation belongs to THIS log at all:
+// one LoTW account can hold several stations (a home call, a portable, an
+// expedition), and a confirmation for a station this logbook has never used is
+// somebody else's log — here, another profile's.
+func (r *Repo) StationCallsigns(ctx context.Context) (map[string]bool, error) {
+ rows, err := r.db.QueryContext(ctx,
+ `SELECT DISTINCT COALESCE(station_callsign,'') FROM qso`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ out := map[string]bool{}
+ for rows.Next() {
+ var c string
+ if err := rows.Scan(&c); err != nil {
+ return nil, err
+ }
+ if c = strings.ToUpper(strings.TrimSpace(c)); c != "" {
+ out[c] = true
+ }
+ }
+ return out, rows.Err()
+}
+
// DedupeKeyIDs returns a map of dedupe key → QSO id, for matching downloaded
// confirmations back to local QSOs.
func (r *Repo) DedupeKeyIDs(ctx context.Context) (map[string]int64, error) {