package main import ( "context" "database/sql" "encoding/json" "errors" "fmt" "math" "os" "path/filepath" "strconv" "strings" "time" "hamlog/internal/adif" "hamlog/internal/backup" "hamlog/internal/cat" "hamlog/internal/cluster" "hamlog/internal/db" "hamlog/internal/operating" "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" keyLookupImages = "lookup.download_images" // 1 = expose QRZ ImageURL to UI 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" keyClusterAutoConnect = "cluster.auto_connect" // open every enabled server at app start keyBackupEnabled = "backup.enabled" keyBackupFolder = "backup.folder" keyBackupRotation = "backup.rotation" keyBackupZip = "backup.zip" keyBackupLast = "backup.last_at" ) // 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"` DownloadImages bool `json:"download_images"` // show QRZ profile pictures in the UI 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 cluster *cluster.Manager operating *operating.Repo startupErr string // captured for surfacing to the frontend dbPath string // shuttingDown gates beforeClose re-entry: the first user attempt to // close fires shutdown tasks (backup, future LoTW upload, ...) while // blocking the window close; the subsequent programmatic Quit() call // must be allowed through. shuttingDown bool // Cached operator location used to compute distance/bearing for // cluster spots. Refreshed on profile activation; zero means // "unknown" and we skip the per-spot computation. opLat float64 opLon float64 opSet bool } // gridToLatLon parses a Maidenhead locator (4 or 6 chars) and returns the // centre lat/lon in degrees. Returns ok=false on malformed input. func gridToLatLon(grid string) (lat, lon float64, ok bool) { g := strings.ToUpper(strings.TrimSpace(grid)) if len(g) < 4 { return 0, 0, false } A := g[0] - 'A' B := g[1] - 'A' C := g[2] - '0' D := g[3] - '0' if A > 17 || B > 17 || C > 9 || D > 9 { return 0, 0, false } lon = -180 + float64(A)*20 + float64(C)*2 lat = -90 + float64(B)*10 + float64(D)*1 if len(g) >= 6 { E := g[4] - 'A' F := g[5] - 'A' if E <= 23 && F <= 23 { lon += float64(E)*(5.0/60.0) + 2.5/60.0 lat += float64(F)*(2.5/60.0) + 1.25/60.0 return lat, lon, true } } // 4-char locator: aim at the centre of the square. lon += 1 lat += 0.5 return lat, lon, true } // haversineKm returns the great-circle distance between two lat/lon pairs // in kilometres. Standard Haversine, mean Earth radius 6371 km. func haversineKm(lat1, lon1, lat2, lon2 float64) float64 { const R = 6371.0 rad := math.Pi / 180.0 dLat := (lat2 - lat1) * rad dLon := (lon2 - lon1) * rad a := math.Sin(dLat/2)*math.Sin(dLat/2) + math.Cos(lat1*rad)*math.Cos(lat2*rad)*math.Sin(dLon/2)*math.Sin(dLon/2) c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a)) return R * c } // initialBearingDeg returns the initial great-circle bearing (azimuth) in // degrees [0, 360) from (lat1, lon1) towards (lat2, lon2). This is the // "short path" heading. func initialBearingDeg(lat1, lon1, lat2, lon2 float64) float64 { rad := math.Pi / 180.0 dLon := (lon2 - lon1) * rad y := math.Sin(dLon) * math.Cos(lat2*rad) x := math.Cos(lat1*rad)*math.Sin(lat2*rad) - math.Sin(lat1*rad)*math.Cos(lat2*rad)*math.Cos(dLon) deg := math.Atan2(y, x) / rad if deg < 0 { deg += 360 } return deg } // refreshOperatorGrid reloads the active profile and caches its grid as // lat/lon. Called at startup and after profile activation so the cluster // onSpot callback can compute distance/bearing without hitting the DB // per spot. func (a *App) refreshOperatorGrid() { a.opSet = false if a.profiles == nil || a.ctx == nil { return } p, err := a.profiles.Active(a.ctx) if err != nil { return } lat, lon, ok := gridToLatLon(p.MyGrid) if !ok { return } a.opLat = lat a.opLon = lon a.opSet = true } // 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) a.operating = operating.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() // DX Cluster (multi-server): the spot callback enriches each spot // with country + continent via cty.dat BEFORE emitting it, so the UI // renders the row with all metadata already filled (no flicker of // empty Country / Cont columns while the batch status fetch runs). a.cluster = cluster.NewManager( func(s cluster.Spot) { if a.dxcc != nil { if m, ok := a.dxcc.Lookup(s.DXCall); ok && m.Entity != nil { s.Country = m.Entity.Name s.Continent = m.Continent s.CQZone = m.CQZone s.ITUZone = m.ITUZone if a.opSet && (m.Lat != 0 || m.Lon != 0) { s.DistanceKm = int(haversineKm(a.opLat, a.opLon, m.Lat, m.Lon) + 0.5) sp := initialBearingDeg(a.opLat, a.opLon, m.Lat, m.Lon) s.ShortPath = int(sp + 0.5) s.LongPath = (s.ShortPath + 180) % 360 } } } if a.ctx != nil { wruntime.EventsEmit(a.ctx, "cluster:spot", s) } }, func() { if a.ctx != nil { wruntime.EventsEmit(a.ctx, "cluster:state", a.cluster.Status()) } }, ) a.refreshOperatorGrid() if cs, _ := a.clusterAutoConnect(); cs { a.startAllEnabledClusters() } 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, } } // beforeClose intercepts the window-close event so we can run shutdown // tasks (backup, future LoTW upload, ...) while showing a progress modal // to the user. Returns true the first time to block the close; the // goroutine eventually calls wruntime.Quit() which re-enters this method // with shuttingDown=true and we let the close proceed. func (a *App) beforeClose(ctx context.Context) bool { if a.shuttingDown { return false } a.shuttingDown = true steps := a.plannedShutdownSteps() if len(steps) == 0 { // Nothing to do — exit immediately, no need to flash a modal. return false } go a.runShutdownTasks(ctx, steps) return true } // shutdownStep is emitted to the frontend so the progress modal can // render the task list and update each row's state as work progresses. type shutdownStep struct { ID string `json:"id"` Label string `json:"label"` Status string `json:"status"` // "pending" | "running" | "done" | "error" Detail string `json:"detail,omitempty"` } // plannedShutdownSteps returns the tasks that will actually run, so the // UI knows the full checklist up front. Right now that's just the backup // (when enabled and not yet done today); LoTW upload, eQSL upload, etc. // will append to this list as they land. func (a *App) plannedShutdownSteps() []shutdownStep { var out []shutdownStep if s, err := a.GetBackupSettings(); err == nil && s.Enabled { folder := s.Folder if folder == "" { folder = s.DefaultFolder } if !backup.HasBackupToday(folder) { out = append(out, shutdownStep{ID: "backup", Label: "Backing up database", Status: "pending"}) } } return out } func (a *App) emitShutdownEvent(name string, payload any) { if a.ctx != nil { wruntime.EventsEmit(a.ctx, name, payload) } } // runShutdownTasks executes every planned shutdown task in order, // emitting progress events at each transition so the frontend modal // stays in sync. Errors don't abort the sequence — we still want to // give later steps a chance and ultimately close the app. func (a *App) runShutdownTasks(ctx context.Context, steps []shutdownStep) { a.emitShutdownEvent("shutdown:start", steps) for i := range steps { steps[i].Status = "running" a.emitShutdownEvent("shutdown:update", steps) var err error switch steps[i].ID { case "backup": err = a.runBackupForShutdown() } if err != nil { steps[i].Status = "error" steps[i].Detail = err.Error() } else { steps[i].Status = "done" } a.emitShutdownEvent("shutdown:update", steps) } a.emitShutdownEvent("shutdown:done", steps) // Give the UI a moment to show the "done" state before we yank the // window away. 600ms feels purposeful without being annoying. time.Sleep(600 * time.Millisecond) wruntime.Quit(ctx) } // runBackupForShutdown is the same logic as maybeShutdownBackup but // returns an error so the shutdown sequence can mark the step as failed. func (a *App) runBackupForShutdown() error { if a.settings == nil || a.db == nil { return fmt.Errorf("db not ready") } s, err := a.GetBackupSettings() if err != nil { return err } folder := s.Folder if folder == "" { folder = s.DefaultFolder } if backup.HasBackupToday(folder) { return nil } if _, err := backup.Run(a.ctx, a.db, a.dbPath, folder, s.Rotation, s.Zip); err != nil { return err } return a.settings.Set(a.ctx, keyBackupLast, time.Now().UTC().Format(time.RFC3339)) } func (a *App) shutdown(ctx context.Context) { // If the user managed to skip beforeClose (force kill, OS shutdown, // crash recovery) we still try the backup here as a best-effort // safety net. HasBackupToday makes a double-run a no-op. if !a.shuttingDown { a.maybeShutdownBackup() } 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) } // SaveADIFFile shows a native Save-As dialog suggesting a timestamped // HamLog_YYYYMMDD_HHMMSS.adi filename. Returns "" if the user cancelled. func (a *App) SaveADIFFile() (string, error) { suggested := "HamLog_" + time.Now().UTC().Format("20060102_150405") + ".adi" return wruntime.SaveFileDialog(a.ctx, wruntime.SaveDialogOptions{ Title: "Export ADIF", DefaultFilename: suggested, Filters: []wruntime.FileFilter{ {DisplayName: "ADIF files (*.adi, *.adif)", Pattern: "*.adi;*.adif"}, {DisplayName: "All files (*.*)", Pattern: "*.*"}, }, }) } // 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) { 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: "HamLog", AppVersion: "0.1"} return ex.ExportFile(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") } // Respect the user's "Download profile images" setting: even if the // cache holds the URL we hide it when the toggle is off so the // frontend doesn't render the (which would still fetch from // QRZ). Cheap to check per call — settings is in-memory after init. if err == nil && r.ImageURL != "" { if s, _ := a.GetLookupSettings(); !s.DownloadImages { r.ImageURL = "" } } return r, err } // OpenExternalURL opens a URL in the user's default browser. Wails ships // runtime.BrowserOpenURL for exactly this — used by the QRZ.com icon // next to the callsign field, the future Clublog/HamQTH shortcuts, etc. func (a *App) OpenExternalURL(url string) error { url = strings.TrimSpace(url) if url == "" { return fmt.Errorf("empty URL") } if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") { return fmt.Errorf("only http(s) URLs allowed, got %q", url) } wruntime.BrowserOpenURL(a.ctx, url) return nil } // 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, keyLookupImages) 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], DownloadImages: m[keyLookupImages] == "1", 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, keyLookupImages: boolStr(s.DownloadImages), } { 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 } // ── Operating conditions ─────────────────────────────────────────────── // ListOperatingTree returns the stations/antennas/bands tree for the // active profile. The UI renders the Settings tree from this. func (a *App) ListOperatingTree() ([]operating.Station, error) { if a.operating == nil || a.profiles == nil { return nil, fmt.Errorf("db not initialized") } p, err := a.profiles.Active(a.ctx) if err != nil { return nil, err } return a.operating.ListTree(a.ctx, p.ID) } // SaveOperatingStation upserts a station. profile_id is set from the // active profile if zero so the frontend doesn't have to know about it. func (a *App) SaveOperatingStation(s operating.Station) (operating.Station, error) { if a.operating == nil || a.profiles == nil { return s, fmt.Errorf("db not initialized") } if s.ProfileID == 0 { p, err := a.profiles.Active(a.ctx) if err != nil { return s, err } s.ProfileID = p.ID } if err := a.operating.SaveStation(a.ctx, &s); err != nil { return s, err } return s, nil } // DeleteOperatingStation cascades to antennas + bands. func (a *App) DeleteOperatingStation(id int64) error { if a.operating == nil { return fmt.Errorf("db not initialized") } return a.operating.DeleteStation(a.ctx, id) } // SaveOperatingAntenna upserts an antenna and replaces its band list. // Setting is_default on a band clears the flag from any other antenna // on the same band within this profile. func (a *App) SaveOperatingAntenna(ant operating.Antenna) (operating.Antenna, error) { if a.operating == nil { return ant, fmt.Errorf("db not initialized") } if err := a.operating.SaveAntenna(a.ctx, &ant); err != nil { return ant, err } return ant, nil } // DeleteOperatingAntenna cascades to bands. func (a *App) DeleteOperatingAntenna(id int64) error { if a.operating == nil { return fmt.Errorf("db not initialized") } return a.operating.DeleteAntenna(a.ctx, id) } // OperatingDefaultForBand returns the (station, antenna) flagged default // for `band` in the active profile. Used by the entry strip to auto-fill // MY_RIG and MY_ANTENNA when the user picks a band. func (a *App) OperatingDefaultForBand(band string) (operating.BandDefault, error) { if a.operating == nil || a.profiles == nil { return operating.BandDefault{}, fmt.Errorf("db not initialized") } p, err := a.profiles.Active(a.ctx) if err != nil { return operating.BandDefault{}, err } d, _, err := a.operating.BandDefault(a.ctx, p.ID, band) return d, err } // ── Backup ────────────────────────────────────────────────────────────── // BackupSettings is the user-tweakable database backup configuration. type BackupSettings struct { Enabled bool `json:"enabled"` Folder string `json:"folder"` Rotation int `json:"rotation"` Zip bool `json:"zip"` LastBackupAt string `json:"last_backup_at"` DefaultFolder string `json:"default_folder"` // computed, read-only — shown as a hint } // GetBackupSettings returns stored backup config with safe defaults. func (a *App) GetBackupSettings() (BackupSettings, error) { out := BackupSettings{ Rotation: 5, DefaultFolder: backup.DefaultFolder(filepath.Dir(a.dbPath)), } if a.settings == nil { return out, nil } m, err := a.settings.GetMany(a.ctx, keyBackupEnabled, keyBackupFolder, keyBackupRotation, keyBackupZip, keyBackupLast) if err != nil { return out, err } out.Enabled = m[keyBackupEnabled] == "1" out.Folder = m[keyBackupFolder] if n, _ := strconv.Atoi(m[keyBackupRotation]); n > 0 { out.Rotation = n } out.Zip = m[keyBackupZip] == "1" out.LastBackupAt = m[keyBackupLast] return out, nil } // SaveBackupSettings persists backup config (no immediate backup — // trigger it explicitly with RunBackupNow). func (a *App) SaveBackupSettings(s BackupSettings) error { if a.settings == nil { return fmt.Errorf("db not initialized") } if s.Rotation <= 0 { s.Rotation = 5 } enabled := "0" if s.Enabled { enabled = "1" } doZip := "0" if s.Zip { doZip = "1" } for k, v := range map[string]string{ keyBackupEnabled: enabled, keyBackupFolder: strings.TrimSpace(s.Folder), keyBackupRotation: strconv.Itoa(s.Rotation), keyBackupZip: doZip, } { if err := a.settings.Set(a.ctx, k, v); err != nil { return err } } return nil } // RunBackupNow forces an immediate backup using the persisted settings. // Returns the destination path of the file that was written. func (a *App) RunBackupNow() (string, error) { s, err := a.GetBackupSettings() if err != nil { return "", err } folder := s.Folder if folder == "" { folder = s.DefaultFolder } path, err := backup.Run(a.ctx, a.db, a.dbPath, folder, s.Rotation, s.Zip) if err != nil { return path, err } _ = a.settings.Set(a.ctx, keyBackupLast, time.Now().UTC().Format(time.RFC3339)) return path, nil } // maybeShutdownBackup runs a backup at shutdown if the user enabled it // and no backup for today already exists. Running at shutdown (not at // startup) means the snapshot includes the QSOs the user just logged // this session — exactly what we want to protect. Errors are printed // but never block the close. func (a *App) maybeShutdownBackup() { if a.settings == nil || a.db == nil { return } s, err := a.GetBackupSettings() if err != nil || !s.Enabled { return } folder := s.Folder if folder == "" { folder = s.DefaultFolder } if backup.HasBackupToday(folder) { return } if _, err := backup.Run(a.ctx, a.db, a.dbPath, folder, s.Rotation, s.Zip); err != nil { fmt.Println("HamLog: shutdown backup failed:", err) return } _ = a.settings.Set(a.ctx, keyBackupLast, time.Now().UTC().Format(time.RFC3339)) } // PickBackupFolder opens a native directory picker so the user can browse // to a backup target rather than typing the path. Returns the absolute // path (or empty string if the dialog was cancelled). // // Windows' shell dialog refuses to open when DefaultDirectory points at // a path that doesn't exist yet (typical for our default backups folder // on first launch). We walk up the path until we find an existing // ancestor and use that as the dialog's starting point. func (a *App) PickBackupFolder() (string, error) { if a.ctx == nil { return "", fmt.Errorf("no app context") } current, _ := a.GetBackupSettings() defaultDir := current.Folder if defaultDir == "" { defaultDir = current.DefaultFolder } defaultDir = firstExistingAncestor(defaultDir) return wruntime.OpenDirectoryDialog(a.ctx, wruntime.OpenDialogOptions{ Title: "Pick a folder for HamLog backups", DefaultDirectory: defaultDir, }) } // firstExistingAncestor returns p if it exists, otherwise the closest // parent directory that does. Returns "" if nothing valid is found (the // dialog then opens at the OS default location). func firstExistingAncestor(p string) string { p = strings.TrimSpace(p) for p != "" { if st, err := os.Stat(p); err == nil && st.IsDir() { return p } parent := filepath.Dir(p) if parent == p { break } p = parent } return "" } // 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 } a.refreshOperatorGrid() 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") } if err := a.profiles.SetActive(a.ctx, id); err != nil { return err } a.refreshOperatorGrid() return nil } // 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" } // --- DX Cluster bindings (multi-server) --- // resolveClusterLogin returns the login callsign for a server: explicit // override on the row, else the active profile's callsign. func (a *App) resolveClusterLogin(override string) string { if override != "" { return strings.ToUpper(strings.TrimSpace(override)) } if a.profiles != nil { if p, err := a.profiles.Active(a.ctx); err == nil { return strings.ToUpper(strings.TrimSpace(p.Callsign)) } } return "" } // clusterAutoConnect reads the global "auto-connect on startup" toggle. // Stored in settings (key/value) since it's a single bool, not per-row. func (a *App) clusterAutoConnect() (bool, error) { if a.settings == nil { return false, fmt.Errorf("db not initialized") } v, err := a.settings.Get(a.ctx, keyClusterAutoConnect) if err != nil { return false, err } return v == "1", nil } // startAllEnabledClusters opens a session for every enabled server. func (a *App) startAllEnabledClusters() { servers, err := a.listClusterServers() if err != nil { fmt.Println("HamLog: list cluster servers:", err) return } for _, s := range servers { if s.Enabled { a.cluster.StartServer(s, a.resolveClusterLogin(s.LoginOverride)) } } } // listClusterServers reads the cluster_servers table ordered for display // (sort_order asc, id asc). The first row with Enabled=true is the master. func (a *App) listClusterServers() ([]cluster.ServerConfig, error) { if a.db == nil { return nil, fmt.Errorf("db not initialized") } rows, err := a.db.QueryContext(a.ctx, ` SELECT id, name, host, port, login_override, password, init_commands, enabled, sort_order FROM cluster_servers ORDER BY sort_order ASC, id ASC`) if err != nil { return nil, err } defer rows.Close() var out []cluster.ServerConfig for rows.Next() { var s cluster.ServerConfig var enabled int if err := rows.Scan(&s.ID, &s.Name, &s.Host, &s.Port, &s.LoginOverride, &s.Password, &s.InitCommands, &enabled, &s.SortOrder); err != nil { return nil, err } s.Enabled = enabled == 1 out = append(out, s) } return out, rows.Err() } // ListClusterServers returns all saved cluster nodes. func (a *App) ListClusterServers() ([]cluster.ServerConfig, error) { return a.listClusterServers() } // SaveClusterServer upserts one row. id=0 inserts a new server. Restarts // the session if the row was already running (so config edits take effect // immediately). func (a *App) SaveClusterServer(s cluster.ServerConfig) (cluster.ServerConfig, error) { if a.db == nil { return cluster.ServerConfig{}, fmt.Errorf("db not initialized") } if strings.TrimSpace(s.Name) == "" { return cluster.ServerConfig{}, fmt.Errorf("server name required") } if s.Port <= 0 || s.Port > 65535 { s.Port = 7300 } now := time.Now().UTC().Format("2006-01-02T15:04:05.000Z") enabled := 0 if s.Enabled { enabled = 1 } if s.ID == 0 { res, err := a.db.ExecContext(a.ctx, ` INSERT INTO cluster_servers (name, host, port, login_override, password, init_commands, enabled, sort_order, created_at, updated_at) VALUES(?,?,?,?,?,?,?,?,?,?)`, s.Name, s.Host, s.Port, s.LoginOverride, s.Password, s.InitCommands, enabled, s.SortOrder, now, now) if err != nil { return cluster.ServerConfig{}, err } id, _ := res.LastInsertId() s.ID = id } else { _, err := a.db.ExecContext(a.ctx, ` UPDATE cluster_servers SET name=?, host=?, port=?, login_override=?, password=?, init_commands=?, enabled=?, sort_order=?, updated_at=? WHERE id=?`, s.Name, s.Host, s.Port, s.LoginOverride, s.Password, s.InitCommands, enabled, s.SortOrder, now, s.ID) if err != nil { return cluster.ServerConfig{}, err } } // Apply runtime change: stop and restart if enabled, else just stop. a.cluster.StopServer(s.ID) if s.Enabled { a.cluster.StartServer(s, a.resolveClusterLogin(s.LoginOverride)) } return s, nil } // DeleteClusterServer drops a row and closes its session. func (a *App) DeleteClusterServer(id int64) error { if a.db == nil { return fmt.Errorf("db not initialized") } a.cluster.StopServer(id) _, err := a.db.ExecContext(a.ctx, `DELETE FROM cluster_servers WHERE id=?`, id) return err } // SetClusterAutoConnect persists the global auto-connect toggle. func (a *App) SetClusterAutoConnect(on bool) error { if a.settings == nil { return fmt.Errorf("db not initialized") } return a.settings.Set(a.ctx, keyClusterAutoConnect, boolStr(on)) } // GetClusterAutoConnect reads the persisted toggle. func (a *App) GetClusterAutoConnect() (bool, error) { return a.clusterAutoConnect() } // ConnectClusterServer opens a session for one specific saved server. func (a *App) ConnectClusterServer(id int64) error { if a.cluster == nil { return fmt.Errorf("cluster not initialized") } servers, err := a.listClusterServers() if err != nil { return err } for _, s := range servers { if s.ID == id { if !s.Enabled { return fmt.Errorf("server %q is disabled — enable it first", s.Name) } a.cluster.StartServer(s, a.resolveClusterLogin(s.LoginOverride)) return nil } } return fmt.Errorf("no saved server with id %d", id) } // DisconnectClusterServer closes the session for one server. func (a *App) DisconnectClusterServer(id int64) error { if a.cluster == nil { return fmt.Errorf("cluster not initialized") } a.cluster.StopServer(id) return nil } // ConnectAllClusters opens sessions for every enabled server. func (a *App) ConnectAllClusters() error { if a.cluster == nil { return fmt.Errorf("cluster not initialized") } a.startAllEnabledClusters() return nil } // DisconnectAllClusters closes every running session. func (a *App) DisconnectAllClusters() error { if a.cluster == nil { return fmt.Errorf("cluster not initialized") } a.cluster.StopAll() return nil } // SendClusterCommand writes `cmd` to the **master** cluster — the first // enabled server by sort_order. Returns an error if the master is not // currently connected (the UI should grey the input out in that case). func (a *App) SendClusterCommand(cmd string) error { if a.cluster == nil { return fmt.Errorf("cluster not initialized") } cmd = strings.TrimSpace(cmd) if cmd == "" { return fmt.Errorf("empty command") } servers, err := a.listClusterServers() if err != nil { return err } for _, s := range servers { if s.Enabled { return a.cluster.SendCommand(s.ID, cmd) } } return fmt.Errorf("no enabled cluster server to send to") } // GetClusterStatus returns a snapshot of every active session. Used by // the UI on mount and to hydrate after a `cluster:state` event. func (a *App) GetClusterStatus() []cluster.ServerStatus { if a.cluster == nil { return nil } return a.cluster.Status() } // SpotQuery is one (call, band, mode) tuple sent for status colouring. type SpotQuery struct { Call string `json:"call"` Band string `json:"band"` Mode string `json:"mode"` } // SpotStatus is the per-tuple result. Status is one of: // // "new" — entity never worked // "new-band" — entity worked but never on this band // "new-slot" — entity worked on this band but not in this mode // "worked" — exact band+mode already in the log // "" — couldn't resolve the entity (no cty.dat match) type SpotStatus struct { Call string `json:"call"` Band string `json:"band"` Mode string `json:"mode"` Country string `json:"country,omitempty"` Continent string `json:"continent,omitempty"` Status string `json:"status"` // WorkedCall is true when this exact callsign exists in the log // (any band, any mode). Drives the per-call text highlight, in // addition to the entity-level Status (NEW / NEW BAND / …). WorkedCall bool `json:"worked_call"` } // ClusterSpotStatuses takes a batch of spots and returns slot status for // each. Used by the Cluster tab to color rows (NEW / NEW BAND / NEW SLOT // / WORKED). One cty.dat lookup + one DB scan, regardless of batch size. // // Mode handling: when the caller passes an empty Mode (cluster comment // was ambiguous and the frontend couldn't infer) we degrade gracefully // to band-only — saying "worked" rather than wrongly flagging "new-slot" // just because we don't know the mode. func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus { out := make([]SpotStatus, len(spots)) if a.qso == nil { return out } // Pass a cty.dat-backed resolver so the past-QSO map uses the SAME // entity name we'll compare each spot against. Without it QRZ-stored // "Turkey" wouldn't match cty.dat's "Asiatic Turkey" → false NEW. resolveEntity := func(callsign string) string { if a.dxcc == nil { return "" } m, ok := a.dxcc.Lookup(callsign) if !ok || m.Entity == nil { return "" } return m.Entity.Name } entities, err := a.qso.EntitySlotMap(a.ctx, resolveEntity) if err != nil { return out } // Per-call worked set — separate from the entity check so we can flag // "I've already QSO'd this exact station" even when the band/mode // makes the entity check say "new-band" or "new-slot". workedCalls, _ := a.qso.WorkedCallsigns(a.ctx) for i, q := range spots { out[i] = SpotStatus{ Call: q.Call, Band: strings.ToLower(q.Band), Mode: strings.ToUpper(q.Mode), } if _, ok := workedCalls[strings.ToUpper(q.Call)]; ok { out[i].WorkedCall = true } if a.dxcc == nil { continue } m, ok := a.dxcc.Lookup(q.Call) if !ok || m.Entity == nil { continue } country := strings.ToLower(m.Entity.Name) out[i].Country = m.Entity.Name out[i].Continent = m.Continent e, worked := entities[country] if !worked { out[i].Status = "new" continue } if _, b := e.Bands[out[i].Band]; !b { out[i].Status = "new-band" continue } // Without a mode we can't distinguish "new slot" from "worked"; // the safer default is "worked" so we never falsely claim "new". if out[i].Mode == "" { out[i].Status = "worked" continue } if _, ok := e.Slots[out[i].Band][out[i].Mode]; !ok { out[i].Status = "new-slot" continue } out[i].Status = "worked" } return out }