fix: bug sending LoTW on close

This commit is contained in:
2026-06-18 12:16:39 +02:00
parent b6d991b799
commit e1f1ab4922
6 changed files with 282 additions and 92 deletions
+13 -7
View File
@@ -69,7 +69,7 @@ type ServiceConfig struct {
TQSLPath string `json:"tqsl_path"` // LoTW: path to tqsl.exe
StationLocation string `json:"station_location"` // LoTW: TQSL Station Location name
KeyPassword string `json:"key_password"` // LoTW: certificate private-key password (optional)
UploadFlag string `json:"upload_flag"` // LoTW: sent status that means "ready to upload" — "N" or "R"
UploadFlags []string `json:"upload_flags"` // LoTW: set of lotw_sent values that mean "ready to upload" — any of "N"/"R"
WriteLog bool `json:"write_log"` // LoTW: pass -t to write a TQSL diagnostic log
AutoUpload bool `json:"auto_upload"`
UploadMode UploadMode `json:"upload_mode"`
@@ -84,13 +84,19 @@ func (c ServiceConfig) normalised() ServiceConfig {
c.ForceStationCallsign = strings.ToUpper(strings.TrimSpace(c.ForceStationCallsign))
c.TQSLPath = strings.TrimSpace(c.TQSLPath)
c.StationLocation = strings.TrimSpace(c.StationLocation)
// Upload flag is the LoTW sent-status that marks a QSO ready to upload.
// Only "N" (no) and "R" (requested) are valid; default to "R".
if uf := strings.ToUpper(strings.TrimSpace(c.UploadFlag)); uf == "N" || uf == "R" {
c.UploadFlag = uf
} else {
c.UploadFlag = "R"
// Upload flags are the LoTW sent-statuses that mark a QSO ready to upload.
// Only "N" (no) and "R" (requested) are valid; clean + dedupe, no default
// here (the caller injects N+R when nothing is configured).
var flags []string
seen := map[string]bool{}
for _, f := range c.UploadFlags {
f = strings.ToUpper(strings.TrimSpace(f))
if (f == "N" || f == "R") && !seen[f] {
seen[f] = true
flags = append(flags, f)
}
}
c.UploadFlags = flags
switch c.UploadMode {
case ModeDelayed, ModeOnClose:
// keep
+65 -38
View File
@@ -33,6 +33,11 @@ func sameBaseCall(a, b string) bool {
return baseCall(a) == baseCall(b)
}
// SameBaseCall is the exported form of sameBaseCall, so the host app can apply
// the same "same operator?" rule when filtering an on-close upload batch by the
// active logbook's callsign.
func SameBaseCall(a, b string) bool { return sameBaseCall(a, b) }
// Deps are the host-app callbacks the Manager needs. Keeping them as
// function fields decouples extsvc from the qso/adif/settings packages and
// keeps the upload-scheduling logic testable.
@@ -62,6 +67,13 @@ type Deps struct {
// option would otherwise silently relabel it). "" → no station call known.
StationCallOf func(id int64) string
// CloseUploadIDs returns the QSO ids to upload for a service when the app
// closes — scanning the WHOLE logbook, not just this session: LoTW returns
// rows whose lotw_sent matches the configured status set; QRZ/Club Log
// return anything not yet "Y". This is what makes an imported ADIF (old
// QSOs still marked unsent) upload on close. nil → nothing to do.
CloseUploadIDs func(svc Service) []int64
// Logf is an optional diagnostic logger.
Logf func(format string, args ...any)
}
@@ -72,10 +84,9 @@ type Deps struct {
type Manager struct {
deps Deps
mu sync.Mutex
cfg ExternalServices
rnd *rand.Rand
pending map[Service][]int64 // QSO ids queued for ModeOnClose upload
mu sync.Mutex
cfg ExternalServices
rnd *rand.Rand
}
func NewManager(deps Deps) *Manager {
@@ -86,8 +97,7 @@ func NewManager(deps Deps) *Manager {
deps: deps,
// Seeded from the clock; the delay only needs to be unpredictable
// enough to spread bursts, not cryptographically random.
rnd: rand.New(rand.NewSource(time.Now().UnixNano())),
pending: map[Service][]int64{},
rnd: rand.New(rand.NewSource(time.Now().UnixNano())),
}
}
@@ -103,6 +113,8 @@ func (m *Manager) SetConfig(cfg ExternalServices) {
m.mu.Lock()
defer m.mu.Unlock()
cfg.QRZ = cfg.QRZ.normalised()
cfg.Clublog = cfg.Clublog.normalised()
cfg.LoTW = cfg.LoTW.normalised()
m.cfg = cfg
}
@@ -145,11 +157,9 @@ func (m *Manager) OnQSOLogged(id int64) {
// app-close batch, or schedule an immediate / delayed upload.
func (m *Manager) route(svc Service, id int64, cfg ServiceConfig) {
if cfg.UploadMode == ModeOnClose {
m.mu.Lock()
m.pending[svc] = append(m.pending[svc], id)
n := len(m.pending[svc])
m.mu.Unlock()
m.logf("extsvc: %s queued QSO %d for on-close upload (%d pending)", svc, id, n)
// Nothing to queue: on-close upload sweeps the whole logbook from the
// database at shutdown (see FlushOnClose), so this QSO is picked up by
// its sent-status then — no in-memory tracking needed.
return
}
m.scheduleUpload(svc, id, cfg)
@@ -166,47 +176,64 @@ func (m *Manager) scheduleUpload(svc Service, id int64, cfg ServiceConfig) {
go m.upload(svc, id, cfg)
}
// PendingCount returns how many QSOs are queued for on-close upload across
// all services. The shutdown sequence uses it to decide whether to show the
// upload step.
func (m *Manager) PendingCount() int {
m.mu.Lock()
defer m.mu.Unlock()
// onCloseServices returns the services configured for on-close auto-upload,
// with the minimum credentials to actually run.
func (m *Manager) onCloseServices() []Service {
cfg := m.Config()
var out []Service
if q := cfg.QRZ; q.AutoUpload && q.UploadMode == ModeOnClose && q.APIKey != "" {
out = append(out, ServiceQRZ)
}
if c := cfg.Clublog; c.AutoUpload && c.UploadMode == ModeOnClose && c.Email != "" && c.Password != "" {
out = append(out, ServiceClublog)
}
if l := cfg.LoTW; l.AutoUpload && l.UploadMode == ModeOnClose && l.TQSLPath != "" && l.StationLocation != "" {
out = append(out, ServiceLoTW)
}
return out
}
// CloseUploadCount returns how many QSOs across the whole logbook would be
// uploaded at app close (sum over every on-close service). The shutdown
// sequence uses it to decide whether to show the upload step and its label.
func (m *Manager) CloseUploadCount() int {
if m.deps.CloseUploadIDs == nil {
return 0
}
n := 0
for _, ids := range m.pending {
n += len(ids)
for _, svc := range m.onCloseServices() {
n += len(m.deps.CloseUploadIDs(svc))
}
return n
}
// FlushOnClose uploads every queued QSO. Called from the shutdown sequence.
// QRZ/Club Log go one-by-one (fast HTTP); LoTW is signed and uploaded as a
// single TQSL batch. Returns the number of QSOs uploaded successfully.
// FlushOnClose uploads every QSO due for an on-close push, scanning the whole
// logbook (not just this session). Called from the shutdown sequence. QRZ/Club
// Log go one-by-one (fast HTTP); LoTW is signed and uploaded as a single TQSL
// batch. Returns the number of QSOs uploaded successfully.
func (m *Manager) FlushOnClose() int {
m.mu.Lock()
pending := m.pending
m.pending = map[Service][]int64{}
cfg := m.cfg
m.mu.Unlock()
if m.deps.CloseUploadIDs == nil {
return 0
}
cfg := m.Config()
uploaded := 0
for svc, ids := range pending {
for _, svc := range m.onCloseServices() {
ids := m.deps.CloseUploadIDs(svc)
if len(ids) == 0 {
continue
}
switch svc {
case ServiceLoTW:
uploaded += m.flushLoTWBatch(ids, cfg.LoTW)
default:
var sc ServiceConfig
switch svc {
case ServiceQRZ:
sc = cfg.QRZ
case ServiceClublog:
sc = cfg.Clublog
}
case ServiceQRZ:
for _, id := range ids {
if m.upload(svc, id, sc) {
if m.upload(svc, id, cfg.QRZ) {
uploaded++
}
}
case ServiceClublog:
for _, id := range ids {
if m.upload(svc, id, cfg.Clublog) {
uploaded++
}
}
+50
View File
@@ -519,6 +519,56 @@ func (r *Repo) ListForUpload(ctx context.Context, column, value string) ([]Uploa
return out, rows.Err()
}
// UploadCandidate is a QSO eligible for an on-close upload: its id plus its
// STATION_CALLSIGN, so the caller can keep only the rows that belong to the
// active logbook's callsign (a mixed-call DB — F4BPO, F4BPO/P, TM2Q — must not
// all be signed under one cert).
type UploadCandidate struct {
ID int64
StationCallsign string
}
// ListUploadCandidates returns QSOs eligible for an on-close upload to a
// service, scanning the whole logbook. For LoTW (column "lotw_sent"), statuses
// is the set of sent-status values to treat as "to send" (e.g. N, R); rows
// already "Y" are excluded. For QRZ/Club Log, statuses is ignored and anything
// whose upload status isn't yet "Y" qualifies.
func (r *Repo) ListUploadCandidates(ctx context.Context, column string, statuses []string) ([]UploadCandidate, error) {
if !uploadStatusCols[column] {
return nil, fmt.Errorf("invalid upload column %q", column)
}
var where string
var args []any
if column == "lotw_sent" {
if len(statuses) == 0 {
return nil, nil
}
ph := make([]string, len(statuses))
for i, s := range statuses {
ph[i] = "?"
args = append(args, strings.ToUpper(strings.TrimSpace(s)))
}
where = "UPPER(COALESCE(lotw_sent,'')) IN (" + strings.Join(ph, ",") + ")"
} else {
where = "UPPER(COALESCE(" + column + ",'')) <> 'Y'"
}
rows, err := r.db.QueryContext(ctx,
`SELECT id, COALESCE(station_callsign,'') FROM qso WHERE `+where+` ORDER BY qso_date`, args...)
if err != nil {
return nil, fmt.Errorf("list upload candidates: %w", err)
}
defer rows.Close()
var out []UploadCandidate
for rows.Next() {
var c UploadCandidate
if err := rows.Scan(&c.ID, &c.StationCallsign); err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
// MarkQRZUploaded stamps QRZCOM_QSO_UPLOAD_STATUS=Y and the upload date on
// a QSO after a successful push to the QRZ.com logbook. date is an ADIF
// YYYYMMDD string. Only the two QRZ columns are touched — no full-row