fix(lotw): stop reporting suppressed QSOs as uploaded

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".
This commit is contained in:
2026-08-13 08:55:29 +02:00
parent 6ca808a6c9
commit 61e5736f1e
5 changed files with 115 additions and 6 deletions
+10
View File
@@ -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"
+4 -2
View File
@@ -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."
]
},
{
+6
View File
@@ -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
}
+51 -4
View File
@@ -178,8 +178,12 @@ func fileExists(p string) bool {
//
// tqsl -d -x -a all -l "<location>" -u [-p <keypass>] <file.adi>
//
// 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) {
+44
View File
@@ -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
})()
}