rigs completed

This commit is contained in:
2026-05-28 18:35:22 +02:00
parent d3c9982c66
commit e8cac569e3
26 changed files with 3834 additions and 391 deletions
+475 -1
View File
@@ -6,6 +6,7 @@ import (
"encoding/json"
"errors"
"fmt"
"math"
"os"
"path/filepath"
"strconv"
@@ -13,9 +14,11 @@ import (
"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"
@@ -63,6 +66,12 @@ const (
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
@@ -145,8 +154,104 @@ type App struct {
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
@@ -191,6 +296,7 @@ func (a *App) startup(ctx context.Context) {
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.
@@ -238,6 +344,14 @@ func (a *App) startup(ctx context.Context) {
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 {
@@ -250,6 +364,7 @@ func (a *App) startup(ctx context.Context) {
}
},
)
a.refreshOperatorGrid()
if cs, _ := a.clusterAutoConnect(); cs {
a.startAllEnabledClusters()
}
@@ -275,7 +390,118 @@ func (a *App) GetStartupStatus() StartupStatus {
}
}
// 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()
}
@@ -763,6 +989,238 @@ func (a *App) SaveCATSettings(s CATSettings) error {
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 {
@@ -998,6 +1456,7 @@ func (a *App) SaveProfile(p profile.Profile) (profile.Profile, error) {
if err := a.profiles.Save(a.ctx, &p); err != nil {
return profile.Profile{}, err
}
a.refreshOperatorGrid()
return p, nil
}
@@ -1016,7 +1475,11 @@ func (a *App) ActivateProfile(id int64) error {
if a.profiles == nil {
return fmt.Errorf("profiles not initialized")
}
return a.profiles.SetActive(a.ctx, id)
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
@@ -1396,6 +1859,10 @@ type SpotStatus struct {
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
@@ -1428,12 +1895,19 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
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
}