From 61e5736f1e7a2a5e115b748ff2cf2eb5575df0e3 Mon Sep 17 00:00:00 2001 From: Gregory Salaun Date: Thu, 13 Aug 2026 08:55:29 +0200 Subject: [PATCH] fix(lotw): stop reporting suppressed QSOs as uploaded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported: ten fresh contacts upload and appear on LoTW; twenty older ones, bulk-edited to exactly the same LOTW_SENT/RCVD state, are accepted by OpsLog and never arrive. The operator's reading was that a failed attempt could not be overridden. It is simpler and worse than that. TQSL's exit codes, from its own cmdline documentation: 8 NO QSOs were processed — already uploaded OR OUT OF DATE RANGE 9 some processed, some ignored — same two reasons 14 some already uploaded, the rest signed 8 and 9 were both read as plain success. So on 8 — nothing uploaded at all — OpsLog announced "already uploaded (duplicate)" and stamped every selected contact as sent. They were never on LoTW and now looked as if they were, which is exactly the reported symptom, and it is not recoverable by re-uploading because the operator has no reason to try. "Out of date range" is the cause that bites here: a contact older than the callsign certificate's validity is silently left out. Older QSOs failing while today's succeed is the signature. Now: 8 is a failure and nothing is stamped — a duplicate left at "R" is harmless and will be refused again, whereas a contact wrongly marked sent is one nobody will look at twice. 9 and 14 succeed but carry Ignored, and the caller says so in the console and a toast. TQSL's own sentence ("20 QSO records are out of date range") is captured and shown. It was being read and thrown away, and it is the whole answer to "why is my contact not on LoTW". --- app.go | 10 ++++++ changelog.json | 6 ++-- internal/extsvc/extsvc.go | 6 ++++ internal/extsvc/lotw.go | 55 ++++++++++++++++++++++++++++--- internal/extsvc/lotw_exit_test.go | 44 +++++++++++++++++++++++++ 5 files changed, 115 insertions(+), 6 deletions(-) create mode 100644 internal/extsvc/lotw_exit_test.go diff --git a/app.go b/app.go index 5103a4a..5511d2d 100644 --- a/app.go +++ b/app.go @@ -10227,6 +10227,16 @@ func (a *App) runManualUpload(svc extsvc.Service, ids []int64, cfg extsvc.Extern uploaded++ } emit(fmt.Sprintf("LoTW: %d QSO(s) uploaded", uploaded)) + // TQSL accepted the batch but left contacts out of it — already + // uploaded, or outside the certificate's date range. Said out loud: + // the operator has no other way to learn that some of what they just + // selected is still not on LoTW. + if res.Ignored { + emit(res.Message) + if a.ctx != nil { + wruntime.EventsEmit(a.ctx, "toast", "LoTW: "+res.Message) + } + } } } else if svc == extsvc.ServiceClublog || svc == extsvc.ServiceHRDLog { statusCol, dateCol := "clublog_qso_upload_status", "clublog_qso_upload_date" diff --git a/changelog.json b/changelog.json index 6d6ad46..511a9d2 100644 --- a/changelog.json +++ b/changelog.json @@ -5,12 +5,14 @@ "en": [ "Appearance: row colouring now defaults to a left stripe, with a filled row and its strength offered as choices.", "The band map now follows the cluster filters — LoTW only, spotter continent, hide worked, status and mode chips.", - "Band map: stations that upload to LoTW now carry the same L badge as the cluster list, switchable in Appearance." + "Band map: stations that upload to LoTW now carry the same L badge as the cluster list, switchable in Appearance.", + "LoTW: contacts TQSL leaves out — already uploaded, or outside the certificate date range — are no longer reported as uploaded." ], "fr": [ "Apparence : la coloration des lignes se fait par défaut sur une barre à gauche, la ligne remplie et son intensité restant proposées.", "La band map suit désormais les filtres du cluster — LoTW seulement, continent du spotter, masquer les contactés, statuts et modes.", - "Band map : les stations qui utilisent LoTW portent le même badge L que la liste du cluster, activable dans Apparence." + "Band map : les stations qui utilisent LoTW portent le même badge L que la liste du cluster, activable dans Apparence.", + "LoTW : les contacts que TQSL écarte — déjà envoyés, ou hors de la plage de dates du certificat — ne sont plus annoncés comme envoyés." ] }, { diff --git a/internal/extsvc/extsvc.go b/internal/extsvc/extsvc.go index 84832e4..85ae1d5 100644 --- a/internal/extsvc/extsvc.go +++ b/internal/extsvc/extsvc.go @@ -142,4 +142,10 @@ type UploadResult struct { OK bool // the service accepted (or already had) the QSO LogID string // service-assigned record id, when provided Message string // human-readable detail (reason on failure) + // Ignored is set when the service accepted the submission but left some or + // all of the QSOs out of it. TQSL does this for contacts already uploaded + // AND for contacts outside the certificate's date range, and reports both + // with the same exit code — so the caller must show the message rather than + // decide on its own that everything went through. + Ignored bool } diff --git a/internal/extsvc/lotw.go b/internal/extsvc/lotw.go index e4a1910..7f0ac1e 100644 --- a/internal/extsvc/lotw.go +++ b/internal/extsvc/lotw.go @@ -178,8 +178,12 @@ func fileExists(p string) bool { // // tqsl -d -x -a all -l "" -u [-p ] // -// Exit codes (TQSL): 0 = uploaded; 8 = nothing new (all duplicates/out of -// range); 9 = some uploaded, some skipped; anything else = failure. +// Exit codes are TQSL's own (see the table in its cmdline help). 8 and 9 are +// the ones that matter and both used to be read as plain success: 8 means NO +// QSOs were processed and 9 means some were left out — in each case because +// they were already uploaded OR outside the callsign certificate's date range. +// Reporting either as success is how a contact came to be stamped "uploaded" +// while LoTW had never seen it. func UploadLoTW(ctx context.Context, cfg ServiceConfig, tempDir, adifRecord string) (UploadResult, error) { tqsl := strings.TrimSpace(cfg.TQSLPath) loc := strings.TrimSpace(cfg.StationLocation) @@ -249,11 +253,31 @@ func UploadLoTW(ctx context.Context, cfg ServiceConfig, tempDir, adifRecord stri } } + // TQSL's exit codes, from its own cmdline documentation. Two of them used to + // be read as plain success, and that is how contacts came to be stamped + // "uploaded" while LoTW had never seen them: + // + // 8 NO QSOs were processed — already uploaded OR OUT OF DATE RANGE + // 9 some processed, some ignored — same two reasons + // 14 some already uploaded, the rest signed + // + // "Out of date range" is the one that bites: a contact older than the + // callsign certificate's validity is silently left out, and reporting that as + // success stamped it sent for ever. TQSL says which case it is in its output, + // so the message is carried up rather than replaced with a guess. switch code { - case 0, 9: + case 0: return UploadResult{OK: true, Message: "uploaded to LoTW"}, nil + case 9, 14: + return UploadResult{OK: true, Ignored: true, Message: tqslDetail(msg, + "uploaded — but TQSL left some contacts out (already uploaded, or outside the certificate's date range)")}, nil case 8: - return UploadResult{OK: true, Message: "already uploaded (duplicate)"}, nil + // Nothing reached LoTW. NOT stamped as sent: a duplicate left at "R" is + // harmless and will be refused again, while a contact wrongly marked sent + // is one the operator will never think to look at again. + return UploadResult{OK: false, Ignored: true, Message: tqslDetail(msg, + "TQSL uploaded nothing — every contact was already uploaded, or outside the certificate's date range")}, + fmt.Errorf("lotw: no QSOs processed") default: if msg == "" { msg = fmt.Sprintf("tqsl exit code %d", code) @@ -262,6 +286,29 @@ func UploadLoTW(ctx context.Context, cfg ServiceConfig, tempDir, adifRecord stri } } +// tqslDetail keeps the lines of TQSL's own output that say what happened to the +// contacts, and appends the summary. +// +// TQSL is explicit — "414 QSO records were already uploaded", "N QSO records +// are out of date range" — and that sentence is the whole answer to "why is my +// contact not on LoTW". It used to be captured and thrown away. +func tqslDetail(out, summary string) string { + var keep []string + for _, ln := range strings.Split(out, "\n") { + ln = strings.TrimSpace(ln) + l := strings.ToLower(ln) + if strings.Contains(l, "qso") && (strings.Contains(l, "already uploaded") || + strings.Contains(l, "date range") || strings.Contains(l, "ignored") || + strings.Contains(l, "duplicate")) { + keep = append(keep, ln) + } + } + if len(keep) == 0 { + return summary + } + return summary + " — " + strings.Join(keep, "; ") +} + // TestLoTW validates the LoTW config: tqsl present and the chosen station // location exists in station_data. func TestLoTW(cfg ServiceConfig, stationDataPath string) (string, error) { diff --git a/internal/extsvc/lotw_exit_test.go b/internal/extsvc/lotw_exit_test.go new file mode 100644 index 0000000..f6f93f5 --- /dev/null +++ b/internal/extsvc/lotw_exit_test.go @@ -0,0 +1,44 @@ +package extsvc + +import "testing" + +// TQSL's own exit codes, from its cmdline documentation: +// +// 0 success +// 8 NO QSOs were processed — already uploaded OR out of date range +// 9 some processed, some ignored — same two reasons +// 14 some already uploaded, the rest signed +// +// 8 and 9 both used to be read as plain success, and that is how a contact came +// to be stamped "uploaded to LoTW" while LoTW had never seen it. The one that +// bites is "out of date range": a contact older than the callsign certificate's +// validity is silently left out of the submission. +func TestTQSLDetailKeepsTheReason(t *testing.T) { + out := `13:22:01: Signing using Callsign G0ABC, DXCC Entity ENGLAND +13:22:02: /tmp/x.adi: 20 QSO records are out of date range +13:22:02: Final Status: No QSOs were processed (8)` + got := tqslDetail(out, "summary") + if got == "summary" { + t.Fatal("threw away TQSL's explanation — that sentence is the whole answer to \"why is my contact not on LoTW\"") + } + if !contains(got, "out of date range") { + t.Errorf("the reason was lost: %q", got) + } +} + +func TestTQSLDetailFallsBackToTheSummary(t *testing.T) { + if got := tqslDetail("nothing useful here", "summary"); got != "summary" { + t.Errorf("got %q, want the plain summary when TQSL said nothing specific", got) + } +} + +func contains(h, n string) bool { + return len(h) >= len(n) && (func() bool { + for i := 0; i+len(n) <= len(h); i++ { + if h[i:i+len(n)] == n { + return true + } + } + return false + })() +}