Compare commits

..
6 Commits
Author SHA1 Message Date
rouggy b24f880d62 chore: release v0.26.21 2026-08-28 08:48:14 +02:00
rouggy ef71ddb648 docs(changelog): open 0.26.21 with the entries written after the release
Miles and the LoTW download progress landed after v0.26.20 was tagged, so they
belonged to the next block: an entry appended to a shipped one never reaches the
operators who installed it, and misstates what that version contained. The .20
block is byte-identical to the tag again.
2026-08-28 08:47:00 +02:00
rouggy 0143a84cee fix(lotw): stop the heartbeat when the report starts arriving
Deferred, it stopped at the end of the function — so 'still waiting for LoTW to
build the report' kept printing between the megabyte lines, saying the opposite
of what was happening.
2026-08-28 08:43:06 +02:00
rouggy 82721110ed fix(lotw): count out the wait before the first byte
The progress lines only start once bytes arrive, and on a large account nothing
arrives for minutes — LoTW builds the entire report before sending any of it.
That silence WAS the complaint: the window still just said 'working'.

A heartbeat every fifteen seconds until the headers land, then the HTTP status,
then the megabytes — the first at 256 KB rather than 1 MB, so the very first
sign that it is moving comes early.
2026-08-28 08:38:34 +02:00
rouggy aec363b152 fix(lotw): show the download moving, retry a busy server, fail a dead one
Three reports of the same shape: a 503 for anything wider than a few days
before, and since the timeout was raised, a window that sits at 'working'
forever. Both are the same missing thing — nothing said what the transfer was
doing.

The body is now read in chunks and every megabyte is reported. A 503/502/504 is
retried twice, 20 s then 40 s, each attempt announced: LoTW answers 'busy' to a
wide report often enough that other loggers simply ask again. And the deadline
is no longer on the whole exchange, which either cut off a healthy slow download
or hid a dead one for twenty minutes — it is ten minutes to START answering
(LoTW builds the whole report first) and two minutes of silence once it has,
which is the difference between slow and dead.
2026-08-28 08:29:26 +02:00
rouggy 9a21c936b1 feat(units): show distances in miles
Everything is computed in kilometres and converted once at display time, in
lib/units — storing miles anywhere would give the same number two sources of
truth and a rounding error that grows with every hop.

