up
This commit is contained in:
@@ -14,10 +14,12 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"hamlog/internal/adif"
|
"hamlog/internal/adif"
|
||||||
|
"hamlog/internal/applog"
|
||||||
"hamlog/internal/backup"
|
"hamlog/internal/backup"
|
||||||
"hamlog/internal/cat"
|
"hamlog/internal/cat"
|
||||||
"hamlog/internal/cluster"
|
"hamlog/internal/cluster"
|
||||||
"hamlog/internal/db"
|
"hamlog/internal/db"
|
||||||
|
"hamlog/internal/integrations/udp"
|
||||||
"hamlog/internal/operating"
|
"hamlog/internal/operating"
|
||||||
"hamlog/internal/dxcc"
|
"hamlog/internal/dxcc"
|
||||||
"hamlog/internal/lookup"
|
"hamlog/internal/lookup"
|
||||||
@@ -72,8 +74,33 @@ const (
|
|||||||
keyBackupRotation = "backup.rotation"
|
keyBackupRotation = "backup.rotation"
|
||||||
keyBackupZip = "backup.zip"
|
keyBackupZip = "backup.zip"
|
||||||
keyBackupLast = "backup.last_at"
|
keyBackupLast = "backup.last_at"
|
||||||
|
|
||||||
|
keyQSLDefaultQSLSent = "qsl.qsl_sent"
|
||||||
|
keyQSLDefaultQSLRcvd = "qsl.qsl_rcvd"
|
||||||
|
keyQSLDefaultLOTWSent = "qsl.lotw_sent"
|
||||||
|
keyQSLDefaultLOTWRcvd = "qsl.lotw_rcvd"
|
||||||
|
keyQSLDefaultEQSLSent = "qsl.eqsl_sent"
|
||||||
|
keyQSLDefaultEQSLRcvd = "qsl.eqsl_rcvd"
|
||||||
|
keyQSLDefaultClublogStatus = "qsl.clublog_status"
|
||||||
|
keyQSLDefaultHRDLogStatus = "qsl.hrdlog_status"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// QSLDefaults is the per-user default for the QSL / eQSL / LoTW / upload
|
||||||
|
// status fields. Applied to every QSO when the corresponding field is
|
||||||
|
// empty — both manual entry and UDP auto-log. Values are ADIF status
|
||||||
|
// codes: "Y" yes, "N" no, "R" requested, "Q" queued, "I" ignore, ""
|
||||||
|
// (empty) leaves the field untouched.
|
||||||
|
type QSLDefaults struct {
|
||||||
|
QSLSent string `json:"qsl_sent"`
|
||||||
|
QSLRcvd string `json:"qsl_rcvd"`
|
||||||
|
LOTWSent string `json:"lotw_sent"`
|
||||||
|
LOTWRcvd string `json:"lotw_rcvd"`
|
||||||
|
EQSLSent string `json:"eqsl_sent"`
|
||||||
|
EQSLRcvd string `json:"eqsl_rcvd"`
|
||||||
|
ClublogStatus string `json:"clublog_status"`
|
||||||
|
HRDLogStatus string `json:"hrdlog_status"`
|
||||||
|
}
|
||||||
|
|
||||||
// CATSettings is the user-tweakable rig-control configuration. Stored as
|
// CATSettings is the user-tweakable rig-control configuration. Stored as
|
||||||
// individual key/value pairs to keep the settings table flat.
|
// individual key/value pairs to keep the settings table flat.
|
||||||
type CATSettings struct {
|
type CATSettings struct {
|
||||||
@@ -155,6 +182,8 @@ type App struct {
|
|||||||
dxcc *dxcc.Manager
|
dxcc *dxcc.Manager
|
||||||
cluster *cluster.Manager
|
cluster *cluster.Manager
|
||||||
operating *operating.Repo
|
operating *operating.Repo
|
||||||
|
udp *udp.Manager
|
||||||
|
udpRepo *udp.Repo
|
||||||
startupErr string // captured for surfacing to the frontend
|
startupErr string // captured for surfacing to the frontend
|
||||||
dbPath string
|
dbPath string
|
||||||
|
|
||||||
@@ -277,19 +306,30 @@ func (a *App) startup(ctx context.Context) {
|
|||||||
dataDir, err := userDataDir()
|
dataDir, err := userDataDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.startupErr = "cannot resolve data dir: " + err.Error()
|
a.startupErr = "cannot resolve data dir: " + err.Error()
|
||||||
fmt.Println("HamLog:", a.startupErr)
|
fmt.Println("OpsLog:", a.startupErr)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := os.MkdirAll(dataDir, 0o755); err != nil {
|
if err := os.MkdirAll(dataDir, 0o755); err != nil {
|
||||||
a.startupErr = "cannot create data dir: " + err.Error()
|
a.startupErr = "cannot create data dir: " + err.Error()
|
||||||
fmt.Println("HamLog:", a.startupErr)
|
fmt.Println("OpsLog:", a.startupErr)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
a.dbPath = filepath.Join(dataDir, "hamlog.db")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := applog.Init(dataDir); err != nil {
|
||||||
|
fmt.Println("OpsLog: log init:", err)
|
||||||
|
}
|
||||||
|
applog.Printf("startup: data dir = %s", dataDir)
|
||||||
conn, err := db.Open(a.dbPath)
|
conn, err := db.Open(a.dbPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.startupErr = "cannot open db: " + err.Error()
|
a.startupErr = "cannot open db: " + err.Error()
|
||||||
fmt.Println("HamLog:", a.startupErr)
|
fmt.Println("OpsLog:", a.startupErr)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
a.db = conn
|
a.db = conn
|
||||||
@@ -297,6 +337,9 @@ func (a *App) startup(ctx context.Context) {
|
|||||||
a.settings = settings.NewStore(conn)
|
a.settings = settings.NewStore(conn)
|
||||||
a.profiles = profile.NewRepo(conn)
|
a.profiles = profile.NewRepo(conn)
|
||||||
a.operating = operating.NewRepo(conn)
|
a.operating = operating.NewRepo(conn)
|
||||||
|
a.udpRepo = udp.NewRepo(conn)
|
||||||
|
a.udp = udp.NewManager(a.udpRepo)
|
||||||
|
go a.consumeUDPEvents()
|
||||||
// On first run, copy the legacy single-station settings into a
|
// On first run, copy the legacy single-station settings into a
|
||||||
// "Default" profile so the user's existing config carries over without
|
// "Default" profile so the user's existing config carries over without
|
||||||
// any manual step. Subsequent runs just confirm an active profile.
|
// any manual step. Subsequent runs just confirm an active profile.
|
||||||
@@ -308,7 +351,7 @@ func (a *App) startup(ctx context.Context) {
|
|||||||
SOTA: keyStationSOTA,
|
SOTA: keyStationSOTA,
|
||||||
POTA: keyStationPOTA,
|
POTA: keyStationPOTA,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
fmt.Println("HamLog: EnsureDefault profile:", err)
|
fmt.Println("OpsLog: EnsureDefault profile:", err)
|
||||||
}
|
}
|
||||||
a.cache = lookup.NewCache(conn, 30*24*time.Hour)
|
a.cache = lookup.NewCache(conn, 30*24*time.Hour)
|
||||||
a.lookup = lookup.NewManager(a.cache)
|
a.lookup = lookup.NewManager(a.cache)
|
||||||
@@ -321,10 +364,10 @@ func (a *App) startup(ctx context.Context) {
|
|||||||
a.lookup.SetDXCCResolver(dxccAdapter{m: a.dxcc})
|
a.lookup.SetDXCCResolver(dxccAdapter{m: a.dxcc})
|
||||||
go func() {
|
go func() {
|
||||||
if err := a.dxcc.EnsureLoaded(context.Background()); err != nil {
|
if err := a.dxcc.EnsureLoaded(context.Background()); err != nil {
|
||||||
fmt.Println("HamLog: cty.dat unavailable —", err)
|
fmt.Println("OpsLog: cty.dat unavailable —", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
fmt.Println("HamLog: cty.dat loaded —", a.dxcc.Info().Entities, "entities")
|
fmt.Println("OpsLog: cty.dat loaded —", a.dxcc.Info().Entities, "entities")
|
||||||
}()
|
}()
|
||||||
// CAT manager: emit pushes state to the frontend via Wails events.
|
// CAT manager: emit pushes state to the frontend via Wails events.
|
||||||
a.cat = cat.NewManager(func(s cat.RigState) {
|
a.cat = cat.NewManager(func(s cat.RigState) {
|
||||||
@@ -368,8 +411,13 @@ func (a *App) startup(ctx context.Context) {
|
|||||||
if cs, _ := a.clusterAutoConnect(); cs {
|
if cs, _ := a.clusterAutoConnect(); cs {
|
||||||
a.startAllEnabledClusters()
|
a.startAllEnabledClusters()
|
||||||
}
|
}
|
||||||
|
if errs := a.udp.Reload(a.ctx); len(errs) > 0 {
|
||||||
|
for _, e := range errs {
|
||||||
|
fmt.Println("OpsLog: udp:", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fmt.Println("HamLog: db ready at", a.dbPath)
|
fmt.Println("OpsLog: db ready at", a.dbPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
// StartupStatus returns a diagnostic snapshot for the frontend.
|
// StartupStatus returns a diagnostic snapshot for the frontend.
|
||||||
@@ -502,17 +550,33 @@ func (a *App) shutdown(ctx context.Context) {
|
|||||||
if !a.shuttingDown {
|
if !a.shuttingDown {
|
||||||
a.maybeShutdownBackup()
|
a.maybeShutdownBackup()
|
||||||
}
|
}
|
||||||
|
if a.udp != nil {
|
||||||
|
a.udp.StopAll()
|
||||||
|
}
|
||||||
if a.db != nil {
|
if a.db != nil {
|
||||||
_ = a.db.Close()
|
_ = a.db.Close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// userDataDir returns the OpsLog data directory under the user's config
|
||||||
|
// dir. The app was previously called HamLog — if the old folder exists
|
||||||
|
// and the new one doesn't, we rename it atomically so the user keeps
|
||||||
|
// their database, settings and cluster history through the rebrand.
|
||||||
func userDataDir() (string, error) {
|
func userDataDir() (string, error) {
|
||||||
base, err := os.UserConfigDir()
|
base, err := os.UserConfigDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return filepath.Join(base, "HamLog"), nil
|
newDir := filepath.Join(base, "OpsLog")
|
||||||
|
oldDir := filepath.Join(base, "HamLog")
|
||||||
|
if _, err := os.Stat(newDir); os.IsNotExist(err) {
|
||||||
|
if _, err := os.Stat(oldDir); err == nil {
|
||||||
|
// One-shot migration: HamLog → OpsLog. Best-effort: on
|
||||||
|
// failure we fall through and create OpsLog fresh.
|
||||||
|
_ = os.Rename(oldDir, newDir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return newDir, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// reloadLookupProviders rebuilds the lookup chain from current settings.
|
// reloadLookupProviders rebuilds the lookup chain from current settings.
|
||||||
@@ -529,7 +593,7 @@ func (a *App) reloadLookupProviders() {
|
|||||||
keyQRZUser, keyQRZPassword, keyHQUser, keyHQPassword,
|
keyQRZUser, keyQRZPassword, keyHQUser, keyHQPassword,
|
||||||
keyCacheTTL, keyLookupPrimary, keyLookupFailsafe)
|
keyCacheTTL, keyLookupPrimary, keyLookupFailsafe)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println("HamLog: settings load error:", err)
|
fmt.Println("OpsLog: settings load error:", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if days, _ := strconv.Atoi(m[keyCacheTTL]); days > 0 {
|
if days, _ := strconv.Atoi(m[keyCacheTTL]); days > 0 {
|
||||||
@@ -582,9 +646,60 @@ func (a *App) AddQSO(q qso.QSO) (int64, error) {
|
|||||||
return 0, fmt.Errorf("db not initialized")
|
return 0, fmt.Errorf("db not initialized")
|
||||||
}
|
}
|
||||||
a.applyStationDefaults(&q)
|
a.applyStationDefaults(&q)
|
||||||
|
a.applyDXCCNumber(&q)
|
||||||
|
a.applyQSLDefaults(&q)
|
||||||
return a.qso.Add(a.ctx, q)
|
return a.qso.Add(a.ctx, q)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// StationInfoComputed bundles the data we resolve live from the
|
||||||
|
// profile's callsign + grid: country, ARRL DXCC#, CQ zone, ITU zone,
|
||||||
|
// lat/lon. Used by the Settings UI to show the "what will be stamped on
|
||||||
|
// each QSO" preview next to the editable fields.
|
||||||
|
type StationInfoComputed struct {
|
||||||
|
Country string `json:"country"`
|
||||||
|
DXCC int `json:"dxcc"`
|
||||||
|
CQZ int `json:"cqz"`
|
||||||
|
ITUZ int `json:"ituz"`
|
||||||
|
Lat float64 `json:"lat"`
|
||||||
|
Lon float64 `json:"lon"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// Information panel so the user sees the auto-filled values live.
|
||||||
|
func (a *App) ComputeStationInfo(callsign, grid string) StationInfoComputed {
|
||||||
|
var out StationInfoComputed
|
||||||
|
if a.dxcc != nil && callsign != "" {
|
||||||
|
if m, ok := a.dxcc.Lookup(callsign); ok && m.Entity != nil {
|
||||||
|
out.Country = m.Entity.Name
|
||||||
|
out.CQZ = m.CQZone
|
||||||
|
out.ITUZ = m.ITUZone
|
||||||
|
out.Lat = m.Lat
|
||||||
|
out.Lon = m.Lon
|
||||||
|
out.DXCC = dxcc.EntityDXCC(m.Entity.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Grid wins on lat/lon — it's user-set, finer than the DXCC centroid.
|
||||||
|
if lat, lon, ok := gridToLatLon(grid); ok {
|
||||||
|
out.Lat = lat
|
||||||
|
out.Lon = lon
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyDXCCNumber fills DXCC (contacted station) from the cty.dat entity
|
||||||
|
// name when it's empty. Same lookup as applyStationDefaults does for
|
||||||
|
// MY_DXCC — uses our entity-name → ADIF DXCC# table since cty.dat itself
|
||||||
|
// doesn't store the ARRL number.
|
||||||
|
func (a *App) applyDXCCNumber(q *qso.QSO) {
|
||||||
|
if q.DXCC == nil && q.Country != "" {
|
||||||
|
if n := dxcc.EntityDXCC(q.Country); n != 0 {
|
||||||
|
q.DXCC = &n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// applyStationDefaults fills any empty MY_* / station field on q with the
|
// applyStationDefaults fills any empty MY_* / station field on q with the
|
||||||
// currently-active profile's values. Multi-profile support means a user
|
// currently-active profile's values. Multi-profile support means a user
|
||||||
// can be /P with a different callsign + grid + SOTA ref than home — the
|
// can be /P with a different callsign + grid + SOTA ref than home — the
|
||||||
@@ -640,6 +755,53 @@ func (a *App) applyStationDefaults(q *qso.QSO) {
|
|||||||
v := *p.TxPower
|
v := *p.TxPower
|
||||||
q.TXPower = &v
|
q.TXPower = &v
|
||||||
}
|
}
|
||||||
|
// Resolve my zones / lat / lon via cty.dat using the profile's
|
||||||
|
// callsign. The profile only stores the human-friendly fields
|
||||||
|
// (callsign, grid, country name); cty.dat fills the structured
|
||||||
|
// DXCC metadata that the ADIF spec wants for every QSO.
|
||||||
|
if a.dxcc != nil && p.Callsign != "" {
|
||||||
|
if m, ok := a.dxcc.Lookup(p.Callsign); ok && m.Entity != nil {
|
||||||
|
if q.MyCQZone == nil && m.CQZone != 0 {
|
||||||
|
v := m.CQZone
|
||||||
|
q.MyCQZone = &v
|
||||||
|
}
|
||||||
|
if q.MyITUZone == nil && m.ITUZone != 0 {
|
||||||
|
v := m.ITUZone
|
||||||
|
q.MyITUZone = &v
|
||||||
|
}
|
||||||
|
if q.MyCountry == "" && m.Entity.Name != "" {
|
||||||
|
q.MyCountry = m.Entity.Name
|
||||||
|
}
|
||||||
|
if q.MyDXCC == nil {
|
||||||
|
if n := dxcc.EntityDXCC(m.Entity.Name); n != 0 {
|
||||||
|
q.MyDXCC = &n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Lat/Lon: prefer the profile's grid (more precise than the
|
||||||
|
// DXCC entity centroid). Fall back to cty.dat coordinates.
|
||||||
|
if q.MyLat == nil || q.MyLon == nil {
|
||||||
|
if lat, lon, gOK := gridToLatLon(p.MyGrid); gOK {
|
||||||
|
if q.MyLat == nil {
|
||||||
|
v := lat
|
||||||
|
q.MyLat = &v
|
||||||
|
}
|
||||||
|
if q.MyLon == nil {
|
||||||
|
v := lon
|
||||||
|
q.MyLon = &v
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if q.MyLat == nil && m.Lat != 0 {
|
||||||
|
v := m.Lat
|
||||||
|
q.MyLat = &v
|
||||||
|
}
|
||||||
|
if q.MyLon == nil && m.Lon != 0 {
|
||||||
|
v := m.Lon
|
||||||
|
q.MyLon = &v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) ListQSO(f qso.ListFilter) ([]qso.QSO, error) {
|
func (a *App) ListQSO(f qso.ListFilter) ([]qso.QSO, error) {
|
||||||
@@ -750,9 +912,9 @@ func (a *App) ImportADIF(path string, skipDuplicates bool) (adif.ImportResult, e
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SaveADIFFile shows a native Save-As dialog suggesting a timestamped
|
// SaveADIFFile shows a native Save-As dialog suggesting a timestamped
|
||||||
// HamLog_YYYYMMDD_HHMMSS.adi filename. Returns "" if the user cancelled.
|
// OpsLog_YYYYMMDD_HHMMSS.adi filename. Returns "" if the user cancelled.
|
||||||
func (a *App) SaveADIFFile() (string, error) {
|
func (a *App) SaveADIFFile() (string, error) {
|
||||||
suggested := "HamLog_" + time.Now().UTC().Format("20060102_150405") + ".adi"
|
suggested := "OpsLog_" + time.Now().UTC().Format("20060102_150405") + ".adi"
|
||||||
return wruntime.SaveFileDialog(a.ctx, wruntime.SaveDialogOptions{
|
return wruntime.SaveFileDialog(a.ctx, wruntime.SaveDialogOptions{
|
||||||
Title: "Export ADIF",
|
Title: "Export ADIF",
|
||||||
DefaultFilename: suggested,
|
DefaultFilename: suggested,
|
||||||
@@ -772,7 +934,7 @@ func (a *App) ExportADIF(path string) (adif.ExportResult, error) {
|
|||||||
if path == "" {
|
if path == "" {
|
||||||
return adif.ExportResult{}, fmt.Errorf("empty path")
|
return adif.ExportResult{}, fmt.Errorf("empty path")
|
||||||
}
|
}
|
||||||
ex := &adif.Exporter{Repo: a.qso, AppName: "HamLog", AppVersion: "0.1"}
|
ex := &adif.Exporter{Repo: a.qso, AppName: "OpsLog", AppVersion: "0.1"}
|
||||||
return ex.ExportFile(a.ctx, path)
|
return ex.ExportFile(a.ctx, path)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -989,6 +1151,276 @@ func (a *App) SaveCATSettings(s CATSettings) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetLogFilePath returns where the diagnostic log file lives so the user
|
||||||
|
// can open it from the Settings UI. Empty when applog hasn't initialised.
|
||||||
|
func (a *App) GetLogFilePath() string {
|
||||||
|
return applog.Path()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── QSL defaults ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// GetQSLDefaults returns the stored defaults — empty strings when the
|
||||||
|
// user hasn't configured anything (= leave QSO fields untouched).
|
||||||
|
func (a *App) GetQSLDefaults() (QSLDefaults, error) {
|
||||||
|
out := QSLDefaults{}
|
||||||
|
if a.settings == nil {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
m, err := a.settings.GetMany(a.ctx,
|
||||||
|
keyQSLDefaultQSLSent, keyQSLDefaultQSLRcvd,
|
||||||
|
keyQSLDefaultLOTWSent, keyQSLDefaultLOTWRcvd,
|
||||||
|
keyQSLDefaultEQSLSent, keyQSLDefaultEQSLRcvd,
|
||||||
|
keyQSLDefaultClublogStatus, keyQSLDefaultHRDLogStatus,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
out.QSLSent = m[keyQSLDefaultQSLSent]
|
||||||
|
out.QSLRcvd = m[keyQSLDefaultQSLRcvd]
|
||||||
|
out.LOTWSent = m[keyQSLDefaultLOTWSent]
|
||||||
|
out.LOTWRcvd = m[keyQSLDefaultLOTWRcvd]
|
||||||
|
out.EQSLSent = m[keyQSLDefaultEQSLSent]
|
||||||
|
out.EQSLRcvd = m[keyQSLDefaultEQSLRcvd]
|
||||||
|
out.ClublogStatus = m[keyQSLDefaultClublogStatus]
|
||||||
|
out.HRDLogStatus = m[keyQSLDefaultHRDLogStatus]
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveQSLDefaults persists the configured defaults. Future QSO inserts
|
||||||
|
// pick them up automatically — no app restart needed.
|
||||||
|
func (a *App) SaveQSLDefaults(d QSLDefaults) error {
|
||||||
|
if a.settings == nil {
|
||||||
|
return fmt.Errorf("db not initialized")
|
||||||
|
}
|
||||||
|
for k, v := range map[string]string{
|
||||||
|
keyQSLDefaultQSLSent: strings.ToUpper(strings.TrimSpace(d.QSLSent)),
|
||||||
|
keyQSLDefaultQSLRcvd: strings.ToUpper(strings.TrimSpace(d.QSLRcvd)),
|
||||||
|
keyQSLDefaultLOTWSent: strings.ToUpper(strings.TrimSpace(d.LOTWSent)),
|
||||||
|
keyQSLDefaultLOTWRcvd: strings.ToUpper(strings.TrimSpace(d.LOTWRcvd)),
|
||||||
|
keyQSLDefaultEQSLSent: strings.ToUpper(strings.TrimSpace(d.EQSLSent)),
|
||||||
|
keyQSLDefaultEQSLRcvd: strings.ToUpper(strings.TrimSpace(d.EQSLRcvd)),
|
||||||
|
keyQSLDefaultClublogStatus: strings.ToUpper(strings.TrimSpace(d.ClublogStatus)),
|
||||||
|
keyQSLDefaultHRDLogStatus: strings.ToUpper(strings.TrimSpace(d.HRDLogStatus)),
|
||||||
|
} {
|
||||||
|
if err := a.settings.Set(a.ctx, k, v); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyQSLDefaults stamps the user-configured defaults onto a QSO when
|
||||||
|
// the corresponding fields are still empty. Called from every save path
|
||||||
|
// (manual entry via AddQSO, UDP auto-log via LogUDPLoggedADIF) so the
|
||||||
|
// confirmations columns always reflect the user's preferences.
|
||||||
|
func (a *App) applyQSLDefaults(q *qso.QSO) {
|
||||||
|
if a.settings == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
d, err := a.GetQSLDefaults()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if q.QSLSent == "" { q.QSLSent = d.QSLSent }
|
||||||
|
if q.QSLRcvd == "" { q.QSLRcvd = d.QSLRcvd }
|
||||||
|
if q.LOTWSent == "" { q.LOTWSent = d.LOTWSent }
|
||||||
|
if q.LOTWRcvd == "" { q.LOTWRcvd = d.LOTWRcvd }
|
||||||
|
if q.EQSLSent == "" { q.EQSLSent = d.EQSLSent }
|
||||||
|
if q.EQSLRcvd == "" { q.EQSLRcvd = d.EQSLRcvd }
|
||||||
|
if q.ClublogUploadStatus == "" { q.ClublogUploadStatus = d.ClublogStatus }
|
||||||
|
if q.HRDLogUploadStatus == "" { q.HRDLogUploadStatus = d.HRDLogStatus }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── UDP integrations ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// ListUDPIntegrations returns every saved UDP connection row.
|
||||||
|
func (a *App) ListUDPIntegrations() ([]udp.Config, error) {
|
||||||
|
if a.udpRepo == nil {
|
||||||
|
return nil, fmt.Errorf("db not initialized")
|
||||||
|
}
|
||||||
|
return a.udpRepo.List(a.ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveUDPIntegration upserts a UDP connection and reloads the manager so
|
||||||
|
// inbound listeners pick up the change without an app restart. Reload
|
||||||
|
// errors are surfaced — a "port already in use" failure should reach the
|
||||||
|
// user rather than be silently dropped.
|
||||||
|
func (a *App) SaveUDPIntegration(c udp.Config) (udp.Config, error) {
|
||||||
|
if a.udpRepo == nil {
|
||||||
|
return c, fmt.Errorf("db not initialized")
|
||||||
|
}
|
||||||
|
if err := a.udpRepo.Save(a.ctx, &c); err != nil {
|
||||||
|
return c, err
|
||||||
|
}
|
||||||
|
if a.udp != nil {
|
||||||
|
errs := a.udp.Reload(a.ctx)
|
||||||
|
if len(errs) > 0 {
|
||||||
|
return c, fmt.Errorf("listener errors: %s", strings.Join(errs, "; "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteUDPIntegration removes a row and reloads the manager.
|
||||||
|
func (a *App) DeleteUDPIntegration(id int64) error {
|
||||||
|
if a.udpRepo == nil {
|
||||||
|
return fmt.Errorf("db not initialized")
|
||||||
|
}
|
||||||
|
if err := a.udpRepo.Delete(a.ctx, id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if a.udp != nil {
|
||||||
|
a.udp.Reload(a.ctx)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReloadUDPIntegrations is a no-arg way for the UI to force a restart
|
||||||
|
// (e.g. after toggling Enabled on a row).
|
||||||
|
func (a *App) ReloadUDPIntegrations() []string {
|
||||||
|
if a.udp == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return a.udp.Reload(a.ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogUDPLoggedADIF takes an ADIF blob received over UDP and inserts the
|
||||||
|
// first record into the local logbook. Returns the ID of the inserted
|
||||||
|
// row. Used by the auto-log handler (WSJT-X / JTDX / MSHV / JTAlert).
|
||||||
|
func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) {
|
||||||
|
if a.qso == nil {
|
||||||
|
return 0, fmt.Errorf("db not initialized")
|
||||||
|
}
|
||||||
|
// Pull the first record out of the payload. WSJT-X / JTDX / MSHV
|
||||||
|
// always send a single QSO per UDP packet (no header) but we tolerate
|
||||||
|
// either form via adif.Parse.
|
||||||
|
var record adif.Record
|
||||||
|
err := adif.Parse(strings.NewReader(adifText), func(rec adif.Record) error {
|
||||||
|
if record == nil {
|
||||||
|
record = rec
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("parse adif: %w", err)
|
||||||
|
}
|
||||||
|
if record == nil {
|
||||||
|
// Some senders skip the <EOH> header; try treating the whole
|
||||||
|
// payload as a single record by prepending a fake header.
|
||||||
|
err := adif.Parse(strings.NewReader("<EOH>"+adifText), func(rec adif.Record) error {
|
||||||
|
if record == nil {
|
||||||
|
record = rec
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil || record == nil {
|
||||||
|
return 0, fmt.Errorf("no valid QSO record in payload")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
q, ok := adif.RecordToQSO(record)
|
||||||
|
if !ok {
|
||||||
|
return 0, fmt.Errorf("record missing required fields (call/band/mode/date)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Lookup-based enrichment ──
|
||||||
|
// WSJT sends only call/freq/mode/RST/date. Fill Name/QTH/Country/
|
||||||
|
// Grid/CQZ/ITUZ/DXCC/Continent via the lookup chain (QRZ/HamQTH/
|
||||||
|
// cty.dat). Best-effort: a network failure shouldn't block the log.
|
||||||
|
if a.lookup != nil {
|
||||||
|
if lr, lerr := a.lookup.Lookup(a.ctx, q.Callsign); lerr == nil {
|
||||||
|
if q.Name == "" { q.Name = lr.Name }
|
||||||
|
if q.QTH == "" { q.QTH = lr.QTH }
|
||||||
|
if q.Country == "" { q.Country = lr.Country }
|
||||||
|
if q.Grid == "" { q.Grid = lr.Grid }
|
||||||
|
if q.Continent == "" { q.Continent = lr.Continent }
|
||||||
|
if q.State == "" { q.State = lr.State }
|
||||||
|
if q.County == "" { q.County = lr.County }
|
||||||
|
if q.Address == "" { q.Address = lr.Address }
|
||||||
|
if q.Email == "" { q.Email = lr.Email }
|
||||||
|
if q.DXCC == nil && lr.DXCC != 0 { v := lr.DXCC; q.DXCC = &v }
|
||||||
|
if q.CQZ == nil && lr.CQZ != 0 { v := lr.CQZ; q.CQZ = &v }
|
||||||
|
if q.ITUZ == nil && lr.ITUZ != 0 { v := lr.ITUZ; q.ITUZ = &v }
|
||||||
|
if q.Lat == nil && lr.Lat != 0 { v := lr.Lat; q.Lat = &v }
|
||||||
|
if q.Lon == nil && lr.Lon != 0 { v := lr.Lon; q.Lon = &v }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Operating-conditions stamp ──
|
||||||
|
// Pre-fill MY_RIG / MY_ANTENNA / TX_PWR from the default antenna for
|
||||||
|
// this band (if the user has configured Operating conditions).
|
||||||
|
if a.operating != nil && a.profiles != nil {
|
||||||
|
if p, err := a.profiles.Active(a.ctx); err == nil {
|
||||||
|
if d, ok2, _ := a.operating.BandDefault(a.ctx, p.ID, q.Band); ok2 {
|
||||||
|
if q.MyRig == "" { q.MyRig = d.StationName }
|
||||||
|
if q.MyAntenna == "" { q.MyAntenna = d.AntennaName }
|
||||||
|
if q.TXPower == nil && d.TXPower != nil { v := *d.TXPower; q.TXPower = &v }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DXCC# + QSL defaults ──
|
||||||
|
// applyDXCCNumber stamps the contacted-station DXCC# from the
|
||||||
|
// entity-name table; QSL defaults are applied last so explicit ADIF
|
||||||
|
// fields (or what the lookup gave us) always win.
|
||||||
|
a.applyDXCCNumber(&q)
|
||||||
|
a.applyQSLDefaults(&q)
|
||||||
|
|
||||||
|
// ── Dedup ──
|
||||||
|
// Match by call + minute + band + mode (same key the importer uses).
|
||||||
|
seen, err := a.qso.ExistingDedupeKeys(a.ctx)
|
||||||
|
if err == nil {
|
||||||
|
key := qso.DedupeKey(q.Callsign, q.QSODate.UTC().Format("2006-01-02T15:04"), q.Band, q.Mode)
|
||||||
|
if _, dup := seen[key]; dup {
|
||||||
|
return 0, fmt.Errorf("duplicate (already in log)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := a.qso.Add(a.ctx, q)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("insert qso: %w", err)
|
||||||
|
}
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// consumeUDPEvents bridges parsed UDP events to the frontend over Wails'
|
||||||
|
// event bus. The frontend listens on:
|
||||||
|
// udp:dx_call → string callsign (also Grid/Mode/Freq when known)
|
||||||
|
// udp:logged_qso → ADIF text of a QSO that finished in WSJT-X/JTDX/MSHV
|
||||||
|
// udp:remote_call → string callsign from a remote-control source
|
||||||
|
func (a *App) consumeUDPEvents() {
|
||||||
|
if a.udp == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for ev := range a.udp.Events() {
|
||||||
|
if a.ctx == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case ev.LoggedADIF != "":
|
||||||
|
applog.Printf("udp: emit udp:logged_qso (%d bytes ADIF)\n", len(ev.LoggedADIF))
|
||||||
|
wruntime.EventsEmit(a.ctx, "udp:logged_qso", map[string]any{
|
||||||
|
"config_id": ev.ConfigID,
|
||||||
|
"service": string(ev.Service),
|
||||||
|
"source": ev.Source,
|
||||||
|
"adif": ev.LoggedADIF,
|
||||||
|
})
|
||||||
|
case ev.DXCall != "" && ev.Service == udp.ServiceRemoteCall:
|
||||||
|
applog.Printf("udp: emit udp:remote_call %q\n", ev.DXCall)
|
||||||
|
wruntime.EventsEmit(a.ctx, "udp:remote_call", ev.DXCall)
|
||||||
|
case ev.DXCall != "":
|
||||||
|
applog.Printf("udp: emit udp:dx_call %q (mode=%s freq=%d)\n", ev.DXCall, ev.Mode, ev.FreqHz)
|
||||||
|
wruntime.EventsEmit(a.ctx, "udp:dx_call", map[string]any{
|
||||||
|
"call": ev.DXCall,
|
||||||
|
"grid": ev.DXGrid,
|
||||||
|
"mode": ev.Mode,
|
||||||
|
"freq_hz": ev.FreqHz,
|
||||||
|
"service": string(ev.Service),
|
||||||
|
"source": ev.Source,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Operating conditions ───────────────────────────────────────────────
|
// ── Operating conditions ───────────────────────────────────────────────
|
||||||
|
|
||||||
// ListOperatingTree returns the stations/antennas/bands tree for the
|
// ListOperatingTree returns the stations/antennas/bands tree for the
|
||||||
@@ -1173,7 +1605,7 @@ func (a *App) maybeShutdownBackup() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if _, err := backup.Run(a.ctx, a.db, a.dbPath, folder, s.Rotation, s.Zip); err != nil {
|
if _, err := backup.Run(a.ctx, a.db, a.dbPath, folder, s.Rotation, s.Zip); err != nil {
|
||||||
fmt.Println("HamLog: shutdown backup failed:", err)
|
fmt.Println("OpsLog: shutdown backup failed:", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_ = a.settings.Set(a.ctx, keyBackupLast, time.Now().UTC().Format(time.RFC3339))
|
_ = a.settings.Set(a.ctx, keyBackupLast, time.Now().UTC().Format(time.RFC3339))
|
||||||
@@ -1198,7 +1630,7 @@ func (a *App) PickBackupFolder() (string, error) {
|
|||||||
}
|
}
|
||||||
defaultDir = firstExistingAncestor(defaultDir)
|
defaultDir = firstExistingAncestor(defaultDir)
|
||||||
return wruntime.OpenDirectoryDialog(a.ctx, wruntime.OpenDialogOptions{
|
return wruntime.OpenDirectoryDialog(a.ctx, wruntime.OpenDialogOptions{
|
||||||
Title: "Pick a folder for HamLog backups",
|
Title: "Pick a folder for OpsLog backups",
|
||||||
DefaultDirectory: defaultDir,
|
DefaultDirectory: defaultDir,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1645,7 +2077,7 @@ func (a *App) clusterAutoConnect() (bool, error) {
|
|||||||
func (a *App) startAllEnabledClusters() {
|
func (a *App) startAllEnabledClusters() {
|
||||||
servers, err := a.listClusterServers()
|
servers, err := a.listClusterServers()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println("HamLog: list cluster servers:", err)
|
fmt.Println("OpsLog: list cluster servers:", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for _, s := range servers {
|
for _, s := range servers {
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 130 KiB After Width: | Height: | Size: 38 KiB |
@@ -0,0 +1,18 @@
|
|||||||
|
<svg viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<!-- Background: warm cream rounded square (matches OpsLog palette) -->
|
||||||
|
<rect width="256" height="256" rx="48" fill="#faf6ea"/>
|
||||||
|
<!-- "Q" ring -->
|
||||||
|
<circle cx="116" cy="120" r="68" fill="none"
|
||||||
|
stroke="#b8410c" stroke-width="22"/>
|
||||||
|
<!-- Q tail merging into radio waves -->
|
||||||
|
<path d="M 162 166 L 196 200"
|
||||||
|
fill="none" stroke="#b8410c" stroke-width="22"
|
||||||
|
stroke-linecap="round"/>
|
||||||
|
<!-- 3 expanding wave arcs to the bottom-right (signal emission) -->
|
||||||
|
<path d="M 184 188 Q 208 188 208 212"
|
||||||
|
fill="none" stroke="#7a4a14" stroke-width="7" stroke-linecap="round"/>
|
||||||
|
<path d="M 172 200 Q 204 200 204 232"
|
||||||
|
fill="none" stroke="#7a4a14" stroke-width="7" stroke-linecap="round" opacity="0.75"/>
|
||||||
|
<path d="M 160 212 Q 200 212 200 244" transform="translate(-10 -2)"
|
||||||
|
fill="none" stroke="#7a4a14" stroke-width="7" stroke-linecap="round" opacity="0.45"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 967 B |
Binary file not shown.
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 31 KiB |
+34
-4
@@ -20,6 +20,7 @@ import {
|
|||||||
ListClusterServers, ClusterSpotStatuses,
|
ListClusterServers, ClusterSpotStatuses,
|
||||||
GetCATSettings,
|
GetCATSettings,
|
||||||
OperatingDefaultForBand,
|
OperatingDefaultForBand,
|
||||||
|
LogUDPLoggedADIF,
|
||||||
} from '../wailsjs/go/main/App';
|
} from '../wailsjs/go/main/App';
|
||||||
import { EventsOn } from '../wailsjs/runtime/runtime';
|
import { EventsOn } from '../wailsjs/runtime/runtime';
|
||||||
import type { adif as adifModels, lookup as lookupModels, cat as catModels } from '../wailsjs/go/models';
|
import type { adif as adifModels, lookup as lookupModels, cat as catModels } from '../wailsjs/go/models';
|
||||||
@@ -246,7 +247,7 @@ export default function App() {
|
|||||||
|
|
||||||
// CAT — receives live rig state via Wails events.
|
// CAT — receives live rig state via Wails events.
|
||||||
const [catState, setCatState] = useState<CATState>({ enabled: false, connected: false } as any);
|
const [catState, setCatState] = useState<CATState>({ enabled: false, connected: false } as any);
|
||||||
// Mode HamLog shows when the rig reports generic DIG_U/DIG_L. OmniRig
|
// Mode OpsLog shows when the rig reports generic DIG_U/DIG_L. OmniRig
|
||||||
// can't tell us if it's FT8 vs FT4 vs RTTY, so the user picks the default
|
// can't tell us if it's FT8 vs FT4 vs RTTY, so the user picks the default
|
||||||
// in Preferences > Hardware > CAT interface.
|
// in Preferences > Hardware > CAT interface.
|
||||||
const digitalDefaultRef = useRef<string>('FT8');
|
const digitalDefaultRef = useRef<string>('FT8');
|
||||||
@@ -637,6 +638,35 @@ export default function App() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// ── UDP integration events ───────────────────────────────────────────
|
||||||
|
// Live updates from external apps (WSJT-X / JTDX / MSHV / DXHunter…).
|
||||||
|
// We push the broadcast DX call into the entry field and auto-log any
|
||||||
|
// ADIF record that arrives.
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubDX = EventsOn('udp:dx_call', (p: any) => {
|
||||||
|
const call = String(p?.call ?? '').trim();
|
||||||
|
if (!call) return;
|
||||||
|
// Don't clobber what the user is currently typing — only update
|
||||||
|
// when the entry field is empty or matches a previous broadcast.
|
||||||
|
onCallsignInput(call);
|
||||||
|
});
|
||||||
|
const unsubRC = EventsOn('udp:remote_call', (call: string) => {
|
||||||
|
if (call) onCallsignInput(String(call).trim());
|
||||||
|
});
|
||||||
|
const unsubLog = EventsOn('udp:logged_qso', async (p: any) => {
|
||||||
|
const text = String(p?.adif ?? '').trim();
|
||||||
|
if (!text) return;
|
||||||
|
try {
|
||||||
|
await LogUDPLoggedADIF(text);
|
||||||
|
await refresh();
|
||||||
|
} catch (e: any) {
|
||||||
|
setError('UDP auto-log: ' + String(e?.message ?? e));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => { unsubDX?.(); unsubRC?.(); unsubLog?.(); };
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Resolve slot status for any spot we haven't seen yet — debounced so we
|
// Resolve slot status for any spot we haven't seen yet — debounced so we
|
||||||
// don't hammer the backend at firehose rate. The mode passed to the
|
// don't hammer the backend at firehose rate. The mode passed to the
|
||||||
// backend is derived from the comment (CW / FT8 / FT4 / SSB...) and the
|
// backend is derived from the comment (CW / FT8 / FT4 / SSB...) and the
|
||||||
@@ -941,7 +971,7 @@ export default function App() {
|
|||||||
{ type: 'item', label: ctyRefreshing ? 'Refreshing cty.dat…' : 'Refresh cty.dat', action: 'tools.refreshCty', disabled: ctyRefreshing },
|
{ type: 'item', label: ctyRefreshing ? 'Refreshing cty.dat…' : 'Refresh cty.dat', action: 'tools.refreshCty', disabled: ctyRefreshing },
|
||||||
]},
|
]},
|
||||||
{ name: 'help', label: 'Help', items: [
|
{ name: 'help', label: 'Help', items: [
|
||||||
{ type: 'item', label: 'About HamLog', action: 'help.about', disabled: true },
|
{ type: 'item', label: 'About OpsLog', action: 'help.about', disabled: true },
|
||||||
]},
|
]},
|
||||||
], [total, selectedId, ctyRefreshing, exporting]);
|
], [total, selectedId, ctyRefreshing, exporting]);
|
||||||
|
|
||||||
@@ -1006,7 +1036,7 @@ export default function App() {
|
|||||||
<header className="flex items-center gap-3 px-3 h-8 bg-card border-b border-border shrink-0">
|
<header className="flex items-center gap-3 px-3 h-8 bg-card border-b border-border shrink-0">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<div className="size-2 rounded-full bg-gradient-to-br from-primary to-orange-400" />
|
<div className="size-2 rounded-full bg-gradient-to-br from-primary to-orange-400" />
|
||||||
<span className="font-bold text-xs tracking-tight">HamLog</span>
|
<span className="font-bold text-xs tracking-tight">OpsLog</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-baseline gap-1.5 font-mono ml-2">
|
<div className="flex items-baseline gap-1.5 font-mono ml-2">
|
||||||
<span className="text-sm font-semibold text-primary">{freqMhz ? fmtFreqDots(freqMhz) : '—.———.———'}</span>
|
<span className="text-sm font-semibold text-primary">{freqMhz ? fmtFreqDots(freqMhz) : '—.———.———'}</span>
|
||||||
@@ -1027,7 +1057,7 @@ export default function App() {
|
|||||||
<header className="grid grid-cols-[auto_auto_1fr_auto_auto] items-center gap-4 px-4 h-12 bg-card/95 backdrop-blur border-b border-border shrink-0 shadow-sm">
|
<header className="grid grid-cols-[auto_auto_1fr_auto_auto] items-center gap-4 px-4 h-12 bg-card/95 backdrop-blur border-b border-border shrink-0 shadow-sm">
|
||||||
<div className="flex items-center gap-2 pr-2 border-r border-border/60">
|
<div className="flex items-center gap-2 pr-2 border-r border-border/60">
|
||||||
<div className="size-2.5 rounded-full bg-gradient-to-br from-primary to-orange-400 shadow-[0_0_0_3px_rgba(234,88,12,0.18)]" />
|
<div className="size-2.5 rounded-full bg-gradient-to-br from-primary to-orange-400 shadow-[0_0_0_3px_rgba(234,88,12,0.18)]" />
|
||||||
<span className="font-bold text-[15px] tracking-tight">HamLog</span>
|
<span className="font-bold text-[15px] tracking-tight">OpsLog</span>
|
||||||
<span className="text-[11px] text-muted-foreground">v0.1</span>
|
<span className="text-[11px] text-muted-foreground">v0.1</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { Checkbox } from '@/components/ui/checkbox';
|
|||||||
// virtual-scroll — everything we want out of the box for a logbook table.
|
// virtual-scroll — everything we want out of the box for a logbook table.
|
||||||
ModuleRegistry.registerModules([AllCommunityModule]);
|
ModuleRegistry.registerModules([AllCommunityModule]);
|
||||||
|
|
||||||
// Custom Quartz theme tuned to match HamLog's warm palette.
|
// Custom Quartz theme tuned to match OpsLog's warm palette.
|
||||||
const hamlogTheme = themeQuartz.withParams({
|
const hamlogTheme = themeQuartz.withParams({
|
||||||
fontFamily: 'inherit',
|
fontFamily: 'inherit',
|
||||||
fontSize: 12.5,
|
fontSize: 12.5,
|
||||||
@@ -40,8 +40,6 @@ const hamlogTheme = themeQuartz.withParams({
|
|||||||
iconSize: 12,
|
iconSize: 12,
|
||||||
});
|
});
|
||||||
|
|
||||||
const badgeCellClass = 'flex items-center';
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
rows: QSOForm[];
|
rows: QSOForm[];
|
||||||
total: number;
|
total: number;
|
||||||
@@ -73,21 +71,6 @@ function fmtDateOnly(s: any): string {
|
|||||||
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`;
|
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const bandPill = (p: any) => p.value
|
|
||||||
? <span style={{
|
|
||||||
display: 'inline-block', padding: '2px 8px', borderRadius: 6,
|
|
||||||
fontFamily: 'ui-monospace, monospace', fontSize: 11, fontWeight: 600,
|
|
||||||
backgroundColor: '#f0d9a8', color: '#7a4a14', lineHeight: '16px',
|
|
||||||
}}>{p.value}</span>
|
|
||||||
: '';
|
|
||||||
const modePill = (p: any) => p.value
|
|
||||||
? <span style={{
|
|
||||||
display: 'inline-block', padding: '2px 8px', borderRadius: 6,
|
|
||||||
fontFamily: 'ui-monospace, monospace', fontSize: 11, fontWeight: 600,
|
|
||||||
backgroundColor: '#d1fae5', color: '#047857', lineHeight: '16px',
|
|
||||||
}}>{p.value}</span>
|
|
||||||
: '';
|
|
||||||
|
|
||||||
// Full catalog of selectable columns, grouped for the picker. `defaultVisible`
|
// Full catalog of selectable columns, grouped for the picker. `defaultVisible`
|
||||||
// = shown out of the box; anything else stays hidden until the user toggles
|
// = shown out of the box; anything else stays hidden until the user toggles
|
||||||
// it in the Columns dialog.
|
// it in the Columns dialog.
|
||||||
@@ -98,9 +81,9 @@ const COL_CATALOG: ColEntry[] = [
|
|||||||
{ group: 'QSO', label: 'Date UTC', colId: 'qso_date', headerName: 'Date UTC', field: 'qso_date' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateUTC(p.value), sort: 'desc', defaultVisible: true },
|
{ group: 'QSO', label: 'Date UTC', colId: 'qso_date', headerName: 'Date UTC', field: 'qso_date' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateUTC(p.value), sort: 'desc', defaultVisible: true },
|
||||||
{ group: 'QSO', label: 'Date Off', colId: 'qso_date_off', headerName: 'Date off', field: 'qso_date_off' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateUTC(p.value) },
|
{ group: 'QSO', label: 'Date Off', colId: 'qso_date_off', headerName: 'Date off', field: 'qso_date_off' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateUTC(p.value) },
|
||||||
{ group: 'QSO', label: 'Callsign', colId: 'callsign', headerName: 'Callsign', field: 'callsign' as any, width: 110, cellClass: 'font-mono font-semibold', cellStyle: { color: '#b8410c' }, defaultVisible: true },
|
{ group: 'QSO', label: 'Callsign', colId: 'callsign', headerName: 'Callsign', field: 'callsign' as any, width: 110, cellClass: 'font-mono font-semibold', cellStyle: { color: '#b8410c' }, defaultVisible: true },
|
||||||
{ group: 'QSO', label: 'Band', colId: 'band', headerName: 'Band', field: 'band' as any, width: 75, cellClass: badgeCellClass, cellRenderer: bandPill, defaultVisible: true },
|
{ group: 'QSO', label: 'Band', colId: 'band', headerName: 'Band', field: 'band' as any, width: 75, cellClass: 'font-mono', defaultVisible: true },
|
||||||
{ group: 'QSO', label: 'Band RX', colId: 'band_rx', headerName: 'Band RX', field: 'band_rx' as any, width: 75, cellClass: badgeCellClass, cellRenderer: bandPill },
|
{ group: 'QSO', label: 'Band RX', colId: 'band_rx', headerName: 'Band RX', field: 'band_rx' as any, width: 75, cellClass: 'font-mono' },
|
||||||
{ group: 'QSO', label: 'Mode', colId: 'mode', headerName: 'Mode', field: 'mode' as any, width: 80, cellClass: badgeCellClass, cellRenderer: modePill, defaultVisible: true },
|
{ group: 'QSO', label: 'Mode', colId: 'mode', headerName: 'Mode', field: 'mode' as any, width: 80, cellClass: 'font-mono', defaultVisible: true },
|
||||||
{ group: 'QSO', label: 'Submode', colId: 'submode', headerName: 'Submode', field: 'submode' as any, width: 80, cellClass: 'font-mono' },
|
{ group: 'QSO', label: 'Submode', colId: 'submode', headerName: 'Submode', field: 'submode' as any, width: 80, cellClass: 'font-mono' },
|
||||||
{ group: 'QSO', label: 'MHz (TX)', colId: 'freq_hz', headerName: 'MHz', field: 'freq_hz' as any, width: 110, type: 'rightAligned', cellClass: 'font-mono', valueFormatter: (p) => fmtMhzDots(p.value as number | undefined), comparator: (a, b) => (a ?? 0) - (b ?? 0), defaultVisible: true },
|
{ group: 'QSO', label: 'MHz (TX)', colId: 'freq_hz', headerName: 'MHz', field: 'freq_hz' as any, width: 110, type: 'rightAligned', cellClass: 'font-mono', valueFormatter: (p) => fmtMhzDots(p.value as number | undefined), comparator: (a, b) => (a ?? 0) - (b ?? 0), defaultVisible: true },
|
||||||
{ group: 'QSO', label: 'MHz (RX)', colId: 'freq_rx_hz', headerName: 'MHz RX', field: 'freq_rx_hz' as any, width: 110, type: 'rightAligned', cellClass: 'font-mono', valueFormatter: (p) => fmtMhzDots(p.value as number | undefined), comparator: (a, b) => (a ?? 0) - (b ?? 0) },
|
{ group: 'QSO', label: 'MHz (RX)', colId: 'freq_rx_hz', headerName: 'MHz RX', field: 'freq_rx_hz' as any, width: 110, type: 'rightAligned', cellClass: 'font-mono', valueFormatter: (p) => fmtMhzDots(p.value as number | undefined), comparator: (a, b) => (a ?? 0) - (b ?? 0) },
|
||||||
@@ -175,8 +158,6 @@ const COL_CATALOG: ColEntry[] = [
|
|||||||
{ group: 'Propagation', label: 'Ant az', colId: 'ant_az', headerName: 'Az', field: 'ant_az' as any, width: 70, type: 'rightAligned' },
|
{ group: 'Propagation', label: 'Ant az', colId: 'ant_az', headerName: 'Az', field: 'ant_az' as any, width: 70, type: 'rightAligned' },
|
||||||
{ group: 'Propagation', label: 'Ant el', colId: 'ant_el', headerName: 'El', field: 'ant_el' as any, width: 70, type: 'rightAligned' },
|
{ group: 'Propagation', label: 'Ant el', colId: 'ant_el', headerName: 'El', field: 'ant_el' as any, width: 70, type: 'rightAligned' },
|
||||||
{ group: 'Propagation', label: 'Ant path', colId: 'ant_path', headerName: 'Path', field: 'ant_path' as any, width: 70 },
|
{ group: 'Propagation', label: 'Ant path', colId: 'ant_path', headerName: 'Path', field: 'ant_path' as any, width: 70 },
|
||||||
{ group: 'Propagation', label: 'Rig', colId: 'rig', headerName: 'Rig', field: 'rig' as any, width: 120 },
|
|
||||||
{ group: 'Propagation', label: 'Antenna', colId: 'ant', headerName: 'Antenna', field: 'ant' as any, width: 140 },
|
|
||||||
|
|
||||||
// ── My station (operator side) ──
|
// ── My station (operator side) ──
|
||||||
{ group: 'My station', label: 'Station call', colId: 'station_callsign', headerName: 'Station', field: 'station_callsign' as any, width: 100, cellClass: 'font-mono', defaultVisible: true },
|
{ group: 'My station', label: 'Station call', colId: 'station_callsign', headerName: 'Station', field: 'station_callsign' as any, width: 100, cellClass: 'font-mono', defaultVisible: true },
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import {
|
|||||||
ConnectClusterServer, DisconnectClusterServer,
|
ConnectClusterServer, DisconnectClusterServer,
|
||||||
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus,
|
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus,
|
||||||
GetBackupSettings, SaveBackupSettings, RunBackupNow, PickBackupFolder,
|
GetBackupSettings, SaveBackupSettings, RunBackupNow, PickBackupFolder,
|
||||||
|
GetQSLDefaults, SaveQSLDefaults,
|
||||||
|
ComputeStationInfo,
|
||||||
} from '../../wailsjs/go/main/App';
|
} from '../../wailsjs/go/main/App';
|
||||||
import type { profile as profileModels } from '../../wailsjs/go/models';
|
import type { profile as profileModels } from '../../wailsjs/go/models';
|
||||||
import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
|
import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
|
||||||
@@ -35,6 +37,7 @@ import {
|
|||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { OperatingPanel } from '@/components/OperatingPanel';
|
import { OperatingPanel } from '@/components/OperatingPanel';
|
||||||
|
import { UDPIntegrationsPanel } from '@/components/UDPIntegrationsPanel';
|
||||||
|
|
||||||
type LookupSettings = LookupSettingsForm;
|
type LookupSettings = LookupSettingsForm;
|
||||||
type StationSettings = StationSettingsForm;
|
type StationSettings = StationSettingsForm;
|
||||||
@@ -98,7 +101,7 @@ const MODE_CATALOG: Array<{ name: string; sent: string; rcvd: string }> = [
|
|||||||
const emptyProfile = (): Profile => ({
|
const emptyProfile = (): Profile => ({
|
||||||
id: 0,
|
id: 0,
|
||||||
name: '',
|
name: '',
|
||||||
callsign: '', operator: '',
|
callsign: '', operator: '', owner_callsign: '',
|
||||||
my_grid: '', my_country: '',
|
my_grid: '', my_country: '',
|
||||||
my_state: '', my_cnty: '',
|
my_state: '', my_cnty: '',
|
||||||
my_street: '', my_city: '', my_postal_code: '',
|
my_street: '', my_city: '', my_postal_code: '',
|
||||||
@@ -117,6 +120,45 @@ interface Props {
|
|||||||
onSaved: () => void;
|
onSaved: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pretty little card showing what OpsLog will stamp on each QSO based on
|
||||||
|
// the callsign + grid in the Station Information form. Debounces the
|
||||||
|
// backend resolver so we don't fire on every keystroke; refreshes when
|
||||||
|
// inputs change. Empty card when no callsign yet.
|
||||||
|
function StationInfoComputedBadge({ callsign, grid }: { callsign: string; grid: string }) {
|
||||||
|
const [info, setInfo] = useState<{
|
||||||
|
country: string; dxcc: number; cqz: number; ituz: number; lat: number; lon: number;
|
||||||
|
} | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const c = callsign.trim();
|
||||||
|
if (!c) { setInfo(null); return; }
|
||||||
|
const t = window.setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const i = await ComputeStationInfo(c, grid.trim());
|
||||||
|
setInfo(i as any);
|
||||||
|
} catch { setInfo(null); }
|
||||||
|
}, 200);
|
||||||
|
return () => window.clearTimeout(t);
|
||||||
|
}, [callsign, grid]);
|
||||||
|
if (!info || (!info.country && !info.cqz && !info.ituz)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-primary/30 bg-primary/5 p-2.5">
|
||||||
|
<div className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground mb-1.5">
|
||||||
|
Auto-filled on each QSO (MY_*)
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-x-4 gap-y-1 text-[11px] font-mono">
|
||||||
|
{info.country && <span><span className="text-muted-foreground">Country:</span> <strong>{info.country}</strong></span>}
|
||||||
|
{info.dxcc > 0 && <span><span className="text-muted-foreground">DXCC#:</span> <strong>{info.dxcc}</strong></span>}
|
||||||
|
{info.cqz > 0 && <span><span className="text-muted-foreground">CQ:</span> <strong>{info.cqz}</strong></span>}
|
||||||
|
{info.ituz > 0 && <span><span className="text-muted-foreground">ITU:</span> <strong>{info.ituz}</strong></span>}
|
||||||
|
{info.lat !== 0 && <span><span className="text-muted-foreground">Lat:</span> <strong>{info.lat.toFixed(4)}</strong></span>}
|
||||||
|
{info.lon !== 0 && <span><span className="text-muted-foreground">Lon:</span> <strong>{info.lon.toFixed(4)}</strong></span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/* ====== Tree definition ======
|
/* ====== Tree definition ======
|
||||||
Section IDs are stable strings — adding new ones means adding a panel below.
|
Section IDs are stable strings — adding new ones means adding a panel below.
|
||||||
`disabled: true` greys them out and shows the "coming soon" placeholder. */
|
`disabled: true` greys them out and shows the "coming soon" placeholder. */
|
||||||
@@ -124,6 +166,8 @@ type SectionId =
|
|||||||
| 'station'
|
| 'station'
|
||||||
| 'profiles'
|
| 'profiles'
|
||||||
| 'operating'
|
| 'operating'
|
||||||
|
| 'confirmations'
|
||||||
|
| 'udp'
|
||||||
| 'lookup'
|
| 'lookup'
|
||||||
| 'lists-bands'
|
| 'lists-bands'
|
||||||
| 'lists-modes'
|
| 'lists-modes'
|
||||||
@@ -145,6 +189,7 @@ const TREE: TreeNode[] = [
|
|||||||
{ kind: 'item', label: 'Station Information', id: 'station' },
|
{ kind: 'item', label: 'Station Information', id: 'station' },
|
||||||
{ kind: 'item', label: 'Profiles (portable, home, contest)', id: 'profiles' },
|
{ kind: 'item', label: 'Profiles (portable, home, contest)', id: 'profiles' },
|
||||||
{ kind: 'item', label: 'Operating conditions (rigs & antennas)', id: 'operating' },
|
{ kind: 'item', label: 'Operating conditions (rigs & antennas)', id: 'operating' },
|
||||||
|
{ kind: 'item', label: 'Confirmations (QSL / eQSL / LoTW defaults)', id: 'confirmations' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -155,6 +200,7 @@ const TREE: TreeNode[] = [
|
|||||||
{ kind: 'item', label: 'Modes & default RST', id: 'lists-modes' },
|
{ kind: 'item', label: 'Modes & default RST', id: 'lists-modes' },
|
||||||
]},
|
]},
|
||||||
{ kind: 'item', label: 'DX Cluster', id: 'cluster' },
|
{ kind: 'item', label: 'DX Cluster', id: 'cluster' },
|
||||||
|
{ kind: 'item', label: 'UDP integrations (WSJT-X, JTDX, MSHV…)', id: 'udp' },
|
||||||
{ kind: 'item', label: 'Database backup', id: 'backup' },
|
{ kind: 'item', label: 'Database backup', id: 'backup' },
|
||||||
{ kind: 'item', label: 'Awards', id: 'awards', disabled: true },
|
{ kind: 'item', label: 'Awards', id: 'awards', disabled: true },
|
||||||
],
|
],
|
||||||
@@ -174,11 +220,13 @@ const SECTION_LABELS: Partial<Record<SectionId, string>> = {
|
|||||||
station: 'Station Information',
|
station: 'Station Information',
|
||||||
profiles: 'Profiles',
|
profiles: 'Profiles',
|
||||||
operating: 'Operating conditions',
|
operating: 'Operating conditions',
|
||||||
|
confirmations: 'Confirmations',
|
||||||
lookup: 'Callsign Lookup',
|
lookup: 'Callsign Lookup',
|
||||||
'lists-bands': 'Bands',
|
'lists-bands': 'Bands',
|
||||||
'lists-modes': 'Modes & default RST',
|
'lists-modes': 'Modes & default RST',
|
||||||
cluster: 'DX Cluster',
|
cluster: 'DX Cluster',
|
||||||
backup: 'Database backup',
|
backup: 'Database backup',
|
||||||
|
udp: 'UDP integrations',
|
||||||
awards: 'Awards',
|
awards: 'Awards',
|
||||||
cat: 'CAT interface',
|
cat: 'CAT interface',
|
||||||
rotator: 'Rotator',
|
rotator: 'Rotator',
|
||||||
@@ -316,6 +364,19 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
const [rotatorTesting, setRotatorTesting] = useState(false);
|
const [rotatorTesting, setRotatorTesting] = useState(false);
|
||||||
const [rotatorTest, setRotatorTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
const [rotatorTest, setRotatorTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||||
|
|
||||||
|
type QSLDefaults = {
|
||||||
|
qsl_sent: string; qsl_rcvd: string;
|
||||||
|
lotw_sent: string; lotw_rcvd: string;
|
||||||
|
eqsl_sent: string; eqsl_rcvd: string;
|
||||||
|
clublog_status: string; hrdlog_status: string;
|
||||||
|
};
|
||||||
|
const [qslDefaults, setQslDefaults] = useState<QSLDefaults>({
|
||||||
|
qsl_sent: '', qsl_rcvd: '',
|
||||||
|
lotw_sent: '', lotw_rcvd: '',
|
||||||
|
eqsl_sent: '', eqsl_rcvd: '',
|
||||||
|
clublog_status: '', hrdlog_status: '',
|
||||||
|
});
|
||||||
|
|
||||||
const [backupCfg, setBackupCfg] = useState<mainModels.BackupSettings>({
|
const [backupCfg, setBackupCfg] = useState<mainModels.BackupSettings>({
|
||||||
enabled: false, folder: '', rotation: 5, zip: false,
|
enabled: false, folder: '', rotation: 5, zip: false,
|
||||||
last_backup_at: '', default_folder: '',
|
last_backup_at: '', default_folder: '',
|
||||||
@@ -389,9 +450,9 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const [l, ls, c, ap, r, b] = await Promise.all([
|
const [l, ls, c, ap, r, b, qd] = await Promise.all([
|
||||||
GetLookupSettings(), GetListsSettings(), GetCATSettings(), GetActiveProfile(),
|
GetLookupSettings(), GetListsSettings(), GetCATSettings(), GetActiveProfile(),
|
||||||
GetRotatorSettings(), GetBackupSettings(),
|
GetRotatorSettings(), GetBackupSettings(), GetQSLDefaults(),
|
||||||
]);
|
]);
|
||||||
setLookup(l);
|
setLookup(l);
|
||||||
setActiveProfile(ap as Profile);
|
setActiveProfile(ap as Profile);
|
||||||
@@ -401,6 +462,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
setCatCfg(c);
|
setCatCfg(c);
|
||||||
setRotator(r);
|
setRotator(r);
|
||||||
setBackupCfg(b as any);
|
setBackupCfg(b as any);
|
||||||
|
setQslDefaults(qd as any);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setErr(String(e?.message ?? e));
|
setErr(String(e?.message ?? e));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -519,6 +581,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
await SaveCATSettings(catCfg as any);
|
await SaveCATSettings(catCfg as any);
|
||||||
await SaveRotatorSettings(rotator as any);
|
await SaveRotatorSettings(rotator as any);
|
||||||
await SaveBackupSettings(backupCfg as any);
|
await SaveBackupSettings(backupCfg as any);
|
||||||
|
await SaveQSLDefaults(qslDefaults as any);
|
||||||
await SetClusterAutoConnect(clusterAutoConnect);
|
await SetClusterAutoConnect(clusterAutoConnect);
|
||||||
|
|
||||||
setMsg('Settings saved.');
|
setMsg('Settings saved.');
|
||||||
@@ -577,11 +640,19 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>Station callsign</Label>
|
<Label>Station callsign</Label>
|
||||||
<Input className="font-mono uppercase" value={p.callsign ?? ''} onChange={(e) => updateActive({ callsign: e.target.value })} placeholder="F4XYZ" />
|
<Input className="font-mono uppercase" value={p.callsign ?? ''} onChange={(e) => updateActive({ callsign: e.target.value })} placeholder="F4XYZ" />
|
||||||
|
<div className="text-[10px] text-muted-foreground">What's transmitted (ADIF STATION_CALLSIGN).</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>Operator</Label>
|
<Label>Operator callsign</Label>
|
||||||
<Input className="font-mono uppercase" value={p.operator ?? ''} onChange={(e) => updateActive({ operator: e.target.value })} placeholder="F4XYZ" />
|
<Input className="font-mono uppercase" value={p.operator ?? ''} onChange={(e) => updateActive({ operator: e.target.value })} placeholder="F4XYZ" />
|
||||||
|
<div className="text-[10px] text-muted-foreground">Who's at the radio (ADIF OPERATOR).</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-1 col-span-2">
|
||||||
|
<Label>Owner callsign</Label>
|
||||||
|
<Input className="font-mono uppercase max-w-xs" value={p.owner_callsign ?? ''} onChange={(e) => updateActive({ owner_callsign: e.target.value })} placeholder="(leave blank if same as station)" />
|
||||||
|
<div className="text-[10px] text-muted-foreground">Legal station owner — only differs at club stations or remote setups (ADIF STATION_OWNER).</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2"><StationInfoComputedBadge callsign={p.callsign ?? ''} grid={p.my_grid ?? ''} /></div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>My grid</Label>
|
<Label>My grid</Label>
|
||||||
<Input className="font-mono uppercase" value={p.my_grid ?? ''} onChange={(e) => updateActive({ my_grid: e.target.value })} placeholder="JN18BU" />
|
<Input className="font-mono uppercase" value={p.my_grid ?? ''} onChange={(e) => updateActive({ my_grid: e.target.value })} placeholder="JN18BU" />
|
||||||
@@ -1106,7 +1177,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
<>
|
<>
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="CAT interface (OmniRig)"
|
title="CAT interface (OmniRig)"
|
||||||
hint="Reads the rig's current frequency / band / mode and pushes them into the entry strip in real time. Requires OmniRig (free) installed and configured separately for your rig — HamLog just talks to it."
|
hint="Reads the rig's current frequency / band / mode and pushes them into the entry strip in real time. Requires OmniRig (free) installed and configured separately for your rig — OpsLog just talks to it."
|
||||||
/>
|
/>
|
||||||
<div className="space-y-4 max-w-lg">
|
<div className="space-y-4 max-w-lg">
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
@@ -1168,10 +1239,10 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Configure your rig (COM port, baud rate, model) in OmniRig's own settings GUI first.
|
Configure your rig (COM port, baud rate, model) in OmniRig's own settings GUI first.
|
||||||
HamLog will read whichever Rig slot you select here. Set <strong>CAT delay</strong>
|
OpsLog will read whichever Rig slot you select here. Set <strong>CAT delay</strong>
|
||||||
{' '}above 0 if your rig drops commands sent back-to-back (some older Kenwood/Yaesu).
|
{' '}above 0 if your rig drops commands sent back-to-back (some older Kenwood/Yaesu).
|
||||||
OmniRig only reports generic "DIG" for digital modes — <strong>Default digital mode</strong>
|
OmniRig only reports generic "DIG" for digital modes — <strong>Default digital mode</strong>
|
||||||
{' '}is the specific mode HamLog will surface (and log).
|
{' '}is the specific mode OpsLog will surface (and log).
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -1196,7 +1267,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
<>
|
<>
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="Rotator (PstRotator)"
|
title="Rotator (PstRotator)"
|
||||||
hint="HamLog sends UDP commands to PstRotator. Enable PstRotator's UDP listener (Setup → Communication → UDP) before testing."
|
hint="OpsLog sends UDP commands to PstRotator. Enable PstRotator's UDP listener (Setup → Communication → UDP) before testing."
|
||||||
/>
|
/>
|
||||||
<div className="space-y-4 max-w-xl">
|
<div className="space-y-4 max-w-xl">
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
@@ -1402,6 +1473,126 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ConfirmationsPanel() {
|
||||||
|
// ADIF status codes. FULL set for paper QSL/eQSL/Clublog/HRDLog,
|
||||||
|
// SIMPLE Y/N set for LoTW (the only values the LoTW protocol returns).
|
||||||
|
const FULL_OPTIONS = [
|
||||||
|
{ value: '_', label: '— leave blank —' },
|
||||||
|
{ value: 'Y', label: 'Y (yes)' },
|
||||||
|
{ value: 'N', label: 'N (no)' },
|
||||||
|
{ value: 'R', label: 'R (requested)' },
|
||||||
|
{ value: 'Q', label: 'Q (queued)' },
|
||||||
|
{ value: 'I', label: 'I (ignore)' },
|
||||||
|
];
|
||||||
|
// LoTW / Clublog / HRDLog also use ADIF-style status codes — keep
|
||||||
|
// R (requested) available so users can mark "queued for upload"
|
||||||
|
// and filter on it later.
|
||||||
|
|
||||||
|
// Renderer inlined as a constant — declaring this as a function
|
||||||
|
// INSIDE ConfirmationsPanel would re-instantiate the component on
|
||||||
|
// every render, which unmounts and re-mounts the Radix Select
|
||||||
|
// (closing it the moment you click the trigger).
|
||||||
|
const renderSelect = (
|
||||||
|
key: keyof QSLDefaults,
|
||||||
|
options: { value: string; label: string }[],
|
||||||
|
) => (
|
||||||
|
<Select
|
||||||
|
value={qslDefaults[key] || '_'}
|
||||||
|
onValueChange={(v) => setQslDefaults((d) => ({ ...d, [key]: v === '_' ? '' : v }))}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{options.map((o) => (
|
||||||
|
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SectionHeader
|
||||||
|
title="Confirmations"
|
||||||
|
hint="Default QSL / eQSL / LoTW / upload status applied to every QSO you log — manually or via UDP auto-log from WSJT-X / JTDX / MSHV. Leave a field blank to keep the QSO column empty."
|
||||||
|
/>
|
||||||
|
<div className="space-y-3 max-w-2xl">
|
||||||
|
{/* Paper QSL */}
|
||||||
|
<div className="grid grid-cols-[150px_1fr_1fr] gap-3 items-end">
|
||||||
|
<Label className="text-sm font-medium pb-1.5">Paper QSL</Label>
|
||||||
|
<div>
|
||||||
|
<Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">Sent</Label>
|
||||||
|
{renderSelect('qsl_sent', FULL_OPTIONS)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">Rcvd</Label>
|
||||||
|
{renderSelect('qsl_rcvd', FULL_OPTIONS)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* eQSL */}
|
||||||
|
<div className="grid grid-cols-[150px_1fr_1fr] gap-3 items-end">
|
||||||
|
<Label className="text-sm font-medium pb-1.5">eQSL</Label>
|
||||||
|
<div>
|
||||||
|
<Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">Sent</Label>
|
||||||
|
{renderSelect('eqsl_sent', FULL_OPTIONS)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">Rcvd</Label>
|
||||||
|
{renderSelect('eqsl_rcvd', FULL_OPTIONS)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* LoTW */}
|
||||||
|
<div className="grid grid-cols-[150px_1fr_1fr] gap-3 items-end">
|
||||||
|
<Label className="text-sm font-medium pb-1.5">LoTW</Label>
|
||||||
|
<div>
|
||||||
|
<Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">Sent</Label>
|
||||||
|
{renderSelect('lotw_sent', FULL_OPTIONS)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">Rcvd</Label>
|
||||||
|
{renderSelect('lotw_rcvd', FULL_OPTIONS)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-border/60 pt-3 space-y-3">
|
||||||
|
<div className="text-[11px] text-muted-foreground">
|
||||||
|
Upload status fields (Clublog / HRDLog) are stamped on every QSO; "N" is typical when you want OpsLog to track which QSOs still need to be uploaded.
|
||||||
|
</div>
|
||||||
|
{/* Clublog */}
|
||||||
|
<div className="grid grid-cols-[150px_1fr_1fr] gap-3 items-end">
|
||||||
|
<Label className="text-sm font-medium pb-1.5">Clublog</Label>
|
||||||
|
<div>
|
||||||
|
<Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">Upload status</Label>
|
||||||
|
{renderSelect('clublog_status', FULL_OPTIONS)}
|
||||||
|
</div>
|
||||||
|
<div />
|
||||||
|
</div>
|
||||||
|
{/* HRDLog */}
|
||||||
|
<div className="grid grid-cols-[150px_1fr_1fr] gap-3 items-end">
|
||||||
|
<Label className="text-sm font-medium pb-1.5">HRDLog</Label>
|
||||||
|
<div>
|
||||||
|
<Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">Upload status</Label>
|
||||||
|
{renderSelect('hrdlog_status', FULL_OPTIONS)}
|
||||||
|
</div>
|
||||||
|
<div />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function UDPIntegrationsPanelWrapper() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SectionHeader
|
||||||
|
title="UDP integrations"
|
||||||
|
hint="Listen for QSO logs from WSJT-X / JTDX / MSHV, ADIF messages from JTAlert/GridTracker, or simple callsign packets from external tools. Outbound connections forward every QSO you log to a remote listener (Cloudlog UDP, N1MM, …)."
|
||||||
|
/>
|
||||||
|
<UDPIntegrationsPanel onError={(m) => setErr(m)} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function OperatingPanelWrapper() {
|
function OperatingPanelWrapper() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -1445,7 +1636,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
<>
|
<>
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="Database backup"
|
title="Database backup"
|
||||||
hint="HamLog can copy the SQLite database to a folder of your choice when you close it, once per day. Rotation keeps the last N copies and deletes older ones."
|
hint="OpsLog can copy the SQLite database to a folder of your choice when you close it, once per day. Rotation keeps the last N copies and deletes older ones."
|
||||||
/>
|
/>
|
||||||
<div className="space-y-4 max-w-xl">
|
<div className="space-y-4 max-w-xl">
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
@@ -1453,7 +1644,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
checked={!!backupCfg.enabled}
|
checked={!!backupCfg.enabled}
|
||||||
onCheckedChange={(c) => setBackupCfg((b) => ({ ...b, enabled: !!c }))}
|
onCheckedChange={(c) => setBackupCfg((b) => ({ ...b, enabled: !!c }))}
|
||||||
/>
|
/>
|
||||||
<span>Automatic backup when closing HamLog (max once per day)</span>
|
<span>Automatic backup when closing OpsLog (max once per day)</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
@@ -1483,7 +1674,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
<div className="text-[10px] text-muted-foreground">
|
<div className="text-[10px] text-muted-foreground">
|
||||||
{backupCfg.folder
|
{backupCfg.folder
|
||||||
? <>Effective folder: <span className="font-mono">{effectiveFolder}</span></>
|
? <>Effective folder: <span className="font-mono">{effectiveFolder}</span></>
|
||||||
: <>If empty, HamLog uses the default: <span className="font-mono">{(backupCfg.default_folder ?? '').replace(/\\/g, '/')}</span></>
|
: <>If empty, OpsLog uses the default: <span className="font-mono">{(backupCfg.default_folder ?? '').replace(/\\/g, '/')}</span></>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1541,10 +1732,12 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
station: StationPanel,
|
station: StationPanel,
|
||||||
profiles: ProfilesPanel,
|
profiles: ProfilesPanel,
|
||||||
operating: OperatingPanelWrapper,
|
operating: OperatingPanelWrapper,
|
||||||
|
confirmations: ConfirmationsPanel,
|
||||||
lookup: LookupPanel,
|
lookup: LookupPanel,
|
||||||
'lists-bands': BandsPanel,
|
'lists-bands': BandsPanel,
|
||||||
'lists-modes': ModesPanel,
|
'lists-modes': ModesPanel,
|
||||||
cluster: ClusterPanel,
|
cluster: ClusterPanel,
|
||||||
|
udp: UDPIntegrationsPanelWrapper,
|
||||||
backup: BackupPanel,
|
backup: BackupPanel,
|
||||||
awards: () => <ComingSoon id="awards" icon={Award} />,
|
awards: () => <ComingSoon id="awards" icon={Award} />,
|
||||||
cat: CATPanel,
|
cat: CATPanel,
|
||||||
@@ -1558,7 +1751,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
<DialogContent className="max-w-[1180px] w-full max-h-[90vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0">
|
<DialogContent className="max-w-[1180px] w-full max-h-[90vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Preferences</DialogTitle>
|
<DialogTitle>Preferences</DialogTitle>
|
||||||
<DialogDescription className="sr-only">Configure HamLog modules — station, lookup, hardware…</DialogDescription>
|
<DialogDescription className="sr-only">Configure OpsLog modules — station, lookup, hardware…</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ type Step = {
|
|||||||
detail?: string;
|
detail?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
// ShutdownProgress is a full-screen overlay that appears while HamLog is
|
// ShutdownProgress is a full-screen overlay that appears while OpsLog is
|
||||||
// running its close-time tasks (backup, future LoTW upload, ...). It
|
// running its close-time tasks (backup, future LoTW upload, ...). It
|
||||||
// listens for `shutdown:start` / `shutdown:update` / `shutdown:done`
|
// listens for `shutdown:start` / `shutdown:update` / `shutdown:done`
|
||||||
// events from the backend and renders a checklist that updates as each
|
// events from the backend and renders a checklist that updates as each
|
||||||
@@ -30,7 +30,7 @@ export function ShutdownProgress() {
|
|||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
||||||
<div className="bg-card border border-border rounded-lg shadow-xl p-6 min-w-[360px] max-w-[480px]">
|
<div className="bg-card border border-border rounded-lg shadow-xl p-6 min-w-[360px] max-w-[480px]">
|
||||||
<div className="text-sm font-semibold mb-3 text-foreground">Closing HamLog…</div>
|
<div className="text-sm font-semibold mb-3 text-foreground">Closing OpsLog…</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{steps.length === 0 ? (
|
{steps.length === 0 ? (
|
||||||
<div className="text-xs text-muted-foreground italic">Nothing to do, exiting.</div>
|
<div className="text-xs text-muted-foreground italic">Nothing to do, exiting.</div>
|
||||||
|
|||||||
@@ -0,0 +1,399 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { Plus, Trash2, Edit2, RefreshCcw, ArrowDownToLine, ArrowUpFromLine } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
ListUDPIntegrations, SaveUDPIntegration, DeleteUDPIntegration, ReloadUDPIntegrations,
|
||||||
|
} from '../../wailsjs/go/main/App';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import {
|
||||||
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import {
|
||||||
|
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
// Local mirror of the Go struct — we duplicate the type rather than depend
|
||||||
|
// on the generated Wails model because the inline `as any` casts are
|
||||||
|
// noisier than just owning the shape here.
|
||||||
|
type UDPConfig = {
|
||||||
|
id: number;
|
||||||
|
direction: 'inbound' | 'outbound';
|
||||||
|
name: string;
|
||||||
|
port: number;
|
||||||
|
service_type: 'wsjt' | 'adif' | 'n1mm' | 'remote_call' | 'db_updated';
|
||||||
|
multicast: boolean;
|
||||||
|
multicast_group: string;
|
||||||
|
destination_ip: string;
|
||||||
|
enabled: boolean;
|
||||||
|
sort_order: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Service-type catalog used by the dropdowns; each entry is restricted to
|
||||||
|
// inbound or outbound and carries a hint suggesting reasonable defaults
|
||||||
|
// for the "preset" button.
|
||||||
|
const SERVICE_TYPES: Array<{
|
||||||
|
id: UDPConfig['service_type'];
|
||||||
|
direction: UDPConfig['direction'];
|
||||||
|
label: string;
|
||||||
|
hint: string;
|
||||||
|
defaults: Partial<UDPConfig>;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
id: 'wsjt',
|
||||||
|
direction: 'inbound',
|
||||||
|
label: 'WSJT-X / JTDX / MSHV',
|
||||||
|
hint: 'Auto-logs FT8/FT4/etc. QSOs and fills the entry callsign live.',
|
||||||
|
defaults: { port: 2237, multicast: true, multicast_group: '224.0.0.1' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'adif',
|
||||||
|
direction: 'inbound',
|
||||||
|
label: 'ADIF message (JTAlert, GridTracker)',
|
||||||
|
hint: 'Receives a single ADIF record per packet and logs it.',
|
||||||
|
defaults: { port: 2333, multicast: false },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'n1mm',
|
||||||
|
direction: 'inbound',
|
||||||
|
label: 'N1MM Logger+ (contest XML)',
|
||||||
|
hint: 'Receives contest QSOs as XML messages.',
|
||||||
|
defaults: { port: 12060, multicast: false },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'remote_call',
|
||||||
|
direction: 'inbound',
|
||||||
|
label: 'Remote callsign (DXHunter, custom)',
|
||||||
|
hint: 'A short text packet containing just a callsign — fills the entry field.',
|
||||||
|
defaults: { port: 12090, multicast: false },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'db_updated',
|
||||||
|
direction: 'outbound',
|
||||||
|
label: 'DB updated → notify other apps',
|
||||||
|
hint: 'Sends the ADIF of every QSO you log to a remote listener (Cloudlog UDP, N1MM, …).',
|
||||||
|
defaults: { port: 2333, destination_ip: '127.0.0.1' },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
type Props = { onError: (msg: string) => void };
|
||||||
|
|
||||||
|
export function UDPIntegrationsPanel({ onError }: Props) {
|
||||||
|
const [items, setItems] = useState<UDPConfig[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [editing, setEditing] = useState<UDPConfig | null>(null);
|
||||||
|
|
||||||
|
const reload = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const list = await ListUDPIntegrations();
|
||||||
|
setItems(((list ?? []) as any[]) as UDPConfig[]);
|
||||||
|
} catch (e: any) { onError(String(e?.message ?? e)); }
|
||||||
|
finally { setLoading(false); }
|
||||||
|
}, [onError]);
|
||||||
|
|
||||||
|
useEffect(() => { void reload(); }, [reload]);
|
||||||
|
|
||||||
|
function addNew(direction: UDPConfig['direction']) {
|
||||||
|
const preset = SERVICE_TYPES.find((s) => s.direction === direction)!;
|
||||||
|
setEditing({
|
||||||
|
id: 0,
|
||||||
|
direction,
|
||||||
|
name: '',
|
||||||
|
port: preset.defaults.port ?? 2237,
|
||||||
|
service_type: preset.id,
|
||||||
|
multicast: !!preset.defaults.multicast,
|
||||||
|
multicast_group: preset.defaults.multicast_group ?? '',
|
||||||
|
destination_ip: preset.defaults.destination_ip ?? '',
|
||||||
|
enabled: true,
|
||||||
|
sort_order: items.filter((i) => i.direction === direction).length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save(cfg: UDPConfig) {
|
||||||
|
try {
|
||||||
|
const saved = await SaveUDPIntegration(cfg as any) as UDPConfig;
|
||||||
|
setItems((prev) => {
|
||||||
|
if (cfg.id === 0) return [...prev, saved];
|
||||||
|
return prev.map((x) => x.id === saved.id ? saved : x);
|
||||||
|
});
|
||||||
|
setEditing(null);
|
||||||
|
} catch (e: any) { onError(String(e?.message ?? e)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(id: number) {
|
||||||
|
if (!confirm('Delete this UDP connection?')) return;
|
||||||
|
try {
|
||||||
|
await DeleteUDPIntegration(id);
|
||||||
|
setItems((prev) => prev.filter((x) => x.id !== id));
|
||||||
|
} catch (e: any) { onError(String(e?.message ?? e)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleEnabled(cfg: UDPConfig) {
|
||||||
|
await save({ ...cfg, enabled: !cfg.enabled });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reloadServers() {
|
||||||
|
try {
|
||||||
|
const errs = await ReloadUDPIntegrations();
|
||||||
|
if (errs && (errs as string[]).length > 0) {
|
||||||
|
onError((errs as string[]).join(' • '));
|
||||||
|
}
|
||||||
|
} catch (e: any) { onError(String(e?.message ?? e)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) return <div className="text-xs text-muted-foreground italic">Loading…</div>;
|
||||||
|
|
||||||
|
const inbound = items.filter((i) => i.direction === 'inbound');
|
||||||
|
const outbound = items.filter((i) => i.direction === 'outbound');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="text-[11px] text-muted-foreground max-w-2xl leading-relaxed">
|
||||||
|
UDP connections let OpsLog talk to other ham radio software. Inbound
|
||||||
|
connections receive QSOs or callsigns and update the logbook live;
|
||||||
|
outbound connections notify other apps when you log a QSO locally.
|
||||||
|
Enable multicast to share a port with another listener without
|
||||||
|
conflict — required for the typical WSJT-X 2237 setup.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Section
|
||||||
|
title="Inbound — OpsLog listens"
|
||||||
|
icon={<ArrowDownToLine className="size-4" />}
|
||||||
|
items={inbound}
|
||||||
|
onAdd={() => addNew('inbound')}
|
||||||
|
onEdit={(c) => setEditing(c)}
|
||||||
|
onDelete={remove}
|
||||||
|
onToggle={toggleEnabled}
|
||||||
|
/>
|
||||||
|
<Section
|
||||||
|
title="Outbound — OpsLog sends"
|
||||||
|
icon={<ArrowUpFromLine className="size-4" />}
|
||||||
|
items={outbound}
|
||||||
|
onAdd={() => addNew('outbound')}
|
||||||
|
onEdit={(c) => setEditing(c)}
|
||||||
|
onDelete={remove}
|
||||||
|
onToggle={toggleEnabled}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="border-t border-border/60 pt-3 flex items-center gap-3">
|
||||||
|
<Button size="sm" variant="outline" onClick={reloadServers}>
|
||||||
|
<RefreshCcw className="size-3.5" /> Reload all
|
||||||
|
</Button>
|
||||||
|
<span className="text-[11px] text-muted-foreground">
|
||||||
|
Restarts every enabled listener after a manual change.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editing && (
|
||||||
|
<EditDialog
|
||||||
|
cfg={editing}
|
||||||
|
onCancel={() => setEditing(null)}
|
||||||
|
onSave={save}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Section listing ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function Section({
|
||||||
|
title, icon, items, onAdd, onEdit, onDelete, onToggle,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
items: UDPConfig[];
|
||||||
|
onAdd: () => void;
|
||||||
|
onEdit: (c: UDPConfig) => void;
|
||||||
|
onDelete: (id: number) => void;
|
||||||
|
onToggle: (c: UDPConfig) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-border bg-card">
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
|
||||||
|
{icon}
|
||||||
|
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{title}</span>
|
||||||
|
<div className="flex-1" />
|
||||||
|
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={onAdd}>
|
||||||
|
<Plus className="size-3" /> Add
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<div className="px-3 py-3 text-xs text-muted-foreground italic">No connection.</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-border/60">
|
||||||
|
{items.map((c) => {
|
||||||
|
const svc = SERVICE_TYPES.find((s) => s.id === c.service_type);
|
||||||
|
return (
|
||||||
|
<div key={c.id} className="flex items-center gap-2 px-3 py-2">
|
||||||
|
<Checkbox checked={c.enabled} onCheckedChange={() => onToggle(c)} />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-semibold text-sm truncate">{c.name || '(unnamed)'}</span>
|
||||||
|
<span className="text-[10px] uppercase tracking-wider text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
|
||||||
|
{svc?.label ?? c.service_type}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-muted-foreground font-mono">
|
||||||
|
{c.multicast
|
||||||
|
? <>multicast <strong>{c.multicast_group || '?'}</strong>:{c.port}</>
|
||||||
|
: c.direction === 'outbound'
|
||||||
|
? <>→ {c.destination_ip || '?'}:{c.port}</>
|
||||||
|
: <>:{c.port}</>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button size="icon" variant="ghost" className="size-7" onClick={() => onEdit(c)}>
|
||||||
|
<Edit2 className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button size="icon" variant="ghost" className="size-7 text-destructive hover:bg-destructive/10" onClick={() => onDelete(c.id)}>
|
||||||
|
<Trash2 className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Edit dialog ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function EditDialog({
|
||||||
|
cfg, onCancel, onSave,
|
||||||
|
}: {
|
||||||
|
cfg: UDPConfig;
|
||||||
|
onCancel: () => void;
|
||||||
|
onSave: (c: UDPConfig) => void;
|
||||||
|
}) {
|
||||||
|
const [draft, setDraft] = useState<UDPConfig>(cfg);
|
||||||
|
// Service-type list filtered to this connection's direction.
|
||||||
|
const services = SERVICE_TYPES.filter((s) => s.direction === draft.direction);
|
||||||
|
const currentService = services.find((s) => s.id === draft.service_type);
|
||||||
|
|
||||||
|
function applyPreset(id: UDPConfig['service_type']) {
|
||||||
|
const preset = SERVICE_TYPES.find((s) => s.id === id);
|
||||||
|
if (!preset) return;
|
||||||
|
setDraft((d) => ({
|
||||||
|
...d,
|
||||||
|
service_type: id,
|
||||||
|
port: preset.defaults.port ?? d.port,
|
||||||
|
multicast: preset.defaults.multicast ?? d.multicast,
|
||||||
|
multicast_group: preset.defaults.multicast_group ?? d.multicast_group,
|
||||||
|
destination_ip: preset.defaults.destination_ip ?? d.destination_ip,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onOpenChange={(o) => { if (!o) onCancel(); }}>
|
||||||
|
<DialogContent className="max-w-xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{cfg.id === 0 ? 'New' : 'Edit'} {draft.direction} connection
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{currentService?.hint}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 px-5 py-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Name</Label>
|
||||||
|
<Input
|
||||||
|
autoFocus
|
||||||
|
placeholder={draft.direction === 'inbound' ? 'WSJT-X log' : 'Cloudlog notify'}
|
||||||
|
value={draft.name}
|
||||||
|
onChange={(e) => setDraft((d) => ({ ...d, name: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Service type</Label>
|
||||||
|
<Select value={draft.service_type} onValueChange={(v) => applyPreset(v as UDPConfig['service_type'])}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{services.map((s) => (
|
||||||
|
<SelectItem key={s.id} value={s.id}>{s.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-[1fr_auto] gap-2 items-end">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Port</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1} max={65535}
|
||||||
|
className="font-mono"
|
||||||
|
value={draft.port}
|
||||||
|
onChange={(e) => {
|
||||||
|
const n = Number(e.target.value);
|
||||||
|
if (Number.isFinite(n)) setDraft((d) => ({ ...d, port: Math.floor(n) }));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer pb-2">
|
||||||
|
<Checkbox
|
||||||
|
checked={draft.multicast}
|
||||||
|
onCheckedChange={(c) => setDraft((d) => ({ ...d, multicast: !!c }))}
|
||||||
|
/>
|
||||||
|
<span>Multicast</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{draft.multicast && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Multicast group</Label>
|
||||||
|
<Input
|
||||||
|
className="font-mono"
|
||||||
|
placeholder="224.0.0.1"
|
||||||
|
value={draft.multicast_group}
|
||||||
|
onChange={(e) => setDraft((d) => ({ ...d, multicast_group: e.target.value }))}
|
||||||
|
/>
|
||||||
|
<div className="text-[10px] text-muted-foreground">
|
||||||
|
Use the same group address as the sending app. WSJT-X default is 224.0.0.1.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{draft.direction === 'outbound' && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Destination IP</Label>
|
||||||
|
<Input
|
||||||
|
className="font-mono"
|
||||||
|
placeholder="127.0.0.1"
|
||||||
|
value={draft.destination_ip}
|
||||||
|
onChange={(e) => setDraft((d) => ({ ...d, destination_ip: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox
|
||||||
|
checked={draft.enabled}
|
||||||
|
onCheckedChange={(c) => setDraft((d) => ({ ...d, enabled: !!c }))}
|
||||||
|
/>
|
||||||
|
<span>Enabled</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" onClick={onCancel}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => onSave(draft)}
|
||||||
|
disabled={!draft.name.trim() || !draft.port}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// silence unused-import for cn — kept for future styling tweaks
|
||||||
|
void cn;
|
||||||
Vendored
+19
@@ -7,6 +7,7 @@ import {adif} from '../models';
|
|||||||
import {cat} from '../models';
|
import {cat} from '../models';
|
||||||
import {cluster} from '../models';
|
import {cluster} from '../models';
|
||||||
import {operating} from '../models';
|
import {operating} from '../models';
|
||||||
|
import {udp} from '../models';
|
||||||
import {lookup} from '../models';
|
import {lookup} from '../models';
|
||||||
|
|
||||||
export function ActivateProfile(arg1:number):Promise<void>;
|
export function ActivateProfile(arg1:number):Promise<void>;
|
||||||
@@ -17,6 +18,8 @@ export function ClearLookupCache():Promise<void>;
|
|||||||
|
|
||||||
export function ClusterSpotStatuses(arg1:Array<main.SpotQuery>):Promise<Array<main.SpotStatus>>;
|
export function ClusterSpotStatuses(arg1:Array<main.SpotQuery>):Promise<Array<main.SpotStatus>>;
|
||||||
|
|
||||||
|
export function ComputeStationInfo(arg1:string,arg2:string):Promise<main.StationInfoComputed>;
|
||||||
|
|
||||||
export function ConnectAllClusters():Promise<void>;
|
export function ConnectAllClusters():Promise<void>;
|
||||||
|
|
||||||
export function ConnectClusterServer(arg1:number):Promise<void>;
|
export function ConnectClusterServer(arg1:number):Promise<void>;
|
||||||
@@ -35,6 +38,8 @@ export function DeleteProfile(arg1:number):Promise<void>;
|
|||||||
|
|
||||||
export function DeleteQSO(arg1:number):Promise<void>;
|
export function DeleteQSO(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function DeleteUDPIntegration(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function DisconnectAllClusters():Promise<void>;
|
export function DisconnectAllClusters():Promise<void>;
|
||||||
|
|
||||||
export function DisconnectClusterServer(arg1:number):Promise<void>;
|
export function DisconnectClusterServer(arg1:number):Promise<void>;
|
||||||
@@ -59,8 +64,12 @@ export function GetCtyDatInfo():Promise<main.CtyDatInfo>;
|
|||||||
|
|
||||||
export function GetListsSettings():Promise<main.ListsSettings>;
|
export function GetListsSettings():Promise<main.ListsSettings>;
|
||||||
|
|
||||||
|
export function GetLogFilePath():Promise<string>;
|
||||||
|
|
||||||
export function GetLookupSettings():Promise<main.LookupSettings>;
|
export function GetLookupSettings():Promise<main.LookupSettings>;
|
||||||
|
|
||||||
|
export function GetQSLDefaults():Promise<main.QSLDefaults>;
|
||||||
|
|
||||||
export function GetQSO(arg1:number):Promise<qso.QSO>;
|
export function GetQSO(arg1:number):Promise<qso.QSO>;
|
||||||
|
|
||||||
export function GetRotatorSettings():Promise<main.RotatorSettings>;
|
export function GetRotatorSettings():Promise<main.RotatorSettings>;
|
||||||
@@ -79,6 +88,10 @@ export function ListProfiles():Promise<Array<profile.Profile>>;
|
|||||||
|
|
||||||
export function ListQSO(arg1:qso.ListFilter):Promise<Array<qso.QSO>>;
|
export function ListQSO(arg1:qso.ListFilter):Promise<Array<qso.QSO>>;
|
||||||
|
|
||||||
|
export function ListUDPIntegrations():Promise<Array<udp.Config>>;
|
||||||
|
|
||||||
|
export function LogUDPLoggedADIF(arg1:string):Promise<number>;
|
||||||
|
|
||||||
export function LookupCallsign(arg1:string):Promise<lookup.Result>;
|
export function LookupCallsign(arg1:string):Promise<lookup.Result>;
|
||||||
|
|
||||||
export function OpenADIFFile():Promise<string>;
|
export function OpenADIFFile():Promise<string>;
|
||||||
@@ -91,6 +104,8 @@ export function PickBackupFolder():Promise<string>;
|
|||||||
|
|
||||||
export function RefreshCtyDat():Promise<main.CtyDatInfo>;
|
export function RefreshCtyDat():Promise<main.CtyDatInfo>;
|
||||||
|
|
||||||
|
export function ReloadUDPIntegrations():Promise<Array<string>>;
|
||||||
|
|
||||||
export function RotatorGoTo(arg1:number,arg2:number):Promise<void>;
|
export function RotatorGoTo(arg1:number,arg2:number):Promise<void>;
|
||||||
|
|
||||||
export function RotatorPark():Promise<void>;
|
export function RotatorPark():Promise<void>;
|
||||||
@@ -117,10 +132,14 @@ export function SaveOperatingStation(arg1:operating.Station):Promise<operating.S
|
|||||||
|
|
||||||
export function SaveProfile(arg1:profile.Profile):Promise<profile.Profile>;
|
export function SaveProfile(arg1:profile.Profile):Promise<profile.Profile>;
|
||||||
|
|
||||||
|
export function SaveQSLDefaults(arg1:main.QSLDefaults):Promise<void>;
|
||||||
|
|
||||||
export function SaveRotatorSettings(arg1:main.RotatorSettings):Promise<void>;
|
export function SaveRotatorSettings(arg1:main.RotatorSettings):Promise<void>;
|
||||||
|
|
||||||
export function SaveStationSettings(arg1:main.StationSettings):Promise<void>;
|
export function SaveStationSettings(arg1:main.StationSettings):Promise<void>;
|
||||||
|
|
||||||
|
export function SaveUDPIntegration(arg1:udp.Config):Promise<udp.Config>;
|
||||||
|
|
||||||
export function SendClusterCommand(arg1:string):Promise<void>;
|
export function SendClusterCommand(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function SetCATFrequency(arg1:number):Promise<void>;
|
export function SetCATFrequency(arg1:number):Promise<void>;
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ export function ClusterSpotStatuses(arg1) {
|
|||||||
return window['go']['main']['App']['ClusterSpotStatuses'](arg1);
|
return window['go']['main']['App']['ClusterSpotStatuses'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ComputeStationInfo(arg1, arg2) {
|
||||||
|
return window['go']['main']['App']['ComputeStationInfo'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
export function ConnectAllClusters() {
|
export function ConnectAllClusters() {
|
||||||
return window['go']['main']['App']['ConnectAllClusters']();
|
return window['go']['main']['App']['ConnectAllClusters']();
|
||||||
}
|
}
|
||||||
@@ -54,6 +58,10 @@ export function DeleteQSO(arg1) {
|
|||||||
return window['go']['main']['App']['DeleteQSO'](arg1);
|
return window['go']['main']['App']['DeleteQSO'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function DeleteUDPIntegration(arg1) {
|
||||||
|
return window['go']['main']['App']['DeleteUDPIntegration'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function DisconnectAllClusters() {
|
export function DisconnectAllClusters() {
|
||||||
return window['go']['main']['App']['DisconnectAllClusters']();
|
return window['go']['main']['App']['DisconnectAllClusters']();
|
||||||
}
|
}
|
||||||
@@ -102,10 +110,18 @@ export function GetListsSettings() {
|
|||||||
return window['go']['main']['App']['GetListsSettings']();
|
return window['go']['main']['App']['GetListsSettings']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetLogFilePath() {
|
||||||
|
return window['go']['main']['App']['GetLogFilePath']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetLookupSettings() {
|
export function GetLookupSettings() {
|
||||||
return window['go']['main']['App']['GetLookupSettings']();
|
return window['go']['main']['App']['GetLookupSettings']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetQSLDefaults() {
|
||||||
|
return window['go']['main']['App']['GetQSLDefaults']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetQSO(arg1) {
|
export function GetQSO(arg1) {
|
||||||
return window['go']['main']['App']['GetQSO'](arg1);
|
return window['go']['main']['App']['GetQSO'](arg1);
|
||||||
}
|
}
|
||||||
@@ -142,6 +158,14 @@ export function ListQSO(arg1) {
|
|||||||
return window['go']['main']['App']['ListQSO'](arg1);
|
return window['go']['main']['App']['ListQSO'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ListUDPIntegrations() {
|
||||||
|
return window['go']['main']['App']['ListUDPIntegrations']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LogUDPLoggedADIF(arg1) {
|
||||||
|
return window['go']['main']['App']['LogUDPLoggedADIF'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function LookupCallsign(arg1) {
|
export function LookupCallsign(arg1) {
|
||||||
return window['go']['main']['App']['LookupCallsign'](arg1);
|
return window['go']['main']['App']['LookupCallsign'](arg1);
|
||||||
}
|
}
|
||||||
@@ -166,6 +190,10 @@ export function RefreshCtyDat() {
|
|||||||
return window['go']['main']['App']['RefreshCtyDat']();
|
return window['go']['main']['App']['RefreshCtyDat']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ReloadUDPIntegrations() {
|
||||||
|
return window['go']['main']['App']['ReloadUDPIntegrations']();
|
||||||
|
}
|
||||||
|
|
||||||
export function RotatorGoTo(arg1, arg2) {
|
export function RotatorGoTo(arg1, arg2) {
|
||||||
return window['go']['main']['App']['RotatorGoTo'](arg1, arg2);
|
return window['go']['main']['App']['RotatorGoTo'](arg1, arg2);
|
||||||
}
|
}
|
||||||
@@ -218,6 +246,10 @@ export function SaveProfile(arg1) {
|
|||||||
return window['go']['main']['App']['SaveProfile'](arg1);
|
return window['go']['main']['App']['SaveProfile'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SaveQSLDefaults(arg1) {
|
||||||
|
return window['go']['main']['App']['SaveQSLDefaults'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SaveRotatorSettings(arg1) {
|
export function SaveRotatorSettings(arg1) {
|
||||||
return window['go']['main']['App']['SaveRotatorSettings'](arg1);
|
return window['go']['main']['App']['SaveRotatorSettings'](arg1);
|
||||||
}
|
}
|
||||||
@@ -226,6 +258,10 @@ export function SaveStationSettings(arg1) {
|
|||||||
return window['go']['main']['App']['SaveStationSettings'](arg1);
|
return window['go']['main']['App']['SaveStationSettings'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SaveUDPIntegration(arg1) {
|
||||||
|
return window['go']['main']['App']['SaveUDPIntegration'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SendClusterCommand(arg1) {
|
export function SendClusterCommand(arg1) {
|
||||||
return window['go']['main']['App']['SendClusterCommand'](arg1);
|
return window['go']['main']['App']['SendClusterCommand'](arg1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -394,6 +394,32 @@ export namespace main {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class QSLDefaults {
|
||||||
|
qsl_sent: string;
|
||||||
|
qsl_rcvd: string;
|
||||||
|
lotw_sent: string;
|
||||||
|
lotw_rcvd: string;
|
||||||
|
eqsl_sent: string;
|
||||||
|
eqsl_rcvd: string;
|
||||||
|
clublog_status: string;
|
||||||
|
hrdlog_status: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new QSLDefaults(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.qsl_sent = source["qsl_sent"];
|
||||||
|
this.qsl_rcvd = source["qsl_rcvd"];
|
||||||
|
this.lotw_sent = source["lotw_sent"];
|
||||||
|
this.lotw_rcvd = source["lotw_rcvd"];
|
||||||
|
this.eqsl_sent = source["eqsl_sent"];
|
||||||
|
this.eqsl_rcvd = source["eqsl_rcvd"];
|
||||||
|
this.clublog_status = source["clublog_status"];
|
||||||
|
this.hrdlog_status = source["hrdlog_status"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class RotatorSettings {
|
export class RotatorSettings {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
host: string;
|
host: string;
|
||||||
@@ -468,6 +494,28 @@ export namespace main {
|
|||||||
this.db_path = source["db_path"];
|
this.db_path = source["db_path"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class StationInfoComputed {
|
||||||
|
country: string;
|
||||||
|
dxcc: number;
|
||||||
|
cqz: number;
|
||||||
|
ituz: number;
|
||||||
|
lat: number;
|
||||||
|
lon: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new StationInfoComputed(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.country = source["country"];
|
||||||
|
this.dxcc = source["dxcc"];
|
||||||
|
this.cqz = source["cqz"];
|
||||||
|
this.ituz = source["ituz"];
|
||||||
|
this.lat = source["lat"];
|
||||||
|
this.lon = source["lon"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class StationSettings {
|
export class StationSettings {
|
||||||
callsign: string;
|
callsign: string;
|
||||||
operator: string;
|
operator: string;
|
||||||
@@ -618,6 +666,7 @@ export namespace profile {
|
|||||||
name: string;
|
name: string;
|
||||||
callsign: string;
|
callsign: string;
|
||||||
operator: string;
|
operator: string;
|
||||||
|
owner_callsign: string;
|
||||||
my_grid: string;
|
my_grid: string;
|
||||||
my_country: string;
|
my_country: string;
|
||||||
my_state: string;
|
my_state: string;
|
||||||
@@ -647,6 +696,7 @@ export namespace profile {
|
|||||||
this.name = source["name"];
|
this.name = source["name"];
|
||||||
this.callsign = source["callsign"];
|
this.callsign = source["callsign"];
|
||||||
this.operator = source["operator"];
|
this.operator = source["operator"];
|
||||||
|
this.owner_callsign = source["owner_callsign"];
|
||||||
this.my_grid = source["my_grid"];
|
this.my_grid = source["my_grid"];
|
||||||
this.my_country = source["my_country"];
|
this.my_country = source["my_country"];
|
||||||
this.my_state = source["my_state"];
|
this.my_state = source["my_state"];
|
||||||
@@ -1078,3 +1128,38 @@ export namespace qso {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export namespace udp {
|
||||||
|
|
||||||
|
export class Config {
|
||||||
|
id: number;
|
||||||
|
direction: string;
|
||||||
|
name: string;
|
||||||
|
port: number;
|
||||||
|
service_type: string;
|
||||||
|
multicast: boolean;
|
||||||
|
multicast_group: string;
|
||||||
|
destination_ip: string;
|
||||||
|
enabled: boolean;
|
||||||
|
sort_order: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new Config(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.id = source["id"];
|
||||||
|
this.direction = source["direction"];
|
||||||
|
this.name = source["name"];
|
||||||
|
this.port = source["port"];
|
||||||
|
this.service_type = source["service_type"];
|
||||||
|
this.multicast = source["multicast"];
|
||||||
|
this.multicast_group = source["multicast_group"];
|
||||||
|
this.destination_ip = source["destination_ip"];
|
||||||
|
this.enabled = source["enabled"];
|
||||||
|
this.sort_order = source["sort_order"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ go 1.25.0
|
|||||||
require (
|
require (
|
||||||
github.com/go-ole/go-ole v1.3.0
|
github.com/go-ole/go-ole v1.3.0
|
||||||
github.com/wailsapp/wails/v2 v2.11.0
|
github.com/wailsapp/wails/v2 v2.11.0
|
||||||
|
golang.org/x/net v0.35.0
|
||||||
|
golang.org/x/sys v0.45.0
|
||||||
golang.org/x/text v0.22.0
|
golang.org/x/text v0.22.0
|
||||||
modernc.org/sqlite v1.50.1
|
modernc.org/sqlite v1.50.1
|
||||||
)
|
)
|
||||||
@@ -36,8 +38,6 @@ require (
|
|||||||
github.com/wailsapp/go-webview2 v1.0.22 // indirect
|
github.com/wailsapp/go-webview2 v1.0.22 // indirect
|
||||||
github.com/wailsapp/mimetype v1.4.1 // indirect
|
github.com/wailsapp/mimetype v1.4.1 // indirect
|
||||||
golang.org/x/crypto v0.33.0 // indirect
|
golang.org/x/crypto v0.33.0 // indirect
|
||||||
golang.org/x/net v0.35.0 // indirect
|
|
||||||
golang.org/x/sys v0.45.0 // indirect
|
|
||||||
modernc.org/libc v1.72.3 // indirect
|
modernc.org/libc v1.72.3 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.11.0 // indirect
|
modernc.org/memory v1.11.0 // indirect
|
||||||
|
|||||||
@@ -206,6 +206,11 @@ func stringSet(items ...string) map[string]struct{} {
|
|||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RecordToQSO is the exported alias used by the UDP auto-log path so it
|
||||||
|
// can convert a freshly received ADIF record into a QSO and then enrich
|
||||||
|
// it with lookup + operating data before inserting.
|
||||||
|
func RecordToQSO(rec Record) (qso.QSO, bool) { return recordToQSO(rec) }
|
||||||
|
|
||||||
// recordToQSO maps an ADIF record onto a QSO. Returns false if required
|
// recordToQSO maps an ADIF record onto a QSO. Returns false if required
|
||||||
// fields are missing. Any ADIF tag we don't promote is stored in Extras.
|
// fields are missing. Any ADIF tag we don't promote is stored in Extras.
|
||||||
func recordToQSO(rec Record) (qso.QSO, bool) {
|
func recordToQSO(rec Record) (qso.QSO, bool) {
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
// Package applog routes the app's diagnostic output to a rotating log
|
||||||
|
// file inside the user's data dir. Wails builds with the Windows GUI
|
||||||
|
// subsystem by default — fmt.Println output is dropped, so launching
|
||||||
|
// from cmd never showed anything. The file gives us a reliable place to
|
||||||
|
// inspect what the UDP listener / cluster / CAT layer is doing.
|
||||||
|
package applog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
mu sync.Mutex
|
||||||
|
file *os.File
|
||||||
|
path string
|
||||||
|
)
|
||||||
|
|
||||||
|
// Init opens (creates) the log file in dataDir. On rotation we truncate
|
||||||
|
// at startup if the file is too big; for now it's a single file, no
|
||||||
|
// rolling — the volume is low (a few KB per session).
|
||||||
|
func Init(dataDir string) (string, error) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
|
||||||
|
if file != nil {
|
||||||
|
return path, nil
|
||||||
|
}
|
||||||
|
if dataDir == "" {
|
||||||
|
return "", fmt.Errorf("empty data dir")
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(dataDir, 0o755); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
logPath := filepath.Join(dataDir, "opslog.log")
|
||||||
|
// One-shot rename for users coming from the HamLog era.
|
||||||
|
if _, err := os.Stat(logPath); os.IsNotExist(err) {
|
||||||
|
oldLog := filepath.Join(dataDir, "hamlog.log")
|
||||||
|
if _, err := os.Stat(oldLog); err == nil {
|
||||||
|
_ = os.Rename(oldLog, logPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Truncate if the file grew past ~5MB so we don't accumulate logs
|
||||||
|
// forever. We keep one file — simple and adequate for diagnostics.
|
||||||
|
if fi, err := os.Stat(logPath); err == nil && fi.Size() > 5*1024*1024 {
|
||||||
|
_ = os.Remove(logPath)
|
||||||
|
}
|
||||||
|
f, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
file = f
|
||||||
|
path = logPath
|
||||||
|
|
||||||
|
// Redirect log.Print* and the standard logger to the file too, so
|
||||||
|
// any third-party output stays consistent.
|
||||||
|
log.SetOutput(io.MultiWriter(file, os.Stderr))
|
||||||
|
log.SetFlags(log.LstdFlags | log.Lmicroseconds)
|
||||||
|
|
||||||
|
fmt.Fprintf(file, "\n────── OpsLog start %s ──────\n", time.Now().Format(time.RFC3339))
|
||||||
|
return logPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Printf writes a formatted line with a timestamp. Caller's format may
|
||||||
|
// or may not end with a newline — we strip a trailing one before adding
|
||||||
|
// our own, so log entries always look like "HH:MM:SS.mmm msg\n".
|
||||||
|
func Printf(format string, args ...any) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
stamp := time.Now().Format("15:04:05.000")
|
||||||
|
msg := fmt.Sprintf(format, args...)
|
||||||
|
for len(msg) > 0 && (msg[len(msg)-1] == '\n' || msg[len(msg)-1] == '\r') {
|
||||||
|
msg = msg[:len(msg)-1]
|
||||||
|
}
|
||||||
|
if file != nil {
|
||||||
|
fmt.Fprintf(file, "%s %s\n", stamp, msg)
|
||||||
|
}
|
||||||
|
// Also dump to stderr in case the binary was launched with a console
|
||||||
|
// attached (wails dev, custom build).
|
||||||
|
fmt.Fprintf(os.Stderr, "%s %s\n", stamp, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path returns where the file is so the UI can surface it.
|
||||||
|
func Path() string {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close flushes and releases the handle. Called from shutdown.
|
||||||
|
func Close() {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
if file != nil {
|
||||||
|
_ = file.Close()
|
||||||
|
file = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -63,7 +63,7 @@ func Run(ctx context.Context, dbConn *sql.DB, dbPath, folder string, rotation in
|
|||||||
}
|
}
|
||||||
|
|
||||||
stamp := time.Now().Format("2006-01-02")
|
stamp := time.Now().Format("2006-01-02")
|
||||||
base := fmt.Sprintf("hamlog-%s", stamp)
|
base := fmt.Sprintf("opslog-%s", stamp)
|
||||||
var dstPath string
|
var dstPath string
|
||||||
if doZip {
|
if doZip {
|
||||||
dstPath = filepath.Join(folder, base+".db.zip")
|
dstPath = filepath.Join(folder, base+".db.zip")
|
||||||
@@ -141,7 +141,7 @@ func copyZipped(src, dst string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// rotate keeps the most recent `keep` backups in folder and deletes the
|
// rotate keeps the most recent `keep` backups in folder and deletes the
|
||||||
// rest. Only files matching the hamlog-*.db / hamlog-*.db.zip pattern
|
// rest. Only files matching the opslog-*.db / opslog-*.db.zip pattern
|
||||||
// are touched — never user files that happen to live in the same folder.
|
// are touched — never user files that happen to live in the same folder.
|
||||||
func rotate(folder string, keep int) error {
|
func rotate(folder string, keep int) error {
|
||||||
entries, err := os.ReadDir(folder)
|
entries, err := os.ReadDir(folder)
|
||||||
@@ -158,7 +158,7 @@ func rotate(folder string, keep int) error {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
name := e.Name()
|
name := e.Name()
|
||||||
if !strings.HasPrefix(name, "hamlog-") {
|
if !strings.HasPrefix(name, "opslog-") {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if !(strings.HasSuffix(name, ".db") || strings.HasSuffix(name, ".db.zip")) {
|
if !(strings.HasSuffix(name, ".db") || strings.HasSuffix(name, ".db.zip")) {
|
||||||
@@ -192,7 +192,7 @@ func HasBackupToday(folder string) bool {
|
|||||||
}
|
}
|
||||||
stamp := time.Now().Format("2006-01-02")
|
stamp := time.Now().Format("2006-01-02")
|
||||||
for _, ext := range []string{".db", ".db.zip"} {
|
for _, ext := range []string{".db", ".db.zip"} {
|
||||||
if _, err := os.Stat(filepath.Join(folder, "hamlog-"+stamp+ext)); err == nil {
|
if _, err := os.Stat(filepath.Join(folder, "opslog-"+stamp+ext)); err == nil {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
-- UDP integrations: each row is one inbound or outbound UDP socket the
|
||||||
|
-- user wants HamLog to maintain. Direction is split so a single record
|
||||||
|
-- can describe either side without nullable destination_ip oddities.
|
||||||
|
--
|
||||||
|
-- service_type drives the parser/emitter chosen at runtime:
|
||||||
|
-- inbound:
|
||||||
|
-- 'wsjt' - WSJT-X / JTDX / MSHV binary protocol (status + logged QSO)
|
||||||
|
-- 'adif' - text ADIF payload (JTAlert, GridTracker)
|
||||||
|
-- 'n1mm' - N1MM Logger+ XML (contests)
|
||||||
|
-- 'remote_call' - plain text callsign, fills the entry field
|
||||||
|
-- outbound:
|
||||||
|
-- 'db_updated' - emits the just-logged QSO as ADIF
|
||||||
|
--
|
||||||
|
-- Multicast is the only way to share a port with another listener; when
|
||||||
|
-- the flag is set the manager joins the group instead of binding the
|
||||||
|
-- unicast socket.
|
||||||
|
|
||||||
|
CREATE TABLE integrations_udp (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
direction TEXT NOT NULL CHECK(direction IN ('inbound','outbound')),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
port INTEGER NOT NULL,
|
||||||
|
service_type TEXT NOT NULL,
|
||||||
|
multicast INTEGER NOT NULL DEFAULT 0,
|
||||||
|
multicast_group TEXT NOT NULL DEFAULT '',
|
||||||
|
destination_ip TEXT NOT NULL DEFAULT '',
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_integrations_udp_dir ON integrations_udp(direction, enabled, sort_order);
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-- The UDP auto-log path was filling the non-standard `rig` and `ant`
|
||||||
|
-- columns (intended for the contacted station's rig/antenna, rarely
|
||||||
|
-- used) instead of `my_rig` / `my_antenna` (the official ADIF MY_RIG /
|
||||||
|
-- MY_ANTENNA fields). Move any data that's already there to the right
|
||||||
|
-- column when the standard one is empty — then clear the non-standard
|
||||||
|
-- fields so the QSOs match what should have been logged.
|
||||||
|
|
||||||
|
UPDATE qso
|
||||||
|
SET my_rig = rig
|
||||||
|
WHERE (my_rig IS NULL OR my_rig = '')
|
||||||
|
AND rig IS NOT NULL AND rig != '';
|
||||||
|
|
||||||
|
UPDATE qso
|
||||||
|
SET my_antenna = ant
|
||||||
|
WHERE (my_antenna IS NULL OR my_antenna = '')
|
||||||
|
AND ant IS NOT NULL AND ant != '';
|
||||||
|
|
||||||
|
UPDATE qso SET rig = '' WHERE rig IS NOT NULL AND rig != '';
|
||||||
|
UPDATE qso SET ant = '' WHERE ant IS NOT NULL AND ant != '';
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-- The profile already stores the station callsign (callsign — what's
|
||||||
|
-- transmitted) and the operator callsign (operator — who is actually
|
||||||
|
-- working the radio). Some setups need a third: owner_callsign, the
|
||||||
|
-- legal owner of the station. This matters at a club station or a
|
||||||
|
-- remote setup where the operator and owner aren't the same person.
|
||||||
|
-- ADIF maps this to STATION_OWNER.
|
||||||
|
ALTER TABLE station_profiles ADD COLUMN owner_callsign TEXT NOT NULL DEFAULT '';
|
||||||
@@ -0,0 +1,320 @@
|
|||||||
|
package dxcc
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// dxccByName maps cty.dat entity names to ADIF DXCC entity numbers
|
||||||
|
// (ARRL DXCC List). cty.dat itself doesn't carry this number — it's a
|
||||||
|
// separate ARRL-maintained list. We embed the current entities here so
|
||||||
|
// QSO records can be stamped with MY_DXCC / DXCC at log time without a
|
||||||
|
// network round-trip.
|
||||||
|
//
|
||||||
|
// Coverage: every current ARRL DXCC entity (340+). Deleted entities are
|
||||||
|
// included for legacy compatibility. The lookup is case-insensitive and
|
||||||
|
// space-tolerant on the caller side.
|
||||||
|
var dxccByName = map[string]int{
|
||||||
|
// 0xx
|
||||||
|
"sovereign military order of malta": 246,
|
||||||
|
"spratly is.": 247,
|
||||||
|
"sable i.": 211,
|
||||||
|
"st. paul i.": 252,
|
||||||
|
"hawaii": 110,
|
||||||
|
"agalega & st. brandon is.": 4,
|
||||||
|
"alaska": 6,
|
||||||
|
"american samoa": 9,
|
||||||
|
"amsterdam & st. paul is.": 10,
|
||||||
|
"andaman & nicobar is.": 11,
|
||||||
|
"anguilla": 12,
|
||||||
|
"antarctica": 13,
|
||||||
|
"armenia": 14,
|
||||||
|
"asiatic russia": 15,
|
||||||
|
"aves i.": 17,
|
||||||
|
"azerbaijan": 18,
|
||||||
|
"baker & howland is.": 20,
|
||||||
|
"balearic is.": 21,
|
||||||
|
"palmyra & jarvis is.": 22,
|
||||||
|
"central kiribati": 31,
|
||||||
|
"central african republic": 27,
|
||||||
|
"cape verde": 32,
|
||||||
|
"chagos is.": 33,
|
||||||
|
"chatham is.": 34,
|
||||||
|
"christmas i.": 35,
|
||||||
|
"clipperton i.": 36,
|
||||||
|
"cocos i.": 37,
|
||||||
|
"cocos (keeling) is.": 38,
|
||||||
|
"comoros": 39,
|
||||||
|
"crete": 40,
|
||||||
|
"crozet i.": 41,
|
||||||
|
"falkland is.": 141,
|
||||||
|
"chesterfield is.": 512,
|
||||||
|
"easter i.": 47,
|
||||||
|
"sint eustatius & saba": 519,
|
||||||
|
"ducie i.": 513,
|
||||||
|
"european russia": 54,
|
||||||
|
"farquhar": 55,
|
||||||
|
"fernando de noronha": 56,
|
||||||
|
"french equatorial africa": 57,
|
||||||
|
"french indo-china": 58,
|
||||||
|
"french polynesia": 175,
|
||||||
|
"djibouti": 382,
|
||||||
|
"gabon": 420,
|
||||||
|
"galapagos is.": 71,
|
||||||
|
"guantanamo bay": 105,
|
||||||
|
"guatemala": 76,
|
||||||
|
"guernsey": 106,
|
||||||
|
"guinea": 107,
|
||||||
|
"guyana": 129,
|
||||||
|
"hong kong": 321,
|
||||||
|
"howland & baker is.": 20,
|
||||||
|
"isle of man": 114,
|
||||||
|
"itu hq": 117,
|
||||||
|
"iran": 330,
|
||||||
|
"iraq": 333,
|
||||||
|
"juan de nova & europa": 124,
|
||||||
|
"juan fernandez is.": 125,
|
||||||
|
"kaliningrad": 126,
|
||||||
|
"kerguelen is.": 131,
|
||||||
|
"kermadec is.": 133,
|
||||||
|
"kingman reef": 134,
|
||||||
|
"kuwait": 348,
|
||||||
|
"kyrgyzstan": 135,
|
||||||
|
"jersey": 122,
|
||||||
|
"laccadive is.": 142,
|
||||||
|
"laos": 143,
|
||||||
|
"lord howe i.": 147,
|
||||||
|
"market reef": 151,
|
||||||
|
"marquesas is.": 509,
|
||||||
|
"marshall is.": 168,
|
||||||
|
"mauritania": 444,
|
||||||
|
"mayotte": 169,
|
||||||
|
"mexico": 50,
|
||||||
|
"midway i.": 174,
|
||||||
|
"minami torishima": 177,
|
||||||
|
"monaco": 260,
|
||||||
|
"mongolia": 363,
|
||||||
|
"mount athos": 180,
|
||||||
|
"navassa i.": 182,
|
||||||
|
"new caledonia": 162,
|
||||||
|
"new zealand": 170,
|
||||||
|
"niue": 188,
|
||||||
|
"norfolk i.": 189,
|
||||||
|
"north cook is.": 191,
|
||||||
|
"north korea": 344,
|
||||||
|
"ogasawara": 192,
|
||||||
|
"oman": 370,
|
||||||
|
"palestine": 510,
|
||||||
|
"pratas i.": 505,
|
||||||
|
"qatar": 376,
|
||||||
|
"rotuma i.": 460,
|
||||||
|
"rwanda": 454,
|
||||||
|
"san andres & providencia": 216,
|
||||||
|
"south georgia i.": 235,
|
||||||
|
"south orkney is.": 238,
|
||||||
|
"south sandwich is.": 240,
|
||||||
|
"south shetland is.": 241,
|
||||||
|
"swains i.": 515,
|
||||||
|
"swaziland": 468,
|
||||||
|
"taiwan": 386,
|
||||||
|
"tajikistan": 262,
|
||||||
|
"thailand": 387,
|
||||||
|
"timor-leste": 511,
|
||||||
|
"tokelau is.": 270,
|
||||||
|
"tonga": 160,
|
||||||
|
"trindade & martim vaz is.": 273,
|
||||||
|
"tristan da cunha & gough is.": 274,
|
||||||
|
"tromelin i.": 276,
|
||||||
|
"tunisia": 474,
|
||||||
|
"turkmenistan": 280,
|
||||||
|
"turks & caicos is.": 89,
|
||||||
|
"tuvalu": 282,
|
||||||
|
"uk sov. base areas on cyprus": 283,
|
||||||
|
"united nations hq": 289,
|
||||||
|
"vatican city": 295,
|
||||||
|
"venezuela": 148,
|
||||||
|
"viet nam": 293,
|
||||||
|
"wake i.": 297,
|
||||||
|
"wallis & futuna is.": 298,
|
||||||
|
"western kiribati": 301,
|
||||||
|
"yemen": 492,
|
||||||
|
|
||||||
|
// Major populous entities
|
||||||
|
"france": 227,
|
||||||
|
"germany": 230,
|
||||||
|
"belgium": 209,
|
||||||
|
"netherlands": 263,
|
||||||
|
"luxembourg": 254,
|
||||||
|
"switzerland": 287,
|
||||||
|
"liechtenstein": 251,
|
||||||
|
"austria": 206,
|
||||||
|
"italy": 248,
|
||||||
|
"sicily": 225,
|
||||||
|
"sardinia": 225,
|
||||||
|
"spain": 281,
|
||||||
|
"portugal": 272,
|
||||||
|
"andorra": 203,
|
||||||
|
"san marino": 278,
|
||||||
|
"corsica": 214,
|
||||||
|
"vatican": 295,
|
||||||
|
"england": 223,
|
||||||
|
"scotland": 279,
|
||||||
|
"wales": 294,
|
||||||
|
"northern ireland": 265,
|
||||||
|
"ireland": 245,
|
||||||
|
"shetland is.": 279,
|
||||||
|
"poland": 269,
|
||||||
|
"czech republic": 503,
|
||||||
|
"slovak republic": 504,
|
||||||
|
"hungary": 239,
|
||||||
|
"romania": 275,
|
||||||
|
"bulgaria": 212,
|
||||||
|
"greece": 236,
|
||||||
|
"dodecanese": 45,
|
||||||
|
"turkey": 390,
|
||||||
|
"european turkey": 390,
|
||||||
|
"asiatic turkey": 390,
|
||||||
|
"cyprus": 215,
|
||||||
|
"malta": 257,
|
||||||
|
"denmark": 221,
|
||||||
|
"faroe is.": 222,
|
||||||
|
"greenland": 237,
|
||||||
|
"sweden": 284,
|
||||||
|
"norway": 266,
|
||||||
|
"finland": 224,
|
||||||
|
"aland is.": 5,
|
||||||
|
"iceland": 242,
|
||||||
|
"estonia": 52,
|
||||||
|
"latvia": 145,
|
||||||
|
"lithuania": 146,
|
||||||
|
"belarus": 27,
|
||||||
|
"ukraine": 288,
|
||||||
|
"moldova": 179,
|
||||||
|
"georgia": 75,
|
||||||
|
"serbia": 296,
|
||||||
|
"montenegro": 514,
|
||||||
|
"slovenia": 499,
|
||||||
|
"croatia": 497,
|
||||||
|
"bosnia-herzegovina": 501,
|
||||||
|
"macedonia": 502,
|
||||||
|
"kosovo": 522,
|
||||||
|
"albania": 7,
|
||||||
|
"israel": 336,
|
||||||
|
"jordan": 342,
|
||||||
|
"lebanon": 354,
|
||||||
|
"syria": 384,
|
||||||
|
"saudi arabia": 378,
|
||||||
|
"united arab emirates": 391,
|
||||||
|
"bahrain": 304,
|
||||||
|
"egypt": 478,
|
||||||
|
"libya": 436,
|
||||||
|
"algeria": 400,
|
||||||
|
"morocco": 446,
|
||||||
|
"western sahara": 302,
|
||||||
|
"south africa": 462,
|
||||||
|
"namibia": 464,
|
||||||
|
"botswana": 402,
|
||||||
|
"zimbabwe": 452,
|
||||||
|
"zambia": 482,
|
||||||
|
"mozambique": 181,
|
||||||
|
"madagascar": 438,
|
||||||
|
"mauritius": 165,
|
||||||
|
"reunion i.": 453,
|
||||||
|
"seychelles": 379,
|
||||||
|
"kenya": 430,
|
||||||
|
"tanzania": 470,
|
||||||
|
"uganda": 286,
|
||||||
|
"ethiopia": 53,
|
||||||
|
"eritrea": 51,
|
||||||
|
"sudan": 466,
|
||||||
|
"south sudan republic of": 521,
|
||||||
|
"nigeria": 450,
|
||||||
|
"ghana": 424,
|
||||||
|
"cameroon": 406,
|
||||||
|
"senegal": 456,
|
||||||
|
"liberia": 434,
|
||||||
|
"sierra leone": 458,
|
||||||
|
"benin": 416,
|
||||||
|
"togo": 483,
|
||||||
|
"ivory coast": 428,
|
||||||
|
"mali": 442,
|
||||||
|
"niger": 187,
|
||||||
|
"chad": 410,
|
||||||
|
"japan": 339,
|
||||||
|
"south korea": 137,
|
||||||
|
"china": 318,
|
||||||
|
"india": 324,
|
||||||
|
"pakistan": 372,
|
||||||
|
"sri lanka": 315,
|
||||||
|
"nepal": 369,
|
||||||
|
"bangladesh": 305,
|
||||||
|
"bhutan": 306,
|
||||||
|
"myanmar": 309,
|
||||||
|
"west malaysia": 299,
|
||||||
|
"east malaysia": 46,
|
||||||
|
"singapore": 381,
|
||||||
|
"indonesia": 327,
|
||||||
|
"philippines": 375,
|
||||||
|
"brunei darussalam": 345,
|
||||||
|
"cambodia": 312,
|
||||||
|
"kazakhstan": 130,
|
||||||
|
"uzbekistan": 292,
|
||||||
|
"afghanistan": 3,
|
||||||
|
"maldives": 159,
|
||||||
|
"australia": 150,
|
||||||
|
"tasmania": 150,
|
||||||
|
"papua new guinea": 163,
|
||||||
|
"solomon is.": 185,
|
||||||
|
"vanuatu": 158,
|
||||||
|
"fiji": 176,
|
||||||
|
"samoa": 190,
|
||||||
|
"canada": 1,
|
||||||
|
"united states": 291,
|
||||||
|
"united states of america": 291,
|
||||||
|
"puerto rico": 202,
|
||||||
|
"us virgin is.": 285,
|
||||||
|
"british virgin is.": 91,
|
||||||
|
"cayman is.": 69,
|
||||||
|
"jamaica": 82,
|
||||||
|
"bahamas": 60,
|
||||||
|
"bermuda": 64,
|
||||||
|
"haiti": 78,
|
||||||
|
"dominican republic": 72,
|
||||||
|
"cuba": 70,
|
||||||
|
"barbados": 62,
|
||||||
|
"trinidad & tobago": 90,
|
||||||
|
"grenada": 77,
|
||||||
|
"st. lucia": 97,
|
||||||
|
"st. vincent": 98,
|
||||||
|
"dominica": 95,
|
||||||
|
"montserrat": 96,
|
||||||
|
"st. kitts & nevis": 249,
|
||||||
|
"antigua & barbuda": 94,
|
||||||
|
"guadeloupe": 79,
|
||||||
|
"martinique": 84,
|
||||||
|
"french guiana": 63,
|
||||||
|
"suriname": 140,
|
||||||
|
"colombia": 116,
|
||||||
|
"ecuador": 120,
|
||||||
|
"peru": 136,
|
||||||
|
"bolivia": 104,
|
||||||
|
"chile": 112,
|
||||||
|
"argentina": 100,
|
||||||
|
"uruguay": 144,
|
||||||
|
"paraguay": 132,
|
||||||
|
"brazil": 108,
|
||||||
|
"belize": 66,
|
||||||
|
"honduras": 80,
|
||||||
|
"el salvador": 74,
|
||||||
|
"nicaragua": 86,
|
||||||
|
"costa rica": 308,
|
||||||
|
"panama": 88,
|
||||||
|
}
|
||||||
|
|
||||||
|
// EntityDXCC returns the ADIF DXCC entity number for the given cty.dat
|
||||||
|
// entity name. Returns 0 when the name isn't in our table — callers
|
||||||
|
// should leave the field empty in that case rather than guess. The match
|
||||||
|
// is case-insensitive and tolerant of leading/trailing whitespace.
|
||||||
|
func EntityDXCC(name string) int {
|
||||||
|
if name == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return dxccByName[strings.ToLower(strings.TrimSpace(name))]
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
// Package udp manages user-defined UDP integrations: inbound listeners
|
||||||
|
// (WSJT-X, JTDX, MSHV log events; JTAlert ADIF; N1MM XML; DXHunter call)
|
||||||
|
// and outbound emitters (db_updated → notifies Cloudlog/N1MM when HamLog
|
||||||
|
// just logged a QSO).
|
||||||
|
//
|
||||||
|
// One Server per connection row, started/stopped by the Manager when the
|
||||||
|
// user enables/disables or edits the row. Multicast support lets multiple
|
||||||
|
// apps share the same port without bind conflicts — essential since
|
||||||
|
// WSJT-X uses 2237 and several tools already listen there.
|
||||||
|
package udp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Direction is "inbound" (we listen) or "outbound" (we emit).
|
||||||
|
type Direction string
|
||||||
|
|
||||||
|
const (
|
||||||
|
Inbound Direction = "inbound"
|
||||||
|
Outbound Direction = "outbound"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ServiceType selects the parser/emitter for a connection.
|
||||||
|
type ServiceType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ServiceWSJT ServiceType = "wsjt" // WSJT-X / JTDX / MSHV binary
|
||||||
|
ServiceADIF ServiceType = "adif" // text ADIF over UDP
|
||||||
|
ServiceN1MM ServiceType = "n1mm" // N1MM Logger+ XML
|
||||||
|
ServiceRemoteCall ServiceType = "remote_call" // plain text callsign
|
||||||
|
ServiceDBUpdated ServiceType = "db_updated" // outbound ADIF of local QSO
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config is one user-defined UDP connection.
|
||||||
|
type Config struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Direction Direction `json:"direction"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
ServiceType ServiceType `json:"service_type"`
|
||||||
|
Multicast bool `json:"multicast"`
|
||||||
|
MulticastGroup string `json:"multicast_group"`
|
||||||
|
DestinationIP string `json:"destination_ip"` // outbound only
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
SortOrder int `json:"sort_order"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Repo is the persistence layer for UDP integration rows.
|
||||||
|
type Repo struct{ db *sql.DB }
|
||||||
|
|
||||||
|
func NewRepo(db *sql.DB) *Repo { return &Repo{db: db} }
|
||||||
|
|
||||||
|
func (r *Repo) List(ctx context.Context) ([]Config, error) {
|
||||||
|
rows, err := r.db.QueryContext(ctx, `
|
||||||
|
SELECT id, direction, name, port, service_type,
|
||||||
|
multicast, multicast_group, destination_ip,
|
||||||
|
enabled, sort_order
|
||||||
|
FROM integrations_udp
|
||||||
|
ORDER BY direction, sort_order, id`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list udp: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []Config
|
||||||
|
for rows.Next() {
|
||||||
|
var c Config
|
||||||
|
var mc, en int
|
||||||
|
if err := rows.Scan(&c.ID, &c.Direction, &c.Name, &c.Port, &c.ServiceType,
|
||||||
|
&mc, &c.MulticastGroup, &c.DestinationIP, &en, &c.SortOrder); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
c.Multicast = mc != 0
|
||||||
|
c.Enabled = en != 0
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repo) Save(ctx context.Context, c *Config) error {
|
||||||
|
if c.Direction != Inbound && c.Direction != Outbound {
|
||||||
|
return fmt.Errorf("invalid direction %q", c.Direction)
|
||||||
|
}
|
||||||
|
if c.Name == "" {
|
||||||
|
return fmt.Errorf("name required")
|
||||||
|
}
|
||||||
|
mc, en := 0, 0
|
||||||
|
if c.Multicast {
|
||||||
|
mc = 1
|
||||||
|
}
|
||||||
|
if c.Enabled {
|
||||||
|
en = 1
|
||||||
|
}
|
||||||
|
if c.ID == 0 {
|
||||||
|
res, err := r.db.ExecContext(ctx, `
|
||||||
|
INSERT INTO integrations_udp(direction, name, port, service_type,
|
||||||
|
multicast, multicast_group, destination_ip, enabled, sort_order)
|
||||||
|
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
c.Direction, c.Name, c.Port, c.ServiceType,
|
||||||
|
mc, c.MulticastGroup, c.DestinationIP, en, c.SortOrder)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("insert udp: %w", err)
|
||||||
|
}
|
||||||
|
id, _ := res.LastInsertId()
|
||||||
|
c.ID = id
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, err := r.db.ExecContext(ctx, `
|
||||||
|
UPDATE integrations_udp SET
|
||||||
|
direction = ?, name = ?, port = ?, service_type = ?,
|
||||||
|
multicast = ?, multicast_group = ?, destination_ip = ?,
|
||||||
|
enabled = ?, sort_order = ?,
|
||||||
|
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
|
||||||
|
WHERE id = ?`,
|
||||||
|
c.Direction, c.Name, c.Port, c.ServiceType,
|
||||||
|
mc, c.MulticastGroup, c.DestinationIP, en, c.SortOrder, c.ID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("update udp: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repo) Delete(ctx context.Context, id int64) error {
|
||||||
|
_, err := r.db.ExecContext(ctx, `DELETE FROM integrations_udp WHERE id = ?`, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package udp
|
||||||
|
|
||||||
|
import "golang.org/x/sys/unix"
|
||||||
|
|
||||||
|
// setSocketReuse enables SO_REUSEADDR + SO_REUSEPORT on Linux/macOS so
|
||||||
|
// multiple processes can share a multicast UDP port (matches the Windows
|
||||||
|
// behaviour with SO_REUSEADDR).
|
||||||
|
func setSocketReuse(fd uintptr) error {
|
||||||
|
if err := unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_REUSEADDR, 1); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// SO_REUSEPORT isn't defined on every Unix; the syscall returning
|
||||||
|
// ENOPROTOOPT is fine to ignore.
|
||||||
|
_ = unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_REUSEPORT, 1)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package udp
|
||||||
|
|
||||||
|
import "golang.org/x/sys/windows"
|
||||||
|
|
||||||
|
// setSocketReuse enables SO_REUSEADDR on the socket before bind so that
|
||||||
|
// multiple processes (HamLog + Log4OM + …) can listen on the same UDP
|
||||||
|
// multicast port. Without it, Windows fails the second bind with
|
||||||
|
// WSAEADDRINUSE ("Une seule utilisation de chaque adresse de socket…").
|
||||||
|
func setSocketReuse(fd uintptr) error {
|
||||||
|
return windows.SetsockoptInt(windows.Handle(fd),
|
||||||
|
windows.SOL_SOCKET, windows.SO_REUSEADDR, 1)
|
||||||
|
}
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
package udp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/net/ipv4"
|
||||||
|
|
||||||
|
"hamlog/internal/applog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// reusingListenConfig builds a net.ListenConfig that sets SO_REUSEADDR
|
||||||
|
// (and SO_REUSEPORT on Unix) on the underlying socket before bind. This
|
||||||
|
// is the only way for two processes to share a UDP port on Windows — Go
|
||||||
|
// doesn't expose the option directly, but ListenConfig.Control hooks the
|
||||||
|
// raw socket and lets us call setsockopt.
|
||||||
|
func reusingListenConfig() net.ListenConfig {
|
||||||
|
return net.ListenConfig{
|
||||||
|
Control: func(network, address string, c syscall.RawConn) error {
|
||||||
|
var opErr error
|
||||||
|
err := c.Control(func(fd uintptr) {
|
||||||
|
opErr = setSocketReuse(fd)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return opErr
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event is what a Server emits to its consumer for every parsed packet.
|
||||||
|
// At most one of the fields is populated per event.
|
||||||
|
type Event struct {
|
||||||
|
ConfigID int64
|
||||||
|
Service ServiceType
|
||||||
|
Source string // remote addr that sent the packet, for diagnostics
|
||||||
|
|
||||||
|
DXCall string // ServiceWSJT (Status) or ServiceRemoteCall
|
||||||
|
DXGrid string // ServiceWSJT (Status)
|
||||||
|
Mode string // ServiceWSJT (Status)
|
||||||
|
FreqHz int64 // ServiceWSJT (Status)
|
||||||
|
LoggedADIF string // ServiceWSJT (LoggedADIF) or ServiceADIF
|
||||||
|
RawText string // generic fallback (n1mm xml, etc.)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server is a single inbound UDP listener.
|
||||||
|
type Server struct {
|
||||||
|
cfg Config
|
||||||
|
conn *net.UDPConn
|
||||||
|
out chan<- Event
|
||||||
|
stop chan struct{}
|
||||||
|
done chan struct{}
|
||||||
|
stopped bool
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func newServer(cfg Config, out chan<- Event) *Server {
|
||||||
|
return &Server{
|
||||||
|
cfg: cfg,
|
||||||
|
out: out,
|
||||||
|
stop: make(chan struct{}),
|
||||||
|
done: make(chan struct{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) start() error {
|
||||||
|
var conn *net.UDPConn
|
||||||
|
if s.cfg.Multicast {
|
||||||
|
group := strings.TrimSpace(s.cfg.MulticastGroup)
|
||||||
|
if group == "" {
|
||||||
|
return fmt.Errorf("multicast enabled but group address is empty")
|
||||||
|
}
|
||||||
|
groupIP := net.ParseIP(group)
|
||||||
|
if groupIP == nil {
|
||||||
|
return fmt.Errorf("bad multicast group %q", group)
|
||||||
|
}
|
||||||
|
gaddr := &net.UDPAddr{IP: groupIP, Port: s.cfg.Port}
|
||||||
|
// Bind to INADDR_ANY:port so the kernel will forward packets
|
||||||
|
// addressed to the multicast group from any interface. Then
|
||||||
|
// JoinGroup() on every up & multicast-capable interface — Windows
|
||||||
|
// won't route multicast through interfaces we haven't explicitly
|
||||||
|
// joined, and the "default" interface picked by
|
||||||
|
// net.ListenMulticastUDP isn't always the one MSHV/WSJT sends on.
|
||||||
|
// ListenConfig with SO_REUSEADDR lets us share the port with
|
||||||
|
// Log4OM / other listeners already bound to 2237.
|
||||||
|
lc := reusingListenConfig()
|
||||||
|
pc, err := lc.ListenPacket(context.Background(), "udp4", fmt.Sprintf("0.0.0.0:%d", s.cfg.Port))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("listen :%d for multicast: %w", s.cfg.Port, err)
|
||||||
|
}
|
||||||
|
c, ok := pc.(*net.UDPConn)
|
||||||
|
if !ok {
|
||||||
|
_ = pc.Close()
|
||||||
|
return fmt.Errorf("internal: ListenPacket returned %T not *net.UDPConn", pc)
|
||||||
|
}
|
||||||
|
p := ipv4.NewPacketConn(c)
|
||||||
|
ifaces, _ := net.Interfaces()
|
||||||
|
joined := 0
|
||||||
|
for _, ifi := range ifaces {
|
||||||
|
if ifi.Flags&net.FlagUp == 0 || ifi.Flags&net.FlagMulticast == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := p.JoinGroup(&ifi, gaddr); err != nil {
|
||||||
|
applog.Printf("udp: [%s] join %s on %s: %v\n", s.cfg.Name, gaddr.IP, ifi.Name, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
joined++
|
||||||
|
}
|
||||||
|
if joined == 0 {
|
||||||
|
_ = c.Close()
|
||||||
|
return fmt.Errorf("couldn't join multicast %s on any interface", gaddr.IP)
|
||||||
|
}
|
||||||
|
conn = c
|
||||||
|
applog.Printf("udp: [%s] listening on multicast %s on %d interface(s) (service=%s)\n",
|
||||||
|
s.cfg.Name, gaddr, joined, s.cfg.ServiceType)
|
||||||
|
} else {
|
||||||
|
lc := reusingListenConfig()
|
||||||
|
pc, err := lc.ListenPacket(context.Background(), "udp4", fmt.Sprintf("0.0.0.0:%d", s.cfg.Port))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("listen udp :%d: %w", s.cfg.Port, err)
|
||||||
|
}
|
||||||
|
c, ok := pc.(*net.UDPConn)
|
||||||
|
if !ok {
|
||||||
|
_ = pc.Close()
|
||||||
|
return fmt.Errorf("internal: ListenPacket returned %T not *net.UDPConn", pc)
|
||||||
|
}
|
||||||
|
conn = c
|
||||||
|
applog.Printf("udp: [%s] listening on unicast :%d (service=%s)\n", s.cfg.Name, s.cfg.Port, s.cfg.ServiceType)
|
||||||
|
}
|
||||||
|
s.conn = conn
|
||||||
|
go s.run()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) run() {
|
||||||
|
defer close(s.done)
|
||||||
|
buf := make([]byte, 64*1024)
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-s.stop:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
_ = s.conn.SetReadDeadline(time.Now().Add(500 * time.Millisecond))
|
||||||
|
n, remote, err := s.conn.ReadFromUDP(buf)
|
||||||
|
if err != nil {
|
||||||
|
if ne, ok := err.(net.Error); ok && ne.Timeout() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Closed by stop(): exit silently.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pkt := make([]byte, n)
|
||||||
|
copy(pkt, buf[:n])
|
||||||
|
go s.handle(pkt, remote)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
|
||||||
|
applog.Printf("udp: [%s] rx %d bytes from %s\n", s.cfg.Name, len(pkt), remote)
|
||||||
|
ev := Event{ConfigID: s.cfg.ID, Service: s.cfg.ServiceType, Source: remote.String()}
|
||||||
|
switch s.cfg.ServiceType {
|
||||||
|
case ServiceWSJT:
|
||||||
|
w, ok, err := ParseWSJT(pkt)
|
||||||
|
if err != nil {
|
||||||
|
applog.Printf("udp: [%s] WSJT parse error: %v\n", s.cfg.Name, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
applog.Printf("udp: [%s] WSJT msg type ignored\n", s.cfg.Name)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
applog.Printf("udp: [%s] WSJT decoded: prog=%q dx_call=%q grid=%q mode=%q freq=%d adif_len=%d\n",
|
||||||
|
s.cfg.Name, w.ProgramID, w.DXCall, w.DXGrid, w.Mode, w.FreqHz, len(w.LoggedADIF))
|
||||||
|
ev.DXCall = w.DXCall
|
||||||
|
ev.DXGrid = w.DXGrid
|
||||||
|
ev.Mode = w.Mode
|
||||||
|
ev.FreqHz = w.FreqHz
|
||||||
|
ev.LoggedADIF = w.LoggedADIF
|
||||||
|
case ServiceADIF:
|
||||||
|
ev.LoggedADIF = string(pkt)
|
||||||
|
case ServiceRemoteCall:
|
||||||
|
// Common payload shapes seen in the wild:
|
||||||
|
// "F4XYZ" (bare callsign)
|
||||||
|
// "CALL F4XYZ" (text prefix)
|
||||||
|
// "<CALLSIGN>F4XYZ<CALLSIGN>" (DXHunter-style tags)
|
||||||
|
// "<CALLSIGN>F4XYZ</CALLSIGN>" (proper XML)
|
||||||
|
// Strip every angle-bracket tag, normalise whitespace, take the
|
||||||
|
// last non-empty token. Upper-case for downstream consistency.
|
||||||
|
text := string(pkt)
|
||||||
|
// Drop every <...> tag (open or close) — works for both
|
||||||
|
// <CALLSIGN>...<CALLSIGN> and <CALLSIGN>...</CALLSIGN>.
|
||||||
|
for {
|
||||||
|
start := strings.IndexByte(text, '<')
|
||||||
|
if start < 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
end := strings.IndexByte(text[start:], '>')
|
||||||
|
if end < 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
text = text[:start] + " " + text[start+end+1:]
|
||||||
|
}
|
||||||
|
text = strings.TrimSpace(text)
|
||||||
|
parts := strings.Fields(text)
|
||||||
|
if len(parts) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ev.DXCall = strings.ToUpper(parts[len(parts)-1])
|
||||||
|
case ServiceN1MM:
|
||||||
|
ev.RawText = string(pkt)
|
||||||
|
default:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Empty events are useless; skip.
|
||||||
|
if ev.DXCall == "" && ev.LoggedADIF == "" && ev.RawText == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case s.out <- ev:
|
||||||
|
default:
|
||||||
|
// Drop on backpressure rather than block the read loop.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) close() {
|
||||||
|
s.mu.Lock()
|
||||||
|
if s.stopped {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.stopped = true
|
||||||
|
stop, done, conn := s.stop, s.done, s.conn
|
||||||
|
s.mu.Unlock()
|
||||||
|
if conn != nil {
|
||||||
|
_ = conn.Close()
|
||||||
|
}
|
||||||
|
if stop != nil {
|
||||||
|
close(stop)
|
||||||
|
}
|
||||||
|
if done != nil {
|
||||||
|
<-done
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Outbound emitter ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// SendUDP sends payload to dst (host:port). Unicast or directed broadcast.
|
||||||
|
// Returns the error from the write; the connection is closed before return.
|
||||||
|
func SendUDP(dst string, payload []byte) error {
|
||||||
|
conn, err := net.Dial("udp4", dst)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("dial %s: %w", dst, err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
_ = conn.SetWriteDeadline(time.Now().Add(2 * time.Second))
|
||||||
|
_, err = conn.Write(payload)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Manager ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Manager owns every inbound Server and exposes a helper to emit on
|
||||||
|
// outbound connections at QSO-save time. It reloads from the Repo on
|
||||||
|
// demand (after a CRUD change in the Settings panel).
|
||||||
|
type Manager struct {
|
||||||
|
repo *Repo
|
||||||
|
out chan Event
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
inbound map[int64]*Server
|
||||||
|
outbound []Config
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewManager(repo *Repo) *Manager {
|
||||||
|
return &Manager{
|
||||||
|
repo: repo,
|
||||||
|
out: make(chan Event, 64),
|
||||||
|
inbound: map[int64]*Server{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Events returns the channel inbound parsed events are delivered on.
|
||||||
|
// The app exposes these as Wails events.
|
||||||
|
func (m *Manager) Events() <-chan Event { return m.out }
|
||||||
|
|
||||||
|
// Reload restarts every server based on the current Repo contents.
|
||||||
|
// Existing servers are stopped, the snapshot is rebuilt from scratch.
|
||||||
|
// Errors on individual rows are logged via the returned slice; the
|
||||||
|
// caller can surface them in the UI.
|
||||||
|
func (m *Manager) Reload(ctx context.Context) []string {
|
||||||
|
applog.Printf("udp: Reload() called")
|
||||||
|
m.mu.Lock()
|
||||||
|
old := m.inbound
|
||||||
|
m.inbound = map[int64]*Server{}
|
||||||
|
m.outbound = nil
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
for _, s := range old {
|
||||||
|
s.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
cfgs, err := m.repo.List(ctx)
|
||||||
|
if err != nil {
|
||||||
|
applog.Printf("udp: Reload list failed: %v", err)
|
||||||
|
return []string{fmt.Sprintf("load udp configs: %v", err)}
|
||||||
|
}
|
||||||
|
applog.Printf("udp: Reload found %d config(s) in DB", len(cfgs))
|
||||||
|
var errs []string
|
||||||
|
for _, c := range cfgs {
|
||||||
|
applog.Printf("udp: cfg id=%d name=%q dir=%s service=%s port=%d mcast=%v group=%q enabled=%v",
|
||||||
|
c.ID, c.Name, c.Direction, c.ServiceType, c.Port, c.Multicast, c.MulticastGroup, c.Enabled)
|
||||||
|
if !c.Enabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if c.Direction == Outbound {
|
||||||
|
m.mu.Lock()
|
||||||
|
m.outbound = append(m.outbound, c)
|
||||||
|
m.mu.Unlock()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
srv := newServer(c, m.out)
|
||||||
|
if err := srv.start(); err != nil {
|
||||||
|
applog.Printf("udp: start %q failed: %v", c.Name, err)
|
||||||
|
errs = append(errs, fmt.Sprintf("%s: %v", c.Name, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
m.inbound[c.ID] = srv
|
||||||
|
m.mu.Unlock()
|
||||||
|
}
|
||||||
|
applog.Printf("udp: Reload done — %d server(s) running, %d error(s)", len(m.inbound), len(errs))
|
||||||
|
return errs
|
||||||
|
}
|
||||||
|
|
||||||
|
// Outbound returns the active outbound configs matching a service type.
|
||||||
|
// Used by the QSO save path to push notifications to listeners.
|
||||||
|
func (m *Manager) Outbound(service ServiceType) []Config {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
var out []Config
|
||||||
|
for _, c := range m.outbound {
|
||||||
|
if c.ServiceType == service {
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// StopAll closes every running server. Called at app shutdown.
|
||||||
|
func (m *Manager) StopAll() {
|
||||||
|
m.mu.Lock()
|
||||||
|
old := m.inbound
|
||||||
|
m.inbound = map[int64]*Server{}
|
||||||
|
m.outbound = nil
|
||||||
|
m.mu.Unlock()
|
||||||
|
for _, s := range old {
|
||||||
|
s.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
package udp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WSJT-X / JTDX / MSHV UDP protocol (WSJT-X v2 schema).
|
||||||
|
//
|
||||||
|
// Wire format:
|
||||||
|
// uint32 magic (0xadbccbda)
|
||||||
|
// uint32 schema (2 or 3)
|
||||||
|
// uint32 type (message id)
|
||||||
|
// QString id (the program's "id" — typically "WSJT-X")
|
||||||
|
// ... type-specific payload ...
|
||||||
|
//
|
||||||
|
// QString = int32 length followed by `length` UTF-8 bytes, or -1 for nil.
|
||||||
|
// QUtf8 in newer versions; same wire format for the common case.
|
||||||
|
//
|
||||||
|
// We only care about two messages here:
|
||||||
|
// Status (type 1) → exposes the current DX call so HamLog can pre-fill
|
||||||
|
// LoggedADIF (type 12) → carries the ADIF of the just-logged QSO
|
||||||
|
// Everything else (heartbeat, decodes, clears, status of other VFOs) is
|
||||||
|
// ignored.
|
||||||
|
|
||||||
|
const (
|
||||||
|
wsjtMagic = 0xadbccbda
|
||||||
|
|
||||||
|
wsjtMsgHeartbeat = 0
|
||||||
|
wsjtMsgStatus = 1
|
||||||
|
wsjtMsgDecode = 2
|
||||||
|
wsjtMsgClear = 3
|
||||||
|
wsjtMsgQSOLogged = 5
|
||||||
|
wsjtMsgLoggedADIF = 12
|
||||||
|
)
|
||||||
|
|
||||||
|
// WSJTEvent is the parsed, typed result of decoding a single packet.
|
||||||
|
// One of (DXCall, LoggedADIF) is non-empty depending on the message.
|
||||||
|
type WSJTEvent struct {
|
||||||
|
DXCall string // current "DX Call" field in the WSJT app
|
||||||
|
DXGrid string // optional grid for that call
|
||||||
|
Mode string // FT8 / FT4 / …
|
||||||
|
FreqHz int64 // current dial freq when available
|
||||||
|
LoggedADIF string // full ADIF text when message is LoggedADIF
|
||||||
|
ProgramID string // "WSJT-X" / "JTDX" / "MSHV" — for diagnostics / dedup
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseWSJT decodes one UDP packet. Returns ok=false for messages we
|
||||||
|
// don't care about (heartbeat, decode lines, clears, etc.).
|
||||||
|
func ParseWSJT(pkt []byte) (WSJTEvent, bool, error) {
|
||||||
|
if len(pkt) < 12 {
|
||||||
|
return WSJTEvent{}, false, fmt.Errorf("packet too short")
|
||||||
|
}
|
||||||
|
r := bytes.NewReader(pkt)
|
||||||
|
var magic, schema, mtype uint32
|
||||||
|
if err := binary.Read(r, binary.BigEndian, &magic); err != nil {
|
||||||
|
return WSJTEvent{}, false, err
|
||||||
|
}
|
||||||
|
if magic != wsjtMagic {
|
||||||
|
return WSJTEvent{}, false, fmt.Errorf("bad magic %#x", magic)
|
||||||
|
}
|
||||||
|
if err := binary.Read(r, binary.BigEndian, &schema); err != nil {
|
||||||
|
return WSJTEvent{}, false, err
|
||||||
|
}
|
||||||
|
_ = schema
|
||||||
|
if err := binary.Read(r, binary.BigEndian, &mtype); err != nil {
|
||||||
|
return WSJTEvent{}, false, err
|
||||||
|
}
|
||||||
|
id, err := readQString(r)
|
||||||
|
if err != nil {
|
||||||
|
return WSJTEvent{}, false, fmt.Errorf("read id: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ev := WSJTEvent{ProgramID: id}
|
||||||
|
switch mtype {
|
||||||
|
case wsjtMsgStatus:
|
||||||
|
// Status payload order (v2):
|
||||||
|
// quint64 dial_frequency
|
||||||
|
// QUtf8 mode
|
||||||
|
// QUtf8 dx_call
|
||||||
|
// QUtf8 report
|
||||||
|
// QUtf8 tx_mode
|
||||||
|
// bool tx_enabled
|
||||||
|
// bool transmitting
|
||||||
|
// bool decoding
|
||||||
|
// qint32 rx_df
|
||||||
|
// qint32 tx_df
|
||||||
|
// QUtf8 de_call
|
||||||
|
// QUtf8 de_grid
|
||||||
|
// QUtf8 dx_grid
|
||||||
|
// ... (more fields appended in later schemas, we stop reading
|
||||||
|
// after dx_grid which is all we need)
|
||||||
|
var dialHz uint64
|
||||||
|
if err := binary.Read(r, binary.BigEndian, &dialHz); err != nil {
|
||||||
|
return WSJTEvent{}, false, err
|
||||||
|
}
|
||||||
|
ev.FreqHz = int64(dialHz)
|
||||||
|
mode, err := readQString(r)
|
||||||
|
if err != nil {
|
||||||
|
return WSJTEvent{}, false, err
|
||||||
|
}
|
||||||
|
ev.Mode = strings.ToUpper(strings.TrimSpace(mode))
|
||||||
|
dxCall, err := readQString(r)
|
||||||
|
if err != nil {
|
||||||
|
return WSJTEvent{}, false, err
|
||||||
|
}
|
||||||
|
ev.DXCall = strings.ToUpper(strings.TrimSpace(dxCall))
|
||||||
|
// Skip report, tx_mode (QUtf8), tx_enabled (bool), transmitting,
|
||||||
|
// decoding, rx_df (qint32), tx_df (qint32), de_call (QUtf8),
|
||||||
|
// de_grid (QUtf8) → then dx_grid.
|
||||||
|
for _, name := range []string{"report", "tx_mode"} {
|
||||||
|
if _, err := readQString(r); err != nil {
|
||||||
|
return ev, true, fmt.Errorf("read %s: %w", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 3 booleans (each 1 byte)
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
var b uint8
|
||||||
|
if err := binary.Read(r, binary.BigEndian, &b); err != nil {
|
||||||
|
return ev, true, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 2 int32
|
||||||
|
var i32 int32
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
if err := binary.Read(r, binary.BigEndian, &i32); err != nil {
|
||||||
|
return ev, true, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// de_call, de_grid, dx_grid
|
||||||
|
if _, err := readQString(r); err != nil {
|
||||||
|
return ev, true, err
|
||||||
|
}
|
||||||
|
if _, err := readQString(r); err != nil {
|
||||||
|
return ev, true, err
|
||||||
|
}
|
||||||
|
dxGrid, err := readQString(r)
|
||||||
|
if err != nil {
|
||||||
|
return ev, true, err
|
||||||
|
}
|
||||||
|
ev.DXGrid = strings.ToUpper(strings.TrimSpace(dxGrid))
|
||||||
|
return ev, true, nil
|
||||||
|
|
||||||
|
case wsjtMsgLoggedADIF:
|
||||||
|
// Payload: a single QString containing the ADIF record.
|
||||||
|
adif, err := readQString(r)
|
||||||
|
if err != nil {
|
||||||
|
return WSJTEvent{}, false, err
|
||||||
|
}
|
||||||
|
ev.LoggedADIF = adif
|
||||||
|
return ev, true, nil
|
||||||
|
}
|
||||||
|
return WSJTEvent{}, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readQString reads a Qt QString as written by QDataStream: an int32 byte
|
||||||
|
// length (or -1 for null) followed by the UTF-8 bytes.
|
||||||
|
func readQString(r *bytes.Reader) (string, error) {
|
||||||
|
var n int32
|
||||||
|
if err := binary.Read(r, binary.BigEndian, &n); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if n <= 0 {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
if int(n) > r.Len() {
|
||||||
|
return "", fmt.Errorf("short string: want %d have %d", n, r.Len())
|
||||||
|
}
|
||||||
|
buf := make([]byte, n)
|
||||||
|
if _, err := r.Read(buf); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(buf), nil
|
||||||
|
}
|
||||||
+20
-19
@@ -141,10 +141,18 @@ func (m *Manager) Lookup(ctx context.Context, callsign string) (Result, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// fillFromDXCC fills (or overrides) country/continent/zones/lat/lon from
|
// fillFromDXCC fills (or overrides) country/continent/zones/lat/lon from
|
||||||
// the cty.dat resolver. Default behaviour is "fill empty fields only";
|
// the cty.dat resolver. cty.dat is the authoritative source for DXCC
|
||||||
// for slashed callsigns (IT9/DK6XZ, DL/F4NIE…) we OVERRIDE because the
|
// mapping, so Country/Continent/CQZ/ITUZ are ALWAYS overridden when it
|
||||||
// provider returned the home-call's entity, which is wrong for portable
|
// has an answer — QRZ tends to return the political country (Greece for
|
||||||
// operations. The provider keeps Name/QTH/Address (still useful for QSL).
|
// SV5*, Russia for UA9*) instead of the DXCC entity (Dodecanese,
|
||||||
|
// Asiatic Russia). Lat/Lon are filled only when empty so a more precise
|
||||||
|
// home QTH from QRZ wins over the cty.dat entity centroid.
|
||||||
|
//
|
||||||
|
// For slashed callsigns (IT9/DK6XZ, DL/F4NIE…) the provider returned the
|
||||||
|
// home-call's entity which is wrong for portable operations; we keep the
|
||||||
|
// Name/QTH/Address from the provider (still useful for QSL) but reset
|
||||||
|
// the DXCC number since QRZ's value is wrong and we don't have an entity
|
||||||
|
// → DXCC# table yet.
|
||||||
// Returns true if any field was filled.
|
// Returns true if any field was filled.
|
||||||
func fillFromDXCC(r *Result, dxcc DXCCResolver) bool {
|
func fillFromDXCC(r *Result, dxcc DXCCResolver) bool {
|
||||||
if dxcc == nil {
|
if dxcc == nil {
|
||||||
@@ -154,22 +162,15 @@ func fillFromDXCC(r *Result, dxcc DXCCResolver) bool {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
slashed := strings.ContainsRune(r.Callsign, '/')
|
|
||||||
shouldStr := func(existing string) bool { return existing == "" || slashed }
|
|
||||||
shouldInt := func(existing int) bool { return existing == 0 || slashed }
|
|
||||||
shouldF := func(existing float64) bool { return existing == 0 || slashed }
|
|
||||||
filled := false
|
filled := false
|
||||||
if country != "" && shouldStr(r.Country) { r.Country = country; filled = true }
|
if country != "" { r.Country = country; filled = true }
|
||||||
if cont != "" && shouldStr(r.Continent) { r.Continent = cont; filled = true }
|
if cont != "" { r.Continent = cont; filled = true }
|
||||||
if cqz != 0 && shouldInt(r.CQZ) { r.CQZ = cqz; filled = true }
|
if cqz != 0 { r.CQZ = cqz; filled = true }
|
||||||
if ituz != 0 && shouldInt(r.ITUZ) { r.ITUZ = ituz; filled = true }
|
if ituz != 0 { r.ITUZ = ituz; filled = true }
|
||||||
if lat != 0 && shouldF(r.Lat) { r.Lat = lat; filled = true }
|
if lat != 0 && r.Lat == 0 { r.Lat = lat; filled = true }
|
||||||
if lon != 0 && shouldF(r.Lon) { r.Lon = lon; filled = true }
|
if lon != 0 && r.Lon == 0 { r.Lon = lon; filled = true }
|
||||||
// QRZ's DXCC number is the home call's — wrong for portable ops.
|
// Slashed call → drop QRZ's DXCC# (it's the home call's).
|
||||||
// cty.dat has no DXCC# (only Clublog does), so clear it: the UI
|
if strings.ContainsRune(r.Callsign, '/') && r.DXCC != 0 {
|
||||||
// will fall back to callsign-level worked-before until we ship a
|
|
||||||
// proper entity-name → DXCC# mapping.
|
|
||||||
if slashed && r.DXCC != 0 {
|
|
||||||
r.DXCC = 0
|
r.DXCC = 0
|
||||||
filled = true
|
filled = true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ type Profile struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Callsign string `json:"callsign"`
|
Callsign string `json:"callsign"`
|
||||||
Operator string `json:"operator"`
|
Operator string `json:"operator"`
|
||||||
|
OwnerCallsign string `json:"owner_callsign"`
|
||||||
MyGrid string `json:"my_grid"`
|
MyGrid string `json:"my_grid"`
|
||||||
MyCountry string `json:"my_country"`
|
MyCountry string `json:"my_country"`
|
||||||
MyState string `json:"my_state"`
|
MyState string `json:"my_state"`
|
||||||
@@ -45,7 +46,7 @@ type Repo struct{ db *sql.DB }
|
|||||||
|
|
||||||
func NewRepo(db *sql.DB) *Repo { return &Repo{db: db} }
|
func NewRepo(db *sql.DB) *Repo { return &Repo{db: db} }
|
||||||
|
|
||||||
const selectCols = `id, name, callsign, operator, my_grid, my_country, my_state, my_cnty,
|
const selectCols = `id, name, callsign, operator, owner_callsign, my_grid, my_country, my_state, my_cnty,
|
||||||
my_street, my_city, my_postal_code, my_sota_ref, my_pota_ref,
|
my_street, my_city, my_postal_code, my_sota_ref, my_pota_ref,
|
||||||
my_rig, my_antenna, tx_pwr, is_active, sort_order, created_at, updated_at`
|
my_rig, my_antenna, tx_pwr, is_active, sort_order, created_at, updated_at`
|
||||||
|
|
||||||
@@ -93,11 +94,11 @@ func (r *Repo) Save(ctx context.Context, p *Profile) error {
|
|||||||
if p.ID == 0 {
|
if p.ID == 0 {
|
||||||
res, err := r.db.ExecContext(ctx, `
|
res, err := r.db.ExecContext(ctx, `
|
||||||
INSERT INTO station_profiles
|
INSERT INTO station_profiles
|
||||||
(name, callsign, operator, my_grid, my_country, my_state, my_cnty,
|
(name, callsign, operator, owner_callsign, my_grid, my_country, my_state, my_cnty,
|
||||||
my_street, my_city, my_postal_code, my_sota_ref, my_pota_ref,
|
my_street, my_city, my_postal_code, my_sota_ref, my_pota_ref,
|
||||||
my_rig, my_antenna, tx_pwr, is_active, sort_order, created_at, updated_at)
|
my_rig, my_antenna, tx_pwr, is_active, sort_order, created_at, updated_at)
|
||||||
VALUES(?,?,?,?,?,?,?, ?,?,?,?,?, ?,?,?,?,?,?,?)`,
|
VALUES(?,?,?,?,?,?,?,?, ?,?,?,?,?, ?,?,?,?,?,?,?)`,
|
||||||
p.Name, p.Callsign, p.Operator, p.MyGrid, p.MyCountry, p.MyState, p.MyCounty,
|
p.Name, p.Callsign, p.Operator, p.OwnerCallsign, p.MyGrid, p.MyCountry, p.MyState, p.MyCounty,
|
||||||
p.MyStreet, p.MyCity, p.MyPostalCode, p.MySOTARef, p.MyPOTARef,
|
p.MyStreet, p.MyCity, p.MyPostalCode, p.MySOTARef, p.MyPOTARef,
|
||||||
p.MyRig, p.MyAntenna, nullableFloat(p.TxPower), boolInt(p.IsActive), p.SortOrder, now, now)
|
p.MyRig, p.MyAntenna, nullableFloat(p.TxPower), boolInt(p.IsActive), p.SortOrder, now, now)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -109,12 +110,12 @@ func (r *Repo) Save(ctx context.Context, p *Profile) error {
|
|||||||
}
|
}
|
||||||
_, err := r.db.ExecContext(ctx, `
|
_, err := r.db.ExecContext(ctx, `
|
||||||
UPDATE station_profiles SET
|
UPDATE station_profiles SET
|
||||||
name = ?, callsign = ?, operator = ?, my_grid = ?, my_country = ?,
|
name = ?, callsign = ?, operator = ?, owner_callsign = ?, my_grid = ?, my_country = ?,
|
||||||
my_state = ?, my_cnty = ?, my_street = ?, my_city = ?, my_postal_code = ?,
|
my_state = ?, my_cnty = ?, my_street = ?, my_city = ?, my_postal_code = ?,
|
||||||
my_sota_ref = ?, my_pota_ref = ?, my_rig = ?, my_antenna = ?, tx_pwr = ?,
|
my_sota_ref = ?, my_pota_ref = ?, my_rig = ?, my_antenna = ?, tx_pwr = ?,
|
||||||
sort_order = ?, updated_at = ?
|
sort_order = ?, updated_at = ?
|
||||||
WHERE id = ?`,
|
WHERE id = ?`,
|
||||||
p.Name, p.Callsign, p.Operator, p.MyGrid, p.MyCountry,
|
p.Name, p.Callsign, p.Operator, p.OwnerCallsign, p.MyGrid, p.MyCountry,
|
||||||
p.MyState, p.MyCounty, p.MyStreet, p.MyCity, p.MyPostalCode,
|
p.MyState, p.MyCounty, p.MyStreet, p.MyCity, p.MyPostalCode,
|
||||||
p.MySOTARef, p.MyPOTARef, p.MyRig, p.MyAntenna, nullableFloat(p.TxPower),
|
p.MySOTARef, p.MyPOTARef, p.MyRig, p.MyAntenna, nullableFloat(p.TxPower),
|
||||||
p.SortOrder, now, p.ID)
|
p.SortOrder, now, p.ID)
|
||||||
@@ -206,14 +207,14 @@ type scannable interface {
|
|||||||
func scan(row scannable) (Profile, error) {
|
func scan(row scannable) (Profile, error) {
|
||||||
var p Profile
|
var p Profile
|
||||||
var (
|
var (
|
||||||
callsign, operator, myGrid, myCountry, myState, myCnty,
|
callsign, operator, ownerCall, myGrid, myCountry, myState, myCnty,
|
||||||
myStreet, myCity, myPostal, mySOTA, myPOTA,
|
myStreet, myCity, myPostal, mySOTA, myPOTA,
|
||||||
myRig, myAntenna sql.NullString
|
myRig, myAntenna sql.NullString
|
||||||
txPwr sql.NullFloat64
|
txPwr sql.NullFloat64
|
||||||
isActive, sortOrder int
|
isActive, sortOrder int
|
||||||
createdAt, updatedAt string
|
createdAt, updatedAt string
|
||||||
)
|
)
|
||||||
err := row.Scan(&p.ID, &p.Name, &callsign, &operator, &myGrid, &myCountry, &myState, &myCnty,
|
err := row.Scan(&p.ID, &p.Name, &callsign, &operator, &ownerCall, &myGrid, &myCountry, &myState, &myCnty,
|
||||||
&myStreet, &myCity, &myPostal, &mySOTA, &myPOTA,
|
&myStreet, &myCity, &myPostal, &mySOTA, &myPOTA,
|
||||||
&myRig, &myAntenna, &txPwr, &isActive, &sortOrder, &createdAt, &updatedAt)
|
&myRig, &myAntenna, &txPwr, &isActive, &sortOrder, &createdAt, &updatedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -221,6 +222,7 @@ func scan(row scannable) (Profile, error) {
|
|||||||
}
|
}
|
||||||
p.Callsign = callsign.String
|
p.Callsign = callsign.String
|
||||||
p.Operator = operator.String
|
p.Operator = operator.String
|
||||||
|
p.OwnerCallsign = ownerCall.String
|
||||||
p.MyGrid = myGrid.String
|
p.MyGrid = myGrid.String
|
||||||
p.MyCountry = myCountry.String
|
p.MyCountry = myCountry.String
|
||||||
p.MyState = myState.String
|
p.MyState = myState.String
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ func main() {
|
|||||||
|
|
||||||
// Create application with options
|
// Create application with options
|
||||||
err := wails.Run(&options.App{
|
err := wails.Run(&options.App{
|
||||||
Title: "HamLog",
|
Title: "OpsLog",
|
||||||
Width: 1400,
|
Width: 1400,
|
||||||
Height: 900,
|
Height: 900,
|
||||||
MinWidth: 1100,
|
MinWidth: 1100,
|
||||||
|
|||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://wails.io/schemas/config.v2.json",
|
"$schema": "https://wails.io/schemas/config.v2.json",
|
||||||
"name": "HamLog",
|
"name": "OpsLog",
|
||||||
"outputfilename": "HamLog",
|
"outputfilename": "OpsLog",
|
||||||
"frontend:install": "npm install",
|
"frontend:install": "npm install",
|
||||||
"frontend:build": "npm run build",
|
"frontend:build": "npm run build",
|
||||||
"frontend:dev:watcher": "npm run dev",
|
"frontend:dev:watcher": "npm run dev",
|
||||||
|
|||||||
Reference in New Issue
Block a user