package main import ( "context" "database/sql" "encoding/json" "errors" "fmt" "os" "path/filepath" "strconv" "strings" "time" "hamlog/internal/adif" "hamlog/internal/cat" "hamlog/internal/db" "hamlog/internal/dxcc" "hamlog/internal/lookup" "hamlog/internal/profile" "hamlog/internal/qso" "hamlog/internal/rotator/pst" "hamlog/internal/settings" wruntime "github.com/wailsapp/wails/v2/pkg/runtime" ) // Setting keys. const ( keyQRZUser = "lookup.qrz.user" keyQRZPassword = "lookup.qrz.password" keyHQUser = "lookup.hamqth.user" keyHQPassword = "lookup.hamqth.password" keyCacheTTL = "lookup.cache.ttl_days" // Provider routing. Each value is a provider name (qrz | hamqth) // or empty to disable that slot. Primary is consulted first; // Failsafe is the fallback when Primary returns not-found or errs. keyLookupPrimary = "lookup.primary" keyLookupFailsafe = "lookup.failsafe" keyStationCallsign = "station.callsign" keyStationOperator = "station.operator" keyStationMyGrid = "station.my_grid" keyStationCountry = "station.my_country" keyStationSOTA = "station.my_sota_ref" keyStationPOTA = "station.my_pota_ref" keyListsBands = "lists.bands" keyListsModes = "lists.modes" keyCATEnabled = "cat.enabled" keyCATBackend = "cat.backend" // "omnirig" (only one for now) keyCATOmniRigNum = "cat.omnirig.rig" // 1 or 2 keyCATPollMs = "cat.poll_ms" keyCATDelayMs = "cat.delay_ms" // pause between commands keyCATDigitalDefault = "cat.digital_default" // mode to use when CAT reports DATA keyRotatorEnabled = "rotator.enabled" keyRotatorHost = "rotator.host" keyRotatorPort = "rotator.port" keyRotatorHasElevation = "rotator.has_elevation" ) // CATSettings is the user-tweakable rig-control configuration. Stored as // individual key/value pairs to keep the settings table flat. type CATSettings struct { Enabled bool `json:"enabled"` Backend string `json:"backend"` // currently always "omnirig" OmniRigNum int `json:"omnirig_rig"` // 1 or 2 (OmniRig "Rig1"/"Rig2" slot) PollMs int `json:"poll_ms"` // poll interval in ms (default 250) DelayMs int `json:"delay_ms"` // pause between commands (default 0) DigitalDefault string `json:"digital_default"` // when CAT says DATA, surface this mode (FT8/FT4/RTTY/…) } // ModePreset is a mode entry with default RST values to auto-populate // the entry form when the user picks this mode. type ModePreset struct { Name string `json:"name"` DefaultRSTSent string `json:"default_rst_sent,omitempty"` DefaultRSTRcvd string `json:"default_rst_rcvd,omitempty"` } // ListsSettings holds the user-customisable dropdown lists used by the // entry form. Default values match common HF/VHF practice. type ListsSettings struct { Bands []string `json:"bands"` Modes []ModePreset `json:"modes"` } var defaultBands = []string{ "160m", "80m", "60m", "40m", "30m", "20m", "17m", "15m", "12m", "10m", "6m", "2m", "70cm", "23cm", } var defaultModes = []ModePreset{ {Name: "SSB", DefaultRSTSent: "59", DefaultRSTRcvd: "59"}, {Name: "CW", DefaultRSTSent: "599", DefaultRSTRcvd: "599"}, {Name: "FT8", DefaultRSTSent: "+00", DefaultRSTRcvd: "+00"}, {Name: "FT4", DefaultRSTSent: "+00", DefaultRSTRcvd: "+00"}, {Name: "RTTY", DefaultRSTSent: "599", DefaultRSTRcvd: "599"}, {Name: "PSK31", DefaultRSTSent: "599", DefaultRSTRcvd: "599"}, {Name: "AM", DefaultRSTSent: "59", DefaultRSTRcvd: "59"}, {Name: "FM", DefaultRSTSent: "59", DefaultRSTRcvd: "59"}, {Name: "DIGITALVOICE", DefaultRSTSent: "59", DefaultRSTRcvd: "59"}, } // StationSettings holds the active operator profile. Used to stamp every // new QSO so we don't ask the user to retype it for each contact. // Multi-profile support (portable / SOTA …) will layer on top of this. type StationSettings struct { Callsign string `json:"callsign"` Operator string `json:"operator"` MyGrid string `json:"my_grid"` MyCountry string `json:"my_country"` MySOTARef string `json:"my_sota_ref"` MyPOTARef string `json:"my_pota_ref"` } // LookupSettings is the JSON shape exchanged with the frontend. // Primary / Failsafe hold a provider name ("qrz" | "hamqth" | "") to // route lookups: primary first, failsafe on not-found / error. type LookupSettings struct { QRZUser string `json:"qrz_user"` QRZPassword string `json:"qrz_password"` HamQTHUser string `json:"hamqth_user"` HamQTHPassword string `json:"hamqth_password"` Primary string `json:"primary"` Failsafe string `json:"failsafe"` CacheTTLDays int `json:"cache_ttl_days"` } // App is the application context bound to the Wails runtime. type App struct { ctx context.Context db *sql.DB qso *qso.Repo settings *settings.Store profiles *profile.Repo lookup *lookup.Manager cache *lookup.Cache cat *cat.Manager dxcc *dxcc.Manager startupErr string // captured for surfacing to the frontend dbPath string } // dxccAdapter bridges *dxcc.Manager to the lookup.DXCCResolver interface // without making the lookup package import dxcc. type dxccAdapter struct{ m *dxcc.Manager } func (a dxccAdapter) Resolve(call string) (country, continent string, cqz, ituz int, lat, lon float64, ok bool) { if a.m == nil { return } mm, found := a.m.Lookup(call) if !found || mm.Entity == nil { return } return mm.Entity.Name, mm.Continent, mm.CQZone, mm.ITUZone, mm.Lat, mm.Lon, true } func NewApp() *App { return &App{} } func (a *App) startup(ctx context.Context) { a.ctx = ctx dataDir, err := userDataDir() if err != nil { a.startupErr = "cannot resolve data dir: " + err.Error() fmt.Println("HamLog:", a.startupErr) return } if err := os.MkdirAll(dataDir, 0o755); err != nil { a.startupErr = "cannot create data dir: " + err.Error() fmt.Println("HamLog:", a.startupErr) return } a.dbPath = filepath.Join(dataDir, "hamlog.db") conn, err := db.Open(a.dbPath) if err != nil { a.startupErr = "cannot open db: " + err.Error() fmt.Println("HamLog:", a.startupErr) return } a.db = conn a.qso = qso.NewRepo(conn) a.settings = settings.NewStore(conn) a.profiles = profile.NewRepo(conn) // On first run, copy the legacy single-station settings into a // "Default" profile so the user's existing config carries over without // any manual step. Subsequent runs just confirm an active profile. if _, err := profile.EnsureDefault(a.ctx, conn, a.settings, profile.LegacyStationKeys{ Callsign: keyStationCallsign, Operator: keyStationOperator, MyGrid: keyStationMyGrid, Country: keyStationCountry, SOTA: keyStationSOTA, POTA: keyStationPOTA, }); err != nil { fmt.Println("HamLog: EnsureDefault profile:", err) } a.cache = lookup.NewCache(conn, 30*24*time.Hour) a.lookup = lookup.NewManager(a.cache) a.reloadLookupProviders() // cty.dat for offline DXCC / country resolution. Cached on disk; first // run downloads it from country-files.com in the background so startup // stays fast even if the network is slow. a.dxcc = dxcc.NewManager(dataDir) a.lookup.SetDXCCResolver(dxccAdapter{m: a.dxcc}) go func() { if err := a.dxcc.EnsureLoaded(context.Background()); err != nil { fmt.Println("HamLog: cty.dat unavailable —", err) return } fmt.Println("HamLog: cty.dat loaded —", a.dxcc.Info().Entities, "entities") }() // CAT manager: emit pushes state to the frontend via Wails events. a.cat = cat.NewManager(func(s cat.RigState) { if a.ctx != nil { wruntime.EventsEmit(a.ctx, "cat:state", s) } }) a.reloadCAT() fmt.Println("HamLog: db ready at", a.dbPath) } // StartupStatus returns a diagnostic snapshot for the frontend. // dbPath is always populated; err is empty when the app is healthy. type StartupStatus struct { OK bool `json:"ok"` Err string `json:"err"` DBPath string `json:"db_path"` } // GetStartupStatus exposes whatever happened during startup so the UI // can show a useful error instead of just "db not initialized". func (a *App) GetStartupStatus() StartupStatus { return StartupStatus{ OK: a.startupErr == "", Err: a.startupErr, DBPath: a.dbPath, } } func (a *App) shutdown(ctx context.Context) { if a.db != nil { _ = a.db.Close() } } func userDataDir() (string, error) { base, err := os.UserConfigDir() if err != nil { return "", err } return filepath.Join(base, "HamLog"), nil } // reloadLookupProviders rebuilds the lookup chain from current settings. // Called at startup and after the user saves new credentials. // // Provider order honours the user's primary/failsafe choice. If they // haven't picked one yet (fresh install), we default to "primary = first // provider with creds" so the app still works out of the box. func (a *App) reloadLookupProviders() { if a.lookup == nil { return } m, err := a.settings.GetMany(a.ctx, keyQRZUser, keyQRZPassword, keyHQUser, keyHQPassword, keyCacheTTL, keyLookupPrimary, keyLookupFailsafe) if err != nil { fmt.Println("HamLog: settings load error:", err) return } if days, _ := strconv.Atoi(m[keyCacheTTL]); days > 0 { a.cache.SetTTL(time.Duration(days) * 24 * time.Hour) } build := func(name string) lookup.Provider { switch name { case "qrz": if m[keyQRZUser] != "" && m[keyQRZPassword] != "" { return lookup.NewQRZ(m[keyQRZUser], m[keyQRZPassword]) } case "hamqth": if m[keyHQUser] != "" && m[keyHQPassword] != "" { return lookup.NewHamQTH(m[keyHQUser], m[keyHQPassword]) } } return nil } primary, failsafe := m[keyLookupPrimary], m[keyLookupFailsafe] // Fresh install fallback: prefer QRZ over HamQTH when both creds exist. if primary == "" && failsafe == "" { if m[keyQRZUser] != "" && m[keyQRZPassword] != "" { primary = "qrz" if m[keyHQUser] != "" && m[keyHQPassword] != "" { failsafe = "hamqth" } } else if m[keyHQUser] != "" && m[keyHQPassword] != "" { primary = "hamqth" } } var providers []lookup.Provider if p := build(primary); p != nil { providers = append(providers, p) } if failsafe != "" && failsafe != primary { if p := build(failsafe); p != nil { providers = append(providers, p) } } a.lookup.SetProviders(providers...) } // --- QSO bindings --- func (a *App) AddQSO(q qso.QSO) (int64, error) { if a.qso == nil { return 0, fmt.Errorf("db not initialized") } a.applyStationDefaults(&q) return a.qso.Add(a.ctx, q) } // applyStationDefaults fills any empty MY_* / station field on q with the // currently-active profile's values. Multi-profile support means a user // can be /P with a different callsign + grid + SOTA ref than home — the // QSO carries whichever profile was selected at log time. func (a *App) applyStationDefaults(q *qso.QSO) { if a.profiles == nil { return } p, err := a.profiles.Active(a.ctx) if err != nil { return } if q.StationCallsign == "" { q.StationCallsign = p.Callsign } if q.Operator == "" { q.Operator = p.Operator } if q.MyGrid == "" { q.MyGrid = p.MyGrid } if q.MyCountry == "" { q.MyCountry = p.MyCountry } if q.MyState == "" { q.MyState = p.MyState } if q.MyCounty == "" { q.MyCounty = p.MyCounty } if q.MyStreet == "" { q.MyStreet = p.MyStreet } if q.MyCity == "" { q.MyCity = p.MyCity } if q.MyPostalCode == "" { q.MyPostalCode = p.MyPostalCode } if q.MySOTARef == "" { q.MySOTARef = p.MySOTARef } if q.MyPOTARef == "" { q.MyPOTARef = p.MyPOTARef } if q.MyRig == "" { q.MyRig = p.MyRig } if q.MyAntenna == "" { q.MyAntenna = p.MyAntenna } if q.TXPower == nil && p.TxPower != nil { v := *p.TxPower q.TXPower = &v } } func (a *App) ListQSO(f qso.ListFilter) ([]qso.QSO, error) { if a.qso == nil { return nil, fmt.Errorf("db not initialized") } return a.qso.List(a.ctx, f) } func (a *App) CountQSO() (int64, error) { if a.qso == nil { return 0, fmt.Errorf("db not initialized") } return a.qso.Count(a.ctx) } func (a *App) GetQSO(id int64) (qso.QSO, error) { if a.qso == nil { return qso.QSO{}, fmt.Errorf("db not initialized") } return a.qso.GetByID(a.ctx, id) } func (a *App) UpdateQSO(q qso.QSO) error { if a.qso == nil { return fmt.Errorf("db not initialized") } return a.qso.Update(a.ctx, q) } func (a *App) DeleteQSO(id int64) error { if a.qso == nil { return fmt.Errorf("db not initialized") } return a.qso.Delete(a.ctx, id) } // WorkedBefore returns prior contacts with the given callsign at both // call and DXCC granularity. Pass dxccHint=0 when unknown — the function // will infer it from past QSOs with the same call when possible. func (a *App) WorkedBefore(callsign string, dxccHint int) (qso.WorkedBefore, error) { if a.qso == nil { return qso.WorkedBefore{}, fmt.Errorf("db not initialized") } return a.qso.WorkedBefore(a.ctx, callsign, dxccHint) } // SetCompactMode toggles a tiny always-on-top window that exposes just the // QSO entry — useful when running on a single screen alongside WSJT-X, // JT-Alert or the cluster. // // We can't easily spawn a real second OS window in Wails v2, but a resized // always-on-top main window does the job from the user's perspective. // Sizes tuned so the compact entry strip fits in a single row (no wrap). // 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 normalW, normalH = 1400, 900 normalMinW, normalMinH = 1100, 700 ) func (a *App) SetCompactMode(on bool) { if a.ctx == nil { return } if on { wruntime.WindowSetMinSize(a.ctx, compactW, compactH) wruntime.WindowSetSize(a.ctx, compactW, compactH) wruntime.WindowSetAlwaysOnTop(a.ctx, true) } else { wruntime.WindowSetAlwaysOnTop(a.ctx, false) wruntime.WindowSetMinSize(a.ctx, normalMinW, normalMinH) wruntime.WindowSetSize(a.ctx, normalW, normalH) } } // DeleteAllQSO wipes every QSO. Returns the number of rows removed. // The frontend MUST gate this behind a strong confirmation prompt. func (a *App) DeleteAllQSO() (int64, error) { if a.qso == nil { return 0, fmt.Errorf("db not initialized") } return a.qso.DeleteAll(a.ctx) } // --- ADIF bindings --- func (a *App) OpenADIFFile() (string, error) { return wruntime.OpenFileDialog(a.ctx, wruntime.OpenDialogOptions{ Title: "Import ADIF", Filters: []wruntime.FileFilter{ {DisplayName: "ADIF files (*.adi, *.adif)", Pattern: "*.adi;*.adif"}, {DisplayName: "All files (*.*)", Pattern: "*.*"}, }, }) } func (a *App) ImportADIF(path string, skipDuplicates 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} return im.ImportFile(a.ctx, path) } // --- Lookup bindings --- // LookupCallsign returns the cached or freshly-fetched info for a callsign. // Errors are returned as-is to the frontend; ErrNotFound surfaces as // "callsign not found". func (a *App) LookupCallsign(callsign string) (lookup.Result, error) { if a.lookup == nil { return lookup.Result{}, fmt.Errorf("lookup not initialized") } r, err := a.lookup.Lookup(a.ctx, callsign) if errors.Is(err, lookup.ErrNotFound) { return lookup.Result{}, fmt.Errorf("callsign not found") } return r, err } // GetLookupSettings returns current credentials and cache TTL. func (a *App) GetLookupSettings() (LookupSettings, error) { if a.settings == nil { return LookupSettings{}, fmt.Errorf("db not initialized") } m, err := a.settings.GetMany(a.ctx, keyQRZUser, keyQRZPassword, keyHQUser, keyHQPassword, keyCacheTTL, keyLookupPrimary, keyLookupFailsafe) if err != nil { return LookupSettings{}, err } ttl, _ := strconv.Atoi(m[keyCacheTTL]) if ttl <= 0 { ttl = 30 } return LookupSettings{ QRZUser: m[keyQRZUser], QRZPassword: m[keyQRZPassword], HamQTHUser: m[keyHQUser], HamQTHPassword: m[keyHQPassword], Primary: m[keyLookupPrimary], Failsafe: m[keyLookupFailsafe], CacheTTLDays: ttl, }, nil } // SaveLookupSettings persists credentials and rebuilds the provider chain. func (a *App) SaveLookupSettings(s LookupSettings) error { if a.settings == nil { return fmt.Errorf("db not initialized") } if s.CacheTTLDays <= 0 { s.CacheTTLDays = 30 } // Reject a primary == failsafe routing combo — would just hit the same // provider twice. Frontend should prevent this but defend in depth. if s.Primary != "" && s.Primary == s.Failsafe { s.Failsafe = "" } for k, v := range map[string]string{ keyQRZUser: s.QRZUser, keyQRZPassword: s.QRZPassword, keyHQUser: s.HamQTHUser, keyHQPassword: s.HamQTHPassword, keyCacheTTL: strconv.Itoa(s.CacheTTLDays), keyLookupPrimary: s.Primary, keyLookupFailsafe: s.Failsafe, } { if err := a.settings.Set(a.ctx, k, v); err != nil { return err } } a.reloadLookupProviders() return nil } // TestLookupProvider runs a one-shot lookup against a specific provider so // the user can verify credentials before saving. callsign defaults to the // active profile's callsign when empty (handy "test against my own call"). // Returns the result on success or a descriptive error. func (a *App) TestLookupProvider(name, callsign, user, password string) (lookup.Result, error) { if user == "" || password == "" { return lookup.Result{}, fmt.Errorf("user and password required") } if callsign == "" { if a.profiles != nil { if p, err := a.profiles.Active(a.ctx); err == nil { callsign = p.Callsign } } if callsign == "" { callsign = "W1AW" // ARRL HQ — always present in every database } } var p lookup.Provider switch name { case "qrz": p = lookup.NewQRZ(user, password) case "hamqth": p = lookup.NewHamQTH(user, password) default: return lookup.Result{}, fmt.Errorf("unknown provider %q", name) } r, err := p.Lookup(a.ctx, callsign) if errors.Is(err, lookup.ErrNotFound) { return lookup.Result{}, fmt.Errorf("%s reachable but %q not found (creds look OK)", name, callsign) } if err != nil { return lookup.Result{}, err } r.Source = name return r, nil } // --- CAT bindings --- // GetCATSettings returns the stored CAT configuration (defaults applied). func (a *App) GetCATSettings() (CATSettings, error) { if a.settings == nil { return CATSettings{Backend: "omnirig", OmniRigNum: 1, PollMs: 250}, fmt.Errorf("db not initialized") } m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATPollMs, keyCATDelayMs, keyCATDigitalDefault) if err != nil { return CATSettings{}, err } out := CATSettings{ Enabled: m[keyCATEnabled] == "1", Backend: m[keyCATBackend], OmniRigNum: 1, PollMs: 250, DelayMs: 0, DigitalDefault: m[keyCATDigitalDefault], } if out.Backend == "" { out.Backend = "omnirig" } if out.DigitalDefault == "" { out.DigitalDefault = "FT8" } if n, _ := strconv.Atoi(m[keyCATOmniRigNum]); n == 1 || n == 2 { out.OmniRigNum = n } if n, _ := strconv.Atoi(m[keyCATPollMs]); n >= 50 && n <= 2000 { out.PollMs = n } if n, _ := strconv.Atoi(m[keyCATDelayMs]); n >= 0 && n <= 500 { out.DelayMs = n } return out, nil } // SaveCATSettings persists CAT config and restarts the manager accordingly. func (a *App) SaveCATSettings(s CATSettings) error { if a.settings == nil { return fmt.Errorf("db not initialized") } if s.Backend == "" { s.Backend = "omnirig" } if s.OmniRigNum != 1 && s.OmniRigNum != 2 { s.OmniRigNum = 1 } if s.PollMs < 50 || s.PollMs > 2000 { s.PollMs = 250 } if s.DelayMs < 0 || s.DelayMs > 500 { s.DelayMs = 0 } enabled := "0" if s.Enabled { enabled = "1" } if s.DigitalDefault == "" { s.DigitalDefault = "FT8" } for k, v := range map[string]string{ keyCATEnabled: enabled, keyCATBackend: s.Backend, keyCATOmniRigNum: strconv.Itoa(s.OmniRigNum), keyCATPollMs: strconv.Itoa(s.PollMs), keyCATDelayMs: strconv.Itoa(s.DelayMs), keyCATDigitalDefault: strings.ToUpper(strings.TrimSpace(s.DigitalDefault)), } { if err := a.settings.Set(a.ctx, k, v); err != nil { return err } } a.reloadCAT() return nil } // GetCATState returns the current snapshot from the CAT manager. Used by the // frontend on mount before any cat:state event has been emitted. func (a *App) GetCATState() cat.RigState { if a.cat == nil { return cat.RigState{} } return a.cat.State() } // SetCATFrequency lets the frontend push a freq to the rig (cluster click, // memory recall, …). Returns an error if CAT isn't running or the backend // refuses (out-of-range, etc.). func (a *App) SetCATFrequency(hz int64) error { if a.cat == nil { return fmt.Errorf("cat not initialized") } return a.cat.SetFrequency(hz) } // SetCATMode sets the rig's mode. ADIF mode names (SSB / CW / FT8 / …) are // translated to backend-specific values by the backend itself. func (a *App) SetCATMode(mode string) error { if a.cat == nil { return fmt.Errorf("cat not initialized") } return a.cat.SetMode(mode) } // SwitchCATRig hot-swaps the active OmniRig slot (Rig1 ↔ Rig2) without // requiring a trip through the full Settings panel. Persists the choice // so it survives restart. func (a *App) SwitchCATRig(n int) error { if n != 1 && n != 2 { return fmt.Errorf("rig num must be 1 or 2, got %d", n) } if a.settings == nil { return fmt.Errorf("db not initialized") } if err := a.settings.Set(a.ctx, keyCATOmniRigNum, strconv.Itoa(n)); err != nil { return err } a.reloadCAT() return nil } // reloadCAT (re)starts the CAT manager based on the current settings. // Called at startup and after the user saves new CAT config. func (a *App) reloadCAT() { if a.cat == nil { return } s, err := a.GetCATSettings() if err != nil { return } a.cat.SetPollInterval(time.Duration(s.PollMs) * time.Millisecond) a.cat.SetCommandDelay(time.Duration(s.DelayMs) * time.Millisecond) if !s.Enabled { a.cat.Stop() return } switch s.Backend { case "omnirig": // No explicit launch — COM auto-activates OmniRig.exe via its // LocalServer32 registration when we CreateObject in Connect(). // Spawning OmniRig.exe ourselves (even with /Embedding) on every // reloadCAT raised the existing instance's window to the front, // which is what Log4OM avoids by relying entirely on COM activation. a.cat.Start(cat.NewOmniRig(s.OmniRigNum)) default: // Unknown backend → stop and emit a dummy state so the UI shows it. a.cat.Stop() } } // ClearLookupCache empties the local callsign cache. func (a *App) ClearLookupCache() error { if a.cache == nil { return fmt.Errorf("cache not initialized") } return a.cache.Clear(a.ctx) } // CtyDatInfo describes the currently-loaded cty.dat file (or zero values // if it hasn't been loaded yet). Exposed for the Maintenance menu so the // user can see what they're working with before triggering a refresh. type CtyDatInfo struct { Path string `json:"path"` Entities int `json:"entities"` LoadedAt string `json:"loaded_at,omitempty"` // RFC3339, "" if not loaded FileModTime string `json:"file_mod_time,omitempty"` // RFC3339, "" if missing } // GetCtyDatInfo returns metadata about the on-disk cty.dat. func (a *App) GetCtyDatInfo() CtyDatInfo { if a.dxcc == nil { return CtyDatInfo{} } src := a.dxcc.Info() out := CtyDatInfo{Path: src.Path, Entities: src.Entities} if !src.LoadedAt.IsZero() { out.LoadedAt = src.LoadedAt.UTC().Format(time.RFC3339) } if !src.FileModTime.IsZero() { out.FileModTime = src.FileModTime.UTC().Format(time.RFC3339) } return out } // RefreshCtyDat re-downloads cty.dat from country-files.com and reloads it // into memory. Synchronous so the UI can show a spinner; ~1s typical. func (a *App) RefreshCtyDat() (CtyDatInfo, error) { if a.dxcc == nil { return CtyDatInfo{}, fmt.Errorf("dxcc manager not initialized") } if err := a.dxcc.Refresh(a.ctx); err != nil { return CtyDatInfo{}, err } return a.GetCtyDatInfo(), nil } // --- Station bindings --- // // GetStationSettings/SaveStationSettings now operate on the **currently // active profile** rather than a flat settings key set. Kept for the // existing topbar/quick-edit code paths; the full profile CRUD lives in // the Profile bindings below. func (a *App) GetStationSettings() (StationSettings, error) { if a.profiles == nil { return StationSettings{}, fmt.Errorf("profiles not initialized") } p, err := a.profiles.Active(a.ctx) if err != nil { return StationSettings{}, err } return StationSettings{ Callsign: p.Callsign, Operator: p.Operator, MyGrid: p.MyGrid, MyCountry: p.MyCountry, MySOTARef: p.MySOTARef, MyPOTARef: p.MyPOTARef, }, nil } // --- Lists bindings (bands + modes with default RST) --- // GetListsSettings returns the user-customisable lists. Defaults are // returned when the user has not customised anything. func (a *App) GetListsSettings() (ListsSettings, error) { if a.settings == nil { return ListsSettings{Bands: defaultBands, Modes: defaultModes}, fmt.Errorf("db not initialized") } out := ListsSettings{} if raw, _ := a.settings.Get(a.ctx, keyListsBands); raw != "" { _ = json.Unmarshal([]byte(raw), &out.Bands) } if raw, _ := a.settings.Get(a.ctx, keyListsModes); raw != "" { _ = json.Unmarshal([]byte(raw), &out.Modes) } if len(out.Bands) == 0 { out.Bands = append([]string(nil), defaultBands...) } if len(out.Modes) == 0 { out.Modes = append([]ModePreset(nil), defaultModes...) } return out, nil } // SaveListsSettings persists the user-customised lists. func (a *App) SaveListsSettings(l ListsSettings) error { if a.settings == nil { return fmt.Errorf("db not initialized") } b, err := json.Marshal(l.Bands) if err != nil { return err } if err := a.settings.Set(a.ctx, keyListsBands, string(b)); err != nil { return err } m, err := json.Marshal(l.Modes) if err != nil { return err } return a.settings.Set(a.ctx, keyListsModes, string(m)) } // SaveStationSettings updates only the six "basic" fields on the active // profile. Use the Profile bindings (ListProfiles / SaveProfile…) for // full multi-profile management. func (a *App) SaveStationSettings(s StationSettings) error { if a.profiles == nil { return fmt.Errorf("profiles not initialized") } p, err := a.profiles.Active(a.ctx) if err != nil { return err } p.Callsign = s.Callsign p.Operator = s.Operator p.MyGrid = s.MyGrid p.MyCountry = s.MyCountry p.MySOTARef = s.MySOTARef p.MyPOTARef = s.MyPOTARef return a.profiles.Save(a.ctx, &p) } // --- Profile bindings (multi-profile CRUD) --- // ListProfiles returns every saved profile, active first. func (a *App) ListProfiles() ([]profile.Profile, error) { if a.profiles == nil { return nil, fmt.Errorf("profiles not initialized") } return a.profiles.List(a.ctx) } // GetActiveProfile returns the currently-selected profile. func (a *App) GetActiveProfile() (profile.Profile, error) { if a.profiles == nil { return profile.Profile{}, fmt.Errorf("profiles not initialized") } return a.profiles.Active(a.ctx) } // SaveProfile upserts a profile. Pass id=0 to create a new one. func (a *App) SaveProfile(p profile.Profile) (profile.Profile, error) { if a.profiles == nil { return profile.Profile{}, fmt.Errorf("profiles not initialized") } if err := a.profiles.Save(a.ctx, &p); err != nil { return profile.Profile{}, err } return p, nil } // DeleteProfile removes a profile. Refuses to delete the last remaining // profile; promotes another to active if the deleted one was selected. func (a *App) DeleteProfile(id int64) error { if a.profiles == nil { return fmt.Errorf("profiles not initialized") } return a.profiles.Delete(a.ctx, id) } // ActivateProfile switches the selected profile. Subsequent QSOs stamp // MY_* fields from this one. func (a *App) ActivateProfile(id int64) error { if a.profiles == nil { return fmt.Errorf("profiles not initialized") } return a.profiles.SetActive(a.ctx, id) } // DuplicateProfile clones an existing profile under newName. Useful when // the user has a "Home" profile and wants to derive "Portable" from it // without retyping every field. func (a *App) DuplicateProfile(id int64, newName string) (profile.Profile, error) { if a.profiles == nil { return profile.Profile{}, fmt.Errorf("profiles not initialized") } return a.profiles.Duplicate(a.ctx, id, newName) } // --- Rotator bindings (PstRotator UDP v0) --- // RotatorSettings is the JSON shape for the Hardware → Rotator panel. type RotatorSettings struct { Enabled bool `json:"enabled"` Host string `json:"host"` // default 127.0.0.1 Port int `json:"port"` // default 12000 HasElevation bool `json:"has_elevation"` // include EL in GoTo packets } // GetRotatorSettings returns the persisted rotator config with defaults. func (a *App) GetRotatorSettings() (RotatorSettings, error) { out := RotatorSettings{Host: "127.0.0.1", Port: 12000} if a.settings == nil { return out, fmt.Errorf("db not initialized") } m, err := a.settings.GetMany(a.ctx, keyRotatorEnabled, keyRotatorHost, keyRotatorPort, keyRotatorHasElevation) if err != nil { return out, err } out.Enabled = m[keyRotatorEnabled] == "1" if h := m[keyRotatorHost]; h != "" { out.Host = h } if p, _ := strconv.Atoi(m[keyRotatorPort]); p > 0 && p <= 65535 { out.Port = p } out.HasElevation = m[keyRotatorHasElevation] == "1" return out, nil } // SaveRotatorSettings persists the rotator config. Connection is per-call // (UDP, no socket to (re)open) so no reload step is needed. func (a *App) SaveRotatorSettings(s RotatorSettings) error { if a.settings == nil { return fmt.Errorf("db not initialized") } if s.Host == "" { s.Host = "127.0.0.1" } if s.Port <= 0 || s.Port > 65535 { s.Port = 12000 } for k, v := range map[string]string{ keyRotatorEnabled: boolStr(s.Enabled), keyRotatorHost: s.Host, keyRotatorPort: strconv.Itoa(s.Port), keyRotatorHasElevation: boolStr(s.HasElevation), } { if err := a.settings.Set(a.ctx, k, v); err != nil { return err } } return nil } // rotatorClient returns a fresh PST UDP client built from current settings, // or an error if the rotator is disabled / misconfigured. func (a *App) rotatorClient() (*pst.Client, RotatorSettings, error) { s, err := a.GetRotatorSettings() if err != nil { return nil, s, err } if !s.Enabled { return nil, s, fmt.Errorf("rotator disabled in settings") } return pst.New(s.Host, s.Port), s, nil } // RotatorGoTo points the antenna at the given azimuth (and optional // elevation if the rotator is configured for it). func (a *App) RotatorGoTo(az int, el int) error { c, s, err := a.rotatorClient() if err != nil { return err } return c.GoTo(az, s.HasElevation, el) } // RotatorStop interrupts any in-progress rotation. func (a *App) RotatorStop() error { c, _, err := a.rotatorClient() if err != nil { return err } return c.Stop() } // RotatorPark moves the antenna to its parked position (configured in // PstRotator itself). func (a *App) RotatorPark() error { c, _, err := a.rotatorClient() if err != nil { return err } return c.Park() } // TestRotator sends a no-op GoTo to the rotator's current heading to // verify the UDP link without actually moving the antenna. We use 0° as // the test target — pick a known direction the user expects to see. // Returns nil on success or a descriptive error. func (a *App) TestRotator(s RotatorSettings) error { if s.Host == "" { s.Host = "127.0.0.1" } if s.Port <= 0 || s.Port > 65535 { s.Port = 12000 } return pst.New(s.Host, s.Port).GoTo(0, false, -1) } func boolStr(b bool) string { if b { return "1" } return "0" }