feat: Winkeyer

This commit is contained in:
2026-06-02 01:17:26 +02:00
parent 2eb77370e4
commit 2b4326b553
26 changed files with 3125 additions and 645 deletions
+554 -22
View File
@@ -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