feat: Winkeyer
This commit is contained in:
@@ -28,6 +28,7 @@ import (
|
||||
"hamlog/internal/profile"
|
||||
"hamlog/internal/qso"
|
||||
"hamlog/internal/rotator/pst"
|
||||
"hamlog/internal/winkeyer"
|
||||
"hamlog/internal/settings"
|
||||
|
||||
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
@@ -72,6 +73,27 @@ const (
|
||||
keyRotatorPort = "rotator.port"
|
||||
keyRotatorHasElevation = "rotator.has_elevation"
|
||||
|
||||
// WinKeyer CW keyer (serial) — Hardware → CW Keyer.
|
||||
keyWKEnabled = "winkeyer.enabled"
|
||||
keyWKPort = "winkeyer.port"
|
||||
keyWKBaud = "winkeyer.baud"
|
||||
keyWKWPM = "winkeyer.wpm"
|
||||
keyWKWeight = "winkeyer.weight"
|
||||
keyWKLeadIn = "winkeyer.lead_in_ms"
|
||||
keyWKTail = "winkeyer.tail_ms"
|
||||
keyWKRatio = "winkeyer.ratio"
|
||||
keyWKFarnsworth = "winkeyer.farnsworth"
|
||||
keyWKSidetone = "winkeyer.sidetone_hz"
|
||||
keyWKMode = "winkeyer.mode"
|
||||
keyWKSwap = "winkeyer.swap"
|
||||
keyWKAutoSpace = "winkeyer.autospace"
|
||||
keyWKUsePTT = "winkeyer.use_ptt"
|
||||
keyWKSerialEcho = "winkeyer.serial_echo"
|
||||
keyWKMacros = "winkeyer.macros" // JSON array of {label,text}
|
||||
keyWKEngine = "winkeyer.engine" // "winkeyer" | "tci"
|
||||
keyWKEscClears = "winkeyer.esc_clears_call" // ESC also clears the callsign
|
||||
keyWKSendOnType = "winkeyer.send_on_type" // key characters live as typed
|
||||
|
||||
keyClusterAutoConnect = "cluster.auto_connect" // open every enabled server at app start
|
||||
|
||||
keyBackupEnabled = "backup.enabled"
|
||||
@@ -89,6 +111,7 @@ const (
|
||||
keyQSLDefaultClublogStatus = "qsl.clublog_status"
|
||||
keyQSLDefaultHRDLogStatus = "qsl.hrdlog_status"
|
||||
keyQSLDefaultQRZComStatus = "qsl.qrzcom_status"
|
||||
keyQSLDefaultQRZComCfm = "qsl.qrzcom_confirmed"
|
||||
|
||||
// External services (logbook upload). QRZ.com first; Clublog / LoTW
|
||||
// will add their own keys under the same extsvc.* prefix.
|
||||
@@ -106,6 +129,7 @@ const (
|
||||
|
||||
keyExtLoTWTQSLPath = "extsvc.lotw.tqsl_path"
|
||||
keyExtLoTWStationLoc = "extsvc.lotw.station_location"
|
||||
keyExtLoTWForceCall = "extsvc.lotw.force_station_callsign" // override STATION_CALLSIGN at sign time (e.g. F4BPO/P on the F4BPO cert)
|
||||
keyExtLoTWKeyPassword = "extsvc.lotw.key_password"
|
||||
keyExtLoTWUploadFlag = "extsvc.lotw.upload_flag"
|
||||
keyExtLoTWWriteLog = "extsvc.lotw.write_log"
|
||||
@@ -131,6 +155,7 @@ type QSLDefaults struct {
|
||||
ClublogStatus string `json:"clublog_status"`
|
||||
HRDLogStatus string `json:"hrdlog_status"`
|
||||
QRZComStatus string `json:"qrzcom_status"`
|
||||
QRZComCfm string `json:"qrzcom_confirmed"` // QRZ.com download/confirmed status
|
||||
}
|
||||
|
||||
// CATSettings is the user-tweakable rig-control configuration. Stored as
|
||||
@@ -263,6 +288,7 @@ type App struct {
|
||||
udp *udp.Manager
|
||||
udpRepo *udp.Repo
|
||||
extsvc *extsvc.Manager
|
||||
winkeyer *winkeyer.Manager
|
||||
startupErr string // captured for surfacing to the frontend
|
||||
dbPath string // active database file (may be a user-chosen location)
|
||||
dataDir string // %APPDATA%/OpsLog — holds config.json, logs, cty.dat
|
||||
@@ -526,6 +552,20 @@ func (a *App) startup(ctx context.Context) {
|
||||
})
|
||||
a.extsvc.SetConfig(a.loadExternalServices())
|
||||
|
||||
// WinKeyer CW keyer (serial). Created idle; the UI connects on demand.
|
||||
a.winkeyer = winkeyer.NewManager(
|
||||
func(s winkeyer.Status) {
|
||||
if a.ctx != nil {
|
||||
wruntime.EventsEmit(a.ctx, "winkeyer:status", s)
|
||||
}
|
||||
},
|
||||
func(ch string) {
|
||||
if a.ctx != nil {
|
||||
wruntime.EventsEmit(a.ctx, "winkeyer:echo", ch)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
fmt.Println("OpsLog: db ready at", a.dbPath)
|
||||
}
|
||||
|
||||
@@ -674,6 +714,9 @@ func (a *App) shutdown(ctx context.Context) {
|
||||
if a.udp != nil {
|
||||
a.udp.StopAll()
|
||||
}
|
||||
if a.winkeyer != nil {
|
||||
a.winkeyer.Disconnect()
|
||||
}
|
||||
if a.db != nil {
|
||||
_ = a.db.Close()
|
||||
}
|
||||
@@ -804,11 +847,51 @@ func (a *App) MoveDatabase(dest string) error {
|
||||
return writeDBPointer(a.dataDir, dest)
|
||||
}
|
||||
|
||||
// CreateDatabase creates a fresh, empty logbook at dest (schema migrated) and
|
||||
// points OpsLog at it for the next launch. dest must not already exist.
|
||||
func (a *App) CreateDatabase(dest string) error {
|
||||
dest = strings.TrimSpace(dest)
|
||||
if dest == "" {
|
||||
return fmt.Errorf("no path given")
|
||||
}
|
||||
if _, err := os.Stat(dest); err == nil {
|
||||
return fmt.Errorf("a file already exists at %s — pick a new name", dest)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
||||
return fmt.Errorf("create folder: %w", err)
|
||||
}
|
||||
// db.Open creates the file and runs every migration → ready-to-use schema.
|
||||
conn, err := db.Open(dest)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create database: %w", err)
|
||||
}
|
||||
_ = conn.Close()
|
||||
return writeDBPointer(a.dataDir, dest)
|
||||
}
|
||||
|
||||
// ResetDatabaseToDefault clears the custom location (back to the data dir).
|
||||
func (a *App) ResetDatabaseToDefault() error {
|
||||
return writeDBPointer(a.dataDir, "")
|
||||
}
|
||||
|
||||
// GetUIPref / SetUIPref persist portable UI preferences (grid column layout,
|
||||
// widths, sort…) in the DB settings table under a "ui." namespace, so they
|
||||
// travel with the logbook and survive a reinstall — unlike the WebView's
|
||||
// localStorage. Values are opaque JSON blobs owned by the frontend.
|
||||
func (a *App) GetUIPref(key string) (string, error) {
|
||||
if a.settings == nil {
|
||||
return "", nil
|
||||
}
|
||||
return a.settings.Get(a.ctx, "ui."+key)
|
||||
}
|
||||
|
||||
func (a *App) SetUIPref(key, value string) error {
|
||||
if a.settings == nil {
|
||||
return fmt.Errorf("db not initialized")
|
||||
}
|
||||
return a.settings.Set(a.ctx, "ui."+key, value)
|
||||
}
|
||||
|
||||
// QuitApp closes OpsLog (used to apply a database change on next launch).
|
||||
func (a *App) QuitApp() {
|
||||
if a.ctx != nil {
|
||||
@@ -1134,9 +1217,11 @@ func (a *App) WorkedBefore(callsign string, dxccHint int) (qso.WorkedBefore, err
|
||||
// Min size must be reduced BEFORE resizing down, otherwise the OS clamps to
|
||||
// the previous (larger) min — and increased BEFORE resizing up.
|
||||
const (
|
||||
compactW, compactH = 980, 140
|
||||
compactW, compactH = 1240, 158
|
||||
normalW, normalH = 1400, 900
|
||||
normalMinW, normalMinH = 1100, 700
|
||||
// Large enough to never constrain a maximised window on big displays.
|
||||
maxW, maxH = 8000, 6000
|
||||
)
|
||||
|
||||
func (a *App) SetCompactMode(on bool) {
|
||||
@@ -1144,11 +1229,17 @@ func (a *App) SetCompactMode(on bool) {
|
||||
return
|
||||
}
|
||||
if on {
|
||||
// Lock the window to the compact size by pinning min == max. Without
|
||||
// the max pin, dragging the frameless window (esp. across monitors /
|
||||
// DPI boundaries) makes Windows snap it back to a large size.
|
||||
wruntime.WindowSetMinSize(a.ctx, compactW, compactH)
|
||||
wruntime.WindowSetMaxSize(a.ctx, compactW, compactH)
|
||||
wruntime.WindowSetSize(a.ctx, compactW, compactH)
|
||||
wruntime.WindowSetAlwaysOnTop(a.ctx, true)
|
||||
} else {
|
||||
wruntime.WindowSetAlwaysOnTop(a.ctx, false)
|
||||
// Release the lock first (raise the max) before growing back.
|
||||
wruntime.WindowSetMaxSize(a.ctx, maxW, maxH)
|
||||
wruntime.WindowSetMinSize(a.ctx, normalMinW, normalMinH)
|
||||
wruntime.WindowSetSize(a.ctx, normalW, normalH)
|
||||
}
|
||||
@@ -1175,14 +1266,44 @@ func (a *App) OpenADIFFile() (string, error) {
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) ImportADIF(path string, skipDuplicates bool) (adif.ImportResult, error) {
|
||||
// ImportADIF imports an ADIF file. dupMode controls how records matching an
|
||||
// existing QSO (same call + UTC-minute + band + mode) are handled:
|
||||
// - "skip" : leave the existing QSO untouched (default, safe)
|
||||
// - "update" : merge the file's non-empty fields onto the existing QSO —
|
||||
// refreshes QSL/confirmation statuses when re-syncing from
|
||||
// Log4OM / LoTW without clobbering fields the file omits
|
||||
// - "all" : insert every record, duplicates included
|
||||
//
|
||||
// applyCty, when true, recomputes country / continent / DXCC / CQ / ITU from
|
||||
// cty.dat for every record, overriding what the file carries — corrects the
|
||||
// wrong COUNTRY that contest software often exports (e.g. RG2Y as Asiatic
|
||||
// Russia). Everything else in the ADIF is still preserved verbatim.
|
||||
func (a *App) ImportADIF(path string, dupMode string, applyCty bool) (adif.ImportResult, error) {
|
||||
if a.qso == nil {
|
||||
return adif.ImportResult{}, fmt.Errorf("db not initialized")
|
||||
}
|
||||
if path == "" {
|
||||
return adif.ImportResult{}, fmt.Errorf("empty path")
|
||||
}
|
||||
im := &adif.Importer{Repo: a.qso, SkipDuplicates: skipDuplicates}
|
||||
// Import preserves the ADIF verbatim — NO station / confirmation defaults
|
||||
// are applied. Defaults are for NEW QSOs only (manual entry + UDP auto-log);
|
||||
// stamping them on a historical import would, e.g., flag old QSOs as
|
||||
// "LoTW requested" and try to re-upload them.
|
||||
im := &adif.Importer{Repo: a.qso}
|
||||
switch dupMode {
|
||||
case "update":
|
||||
im.UpdateDuplicates = true
|
||||
case "all":
|
||||
// insert everything
|
||||
default: // "skip"
|
||||
im.SkipDuplicates = true
|
||||
}
|
||||
if applyCty {
|
||||
im.Enrich = func(q *qso.QSO) { a.enrichContactedFromCtyForce(q) }
|
||||
}
|
||||
im.OnProgress = func(processed, total int) {
|
||||
wruntime.EventsEmit(a.ctx, "import:progress", map[string]int{"processed": processed, "total": total})
|
||||
}
|
||||
return im.ImportFile(a.ctx, path)
|
||||
}
|
||||
|
||||
@@ -1202,14 +1323,16 @@ func (a *App) SaveADIFFile() (string, error) {
|
||||
|
||||
// ExportADIF writes every QSO to the given file path in ADIF 3.1 format.
|
||||
// Streams from DB so memory stays flat even with 100k+ records.
|
||||
func (a *App) ExportADIF(path string) (adif.ExportResult, error) {
|
||||
// includeAppFields=false → portable standard ADIF (for other loggers);
|
||||
// true → full export keeping OpsLog/app-specific APP_* fields (round-trip).
|
||||
func (a *App) ExportADIF(path string, includeAppFields bool) (adif.ExportResult, error) {
|
||||
if a.qso == nil {
|
||||
return adif.ExportResult{}, fmt.Errorf("db not initialized")
|
||||
}
|
||||
if path == "" {
|
||||
return adif.ExportResult{}, fmt.Errorf("empty path")
|
||||
}
|
||||
ex := &adif.Exporter{Repo: a.qso, AppName: "OpsLog", AppVersion: "0.1"}
|
||||
ex := &adif.Exporter{Repo: a.qso, AppName: "OpsLog", AppVersion: "0.1", IncludeAppFields: includeAppFields}
|
||||
return ex.ExportFile(a.ctx, path)
|
||||
}
|
||||
|
||||
@@ -1441,12 +1564,16 @@ func (a *App) GetQSLDefaults() (QSLDefaults, error) {
|
||||
if a.settings == nil {
|
||||
return out, nil
|
||||
}
|
||||
m, err := a.settings.GetMany(a.ctx,
|
||||
prefix := ""
|
||||
if a.profileHasGroup(markerQSL) {
|
||||
prefix = a.profileScope()
|
||||
}
|
||||
m, err := a.getManyScoped(prefix,
|
||||
keyQSLDefaultQSLSent, keyQSLDefaultQSLRcvd,
|
||||
keyQSLDefaultLOTWSent, keyQSLDefaultLOTWRcvd,
|
||||
keyQSLDefaultEQSLSent, keyQSLDefaultEQSLRcvd,
|
||||
keyQSLDefaultClublogStatus, keyQSLDefaultHRDLogStatus,
|
||||
keyQSLDefaultQRZComStatus,
|
||||
keyQSLDefaultQRZComStatus, keyQSLDefaultQRZComCfm,
|
||||
)
|
||||
if err != nil {
|
||||
return out, err
|
||||
@@ -1460,6 +1587,7 @@ func (a *App) GetQSLDefaults() (QSLDefaults, error) {
|
||||
out.ClublogStatus = m[keyQSLDefaultClublogStatus]
|
||||
out.HRDLogStatus = m[keyQSLDefaultHRDLogStatus]
|
||||
out.QRZComStatus = m[keyQSLDefaultQRZComStatus]
|
||||
out.QRZComCfm = m[keyQSLDefaultQRZComCfm]
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -1469,6 +1597,7 @@ func (a *App) SaveQSLDefaults(d QSLDefaults) error {
|
||||
if a.settings == nil {
|
||||
return fmt.Errorf("db not initialized")
|
||||
}
|
||||
scope := a.profileScope()
|
||||
for k, v := range map[string]string{
|
||||
keyQSLDefaultQSLSent: strings.ToUpper(strings.TrimSpace(d.QSLSent)),
|
||||
keyQSLDefaultQSLRcvd: strings.ToUpper(strings.TrimSpace(d.QSLRcvd)),
|
||||
@@ -1479,11 +1608,15 @@ func (a *App) SaveQSLDefaults(d QSLDefaults) error {
|
||||
keyQSLDefaultClublogStatus: strings.ToUpper(strings.TrimSpace(d.ClublogStatus)),
|
||||
keyQSLDefaultHRDLogStatus: strings.ToUpper(strings.TrimSpace(d.HRDLogStatus)),
|
||||
keyQSLDefaultQRZComStatus: strings.ToUpper(strings.TrimSpace(d.QRZComStatus)),
|
||||
keyQSLDefaultQRZComCfm: strings.ToUpper(strings.TrimSpace(d.QRZComCfm)),
|
||||
} {
|
||||
if err := a.settings.Set(a.ctx, k, v); err != nil {
|
||||
if err := a.settings.Set(a.ctx, scope+k, v); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := a.settings.Set(a.ctx, scope+markerQSL, "1"); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1508,21 +1641,75 @@ func (a *App) applyQSLDefaults(q *qso.QSO) {
|
||||
if q.ClublogUploadStatus == "" { q.ClublogUploadStatus = d.ClublogStatus }
|
||||
if q.HRDLogUploadStatus == "" { q.HRDLogUploadStatus = d.HRDLogStatus }
|
||||
if q.QRZComUploadStatus == "" { q.QRZComUploadStatus = d.QRZComStatus }
|
||||
if q.QRZComDownloadStatus == "" { q.QRZComDownloadStatus = d.QRZComCfm }
|
||||
}
|
||||
|
||||
// ── External services (logbook upload) ─────────────────────────────────
|
||||
|
||||
// loadExternalServices reads the configured external-service settings.
|
||||
// ── Per-profile settings scoping ───────────────────────────────────────
|
||||
//
|
||||
// External Services and QSL Confirmations are scoped to the active profile
|
||||
// so each operating identity (e.g. F4BPO vs TM2Q) uploads to its own
|
||||
// accounts. They live under a "p<profileID>." key prefix. A per-group marker
|
||||
// key records that a profile has saved its own copy; until then we
|
||||
// transparently read the legacy un-prefixed (global) keys as the default —
|
||||
// a lossless migration for logs created before profiles carried settings.
|
||||
const (
|
||||
markerExtsvc = "extsvc._set"
|
||||
markerQSL = "qsl._set"
|
||||
)
|
||||
|
||||
// profileScope returns the active profile's settings-key prefix ("p<id>.").
|
||||
func (a *App) profileScope() string {
|
||||
if a.profiles != nil {
|
||||
if p, err := a.profiles.Active(a.ctx); err == nil && p.ID > 0 {
|
||||
return fmt.Sprintf("p%d.", p.ID)
|
||||
}
|
||||
}
|
||||
return "p0."
|
||||
}
|
||||
|
||||
// profileHasGroup reports whether the active profile has saved its own copy
|
||||
// of a settings group (identified by its marker key).
|
||||
func (a *App) profileHasGroup(marker string) bool {
|
||||
if a.settings == nil {
|
||||
return false
|
||||
}
|
||||
v, _ := a.settings.Get(a.ctx, a.profileScope()+marker)
|
||||
return v == "1"
|
||||
}
|
||||
|
||||
// getManyScoped fetches base keys with the given prefix, returning a map
|
||||
// keyed by the BASE key (so callers index with the plain constant).
|
||||
func (a *App) getManyScoped(prefix string, keys ...string) (map[string]string, error) {
|
||||
out := make(map[string]string, len(keys))
|
||||
for _, k := range keys {
|
||||
v, err := a.settings.Get(a.ctx, prefix+k)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[k] = v
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (a *App) loadExternalServices() extsvc.ExternalServices {
|
||||
var out extsvc.ExternalServices
|
||||
if a.settings == nil {
|
||||
return out
|
||||
}
|
||||
m, err := a.settings.GetMany(a.ctx,
|
||||
// Read the active profile's scoped keys once it has saved them; otherwise
|
||||
// fall back to the legacy global keys as the shared default.
|
||||
prefix := ""
|
||||
if a.profileHasGroup(markerExtsvc) {
|
||||
prefix = a.profileScope()
|
||||
}
|
||||
m, err := a.getManyScoped(prefix,
|
||||
keyExtQRZAPIKey, keyExtQRZForceCall, keyExtQRZAutoUpload, keyExtQRZUploadMode,
|
||||
keyExtClublogEmail, keyExtClublogPassword, keyExtClublogCallsign,
|
||||
keyExtClublogAPIKey, keyExtClublogAutoUpload, keyExtClublogUploadMode,
|
||||
keyExtLoTWTQSLPath, keyExtLoTWStationLoc, keyExtLoTWKeyPassword,
|
||||
keyExtLoTWTQSLPath, keyExtLoTWStationLoc, keyExtLoTWForceCall, keyExtLoTWKeyPassword,
|
||||
keyExtLoTWUploadFlag, keyExtLoTWWriteLog,
|
||||
keyExtLoTWAutoUpload, keyExtLoTWUploadMode,
|
||||
keyExtLoTWUsername, keyExtLoTWWebPassword)
|
||||
@@ -1551,9 +1738,10 @@ func (a *App) loadExternalServices() extsvc.ExternalServices {
|
||||
}
|
||||
}
|
||||
out.LoTW = extsvc.ServiceConfig{
|
||||
TQSLPath: m[keyExtLoTWTQSLPath],
|
||||
StationLocation: m[keyExtLoTWStationLoc],
|
||||
KeyPassword: m[keyExtLoTWKeyPassword],
|
||||
TQSLPath: m[keyExtLoTWTQSLPath],
|
||||
StationLocation: m[keyExtLoTWStationLoc],
|
||||
ForceStationCallsign: m[keyExtLoTWForceCall],
|
||||
KeyPassword: m[keyExtLoTWKeyPassword],
|
||||
UploadFlag: m[keyExtLoTWUploadFlag],
|
||||
WriteLog: m[keyExtLoTWWriteLog] == "1",
|
||||
Username: m[keyExtLoTWUsername],
|
||||
@@ -1612,6 +1800,7 @@ func (a *App) SaveExternalServices(cfg extsvc.ExternalServices) error {
|
||||
if cfg.LoTW.WriteLog {
|
||||
ltWriteLog = "1"
|
||||
}
|
||||
scope := a.profileScope() // write under the active profile's prefix
|
||||
for k, v := range map[string]string{
|
||||
keyExtQRZAPIKey: strings.TrimSpace(cfg.QRZ.APIKey),
|
||||
keyExtQRZForceCall: strings.ToUpper(strings.TrimSpace(cfg.QRZ.ForceStationCallsign)),
|
||||
@@ -1627,6 +1816,7 @@ func (a *App) SaveExternalServices(cfg extsvc.ExternalServices) error {
|
||||
|
||||
keyExtLoTWTQSLPath: strings.TrimSpace(cfg.LoTW.TQSLPath),
|
||||
keyExtLoTWStationLoc: strings.TrimSpace(cfg.LoTW.StationLocation),
|
||||
keyExtLoTWForceCall: strings.ToUpper(strings.TrimSpace(cfg.LoTW.ForceStationCallsign)),
|
||||
keyExtLoTWKeyPassword: cfg.LoTW.KeyPassword,
|
||||
keyExtLoTWUploadFlag: ltFlag,
|
||||
keyExtLoTWWriteLog: ltWriteLog,
|
||||
@@ -1635,10 +1825,15 @@ func (a *App) SaveExternalServices(cfg extsvc.ExternalServices) error {
|
||||
keyExtLoTWUsername: strings.TrimSpace(cfg.LoTW.Username),
|
||||
keyExtLoTWWebPassword: cfg.LoTW.Password,
|
||||
} {
|
||||
if err := a.settings.Set(a.ctx, k, v); err != nil {
|
||||
if err := a.settings.Set(a.ctx, scope+k, v); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// Mark this profile as having its own External Services config (so future
|
||||
// loads read the scoped keys instead of falling back to the global ones).
|
||||
if err := a.settings.Set(a.ctx, scope+markerExtsvc, "1"); err != nil {
|
||||
return err
|
||||
}
|
||||
if a.extsvc != nil {
|
||||
a.extsvc.SetConfig(a.loadExternalServices())
|
||||
}
|
||||
@@ -1714,7 +1909,7 @@ func (a *App) runManualUpload(svc extsvc.Service, ids []int64, cfg extsvc.Extern
|
||||
emit(fmt.Sprintf("Signing %d QSO(s) with TQSL…", len(ids)))
|
||||
var recs []string
|
||||
for _, id := range ids {
|
||||
if rec, ok := a.buildUploadADIF(id, ""); ok {
|
||||
if rec, ok := a.buildUploadADIF(id, cfg.LoTW.ForceStationCallsign); ok {
|
||||
recs = append(recs, rec)
|
||||
}
|
||||
}
|
||||
@@ -1819,9 +2014,9 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
|
||||
case extsvc.ServiceLoTW:
|
||||
since := ""
|
||||
if a.settings != nil {
|
||||
if m, e := a.settings.GetMany(ctx, keyExtLoTWLastDownload); e == nil {
|
||||
since = m[keyExtLoTWLastDownload]
|
||||
}
|
||||
// Scoped to the active profile — each identity tracks its own
|
||||
// LoTW account's last incremental-download date.
|
||||
since, _ = a.settings.Get(ctx, a.profileScope()+keyExtLoTWLastDownload)
|
||||
}
|
||||
if since != "" {
|
||||
emit("Downloading LoTW confirmations received since " + since + "…")
|
||||
@@ -1905,9 +2100,9 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
|
||||
} else {
|
||||
emit(fmt.Sprintf("Matched %d of %d confirmed QSO(s)", matched, total))
|
||||
}
|
||||
// Remember today so the next pull is incremental.
|
||||
// Remember today so the next pull is incremental (per active profile).
|
||||
if a.settings != nil {
|
||||
_ = a.settings.Set(ctx, keyExtLoTWLastDownload, time.Now().UTC().Format("2006-01-02"))
|
||||
_ = a.settings.Set(ctx, a.profileScope()+keyExtLoTWLastDownload, time.Now().UTC().Format("2006-01-02"))
|
||||
}
|
||||
|
||||
case extsvc.ServiceQRZ:
|
||||
@@ -2083,6 +2278,123 @@ func (a *App) enrichContactedFromCty(q *qso.QSO) {
|
||||
}
|
||||
}
|
||||
|
||||
// enrichContactedFromCtyForce OVERWRITES the contacted-station country,
|
||||
// continent, DXCC number and CQ/ITU zones from cty.dat. Unlike
|
||||
// enrichContactedFromCty (which only fills blanks), this corrects values
|
||||
// that are present-but-wrong — the case where contest software exports a
|
||||
// bad COUNTRY/DXCC (e.g. RG2Y tagged "Asiatic Russia" instead of European).
|
||||
// Returns true if cty.dat had a match.
|
||||
func (a *App) enrichContactedFromCtyForce(q *qso.QSO) bool {
|
||||
if a.dxcc == nil || q.Callsign == "" {
|
||||
return false
|
||||
}
|
||||
m, ok := a.dxcc.Lookup(q.Callsign)
|
||||
if !ok || m.Entity == nil {
|
||||
return false
|
||||
}
|
||||
q.Country = m.Entity.Name
|
||||
q.Continent = m.Continent
|
||||
if n := dxcc.EntityDXCC(m.Entity.Name); n != 0 {
|
||||
q.DXCC = &n
|
||||
}
|
||||
if m.CQZone != 0 {
|
||||
v := m.CQZone
|
||||
q.CQZ = &v
|
||||
}
|
||||
if m.ITUZone != 0 {
|
||||
v := m.ITUZone
|
||||
q.ITUZ = &v
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// UpdateQSOsFromCty recomputes country / continent / DXCC / CQ / ITU from
|
||||
// cty.dat for the given QSO ids and saves them. Used by the grid's
|
||||
// right-click "Update from cty.dat" on a multi-selection. Returns how many
|
||||
// rows were actually changed.
|
||||
func (a *App) UpdateQSOsFromCty(ids []int64) (int, error) {
|
||||
if a.qso == nil {
|
||||
return 0, fmt.Errorf("db not initialized")
|
||||
}
|
||||
changed := 0
|
||||
for _, id := range ids {
|
||||
q, err := a.qso.GetByID(a.ctx, id)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
before := fmt.Sprint(q.Country, q.Continent, q.DXCC, q.CQZ, q.ITUZ)
|
||||
if !a.enrichContactedFromCtyForce(&q) {
|
||||
continue
|
||||
}
|
||||
if fmt.Sprint(q.Country, q.Continent, q.DXCC, q.CQZ, q.ITUZ) == before {
|
||||
continue // no change
|
||||
}
|
||||
if err := a.qso.Update(a.ctx, q); err == nil {
|
||||
changed++
|
||||
}
|
||||
}
|
||||
return changed, nil
|
||||
}
|
||||
|
||||
// UpdateQSOsFromQRZ re-queries the callsign database (QRZ.com / HamQTH per
|
||||
// the configured providers) for each QSO id and overwrites the geographic
|
||||
// + entity fields (country, continent, DXCC, zones, grid, state, county)
|
||||
// plus name/QTH when the provider returns them. Used by the grid's
|
||||
// right-click "Update from QRZ.com". Returns how many rows were saved.
|
||||
func (a *App) UpdateQSOsFromQRZ(ids []int64) (int, error) {
|
||||
if a.qso == nil || a.lookup == nil {
|
||||
return 0, fmt.Errorf("not initialized")
|
||||
}
|
||||
changed := 0
|
||||
for _, id := range ids {
|
||||
q, err := a.qso.GetByID(a.ctx, id)
|
||||
if err != nil || q.Callsign == "" {
|
||||
continue
|
||||
}
|
||||
r, err := a.lookup.Lookup(a.ctx, q.Callsign)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if r.Country != "" {
|
||||
q.Country = r.Country
|
||||
}
|
||||
if r.Continent != "" {
|
||||
q.Continent = r.Continent
|
||||
}
|
||||
if r.DXCC != 0 {
|
||||
n := r.DXCC
|
||||
q.DXCC = &n
|
||||
}
|
||||
if r.CQZ != 0 {
|
||||
v := r.CQZ
|
||||
q.CQZ = &v
|
||||
}
|
||||
if r.ITUZ != 0 {
|
||||
v := r.ITUZ
|
||||
q.ITUZ = &v
|
||||
}
|
||||
if r.Grid != "" {
|
||||
q.Grid = strings.ToUpper(r.Grid)
|
||||
}
|
||||
if r.State != "" {
|
||||
q.State = r.State
|
||||
}
|
||||
if r.County != "" {
|
||||
q.County = r.County
|
||||
}
|
||||
if r.Name != "" {
|
||||
q.Name = r.Name
|
||||
}
|
||||
if r.QTH != "" {
|
||||
q.QTH = r.QTH
|
||||
}
|
||||
if err := a.qso.Update(a.ctx, q); err == nil {
|
||||
changed++
|
||||
}
|
||||
}
|
||||
return changed, nil
|
||||
}
|
||||
|
||||
// ListTQSLStationLocations returns the Station Locations defined in TQSL,
|
||||
// for the LoTW settings dropdown.
|
||||
func (a *App) ListTQSLStationLocations() ([]extsvc.StationLocation, error) {
|
||||
@@ -2251,8 +2563,12 @@ func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) {
|
||||
// Pull the first record out of the payload. WSJT-X / JTDX / MSHV
|
||||
// always send a single QSO per UDP packet (no header) but we tolerate
|
||||
// either form via adif.Parse.
|
||||
// Pick the field decoder for this payload's encoding (UTF-8 as-is, else
|
||||
// Windows-1252) so accented NAME/QTH from Log4OM/JTAlert aren't mangled.
|
||||
// In UTF-8 mode the parser also repairs character-count field lengths.
|
||||
decode := adif.ValueDecoderFor([]byte(adifText))
|
||||
var record adif.Record
|
||||
err := adif.Parse(strings.NewReader(adifText), func(rec adif.Record) error {
|
||||
err := adif.ParseWithDecoder(strings.NewReader(adifText), decode, func(rec adif.Record) error {
|
||||
if record == nil {
|
||||
record = rec
|
||||
}
|
||||
@@ -2264,7 +2580,7 @@ func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) {
|
||||
if record == nil {
|
||||
// Some senders skip the <EOH> header; try treating the whole
|
||||
// payload as a single record by prepending a fake header.
|
||||
err := adif.Parse(strings.NewReader("<EOH>"+adifText), func(rec adif.Record) error {
|
||||
err := adif.ParseWithDecoder(strings.NewReader("<EOH>"+adifText), decode, func(rec adif.Record) error {
|
||||
if record == nil {
|
||||
record = rec
|
||||
}
|
||||
@@ -2315,6 +2631,13 @@ func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Active-profile station stamp ──
|
||||
// Same as the manual AddQSO path: fill the operator's MY_* fields
|
||||
// (station callsign, grid, country, zones, and the profile's default
|
||||
// MY_RIG / MY_ANTENNA) from the active profile. Without this a UDP /
|
||||
// WSJT-X auto-logged QSO carried none of the operator's own data.
|
||||
a.applyStationDefaults(&q)
|
||||
|
||||
// ── DXCC# + QSL defaults ──
|
||||
// applyDXCCNumber stamps the contacted-station DXCC# from the
|
||||
// entity-name table; QSL defaults are applied last so explicit ADIF
|
||||
@@ -2905,6 +3228,15 @@ func (a *App) ActivateProfile(id int64) error {
|
||||
return err
|
||||
}
|
||||
a.refreshOperatorGrid()
|
||||
// Per-profile config follows the active identity: reload the external-
|
||||
// services manager so uploads now use this profile's accounts, and tell
|
||||
// the frontend to refresh its settings panels.
|
||||
if a.extsvc != nil {
|
||||
a.extsvc.SetConfig(a.loadExternalServices())
|
||||
}
|
||||
if a.ctx != nil {
|
||||
wruntime.EventsEmit(a.ctx, "profile:changed", id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -3060,6 +3392,206 @@ func boolStr(b bool) string {
|
||||
return "0"
|
||||
}
|
||||
|
||||
// --- WinKeyer (CW keyer) bindings ---
|
||||
|
||||
// WKMacro is one CW message slot (F1…): a short label + the macro text, which
|
||||
// may contain <VARIABLE> tokens resolved by the frontend before sending.
|
||||
type WKMacro struct {
|
||||
Label string `json:"label"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// WinkeyerSettings is the Hardware → CW Keyer panel shape. It embeds the
|
||||
// engine Config (keying parameters) plus the enable flag and message macros.
|
||||
type WinkeyerSettings struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
winkeyer.Config
|
||||
Engine string `json:"engine"` // keyer backend: "winkeyer" | "tci"
|
||||
EscClearsCall bool `json:"esc_clears_call"` // ESC also resets the callsign
|
||||
SendOnType bool `json:"send_on_type"` // key chars live as typed
|
||||
Macros []WKMacro `json:"macros"`
|
||||
}
|
||||
|
||||
// ListSerialPorts returns the available COM ports for the keyer dropdown.
|
||||
func (a *App) ListSerialPorts() ([]string, error) {
|
||||
return winkeyer.ListPorts()
|
||||
}
|
||||
|
||||
// GetWinkeyerSettings returns the persisted keyer config (with sane defaults).
|
||||
func (a *App) GetWinkeyerSettings() (WinkeyerSettings, error) {
|
||||
out := WinkeyerSettings{
|
||||
Config: winkeyer.Config{
|
||||
Baud: 1200, WPM: 25, Weight: 50, LeadInMs: 10, TailMs: 50,
|
||||
Ratio: 50, Sidetone: 600, Mode: winkeyer.ModeIambicB, AutoSpace: true,
|
||||
SerialEcho: true, // so the panel shows text as it's transmitted
|
||||
},
|
||||
Engine: "winkeyer",
|
||||
EscClearsCall: true,
|
||||
Macros: defaultWKMacros(),
|
||||
}
|
||||
if a.settings == nil {
|
||||
return out, nil
|
||||
}
|
||||
m, err := a.settings.GetMany(a.ctx,
|
||||
keyWKEnabled, keyWKPort, keyWKBaud, keyWKWPM, keyWKWeight, keyWKLeadIn,
|
||||
keyWKTail, keyWKRatio, keyWKFarnsworth, keyWKSidetone, keyWKMode,
|
||||
keyWKSwap, keyWKAutoSpace, keyWKUsePTT, keyWKSerialEcho, keyWKMacros,
|
||||
keyWKEngine, keyWKEscClears, keyWKSendOnType)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if v := m[keyWKEngine]; v != "" {
|
||||
out.Engine = v
|
||||
}
|
||||
if v := m[keyWKEscClears]; v != "" {
|
||||
out.EscClearsCall = v == "1"
|
||||
}
|
||||
out.SendOnType = m[keyWKSendOnType] == "1"
|
||||
out.Enabled = m[keyWKEnabled] == "1"
|
||||
if v := m[keyWKPort]; v != "" {
|
||||
out.Port = v
|
||||
}
|
||||
atoiInto(m[keyWKBaud], &out.Baud)
|
||||
atoiInto(m[keyWKWPM], &out.WPM)
|
||||
atoiInto(m[keyWKWeight], &out.Weight)
|
||||
atoiInto(m[keyWKLeadIn], &out.LeadInMs)
|
||||
atoiInto(m[keyWKTail], &out.TailMs)
|
||||
atoiInto(m[keyWKRatio], &out.Ratio)
|
||||
atoiInto(m[keyWKFarnsworth], &out.Farnsworth)
|
||||
atoiInto(m[keyWKSidetone], &out.Sidetone)
|
||||
if v := m[keyWKMode]; v != "" {
|
||||
out.Mode = winkeyer.Mode(v)
|
||||
}
|
||||
out.Swap = m[keyWKSwap] == "1"
|
||||
if v := m[keyWKAutoSpace]; v != "" {
|
||||
out.AutoSpace = v == "1"
|
||||
}
|
||||
out.UsePTT = m[keyWKUsePTT] == "1"
|
||||
out.SerialEcho = m[keyWKSerialEcho] == "1"
|
||||
if v := m[keyWKMacros]; v != "" {
|
||||
var mac []WKMacro
|
||||
if json.Unmarshal([]byte(v), &mac) == nil && len(mac) > 0 {
|
||||
out.Macros = mac
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SaveWinkeyerSettings persists the keyer config; if a link is open and the
|
||||
// keying params changed, the caller can reconnect to apply them.
|
||||
func (a *App) SaveWinkeyerSettings(s WinkeyerSettings) error {
|
||||
if a.settings == nil {
|
||||
return fmt.Errorf("db not initialized")
|
||||
}
|
||||
macJSON, _ := json.Marshal(s.Macros)
|
||||
for k, v := range map[string]string{
|
||||
keyWKEnabled: boolStr(s.Enabled),
|
||||
keyWKPort: strings.TrimSpace(s.Port),
|
||||
keyWKBaud: strconv.Itoa(s.Baud),
|
||||
keyWKWPM: strconv.Itoa(s.WPM),
|
||||
keyWKWeight: strconv.Itoa(s.Weight),
|
||||
keyWKLeadIn: strconv.Itoa(s.LeadInMs),
|
||||
keyWKTail: strconv.Itoa(s.TailMs),
|
||||
keyWKRatio: strconv.Itoa(s.Ratio),
|
||||
keyWKFarnsworth: strconv.Itoa(s.Farnsworth),
|
||||
keyWKSidetone: strconv.Itoa(s.Sidetone),
|
||||
keyWKMode: string(s.Mode),
|
||||
keyWKSwap: boolStr(s.Swap),
|
||||
keyWKAutoSpace: boolStr(s.AutoSpace),
|
||||
keyWKUsePTT: boolStr(s.UsePTT),
|
||||
keyWKSerialEcho: boolStr(s.SerialEcho),
|
||||
keyWKMacros: string(macJSON),
|
||||
keyWKEngine: strings.TrimSpace(s.Engine),
|
||||
keyWKEscClears: boolStr(s.EscClearsCall),
|
||||
keyWKSendOnType: boolStr(s.SendOnType),
|
||||
} {
|
||||
if err := a.settings.Set(a.ctx, k, v); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// WinkeyerConnect opens the serial link using the saved config.
|
||||
func (a *App) WinkeyerConnect() error {
|
||||
if a.winkeyer == nil {
|
||||
return fmt.Errorf("winkeyer not initialized")
|
||||
}
|
||||
s, err := a.GetWinkeyerSettings()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return a.winkeyer.Connect(s.Config)
|
||||
}
|
||||
|
||||
// WinkeyerDisconnect closes the serial link.
|
||||
func (a *App) WinkeyerDisconnect() error {
|
||||
if a.winkeyer != nil {
|
||||
a.winkeyer.Disconnect()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// WinkeyerSend keys the (already variable-resolved) text as Morse.
|
||||
func (a *App) WinkeyerSend(text string) error {
|
||||
if a.winkeyer == nil {
|
||||
return fmt.Errorf("winkeyer not initialized")
|
||||
}
|
||||
return a.winkeyer.Send(text)
|
||||
}
|
||||
|
||||
// WinkeyerStop aborts the current message immediately.
|
||||
func (a *App) WinkeyerStop() error {
|
||||
if a.winkeyer == nil {
|
||||
return fmt.Errorf("winkeyer not initialized")
|
||||
}
|
||||
return a.winkeyer.Stop()
|
||||
}
|
||||
|
||||
// WinkeyerBackspace removes the last not-yet-keyed character (send-on-type).
|
||||
func (a *App) WinkeyerBackspace() error {
|
||||
if a.winkeyer == nil {
|
||||
return fmt.Errorf("winkeyer not initialized")
|
||||
}
|
||||
return a.winkeyer.Backspace()
|
||||
}
|
||||
|
||||
// WinkeyerSetSpeed changes the keying speed (WPM) live.
|
||||
func (a *App) WinkeyerSetSpeed(wpm int) error {
|
||||
if a.winkeyer == nil {
|
||||
return fmt.Errorf("winkeyer not initialized")
|
||||
}
|
||||
return a.winkeyer.SetSpeed(wpm)
|
||||
}
|
||||
|
||||
// GetWinkeyerStatus returns the current link status (used on mount).
|
||||
func (a *App) GetWinkeyerStatus() winkeyer.Status {
|
||||
if a.winkeyer == nil {
|
||||
return winkeyer.Status{}
|
||||
}
|
||||
return a.winkeyer.Snapshot()
|
||||
}
|
||||
|
||||
// defaultWKMacros mirrors the classic F-key set (CQ / answer / reports / 73).
|
||||
func defaultWKMacros() []WKMacro {
|
||||
return []WKMacro{
|
||||
{Label: "CQ", Text: "CQ CQ DE <MY_CALL> <MY_CALL> K"},
|
||||
{Label: "His call", Text: "<CALL> "},
|
||||
{Label: "Report", Text: "<CALL> UR <STX> <STX> = "},
|
||||
{Label: "Answer", Text: "<CALL> DE <MY_CALL> TU UR <RST_R> = "},
|
||||
{Label: "Name/QTH", Text: "NAME <MY_NAME> QTH <MY_QTH> = "},
|
||||
{Label: "73", Text: "<CALL> TU 73 DE <MY_CALL> "},
|
||||
{Label: "QRL?", Text: "QRL? "},
|
||||
{Label: "AGN", Text: "AGN "},
|
||||
}
|
||||
}
|
||||
|
||||
func atoiInto(s string, dst *int) {
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(s)); err == nil {
|
||||
*dst = n
|
||||
}
|
||||
}
|
||||
|
||||
// --- DX Cluster bindings (multi-server) ---
|
||||
|
||||
// resolveClusterLogin returns the login callsign for a server: explicit
|
||||
|
||||
Reference in New Issue
Block a user