diff --git a/app.go b/app.go index c848a61..d7b4a2f 100644 --- a/app.go +++ b/app.go @@ -402,6 +402,7 @@ const ( keyExtLoTWTQSLPath = "extsvc.lotw.tqsl_path" keyExtLoTWStationLoc = "extsvc.lotw.station_location" + keyExtLoTWAllCalls = "extsvc.lotw.download_all_calls" // download confirmations for EVERY call on the account, not just this profile's keyExtLoTWForceCall = "extsvc.lotw.force_station_callsign" // override STATION_CALLSIGN at sign time (e.g. F4BPO/P on the F4BPO cert) keyExtLoTWKeyPassword = "extsvc.lotw.key_password" keyExtLoTWUploadFlag = "extsvc.lotw.upload_flag" // legacy single flag (migrated to upload_flags) @@ -8931,6 +8932,11 @@ func (a *App) clusterEventWorker() { } } } + // SOTA: the summit is in the spot's own text — the SOTA feeds put it + // there — so it costs a regex rather than an API lookup. + if s.SOTARef == "" { + s.SOTARef = cluster.SOTARefFrom(s.Comment) + } // POTA: tag the spot when the DX station is currently activating a park. if a.pota != nil { if info, ok := a.pota.Lookup(s.DXCall); ok { @@ -12091,6 +12097,17 @@ func manualRefFor(existing, code string) string { return "" } +// GetLoTWDownloadAllCalls reports whether the LoTW download ignores the +// profile's own call and pulls every callsign on the account. +func (a *App) GetLoTWDownloadAllCalls() bool { + return a.settingOr(keyExtLoTWAllCalls, "") == "1" +} + +// SetLoTWDownloadAllCalls stores that choice. +func (a *App) SetLoTWDownloadAllCalls(on bool) { + a.setSetting(keyExtLoTWAllCalls, map[bool]string{true: "1", false: "0"}[on]) +} + // DownloadConfirmations pulls confirmed QSOs from a service and updates the // matching local QSOs' received status. LoTW only for now (the canonical // confirmation system); runs in the background emitting the same @@ -12162,7 +12179,30 @@ func (a *App) runDownloadConfirmations(ctx context.Context, svc extsvc.Service, case extsvc.ServiceLoTW: sinceDate := resolveSince(keyExtLoTWLastDownload) ownCall := a.uploadOwnerCall(extsvc.ServiceLoTW) + // A LoTW account holds every call its owner operates — F4BPO, F4BPO/P, + // TM2Q — and the download is normally scoped to the profile's own call so + // one profile does not pull another's confirmations. That scope also + // silently hides them: a QSO made as F4BPO/P is confirmed at LoTW and can + // never be downloaded from the F4BPO profile, so it stays unconfirmed here + // for good while the ARRL counts it. Off by default, because the scope is + // right for anyone whose profiles are separate stations. + if a.settingOr(keyExtLoTWAllCalls, "") == "1" { + ownCall = "" + } callLabel := ownCall + // Unscoped, the report carries every station on the account — including + // the ones belonging to ANOTHER profile's logbook (a Vietnam expedition, + // say). Those confirmations have nothing to match here, and with "add the + // ones not found" ticked they would pour a second log into this one. So + // the station callsigns this logbook actually holds become the filter: + // F4BPO/P is kept because it was worked here, XV9Q is skipped because it + // never was. + var ownStations map[string]bool + if ownCall == "" { + if st, e := a.qso.StationCallsigns(ctx); e == nil && len(st) > 0 { + ownStations = st + } + } if callLabel == "" { callLabel = "all callsigns" } @@ -12179,6 +12219,19 @@ func (a *App) runDownloadConfirmations(ctx context.Context, svc extsvc.Service, return } emit(fmt.Sprintf("LoTW returned %d KB of ADIF", len(adifText)/1024)) + // A report far smaller than the account justifies is the one failure that + // looks like success: LoTW answers 200 with a near-empty ADIF when it + // disagrees with the query (an unknown callsign in qso_owncall, a login + // that half-worked). Show its own header rather than leaving "matched 1 of + // 1" to be read as "you have one confirmation". + if len(adifText) < 4096 { + head := strings.TrimSpace(adifText) + if len(head) > 400 { + head = head[:400] + } + emit("The report is unexpectedly small — what LoTW actually sent:") + emit(" " + strings.Join(strings.Fields(head), " ")) + } keyIDs, kerr := a.qso.DedupeKeyIDs(ctx) if kerr != nil { emit("Error reading local log: " + kerr.Error()) @@ -12196,6 +12249,7 @@ func (a *App) runDownloadConfirmations(ctx context.Context, svc extsvc.Service, sets, _ := a.qso.ConfirmedSlots(ctx, []string{"lotw_rcvd", "qsl_rcvd"}) var items []ConfirmationItem var unmatched []string + skippedOtherStation := 0 perr := adif.Parse(strings.NewReader(adifText), func(rec adif.Record) error { if ctx.Err() != nil { return ctx.Err() // window closed / superseded @@ -12204,6 +12258,16 @@ func (a *App) runDownloadConfirmations(ctx context.Context, svc extsvc.Service, if !ok { return nil } + // Another station's confirmation — see ownStations above. Counted so + // the report says how many were left alone rather than silently + // dropping a third of the file. + if ownStations != nil { + st := strings.ToUpper(strings.TrimSpace(rec["station_callsign"])) + if st != "" && !ownStations[st] { + skippedOtherStation++ + return nil + } + } total++ date := rec["qslrdate"] if date == "" { @@ -12284,6 +12348,9 @@ func (a *App) runDownloadConfirmations(ctx context.Context, svc extsvc.Service, } else { emit(fmt.Sprintf("Matched %d of %d confirmed QSO(s)", matched, total)) } + if skippedOtherStation > 0 { + emit(fmt.Sprintf(" (%d confirmation(s) skipped — made under a callsign this logbook has never used)", skippedOtherStation)) + } if byClass > 0 { // Said out loud rather than folded silently into the total: these // matched on the mode CLASS, not the mode. LoTW hands back "DATA" for diff --git a/appearance.go b/appearance.go index b2f25ed..2e75d5f 100644 --- a/appearance.go +++ b/appearance.go @@ -174,6 +174,11 @@ type MatrixColors struct { EntityWorked string `json:"entity_worked"` NotWorked string `json:"not_worked"` CurrentEntry string `json:"current_entry"` + // The dot marking a slot already worked with the callsign in hand. It is a + // mark rather than a background, so it needs its own two colours: it is + // drawn on top of all five of the above and must stay legible over each. + MarkWorked string `json:"mark_worked"` + MarkConfirmed string `json:"mark_confirmed"` } // normMatrixColors keeps only plain hex values. Anything else becomes "" — i.e. @@ -196,6 +201,8 @@ func normMatrixColors(c MatrixColors) MatrixColors { EntityWorked: clean(c.EntityWorked), NotWorked: clean(c.NotWorked), CurrentEntry: clean(c.CurrentEntry), + MarkWorked: clean(c.MarkWorked), + MarkConfirmed: clean(c.MarkConfirmed), } } diff --git a/changelog.json b/changelog.json index f8ed572..08f0d80 100644 --- a/changelog.json +++ b/changelog.json @@ -3,10 +3,26 @@ "version": "0.26.20", "date": "", "en": [ - "Each radio carries its own MY_RIG (Settings → CAT), written on every QSO made with it — ahead of the per-band station in Operating conditions, which says what you planned to use rather than which radio is keying. Left empty, nothing changes." + "Each radio carries its own MY_RIG (Settings → CAT), written on every QSO made with it — ahead of the per-band station in Operating conditions, which says what you planned to use rather than which radio is keying. Left empty, nothing changes.", + "Worked-before matrix: a dot in the corner of a cell now means the callsign you are working has already been worked on that slot, whatever colour the entity status gave the cell. Its two colours (worked / confirmed with this callsign) are in Appearance with the other matrix colours.", + "Icom spectrum scope: the command shape (with or without the main/sub selector) is now worked out from the radio’s own answers instead of a list of models. A radio that has scope control but no waveform stream over CI-V — the IC-7851 — says so in the panel rather than showing a black rectangle.", + "Elecraft KPA: the status-bar chip now shows the amplifier as connected and switches it between OPERATE and STANDBY like the other brands, and an offline KPA no longer calls itself an Acom.", + "Cluster: a SOTA column, read from the summit reference the SOTA feeds put in the spot comment. Clicking the spot fills the QSO’s SOTA award reference, as a POTA spot already did. Turn the column on in Columns.", + "Awards: a \"Slots to confirm\" filter and a running count beside the reference total, so the gap between worked and confirmed band-slots — the Challenge difference — can be seen reference by reference instead of only as two numbers.", + "QSL Manager: a QRZ button next to the Paper QSL search, opening the callsign on QRZ.com.", + "LoTW: an \"All my callsigns\" option beside the download. The download is scoped to the profile’s own callsign, so a QSO made as a portable or contest call was confirmed at LoTW and never marked here. Confirmations made under a callsign this logbook has never used are skipped, and a suspiciously small report now shows what LoTW actually answered.", + "LoTW download: \"All\" really means all — without a date LoTW answered with a handful of recent confirmations, which looked like a successful download of an empty account. A full account also has time to arrive: the two-minute limit that ended in \"context deadline exceeded\" is now twenty." ], "fr": [ - "Chaque radio porte son propre MY_RIG (Réglages → CAT), inscrit sur chaque QSO fait avec elle — avant la station par bande des Conditions de trafic, qui dit ce qui était prévu et non quelle radio émet. Laissé vide, rien ne change." + "Chaque radio porte son propre MY_RIG (Réglages → CAT), inscrit sur chaque QSO fait avec elle — avant la station par bande des Conditions de trafic, qui dit ce qui était prévu et non quelle radio émet. Laissé vide, rien ne change.", + "Matrice des contacts : un point dans le coin d'une case indique que l'indicatif en cours a déjà été contacté sur ce créneau, quelle que soit la couleur donnée par le statut de l'entité. Ses deux couleurs (contacté / confirmé avec cet indicatif) se règlent dans Apparence avec les autres couleurs de la matrice.", + "Scope Icom : la forme des commandes (avec ou sans le sélecteur main/sub) est déduite des réponses de la radio au lieu d'une liste de modèles. Une radio qui pilote son scope mais ne l'envoie pas en CI-V — l'IC-7851 — l'indique dans le panneau au lieu d'afficher un rectangle noir.", + "Elecraft KPA : la pastille de la barre d'état montre enfin l'amplificateur comme connecté et bascule OPERATE / STANDBY comme les autres marques, et un KPA hors ligne ne s'annonce plus comme un Acom.", + "Cluster : une colonne SOTA, lue dans la référence de sommet que les flux SOTA mettent dans le commentaire du spot. Cliquer le spot remplit la référence SOTA du QSO, comme le faisait déjà un spot POTA. Colonne à activer dans Colonnes.", + "Awards : un filtre « Slots à confirmer » et un compteur à côté du total de références, pour voir l'écart entre créneaux contactés et confirmés — la différence du Challenge — référence par référence et non plus seulement en deux chiffres.", + "Gestionnaire QSL : un bouton QRZ à côté de la recherche QSL papier, qui ouvre l'indicatif sur QRZ.com.", + "LoTW : une option « Tous mes indicatifs » à côté du téléchargement. Celui-ci est limité à l'indicatif du profil, si bien qu'un QSO fait sous un indicatif portable ou de contest était confirmé chez LoTW sans jamais être marqué ici. Les confirmations faites sous un indicatif que ce carnet n'a jamais utilisé sont ignorées, et un rapport anormalement petit affiche désormais ce que LoTW a réellement répondu.", + "Téléchargement LoTW : « Tout » veut enfin dire tout — sans date, LoTW ne renvoyait qu'une poignée de confirmations récentes, ce qui ressemblait à un téléchargement réussi d'un compte vide. Un compte complet a aussi le temps d'arriver : la limite de deux minutes, qui finissait en « context deadline exceeded », passe à vingt." ] }, { @@ -1929,4 +1945,4 @@ "Ce résumé « Nouveautés » s'affiche désormais au premier lancement après chaque mise à jour." ] } -] +] \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9f8c267..4f435bd 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3275,7 +3275,7 @@ export default function App() { function handleSpotSelect(s: any) { if (!s?.dx_call?.trim()) return; onCallsignInput(s.dx_call, { force: true }); - applySpotPOTA((s as any).pota_ref); + applySpotRefs((s as any).pota_ref, (s as any).sota_ref); } function handleSpotClick(s: any) { @@ -3300,7 +3300,7 @@ export default function App() { FlexZoomForSpot(m ?? '', s.freq_hz ?? 0).catch(() => {}); if (m) applyModeFromSpot(m); onCallsignInput(s.dx_call, { force: true }); - applySpotPOTA((s as any).pota_ref); + applySpotRefs((s as any).pota_ref, (s as any).sota_ref); if (s.dx_call?.trim()) restartRecordingForNewTarget(s.dx_call); } @@ -3761,14 +3761,14 @@ export default function App() { restartRecordingForNewTarget(call); // The park, like a click in the band map: the radio reports only a // callsign, so the backend looks it up again before sending the event. - applySpotPOTA(String(p?.pota_ref ?? '')); + applySpotRefs(String(p?.pota_ref ?? ''), String(p?.sota_ref ?? '')); }); // Clicking a spot on the ExpertSDR (TCI) panorama fills the call, like Flex. const unsubTciSpot = EventsOn('tci:spot_clicked', (p: any) => { const call = String(p?.call ?? ''); if (!applyUdpCall(call, true)) return; restartRecordingForNewTarget(call); - applySpotPOTA(String(p?.pota_ref ?? '')); + applySpotRefs(String(p?.pota_ref ?? ''), String(p?.sota_ref ?? '')); }); const unsubBulk = EventsOn('bulkupdate:progress', (p: any) => { const total = Number(p?.total ?? 0); @@ -4796,13 +4796,17 @@ export default function App() { } wbTimerRef.current = window.setTimeout(() => runWorkedBefore(call), 150); } - // applySpotPOTA sets the QSO's POTA award reference(s) from a clicked spot's - // park ref ("US-4164" or n-fer "US-1,US-2"). Empty ref clears it (fresh - // target). Routed to the pota_ref column at save via applyAwardRefs. - function applySpotPOTA(potaRef?: string) { - const refs = String(potaRef || '') - .split(/[,;]/).map((x) => x.trim().toUpperCase()).filter(Boolean); - setDetails((d) => ({ ...d, award_refs: refs.map((r) => `POTA@${r}`).join(';') })); + // Award references carried by the spot itself: the park a station is + // activating, the summit it is on, or both. Written into award_refs the same + // way for each, so logging the contact credits it without retyping a + // reference that was on screen. + function applySpotRefs(potaRef?: string, sotaRef?: string) { + const split = (v?: string) => String(v || '').split(/[,;]/).map((x) => x.trim().toUpperCase()).filter(Boolean); + const refs = [ + ...split(potaRef).map((r) => `POTA@${r}`), + ...split(sotaRef).map((r) => `SOTA@${r}`), + ]; + setDetails((d) => ({ ...d, award_refs: refs.join(';') })); } function onCallsignInput(v: string, opts?: { force?: boolean }) { // Programmatic call-sets (force: spot click, UDP, external app) count as @@ -8423,9 +8427,13 @@ export default function App() { STANDBY, red = offline. CLICK toggles OPERATE ↔ STANDBY (optimistic flip, the 2s poll reconciles); offline → click opens the settings. */} {ampSts.map((a: any) => { - const isPGXL = !a.spe && !a.acom; + // Every brand must be listed here. A KPA was not, so its chip + // read the fallback — permanently red on a connected amplifier, + // and clicking it opened the settings instead of switching to + // STANDBY. + const isPGXL = !a.spe && !a.acom && !a.kpa; const viaFlex = isPGXL && !!flexAmp?.amp_available; - const raw = a.spe ?? a.acom ?? a.pgxl ?? { connected: false }; + const raw = a.spe ?? a.acom ?? a.kpa ?? a.pgxl ?? { connected: false }; const connected = !!raw.connected || viaFlex; const operate = viaFlex ? !!flexAmp.amp_operate : !!raw.operate; const dot = !connected ? 'bg-danger' : operate ? 'bg-success' : 'bg-warning'; @@ -8435,7 +8443,7 @@ export default function App() { const want = !operate; if (viaFlex) setFlexAmp((f: any) => ({ ...f, amp_operate: want })); else setAmpSts((l) => l.map((x: any) => x.id === a.id - ? { ...x, spe: x.spe && { ...x.spe, operate: want }, acom: x.acom && { ...x.acom, operate: want }, pgxl: x.pgxl && { ...x.pgxl, operate: want } } + ? { ...x, spe: x.spe && { ...x.spe, operate: want }, acom: x.acom && { ...x.acom, operate: want }, kpa: x.kpa && { ...x.kpa, operate: want }, pgxl: x.pgxl && { ...x.pgxl, operate: want } } : x)); (viaFlex ? FlexAmpOperate(want) : AmpOperate(a.id, want)).catch(() => {}); }; diff --git a/frontend/src/components/AmpCard.tsx b/frontend/src/components/AmpCard.tsx index ff2893b..c360d87 100644 --- a/frontend/src/components/AmpCard.tsx +++ b/frontend/src/components/AmpCard.tsx @@ -194,7 +194,7 @@ export function AmpCard({ amp, flex, t }: { amp: Amp; flex: any; t: (k: string, - {kpa.connected ? (kpa.tuning ? t('flxp.kpaTuning') : (kpa.power_on ? 'ON' : 'OFF')) : t('flxp.acomOffline')} + {kpa.connected ? (kpa.tuning ? t('flxp.kpaTuning') : (kpa.power_on ? 'ON' : 'OFF')) : t('flxp.kpaOffline')} {kpa.connected && ( diff --git a/frontend/src/components/AppearancePanel.tsx b/frontend/src/components/AppearancePanel.tsx index 6cee9e5..1995aa6 100644 --- a/frontend/src/components/AppearancePanel.tsx +++ b/frontend/src/components/AppearancePanel.tsx @@ -127,6 +127,12 @@ function MatrixColorsSection() { + + + + + + 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. */} + {t('qslm.paperHint')} ) : ( @@ -736,6 +751,12 @@ export function QSLManagerPanel({ onEditQSO, actions, paperRequest }: { setAddNotFound(!!c)} /> {t('qslm.addNotFound')} + {service === 'lotw' && ( + + )} )}