The grids capture the unit inside their column definitions, header and formatter
both, so a change is published to them the way the date format already is;
without that a toggle would only appear after a language change or a restart.
The preference is portable, like the other display ones.
2026-08-28 08:25:02 +02:00
14 changed files with 245 additions and 25 deletions
+4 -1
View File
@@ -12212,7 +12212,10 @@ func (a *App) runDownloadConfirmations(ctx context.Context, svc extsvc.Service,
emit(fmt.Sprintf("Downloading all LoTW confirmations for %s…", callLabel))
}
emit(fmt.Sprintf("Window: since=%q → resolved=%q (scope owncall=%q)", since, sinceDate, ownCall))
adifText, err := extsvc.DownloadLoTWConfirmations(ctx, nil, cfg.LoTW, sinceDate, ownCall)
// The report arrives over minutes, and a window that says nothing while it
// does is indistinguishable from one that has hung — which is what it was
// being reported as. Every half-megabyte, say how much has landed.
adifText, err := extsvc.DownloadLoTWConfirmations(ctx, nil, cfg.LoTW, sinceDate, ownCall, emit)
if err != nil {
emit("Download failed: " + err.Error())
done(matched, total)
+12
View File
@@ -1,4 +1,16 @@
[
{
"version": "0.26.21",
"date": "",
"en": [
"Distances can be shown in miles (Settings → General): the cluster and Recent QSOs columns, the map path box, the rotator buttons and the band-opening list all follow, and the column headers name the unit.",
"LoTW download: the report is now counted in megabytes as it arrives, LoTWs \"busy\" answer (HTTP 503) is retried twice instead of failing, and a transfer that stops moving for two minutes says so rather than showing \"working\" indefinitely."
],
"fr": [
"Les distances peuvent s'afficher en miles (Réglages → Général) : les colonnes du cluster et des QSO récents, l'encart du tracé sur la carte, les boutons du rotor et la liste des ouvertures suivent, et l'unité est indiquée dans les en-têtes de colonne.",
"Téléchargement LoTW : le rapport est compté en mégaoctets au fur et à mesure, la réponse « occupé » de LoTW (HTTP 503) est retentée deux fois au lieu d'échouer, et un transfert qui n'avance plus pendant deux minutes le dit au lieu d'afficher « en cours » indéfiniment."
]
},
{
"version": "0.26.20",
"date": "",
+4 -3
View File
@@ -57,6 +57,7 @@ import {
import { Combobox } from '@/components/ui/combobox';
import { applyAwardRefs, parseAwardRefs as parseManualRefs, spotRefList , withIOTARef, withRDARef } from '@/lib/awardRefs';
import { ListRadios, SetActiveRadio } from '../wailsjs/go/main/App';
import { formatDistance } from '@/lib/units';
import { EventsOn, BrowserOpenURL, WindowMinimise, WindowToggleMaximise, WindowIsMaximised, Quit } from '../wailsjs/runtime/runtime';
import type { adif as adifModels, lookup as lookupModels, cat as catModels } from '../wailsjs/go/models';
import type { QSOForm, WorkedBeforeView, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
@@ -6419,7 +6420,7 @@ export default function App() {
disabled={disabled}
onClick={() => p && goto(p.bearingShort, 'SP')}
title={p
? `Rotate short-path · ${Math.round(p.distanceShort).toLocaleString()} km`
? `Rotate short-path · ${formatDistance(p.distanceShort)}`
: (station.my_grid ? 'No remote grid' : 'Set your station grid in Preferences')}
className={cn(
'inline-flex items-center gap-1 px-2 py-0.5 transition-colors',
@@ -6435,7 +6436,7 @@ export default function App() {
type="button"
disabled={disabled}
onClick={() => p && goto(p.bearingLong, 'LP')}
title={p ? `Rotate long-path · ${Math.round(p.distanceLong).toLocaleString()} km` : ''}
title={p ? `Rotate long-path · ${formatDistance(p.distanceLong)}` : ''}
className={cn(
'px-1.5 py-0.5 border-l border-info-border text-[10px] transition-colors',
disabled
@@ -8570,7 +8571,7 @@ export default function App() {
"1.5k" and then appended the unit, giving "1.5kkm" and even
written correctly, "1.5k km" makes a reader do arithmetic to
recover a number that was four characters long to begin with. */}
<span className="font-mono opacity-80">{o.median_km} km</span>
<span className="font-mono opacity-80">{formatDistance(o.median_km)}</span>
{/* Out of season is the one an operator must not learn last, so it
earns a mark on the badge rather than a line in the tooltip. */}
{!o.in_season && <span className="opacity-90">!</span>}
+2 -1
View File
@@ -10,6 +10,7 @@
// new is being decoded on FT8/FT4/JS8 near here — not that the band is dead.
import { useEffect, useMemo, useState } from 'react';
import { Radar, Loader2, X } from 'lucide-react';
import { formatDistance } from '@/lib/units';
import { useI18n } from '@/lib/i18n';
import { markerColour } from '@/lib/spotMarkers';
import { cn } from '@/lib/utils';
@@ -186,7 +187,7 @@ export function ChaseNewPanel({ onPick, onClose }: Props) {
title={[
s.country,
s.grid,
s.dist_km ? `${s.dist_km} km` : '',
s.dist_km ? formatDistance(s.dist_km) : '',
s.freq_hz ? `${(s.freq_hz / 1000).toFixed(1)} kHz` : '',
].filter(Boolean).join(' · ')}
>
+11 -3
View File
@@ -15,6 +15,7 @@ import { cleanSpotter, inferSpotMode, spotStatusKey } from '@/lib/spot';
import { markerColour } from '@/lib/spotMarkers';
import { applySpotDisplay, readSpotDisplayOptions } from '@/lib/spotDisplay';
import { loadLocal, loadRemote, saveState, seedLocal, whenGridPrefsReady } from '@/lib/gridPrefs';
import { distanceUnit, distanceValue, subscribeDistanceUnit } from '@/lib/units';
import { useI18n } from '@/lib/i18n';
type TFn = (key: string, vars?: Record<string, string | number>) => string;
@@ -443,8 +444,13 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
},
{
group: 'Geo', label: t('clg2.c.distance_km'), colId: 'distance_km',
headerName: t('clg2.h.distance_km'), field: 'distance_km' as any, width: 80, type: 'rightAligned', cellClass: 'font-mono',
valueFormatter: (p) => p.value ? String(p.value) : '',
// The header carries the unit, so the cells stay bare numbers and the
// column still sorts on the km the backend sent — converting the VALUE
// would sort miles as if they were kilometres either way, but it would
// also round twice.
headerName: t('clg2.h.distance_km') + ' (' + distanceUnit() + ')',
field: 'distance_km' as any, width: 90, type: 'rightAligned', cellClass: 'font-mono',
valueFormatter: (p) => p.value ? String(distanceValue(p.value)) : '',
comparator: (a, b) => (a ?? 0) - (b ?? 0),
},
{
@@ -523,7 +529,9 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick, onSpotSelect }: Pro
const [pickerOpen, setPickerOpen] = useState(false);
// Localized column catalog — rebuilt when the language changes.
const COL_CATALOG = useMemo(() => makeColCatalog(t), [t]);
const [distUnit, setDistUnit] = useState(distanceUnit);
useEffect(() => subscribeDistanceUnit(() => setDistUnit(distanceUnit())), []);
const COL_CATALOG = useMemo(() => makeColCatalog(t), [t, distUnit]);
// A rebuild makes AG Grid re-apply every colDef hide/width DEFAULT and fire the
// matching column events. Without this guard those events were persisted, so a
+3 -2
View File
@@ -4,6 +4,7 @@ import 'leaflet/dist/leaflet.css';
import { nightPolygon } from '../lib/greyline';
import { gridToLatLon, gridSquareBounds, greatCirclePoints, pathBetween, destinationPoint } from '@/lib/maidenhead';
import { writeUiPref } from '@/lib/uiPref';
import { formatDistance } from '@/lib/units';
// Persisted free-pan view of the world map (when auto-zoom is off).
function loadMapView(): { lat: number; lon: number; zoom: number } | null {
@@ -446,8 +447,8 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
</button>
{path && (
<div className="absolute bottom-1 left-1 z-[500] rounded-md bg-card/90 backdrop-blur px-2 py-1 text-[11px] font-mono shadow border border-border pointer-events-none">
<div><span className="text-muted-foreground">Dist</span> {Math.round(path.distanceShort).toLocaleString()} km
<span className="text-muted-foreground"> · LP</span> {Math.round(path.distanceLong).toLocaleString()} km</div>
<div><span className="text-muted-foreground">Dist</span> {formatDistance(path.distanceShort)}
<span className="text-muted-foreground"> · LP</span> {formatDistance(path.distanceLong)}</div>
<div><span className="text-muted-foreground">Az SP</span> {Math.round(path.bearingShort)}°
<span className="text-muted-foreground"> · LP</span> {Math.round(path.bearingLong)}°</div>
</div>
+7 -3
View File
@@ -9,6 +9,7 @@ import { formatDateTimeUTC, formatDateOnly, getDateFormat, subscribeDateFormat }
import { AgGridReact } from 'ag-grid-react';
import { Columns3, FilterX, ListChecks } from 'lucide-react';
import type { QSOForm } from '@/types';
import { distanceUnit, distanceValue, subscribeDistanceUnit } from '@/lib/units';
import { QSOContextMenu, type QSOMenuState } from './QSOContextMenu';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
@@ -177,8 +178,9 @@ export const makeColCatalog = (t: TFn, myGrid?: string): ColEntry[] => [
{ group: 'Contacted', label: t('rqg.c.lon'), colId: 'lon', headerName: t('rqg.c.lon'), field: 'lon' as any, width: 90, type: 'rightAligned', cellClass: 'font-mono' },
// Derived, not stored: computed from the two locations at display time, like
// the cluster grid's own distance column.
{ group: 'Contacted', label: t('rqg.c.distance_km'), colId: 'distance_km', headerName: t('rqg.h.distance_km'), width: 90, type: 'rightAligned', cellClass: 'font-mono',
valueGetter: (p) => qsoDistanceKm(p.data, myGrid),
{ group: 'Contacted', label: t('rqg.c.distance_km'), colId: 'distance_km',
headerName: t('rqg.h.distance_km') + ' (' + distanceUnit() + ')', width: 95, type: 'rightAligned', cellClass: 'font-mono',
valueGetter: (p) => { const km = qsoDistanceKm(p.data, myGrid); return km ? distanceValue(km) : km; },
comparator: (a, b) => (a ?? 0) - (b ?? 0), defaultVisible: true },
{ group: 'Contacted', label: t('rqg.c.email'), colId: 'email', headerName: t('rqg.c.email'), field: 'email' as any, width: 180 },
{ group: 'Contacted', label: t('rqg.c.web'), colId: 'web', headerName: t('rqg.c.web'), field: 'web' as any, width: 180 },
@@ -335,7 +337,9 @@ export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal,
// inside the column definitions, so nothing else would notice.
const [dateFmt, setDateFmt] = useState(getDateFormat);
useEffect(() => subscribeDateFormat(() => setDateFmt(getDateFormat())), []);
const COL_CATALOG = useMemo(() => makeColCatalog(t, myGrid), [t, myGrid, dateFmt]);
const [distUnit, setDistUnit] = useState(distanceUnit);
useEffect(() => subscribeDistanceUnit(() => setDistUnit(distanceUnit())), []);
const COL_CATALOG = useMemo(() => makeColCatalog(t, myGrid), [t, myGrid, dateFmt, distUnit]);
// Right-click: if the clicked row isn't already part of the selection,
// select just it; then open the bulk-action menu on the whole selection.
@@ -80,6 +80,7 @@ import {
} from '@/components/ui/select';
import { cn } from '@/lib/utils';
import { writeUiPref } from '@/lib/uiPref';
import { setUseMiles } from '@/lib/units';
import { getDateFormat, setDateFormat, type DateFormat } from '@/lib/dateFormat';
import { useI18n, FlagGB, FlagFR, type Lang } from '@/lib/i18n';
import { useTheme, CONCRETE_THEMES, type ThemeChoice } from '@/lib/theme';
@@ -1747,6 +1748,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
const [startEqEnd, setStartEqEnd] = useState(() => localStorage.getItem('opslog.startEqualsEnd') === '1');
const [lookupOnBlur, setLookupOnBlur] = useState(() => localStorage.getItem('opslog.lookupOnBlur') === '1');
const [groupDigital, setGroupDigital] = useState(() => localStorage.getItem('opslog.groupDigitalSlots') === '1');
const [milesUnit, setMilesUnit] = useState(() => localStorage.getItem('opslog.distanceMiles') === '1');
const [clusterWorkedSameSlot, setClusterWorkedSameSlot] = useState(() => localStorage.getItem('opslog.clusterWorkedSameSlot') === '1');
// Declared HERE and not in ClusterPanel: that renderer is called as a plain
// function by the PANELS map, so it must stay hooks-free.
@@ -7221,6 +7223,13 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
<Checkbox checked={groupDigital} onCheckedChange={(c) => { const v = !!c; setGroupDigital(v); writeUiPref('opslog.groupDigitalSlots', v ? '1' : '0'); }} />
{t('gen.groupDigital')} <span className="text-xs text-muted-foreground">{t('gen.groupDigitalHint')}</span>
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer">
{/* Distances are computed in km everywhere and converted at display
time see lib/units. Changing this repaints the columns that
already carry a distance; nothing stored moves. */}
<Checkbox checked={milesUnit} onCheckedChange={(c) => { const v = !!c; setMilesUnit(v); setUseMiles(v); }} />
{t('gen.miles')} <span className="text-xs text-muted-foreground">{t('gen.milesHint')}</span>
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={checkUpdates} onCheckedChange={(c) => { const v = !!c; setCheckUpdates(v); writeUiPref('opslog.checkUpdates', v ? '1' : '0'); }} />
{t('gen.checkUpdates')} <span className="text-xs text-muted-foreground">{t('gen.checkUpdatesHint')}</span>
+2
View File
@@ -128,6 +128,7 @@ const en: Dict = {
'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.',
'gen.miles': 'Distances in miles', 'gen.milesHint': '(instead of kilometres)',
'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.",
@@ -623,6 +624,7 @@ const fr: Dict = {
'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.",
'gen.miles': 'Distances en miles', 'gen.milesHint': '(au lieu des kilomètres)',
'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.",
+1
View File
@@ -40,6 +40,7 @@ const PORTABLE_KEYS = [
'opslog.clusterFilterSource', 'opslog.clusterGroup', 'opslog.clusterBands',
'opslog.clusterLockBand', 'opslog.clusterLockMode', 'opslog.clusterStatusFilter',
'opslog.clusterModeFilter', 'opslog.clusterSearch', 'opslog.clusterHideWorked',
'opslog.distanceMiles', // distances shown in statute miles rather than km
'opslog.activeTab', // last selected tab
'opslog.mainSplit', // Main tab: width share of the left pane (percent) — legacy, read once to seed mainShares
'opslog.mainShares', // Main tab: column shares per column count, as {2:[..],3:[..],4:[..]}
+49
View File
@@ -0,0 +1,49 @@
// Distance units.
//
// Everything is COMPUTED in kilometres — the great-circle maths, the backend's
// distance_km on a spot, the map's path lengths — and converted once, here, at
// display time. Storing miles anywhere would mean two sources of truth for the
// same number and a rounding error that grows with every hop.
//
// The preference is portable (see lib/uiPref): an operator who works in miles
// works in miles on every machine they copy their folder to.
import { writeUiPref } from '@/lib/uiPref';
export const KEY_MILES = 'opslog.distanceMiles';
const KM_PER_MILE = 1.609344; // statute miles, the ones a US licence is used in
export function useMiles(): boolean {
try { return localStorage.getItem(KEY_MILES) === '1'; } catch { return false; }
}
export function setUseMiles(on: boolean): void {
writeUiPref(KEY_MILES, on ? '1' : '0');
listeners.forEach((l) => l());
}
// subscribeDistanceUnit notifies on a change. The grids capture the unit inside
// their column definitions (header text and formatter both), so without this a
// toggle would only show up on the next language change or restart.
const listeners = new Set<() => void>();
export function subscribeDistanceUnit(fn: () => void): () => void {
listeners.add(fn);
return () => { listeners.delete(fn); };
}
// distanceValue converts a distance in km to the operator's unit, rounded to a
// whole unit — the precision the inputs actually justify (a 4-character grid is
// a square tens of kilometres wide).
export function distanceValue(km: number): number {
if (!isFinite(km)) return 0;
return Math.round(useMiles() ? km / KM_PER_MILE : km);
}
// distanceUnit is the short label: "km" or "mi".
export function distanceUnit(): string {
return useMiles() ? 'mi' : 'km';
}
// formatDistance is value + unit, thousands-separated: "12 345 km".
export function formatDistance(km: number): string {
return `${distanceValue(km).toLocaleString()} ${distanceUnit()}`;
}
+1 -1
View File
@@ -1,6 +1,6 @@
// Single source of truth for the app version shown in the UI (header + About).
// Bump this on a release (the release script updates it alongside telemetry.go).
export const APP_VERSION = '0.26.20';
export const APP_VERSION = '0.26.21';
// Author / credits, shown in Help -> About.
export const APP_AUTHOR = 'F4BPO';
+139 -10
View File
@@ -12,6 +12,7 @@ import (
"os/exec"
"path/filepath"
"strings"
"sync"
"syscall"
"time"
)
@@ -20,6 +21,67 @@ import (
// document of the user's QSOs (optionally only confirmed ones).
const lotwReportURL = "https://lotw.arrl.org/lotwuser/lotwreport.adi"
const (
// How long LoTW may take to START answering. It builds the whole report
// before sending anything, so this is the slow part of a big account.
lotwHeaderTimeout = 10 * time.Minute
// How long the transfer may stall once it HAS started. A download that has
// not moved in this long is not slow, it is dead — and saying so beats a
// progress window that sits at "working" until someone gives up.
lotwIdleTimeout = 2 * time.Minute
lotwMaxBytes = 256 * 1024 * 1024
)
// readWithProgress reads the body in chunks, reporting the running total and
// failing fast on a stall.
//
// Reported as it arrives rather than at the end: an 18 MB report over a slow
// link is minutes of silence otherwise, which is indistinguishable from a hang —
// and that is exactly what operators were reporting.
func say(note func(string), msg string) {
if note != nil {
note(msg)
}
LogSink("%s", msg)
}
func readWithProgress(ctx context.Context, r io.Reader, note func(string)) ([]byte, error) {
var (
out []byte
total int64
last = time.Now()
buf = make([]byte, 64*1024)
next = int64(256 * 1024) // first report early — proof it is moving
)
for {
if err := ctx.Err(); err != nil {
return nil, err
}
n, err := r.Read(buf)
if n > 0 {
out = append(out, buf[:n]...)
total += int64(n)
last = time.Now()
if total >= next {
say(note, fmt.Sprintf(" … %.1f MB received", float64(total)/(1024*1024)))
next = total + 512*1024
}
if total >= lotwMaxBytes {
return out, nil
}
}
if err == io.EOF {
return out, nil
}
if err != nil {
return nil, err
}
if time.Since(last) > lotwIdleTimeout {
return nil, fmt.Errorf("the transfer stalled after %d KB — LoTW stopped sending", total/1024)
}
}
}
// DownloadLoTWConfirmations fetches confirmed QSOs from LoTW as ADIF text.
// Uses the LoTW *website* login (Username/Password), not the TQSL cert. When
// since is non-empty (YYYY-MM-DD) only confirmations received since then are
@@ -27,7 +89,7 @@ const lotwReportURL = "https://lotw.arrl.org/lotwuser/lotwreport.adi"
// non-empty, only confirmations for that station callsign are returned (an
// LoTW account holds every call you operate — F4BPO, F4BPO/P, TM2Q — so this
// scopes the pull to the active profile's call).
func DownloadLoTWConfirmations(ctx context.Context, client *http.Client, cfg ServiceConfig, since, ownCall string) (string, error) {
func DownloadLoTWConfirmations(ctx context.Context, client *http.Client, cfg ServiceConfig, since, ownCall string, note func(string)) (string, error) {
user := strings.TrimSpace(cfg.Username)
if user == "" || cfg.Password == "" {
return "", fmt.Errorf("lotw: website login (username/password) not set")
@@ -57,20 +119,87 @@ func DownloadLoTWConfirmations(ctx context.Context, client *http.Client, cfg Ser
if err != nil {
return "", fmt.Errorf("lotw: build request: %w", err)
}
// Named, because LoTW's front end throttles unidentified clients harder than
// it throttles known ones, and an operator reporting a 503 deserves a request
// that says who is asking.
req.Header.Set("User-Agent", "OpsLog")
if client == nil {
// 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}
// NO overall deadline. A full account is tens of megabytes and LoTW spends
// minutes building it before the first byte; a total timeout turns a slow
// but healthy download into "context deadline exceeded", and a longer one
// turns a dead connection into a window that says "working" for twenty
// minutes. What matters is not how long it takes but whether it is still
// moving — see the idle watchdog below.
client = &http.Client{
Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
ResponseHeaderTimeout: lotwHeaderTimeout,
TLSHandshakeTimeout: 30 * time.Second,
},
}
}
// LoTW answers 503 when it is busy, which for a report covering more than a
// few days is often — other loggers get the same answer and simply ask
// again. Three tries, spaced, and each one said out loud: an operator whose
// download takes four minutes because the ARRL is loaded should be able to
// see that rather than guess it.
// LoTW sends nothing at all until the whole report is built — minutes for a
// large account. That silence was the entire complaint: a window saying
// "working" with no way to tell a busy server from a dead one. Count it out
// loud until the first byte.
beat := make(chan struct{})
go func() {
start := time.Now()
tick := time.NewTicker(15 * time.Second)
defer tick.Stop()
for {
select {
case <-beat:
return
case <-ctx.Done():
return
case <-tick.C:
say(note, fmt.Sprintf(" … still waiting for LoTW to build the report (%.0f s)", time.Since(start).Seconds()))
}
}
}()
// Stopped where the WAIT ends, not where the function does: deferred, the
// heartbeat went on counting between the megabyte lines and read as if the
// report were still being built while it was already arriving.
stopBeat := sync.OnceFunc(func() { close(beat) })
defer stopBeat()
var resp *http.Response
for attempt := 1; ; attempt++ {
resp, err = client.Do(req) //nolint:bodyclose // closed below or in the retry
if err == nil && resp.StatusCode != http.StatusServiceUnavailable &&
resp.StatusCode != http.StatusBadGateway && resp.StatusCode != http.StatusGatewayTimeout {
break
}
if attempt >= 3 {
break
}
wait := time.Duration(attempt*20) * time.Second
if resp != nil {
say(note, fmt.Sprintf("LoTW is busy (HTTP %d) — asking again in %s…", resp.StatusCode, wait))
resp.Body.Close()
} else {
say(note, fmt.Sprintf("LoTW did not answer (%v) — asking again in %s…", err, wait))
}
select {
case <-ctx.Done():
return "", ctx.Err()
case <-time.After(wait):
}
req = req.Clone(ctx)
}
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, 256*1024*1024))
stopBeat()
say(note, fmt.Sprintf("LoTW answered (HTTP %d) — receiving…", resp.StatusCode))
body, err := readWithProgress(ctx, resp.Body, note)
if err != nil {
return "", fmt.Errorf("lotw: read response: %w", err)
}
@@ -378,7 +507,7 @@ func TestLoTW(cfg ServiceConfig, stationDataPath string) (string, error) {
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if _, err := DownloadLoTWConfirmations(ctx, nil, cfg, "2099-01-01", ""); err != nil {
if _, err := DownloadLoTWConfirmations(ctx, nil, cfg, "2099-01-01", "", nil); err != nil {
return "", fmt.Errorf("%s — but the DOWNLOAD login failed: %w", up, err)
}
return up + ". Download login accepted.", nil
+1 -1
View File
@@ -21,7 +21,7 @@ import (
const (
// appVersion is stamped on every heartbeat (and could feed the About box).
appVersion = "0.26.20"
appVersion = "0.26.21"
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
// to https://us.i.posthog.com for a US project.