package extsvc import ( "context" "encoding/xml" "fmt" "io" "net/http" "net/url" "os" "os/exec" "path/filepath" "strings" "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. func DownloadLoTWConfirmations(ctx context.Context, client *http.Client, cfg ServiceConfig, since 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 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) } // LoTW returns a plain-text error (not ADIF) on bad login. if !strings.Contains(strings.ToUpper(text), "") && !strings.Contains(strings.ToLower(text), "") { msg := strings.TrimSpace(text) 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 "" -u [-p ] // // Exit codes (TQSL): 0 = uploaded; 8 = nothing new (all duplicates/out of // range); 9 = some uploaded, some skipped; anything else = failure. 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\nOpsLog \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 { return UploadResult{}, fmt.Errorf("lotw: run tqsl: %w", runErr) } } switch code { case 0, 9: return UploadResult{OK: true, Message: "uploaded to LoTW"}, nil case 8: return UploadResult{OK: true, Message: "already uploaded (duplicate)"}, nil 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) } } // 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) } for _, l := range locs { if strings.EqualFold(l.Name, loc) { return fmt.Sprintf("Ready — TQSL found, location %q (%s)", l.Name, l.Call), nil } } return "", fmt.Errorf("lotw: station location %q not found in TQSL", loc) }