192 lines
6.0 KiB
Go
192 lines
6.0 KiB
Go
package extsvc
|
|
|
|
import (
|
|
"context"
|
|
"encoding/xml"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// 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 (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\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 {
|
|
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)
|
|
}
|