feat: status bar added
This commit is contained in:
@@ -9,6 +9,7 @@ import (
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -53,8 +54,11 @@ const (
|
||||
keyStationSOTA = "station.my_sota_ref"
|
||||
keyStationPOTA = "station.my_pota_ref"
|
||||
|
||||
keyListsBands = "lists.bands"
|
||||
keyListsModes = "lists.modes"
|
||||
keyListsBands = "lists.bands"
|
||||
keyListsModes = "lists.modes"
|
||||
keyListsRSTPhone = "lists.rst_phone"
|
||||
keyListsRSTCW = "lists.rst_cw"
|
||||
keyListsRSTDigital = "lists.rst_digital"
|
||||
|
||||
keyCATEnabled = "cat.enabled"
|
||||
keyCATBackend = "cat.backend" // "omnirig" (only one for now)
|
||||
@@ -151,8 +155,11 @@ type ModePreset struct {
|
||||
// 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"`
|
||||
Bands []string `json:"bands"`
|
||||
Modes []ModePreset `json:"modes"`
|
||||
RSTPhone []string `json:"rst_phone"` // RS reports for phone modes
|
||||
RSTCW []string `json:"rst_cw"` // RST reports for CW/RTTY/PSK
|
||||
RSTDigital []string `json:"rst_digital"` // dB reports for FT8/FT4/JT…
|
||||
}
|
||||
|
||||
var defaultBands = []string{
|
||||
@@ -171,6 +178,49 @@ var defaultModes = []ModePreset{
|
||||
{Name: "DIGITALVOICE", DefaultRSTSent: "59", DefaultRSTRcvd: "59"},
|
||||
}
|
||||
|
||||
// Default RST report lists, editable in Settings → Modes. Phone carries the
|
||||
// over-S9 reports (59+10…59+60) plus the full RS grid; CW the full RST grid;
|
||||
// digital the dB reports +30…-30.
|
||||
var defaultRSTPhone = buildPhoneRST()
|
||||
var defaultRSTCW = buildCWRST()
|
||||
var defaultRSTDigital = buildDigitalRST()
|
||||
|
||||
func buildPhoneRST() []string {
|
||||
out := []string{"59+60", "59+50", "59+40", "59+30", "59+20", "59+10"}
|
||||
for r := 5; r >= 1; r-- {
|
||||
for s := 9; s >= 1; s-- {
|
||||
out = append(out, fmt.Sprintf("%d%d", r, s))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
func buildCWRST() []string {
|
||||
var out []string
|
||||
for r := 5; r >= 1; r-- {
|
||||
for s := 9; s >= 1; s-- {
|
||||
for t := 9; t >= 1; t-- {
|
||||
out = append(out, fmt.Sprintf("%d%d%d", r, s, t))
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
func buildDigitalRST() []string {
|
||||
var out []string
|
||||
for db := 30; db >= -30; db-- {
|
||||
sign := "+"
|
||||
if db < 0 {
|
||||
sign = "-"
|
||||
}
|
||||
n := db
|
||||
if n < 0 {
|
||||
n = -n
|
||||
}
|
||||
out = append(out, fmt.Sprintf("%s%02d", sign, n))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -214,7 +264,8 @@ type App struct {
|
||||
udpRepo *udp.Repo
|
||||
extsvc *extsvc.Manager
|
||||
startupErr string // captured for surfacing to the frontend
|
||||
dbPath string
|
||||
dbPath string // active database file (may be a user-chosen location)
|
||||
dataDir string // %APPDATA%/OpsLog — holds config.json, logs, cty.dat
|
||||
|
||||
// shuttingDown gates beforeClose re-entry: the first user attempt to
|
||||
// close fires shutdown tasks (backup, future LoTW upload, ...) while
|
||||
@@ -316,7 +367,7 @@ func (a *App) refreshOperatorGrid() {
|
||||
// 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) {
|
||||
func (a dxccAdapter) Resolve(call string) (dxccNum int, country, continent string, cqz, ituz int, lat, lon float64, ok bool) {
|
||||
if a.m == nil {
|
||||
return
|
||||
}
|
||||
@@ -324,7 +375,7 @@ func (a dxccAdapter) Resolve(call string) (country, continent string, cqz, ituz
|
||||
if !found || mm.Entity == nil {
|
||||
return
|
||||
}
|
||||
return mm.Entity.Name, mm.Continent, mm.CQZone, mm.ITUZone, mm.Lat, mm.Lon, true
|
||||
return dxcc.EntityDXCC(mm.Entity.Name), mm.Entity.Name, mm.Continent, mm.CQZone, mm.ITUZone, mm.Lat, mm.Lon, true
|
||||
}
|
||||
|
||||
func NewApp() *App { return &App{} }
|
||||
@@ -343,12 +394,29 @@ func (a *App) startup(ctx context.Context) {
|
||||
fmt.Println("OpsLog:", a.startupErr)
|
||||
return
|
||||
}
|
||||
a.dataDir = dataDir
|
||||
a.dbPath = filepath.Join(dataDir, "opslog.db")
|
||||
// One-shot rename for users coming from the HamLog era.
|
||||
if _, err := os.Stat(a.dbPath); os.IsNotExist(err) {
|
||||
oldDB := filepath.Join(dataDir, "hamlog.db")
|
||||
if _, err := os.Stat(oldDB); err == nil {
|
||||
_ = os.Rename(oldDB, a.dbPath)
|
||||
usingDefault := true
|
||||
// config.json (in the data dir) may point the database to a user-chosen
|
||||
// location — e.g. another drive or a synced folder, so it survives a
|
||||
// Windows reinstall. It lives OUTSIDE the DB since we must know the path
|
||||
// before opening it.
|
||||
if custom := readDBPointer(dataDir); custom != "" {
|
||||
a.dbPath = custom
|
||||
usingDefault = false
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(a.dbPath), 0o755); err != nil {
|
||||
a.startupErr = "cannot create db folder: " + err.Error()
|
||||
fmt.Println("OpsLog:", a.startupErr)
|
||||
return
|
||||
}
|
||||
// One-shot rename for users coming from the HamLog era (default location only).
|
||||
if usingDefault {
|
||||
if _, err := os.Stat(a.dbPath); os.IsNotExist(err) {
|
||||
oldDB := filepath.Join(dataDir, "hamlog.db")
|
||||
if _, err := os.Stat(oldDB); err == nil {
|
||||
_ = os.Rename(oldDB, a.dbPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
if _, err := applog.Init(dataDir); err != nil {
|
||||
@@ -632,6 +700,122 @@ func userDataDir() (string, error) {
|
||||
return newDir, nil
|
||||
}
|
||||
|
||||
// ── Database location (config.json pointer) ────────────────────────────
|
||||
|
||||
// dbPointer is the tiny bootstrap config stored in the data dir. It must
|
||||
// live outside the database because we read it to decide which DB to open.
|
||||
type dbPointer struct {
|
||||
DBPath string `json:"db_path"`
|
||||
}
|
||||
|
||||
func dbPointerPath(dataDir string) string { return filepath.Join(dataDir, "config.json") }
|
||||
|
||||
// readDBPointer returns the user-chosen DB path, or "" for the default.
|
||||
func readDBPointer(dataDir string) string {
|
||||
b, err := os.ReadFile(dbPointerPath(dataDir))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
var c dbPointer
|
||||
if json.Unmarshal(b, &c) != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(c.DBPath)
|
||||
}
|
||||
|
||||
// writeDBPointer persists the chosen DB path ("" resets to default).
|
||||
func writeDBPointer(dataDir, path string) error {
|
||||
b, _ := json.MarshalIndent(dbPointer{DBPath: strings.TrimSpace(path)}, "", " ")
|
||||
return os.WriteFile(dbPointerPath(dataDir), b, 0o644)
|
||||
}
|
||||
|
||||
// DatabaseSettings describes the active database file for the Settings UI.
|
||||
type DatabaseSettings struct {
|
||||
Path string `json:"path"`
|
||||
DefaultPath string `json:"default_path"`
|
||||
IsCustom bool `json:"is_custom"`
|
||||
}
|
||||
|
||||
// GetDatabaseSettings returns where the active database lives.
|
||||
func (a *App) GetDatabaseSettings() DatabaseSettings {
|
||||
def := filepath.Join(a.dataDir, "opslog.db")
|
||||
return DatabaseSettings{Path: a.dbPath, DefaultPath: def, IsCustom: a.dbPath != def}
|
||||
}
|
||||
|
||||
// PickOpenDatabase opens a file dialog to choose an existing .db file.
|
||||
func (a *App) PickOpenDatabase() (string, error) {
|
||||
if a.ctx == nil {
|
||||
return "", fmt.Errorf("no app context")
|
||||
}
|
||||
return wruntime.OpenFileDialog(a.ctx, wruntime.OpenDialogOptions{
|
||||
Title: "Open an OpsLog database",
|
||||
DefaultDirectory: filepath.Dir(a.dbPath),
|
||||
Filters: []wruntime.FileFilter{{DisplayName: "SQLite database (*.db)", Pattern: "*.db"}},
|
||||
})
|
||||
}
|
||||
|
||||
// PickSaveDatabase opens a save dialog to choose where to put a copy.
|
||||
func (a *App) PickSaveDatabase() (string, error) {
|
||||
if a.ctx == nil {
|
||||
return "", fmt.Errorf("no app context")
|
||||
}
|
||||
return wruntime.SaveFileDialog(a.ctx, wruntime.SaveDialogOptions{
|
||||
Title: "Save the OpsLog database to…",
|
||||
DefaultFilename: "opslog.db",
|
||||
Filters: []wruntime.FileFilter{{DisplayName: "SQLite database (*.db)", Pattern: "*.db"}},
|
||||
})
|
||||
}
|
||||
|
||||
// OpenDatabase points OpsLog at an existing database file. Takes effect on
|
||||
// the next launch.
|
||||
func (a *App) OpenDatabase(path string) error {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return fmt.Errorf("no path given")
|
||||
}
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
return fmt.Errorf("database file not found: %w", err)
|
||||
}
|
||||
return writeDBPointer(a.dataDir, path)
|
||||
}
|
||||
|
||||
// MoveDatabase writes a clean copy of the current database to dest (which
|
||||
// must not exist yet) and switches OpsLog to it on the next launch. Uses
|
||||
// VACUUM INTO so the copy is consistent even with an open WAL.
|
||||
func (a *App) MoveDatabase(dest string) error {
|
||||
dest = strings.TrimSpace(dest)
|
||||
if dest == "" {
|
||||
return fmt.Errorf("no destination given")
|
||||
}
|
||||
if _, err := os.Stat(dest); err == nil {
|
||||
return fmt.Errorf("a file already exists at %s — pick a new name", dest)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
||||
return fmt.Errorf("create folder: %w", err)
|
||||
}
|
||||
if a.db == nil {
|
||||
return fmt.Errorf("database not open")
|
||||
}
|
||||
// VACUUM INTO takes a string literal; escape single quotes in the path.
|
||||
safe := strings.ReplaceAll(dest, "'", "''")
|
||||
if _, err := a.db.ExecContext(a.ctx, "VACUUM INTO '"+safe+"'"); err != nil {
|
||||
return fmt.Errorf("copy database: %w", err)
|
||||
}
|
||||
return writeDBPointer(a.dataDir, dest)
|
||||
}
|
||||
|
||||
// ResetDatabaseToDefault clears the custom location (back to the data dir).
|
||||
func (a *App) ResetDatabaseToDefault() error {
|
||||
return writeDBPointer(a.dataDir, "")
|
||||
}
|
||||
|
||||
// QuitApp closes OpsLog (used to apply a database change on next launch).
|
||||
func (a *App) QuitApp() {
|
||||
if a.ctx != nil {
|
||||
wruntime.Quit(a.ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// reloadLookupProviders rebuilds the lookup chain from current settings.
|
||||
// Called at startup and after the user saves new credentials.
|
||||
//
|
||||
@@ -721,6 +905,16 @@ type StationInfoComputed struct {
|
||||
Lon float64 `json:"lon"`
|
||||
}
|
||||
|
||||
// ListCountries returns the DXCC entity names for the Country picker, so the
|
||||
// user selects from a fixed list instead of typing (avoids typos). Empty
|
||||
// until cty.dat has loaded.
|
||||
func (a *App) ListCountries() []string {
|
||||
if a.dxcc == nil {
|
||||
return nil
|
||||
}
|
||||
return a.dxcc.EntityNames()
|
||||
}
|
||||
|
||||
// ComputeStationInfo resolves a station's structured metadata from the
|
||||
// callsign (via cty.dat) and grid (via Maidenhead → lat/lon). The
|
||||
// frontend calls this whenever Callsign or Grid changes in the Station
|
||||
@@ -1646,9 +1840,9 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
|
||||
done(matched, total)
|
||||
return
|
||||
}
|
||||
// Snapshot what's already confirmed so we can flag each incoming
|
||||
// confirmation as a NEW DXCC / band / slot.
|
||||
sets, _ := a.qso.ConfirmedSlots(ctx)
|
||||
// Snapshot award-valid confirmations (LoTW + paper QSL — the only two
|
||||
// that count for ARRL awards) so each incoming one is flagged NEW.
|
||||
sets, _ := a.qso.ConfirmedSlots(ctx, []string{"lotw_rcvd", "qsl_rcvd"})
|
||||
var items []ConfirmationItem
|
||||
perr := adif.Parse(strings.NewReader(adifText), func(rec adif.Record) error {
|
||||
q, ok := adif.RecordToQSO(rec)
|
||||
@@ -1715,12 +1909,148 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
|
||||
if a.settings != nil {
|
||||
_ = a.settings.Set(ctx, keyExtLoTWLastDownload, time.Now().UTC().Format("2006-01-02"))
|
||||
}
|
||||
|
||||
case extsvc.ServiceQRZ:
|
||||
emit("Fetching QRZ.com logbook…")
|
||||
fr, err := extsvc.FetchQRZ(ctx, nil, cfg.QRZ.APIKey, "ALL")
|
||||
if err != nil {
|
||||
emit("Fetch failed: " + err.Error())
|
||||
done(matched, total)
|
||||
return
|
||||
}
|
||||
adifText := fr.ADIF
|
||||
emit(fmt.Sprintf("QRZ RESULT=%s COUNT=%s, ADIF %d bytes", fr.Result, fr.Count, len(adifText)))
|
||||
if snip := strings.TrimSpace(adifText); snip != "" {
|
||||
if len(snip) > 300 {
|
||||
snip = snip[:300]
|
||||
}
|
||||
emit("ADIF head: " + snip)
|
||||
}
|
||||
keyIDs, _ := a.qso.DedupeKeyIDs(ctx)
|
||||
// QRZ confirmations are QRZ-specific (not award-valid), so NEW is
|
||||
// judged only against other QRZ confirmations.
|
||||
sets, _ := a.qso.ConfirmedSlots(ctx, []string{"qrzcom_qso_download_status"})
|
||||
// Ids already QRZ-confirmed locally → "ALREADY CONFIRMED" vs "UPDATED",
|
||||
// without a per-record DB read.
|
||||
alreadyQrz := map[int64]bool{}
|
||||
if rs, e := a.db.QueryContext(ctx, `SELECT id FROM qso WHERE qrzcom_qso_download_status = 'Y'`); e == nil {
|
||||
for rs.Next() {
|
||||
var id int64
|
||||
if rs.Scan(&id) == nil {
|
||||
alreadyQrz[id] = true
|
||||
}
|
||||
}
|
||||
rs.Close()
|
||||
}
|
||||
var items []ConfirmationItem
|
||||
parsed := 0
|
||||
allKeys := map[string]bool{} // union of field names seen, for diagnostics
|
||||
// QRZ FETCH returns headerless ADIF (no <EOH>); prepend one so the
|
||||
// parser treats the stream as records.
|
||||
perr := adif.Parse(strings.NewReader("<EOH>\n"+adifText), func(rec adif.Record) error {
|
||||
parsed++
|
||||
for k := range rec {
|
||||
allKeys[k] = true
|
||||
}
|
||||
if !qrzRecordConfirmed(rec) {
|
||||
return nil
|
||||
}
|
||||
q, ok := adif.RecordToQSO(rec)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
total++
|
||||
date := rec["qrzcom_qso_download_date"]
|
||||
if date == "" {
|
||||
date = time.Now().UTC().Format("20060102")
|
||||
}
|
||||
a.enrichContactedFromCty(&q)
|
||||
line := fmt.Sprintf("Callsign: %s Date: %s Band: %s Mode: %s",
|
||||
q.Callsign, q.QSODate.UTC().Format("2006-01-02 15:04"), q.Band, q.Mode)
|
||||
key := qso.DedupeKey(q.Callsign, q.QSODate.UTC().Format("2006-01-02T15:04"), q.Band, q.Mode)
|
||||
id, found := keyIDs[key]
|
||||
switch {
|
||||
case found:
|
||||
if alreadyQrz[id] {
|
||||
emit(line + " ### ALREADY CONFIRMED ###")
|
||||
} else if e := a.qso.MarkQRZConfirmed(ctx, id, date); e == nil {
|
||||
alreadyQrz[id] = true
|
||||
matched++
|
||||
emit(line + " ### UPDATED ###")
|
||||
}
|
||||
case addNotFound:
|
||||
q.QRZComUploadStatus = "Y"
|
||||
q.QRZComDownloadStatus = "Y"
|
||||
q.QRZComDownloadDate = date
|
||||
if newID, e := a.qso.Add(ctx, q); e == nil {
|
||||
keyIDs[key] = newID
|
||||
added++
|
||||
emit(line + " ### ADDED ###")
|
||||
}
|
||||
default:
|
||||
emit(line + " ### NOT IN LOG ###")
|
||||
}
|
||||
// Result row + NEW flags.
|
||||
dxccNum := 0
|
||||
if q.DXCC != nil {
|
||||
dxccNum = *q.DXCC
|
||||
}
|
||||
it := ConfirmationItem{
|
||||
Callsign: q.Callsign,
|
||||
QSODate: q.QSODate.UTC().Format(time.RFC3339),
|
||||
Band: q.Band, Mode: q.Mode, Country: q.Country,
|
||||
}
|
||||
if dxccNum != 0 {
|
||||
it.NewDXCC = !sets.DXCC[dxccNum]
|
||||
it.NewBand = !sets.Band[qso.BandKey(dxccNum, q.Band)]
|
||||
it.NewSlot = !sets.Slot[qso.SlotKey(dxccNum, q.Band, q.Mode)]
|
||||
sets.DXCC[dxccNum] = true
|
||||
sets.Band[qso.BandKey(dxccNum, q.Band)] = true
|
||||
sets.Slot[qso.SlotKey(dxccNum, q.Band, q.Mode)] = true
|
||||
}
|
||||
items = append(items, it)
|
||||
return nil
|
||||
})
|
||||
if a.ctx != nil {
|
||||
wruntime.EventsEmit(a.ctx, "qslmgr:confirmations", items)
|
||||
}
|
||||
if perr != nil {
|
||||
emit("Parse error: " + perr.Error())
|
||||
}
|
||||
// Diagnostic: the union of every field name QRZ returned, so we can
|
||||
// pin the confirmation marker against real data.
|
||||
keys := make([]string, 0, len(allKeys))
|
||||
for k := range allKeys {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
emit(fmt.Sprintf("Parsed %d record(s). Fields seen: %s", parsed, strings.Join(keys, ", ")))
|
||||
emit(fmt.Sprintf("Confirmed %d, added %d (of %d returned)", matched, added, total))
|
||||
|
||||
default:
|
||||
emit(fmt.Sprintf("Confirmation download isn't available for %s yet.", svc))
|
||||
}
|
||||
done(matched+added, total)
|
||||
}
|
||||
|
||||
// qrzRecordConfirmed reports whether a QRZ FETCH ADIF record represents a
|
||||
// confirmed QSO. QRZ's confirmation marker isn't clearly documented, so we
|
||||
// accept the likely candidates; the download's one-time field dump lets us
|
||||
// pin the exact field against real data and tighten this if needed.
|
||||
func qrzRecordConfirmed(rec adif.Record) bool {
|
||||
if strings.EqualFold(rec["qsl_rcvd"], "Y") {
|
||||
return true
|
||||
}
|
||||
if strings.EqualFold(rec["qrzcom_qso_download_status"], "Y") {
|
||||
return true
|
||||
}
|
||||
switch strings.ToUpper(strings.TrimSpace(rec["app_qrzlog_status"])) {
|
||||
case "C", "Y":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// enrichContactedFromCty fills a QSO's contacted-station country/DXCC/zones
|
||||
// from cty.dat (offline) — used when adding a not-found confirmation that
|
||||
// only carries call/band/mode/date.
|
||||
@@ -2444,12 +2774,30 @@ func (a *App) GetListsSettings() (ListsSettings, error) {
|
||||
if raw, _ := a.settings.Get(a.ctx, keyListsModes); raw != "" {
|
||||
_ = json.Unmarshal([]byte(raw), &out.Modes)
|
||||
}
|
||||
if raw, _ := a.settings.Get(a.ctx, keyListsRSTPhone); raw != "" {
|
||||
_ = json.Unmarshal([]byte(raw), &out.RSTPhone)
|
||||
}
|
||||
if raw, _ := a.settings.Get(a.ctx, keyListsRSTCW); raw != "" {
|
||||
_ = json.Unmarshal([]byte(raw), &out.RSTCW)
|
||||
}
|
||||
if raw, _ := a.settings.Get(a.ctx, keyListsRSTDigital); raw != "" {
|
||||
_ = json.Unmarshal([]byte(raw), &out.RSTDigital)
|
||||
}
|
||||
if len(out.Bands) == 0 {
|
||||
out.Bands = append([]string(nil), defaultBands...)
|
||||
}
|
||||
if len(out.Modes) == 0 {
|
||||
out.Modes = append([]ModePreset(nil), defaultModes...)
|
||||
}
|
||||
if len(out.RSTPhone) == 0 {
|
||||
out.RSTPhone = append([]string(nil), defaultRSTPhone...)
|
||||
}
|
||||
if len(out.RSTCW) == 0 {
|
||||
out.RSTCW = append([]string(nil), defaultRSTCW...)
|
||||
}
|
||||
if len(out.RSTDigital) == 0 {
|
||||
out.RSTDigital = append([]string(nil), defaultRSTDigital...)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -2469,7 +2817,23 @@ func (a *App) SaveListsSettings(l ListsSettings) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return a.settings.Set(a.ctx, keyListsModes, string(m))
|
||||
if err := a.settings.Set(a.ctx, keyListsModes, string(m)); err != nil {
|
||||
return err
|
||||
}
|
||||
for k, v := range map[string][]string{
|
||||
keyListsRSTPhone: l.RSTPhone,
|
||||
keyListsRSTCW: l.RSTCW,
|
||||
keyListsRSTDigital: l.RSTDigital,
|
||||
} {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.settings.Set(a.ctx, k, string(b)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SaveStationSettings updates only the six "basic" fields on the active
|
||||
@@ -2624,6 +2988,28 @@ func (a *App) rotatorClient() (*pst.Client, RotatorSettings, error) {
|
||||
return pst.New(s.Host, s.Port), s, nil
|
||||
}
|
||||
|
||||
// RotatorHeading is the live antenna heading for the status bar.
|
||||
type RotatorHeading struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
OK bool `json:"ok"`
|
||||
Azimuth int `json:"azimuth"`
|
||||
Raw string `json:"raw"`
|
||||
}
|
||||
|
||||
// GetRotatorHeading queries PstRotator for the current azimuth. Returns
|
||||
// Enabled=false when the rotator isn't configured. Polled by the status bar.
|
||||
func (a *App) GetRotatorHeading() RotatorHeading {
|
||||
s, err := a.GetRotatorSettings()
|
||||
if err != nil || !s.Enabled {
|
||||
return RotatorHeading{Enabled: false}
|
||||
}
|
||||
az, raw, herr := pst.New(s.Host, s.Port).Heading()
|
||||
if herr != nil {
|
||||
return RotatorHeading{Enabled: true, OK: false, Raw: raw}
|
||||
}
|
||||
return RotatorHeading{Enabled: true, OK: true, Azimuth: az, Raw: raw}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -2891,6 +3277,28 @@ func (a *App) SendClusterCommand(cmd string) error {
|
||||
return fmt.Errorf("no enabled cluster server to send to")
|
||||
}
|
||||
|
||||
// SendClusterSpot announces a DX spot on the **master** cluster (first
|
||||
// enabled server). Format is the universal DXSpider/AR-Cluster command
|
||||
// `DX <freq_khz> <call> <comment>`. The frequency is taken in kHz; call is
|
||||
// upper-cased; comment is optional (commonly the mode, e.g. "CW").
|
||||
func (a *App) SendClusterSpot(call string, freqKHz float64, comment string) error {
|
||||
call = strings.ToUpper(strings.TrimSpace(call))
|
||||
if call == "" {
|
||||
return fmt.Errorf("callsign required")
|
||||
}
|
||||
if freqKHz <= 0 {
|
||||
return fmt.Errorf("invalid frequency")
|
||||
}
|
||||
// Trim a trailing ".0" so integer kHz stay clean (14205 not 14205.0),
|
||||
// but keep sub-kHz precision when present (e.g. 10138.7).
|
||||
freqStr := strconv.FormatFloat(freqKHz, 'f', -1, 64)
|
||||
cmd := fmt.Sprintf("DX %s %s", freqStr, call)
|
||||
if c := strings.TrimSpace(comment); c != "" {
|
||||
cmd += " " + c
|
||||
}
|
||||
return a.SendClusterCommand(cmd)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
|
||||
Reference in New Issue
Block a user