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".
357 lines
14 KiB
Go
357 lines
14 KiB
Go
package extsvc
|
|
|
|
import (
|
|
"context"
|
|
"encoding/xml"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
)
|
|
|
|
// lotwReportURL is LoTW's confirmation-report endpoint. It returns an ADIF
|
|
// document of the user's QSOs (optionally only confirmed ones).
|
|
const lotwReportURL = "https://lotw.arrl.org/lotwuser/lotwreport.adi"
|
|
|
|
// 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
|
|
// returned — used for incremental "Last download" updates. When ownCall is
|
|
// 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) {
|
|
user := strings.TrimSpace(cfg.Username)
|
|
if user == "" || cfg.Password == "" {
|
|
return "", fmt.Errorf("lotw: website login (username/password) not set")
|
|
}
|
|
q := url.Values{}
|
|
q.Set("login", user)
|
|
q.Set("password", cfg.Password)
|
|
q.Set("qso_query", "1")
|
|
q.Set("qso_qsl", "yes") // only QSLed (confirmed) records
|
|
q.Set("qso_qsldetail", "yes") // include QSL_RCVD / QSLRDATE detail
|
|
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)
|
|
}
|
|
|
|
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}
|
|
}
|
|
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))
|
|
if err != nil {
|
|
return "", fmt.Errorf("lotw: read response: %w", err)
|
|
}
|
|
text := string(body)
|
|
if resp.StatusCode != http.StatusOK {
|
|
return "", fmt.Errorf("lotw: http %d", resp.StatusCode)
|
|
}
|
|
// Not ADIF. Two very different failures land here, and telling them apart is
|
|
// the difference between a fixable message and a wall of markup.
|
|
if !strings.Contains(strings.ToUpper(text), "<EOH>") && !strings.Contains(strings.ToLower(text), "<eor>") {
|
|
trimmed := strings.TrimSpace(text)
|
|
// Keep the whole thing in the log — that is where a real diagnosis happens,
|
|
// and a 200-character excerpt of an HTML page tells nobody anything.
|
|
snippet := trimmed
|
|
if len(snippet) > 2000 {
|
|
snippet = snippet[:2000]
|
|
}
|
|
LogSink("lotw: expected ADIF, got %d bytes of non-ADIF; first 2000: %s", len(text), snippet)
|
|
|
|
// LoTW answers a REJECTED LOGIN with its ordinary web page rather than an
|
|
// error string, so an HTML body here means the credentials were not
|
|
// accepted — not that the download is broken.
|
|
lower := strings.ToLower(trimmed)
|
|
if strings.HasPrefix(lower, "<!doctype html") || strings.HasPrefix(lower, "<html") || strings.Contains(lower, "logbook of the world</title>") {
|
|
return "", fmt.Errorf("LoTW returned its web page instead of a log, which is how it answers a login it did not accept. " +
|
|
"Check the username and password in Settings → External services: LoTW wants your lotw.arrl.org WEBSITE login, " +
|
|
"not your callsign certificate or your ARRL member number")
|
|
}
|
|
|
|
// Anything else: a plain-text complaint from LoTW, or a maintenance notice.
|
|
msg := trimmed
|
|
if len(msg) > 200 {
|
|
msg = msg[:200] + "…"
|
|
}
|
|
return "", fmt.Errorf("lotw: unexpected response: %s", msg)
|
|
}
|
|
return text, nil
|
|
}
|
|
|
|
// LoTW uploads go through TQSL (ARRL's Trusted QSL signer): there is no
|
|
// plain HTTP API — every QSO must be signed with the station certificate
|
|
// before LoTW accepts it. We write the QSO to a temporary ADIF file and run
|
|
// tqsl in batch mode to sign and upload it in one shot.
|
|
|
|
// StationLocation is one TQSL "Station Location" the user has defined. These
|
|
// pair a callsign with a certificate + grid/zones; the upload picks one by
|
|
// name (the -l flag).
|
|
type StationLocation struct {
|
|
Name string `json:"name"`
|
|
Call string `json:"call"`
|
|
Grid string `json:"grid"`
|
|
DXCC int `json:"dxcc"`
|
|
}
|
|
|
|
// stationDataFile mirrors TQSL's station_data XML.
|
|
type stationDataFile struct {
|
|
XMLName xml.Name `xml:"StationDataFile"`
|
|
Stations []struct {
|
|
Name string `xml:"name,attr"`
|
|
Call string `xml:"CALL"`
|
|
Grid string `xml:"GRIDSQUARE"`
|
|
DXCC int `xml:"DXCC"`
|
|
} `xml:"StationData"`
|
|
}
|
|
|
|
// ListStationLocations parses TQSL's station_data file and returns the
|
|
// defined locations. Used to populate the Station Location dropdown — the
|
|
// same file Log4OM reads.
|
|
func ListStationLocations(stationDataPath string) ([]StationLocation, error) {
|
|
data, err := os.ReadFile(stationDataPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read station_data: %w", err)
|
|
}
|
|
var f stationDataFile
|
|
if err := xml.Unmarshal(data, &f); err != nil {
|
|
return nil, fmt.Errorf("parse station_data: %w", err)
|
|
}
|
|
out := make([]StationLocation, 0, len(f.Stations))
|
|
for _, s := range f.Stations {
|
|
out = append(out, StationLocation{Name: s.Name, Call: s.Call, Grid: s.Grid, DXCC: s.DXCC})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// DefaultTQSLPath returns the usual tqsl.exe install path on Windows, or ""
|
|
// if not found.
|
|
func DefaultTQSLPath() string {
|
|
for _, p := range []string{
|
|
`C:\Program Files (x86)\TrustedQSL\tqsl.exe`,
|
|
`C:\Program Files\TrustedQSL\tqsl.exe`,
|
|
} {
|
|
if fileExists(p) {
|
|
return p
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// DefaultStationDataPath returns TQSL's station_data location (%APPDATA%\
|
|
// TrustedQSL\station_data on Windows), or "" if APPDATA isn't set.
|
|
func DefaultStationDataPath() string {
|
|
if appData := os.Getenv("APPDATA"); appData != "" {
|
|
return filepath.Join(appData, "TrustedQSL", "station_data")
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func fileExists(p string) bool {
|
|
info, err := os.Stat(p)
|
|
return err == nil && !info.IsDir()
|
|
}
|
|
|
|
// UploadLoTW signs and uploads one ADIF record via TQSL. tempDir is where the
|
|
// temporary .adi is written (falls back to the OS temp dir). Returns OK when
|
|
// LoTW accepts the QSO or reports it as a duplicate (already uploaded).
|
|
//
|
|
// TQSL command:
|
|
//
|
|
// tqsl -d -x -a all -l "<location>" -u [-p <keypass>] <file.adi>
|
|
//
|
|
// 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)
|
|
switch {
|
|
case tqsl == "":
|
|
return UploadResult{}, fmt.Errorf("lotw: TQSL path not set")
|
|
case !fileExists(tqsl):
|
|
return UploadResult{}, fmt.Errorf("lotw: tqsl.exe not found at %q", tqsl)
|
|
case loc == "":
|
|
return UploadResult{}, fmt.Errorf("lotw: station location not set")
|
|
case strings.TrimSpace(adifRecord) == "":
|
|
return UploadResult{}, fmt.Errorf("lotw: empty adif record")
|
|
}
|
|
|
|
// Write the QSO to a temp ADIF file (minimal header keeps strict TQSL
|
|
// happy). Cleaned up after upload.
|
|
if strings.TrimSpace(tempDir) == "" {
|
|
tempDir = os.TempDir()
|
|
}
|
|
f, err := os.CreateTemp(tempDir, "opslog-lotw-*.adi")
|
|
if err != nil {
|
|
return UploadResult{}, fmt.Errorf("lotw: create temp file: %w", err)
|
|
}
|
|
tmpPath := f.Name()
|
|
defer os.Remove(tmpPath)
|
|
if _, err := f.WriteString("OpsLog LoTW upload\n<PROGRAMID:6>OpsLog <EOH>\n" + adifRecord + "\n"); err != nil {
|
|
f.Close()
|
|
return UploadResult{}, fmt.Errorf("lotw: write temp file: %w", err)
|
|
}
|
|
f.Close()
|
|
|
|
args := []string{"-d", "-x", "-a", "all", "-l", loc, "-u"}
|
|
if pwd := strings.TrimSpace(cfg.KeyPassword); pwd != "" {
|
|
args = append(args, "-p", pwd)
|
|
}
|
|
if cfg.WriteLog {
|
|
// -t writes a TQSL diagnostic log; drop it next to the temp ADIF.
|
|
args = append(args, "-t", filepath.Join(tempDir, "opslog-tqsl.log"))
|
|
}
|
|
args = append(args, tmpPath)
|
|
|
|
// TQSL launches a child process and contacts LoTW — give it generous
|
|
// time, independent of any short caller deadline.
|
|
runCtx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
|
defer cancel()
|
|
_ = ctx
|
|
|
|
cmd := exec.CommandContext(runCtx, tqsl, args...)
|
|
out, runErr := cmd.CombinedOutput()
|
|
msg := strings.TrimSpace(string(out))
|
|
|
|
code := 0
|
|
if runErr != nil {
|
|
if ee, ok := runErr.(*exec.ExitError); ok {
|
|
code = ee.ExitCode()
|
|
} else if errors.Is(runErr, syscall.Errno(740)) || strings.Contains(strings.ToLower(runErr.Error()), "requires elevation") {
|
|
// ERROR_ELEVATION_REQUIRED (740): tqsl.exe is set to require admin
|
|
// rights (its "Run as administrator" compatibility flag, or an
|
|
// AppCompat RUNASADMIN entry), but OpsLog isn't elevated so Windows
|
|
// refuses to launch it. Actionable message instead of the raw error.
|
|
return UploadResult{}, fmt.Errorf(
|
|
"lotw: Windows won't launch tqsl.exe because it's marked \"Run as administrator\". "+
|
|
"Fix: right-click %q → Properties → Compatibility → UNTICK \"Run this program as an administrator\" (Apply). "+
|
|
"Or run OpsLog itself as administrator.", tqsl)
|
|
} else {
|
|
return UploadResult{}, fmt.Errorf("lotw: run tqsl: %w", runErr)
|
|
}
|
|
}
|
|
|
|
// 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:
|
|
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:
|
|
// 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)
|
|
}
|
|
return UploadResult{OK: false, Message: msg}, fmt.Errorf("lotw: tqsl failed (code %d): %s", code, msg)
|
|
}
|
|
}
|
|
|
|
// 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) {
|
|
tqsl := strings.TrimSpace(cfg.TQSLPath)
|
|
loc := strings.TrimSpace(cfg.StationLocation)
|
|
if tqsl == "" || !fileExists(tqsl) {
|
|
return "", fmt.Errorf("lotw: tqsl.exe not found (set the TQSL path)")
|
|
}
|
|
if loc == "" {
|
|
return "", fmt.Errorf("lotw: pick a station location")
|
|
}
|
|
locs, err := ListStationLocations(stationDataPath)
|
|
if err != nil {
|
|
return "", fmt.Errorf("lotw: can't read station locations: %w", err)
|
|
}
|
|
found := ""
|
|
for _, l := range locs {
|
|
if strings.EqualFold(l.Name, loc) {
|
|
found = l.Call
|
|
break
|
|
}
|
|
}
|
|
if found == "" {
|
|
return "", fmt.Errorf("lotw: station location %q not found in TQSL", loc)
|
|
}
|
|
|
|
// LoTW is TWO credentials doing two jobs, and the button used to report only
|
|
// the first. Uploading goes through TQSL and is signed by the certificate —
|
|
// the website password is never involved, so a wrong one breaks nothing until
|
|
// the day confirmations are downloaded and nobody connects the two events.
|
|
//
|
|
// So the download login is tested separately, and said separately. A future
|
|
// "since" date makes LoTW return an empty report rather than the whole
|
|
// account: the credentials are what is being checked, not the log.
|
|
up := fmt.Sprintf("Ready — TQSL found, location %q (%s)", loc, found)
|
|
if strings.TrimSpace(cfg.Username) == "" || cfg.Password == "" {
|
|
return up + ". Download login not set — confirmations cannot be fetched.", nil
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
if _, err := DownloadLoTWConfirmations(ctx, nil, cfg, "2099-01-01", ""); err != nil {
|
|
return "", fmt.Errorf("%s — but the DOWNLOAD login failed: %w", up, err)
|
|
}
|
|
return up + ". Download login accepted.", nil
|
|
}
|