This commit is contained in:
2026-05-28 21:32:46 +02:00
parent e8cac569e3
commit e82e30dd02
29 changed files with 2485 additions and 97 deletions
+448 -16
View File
@@ -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 {
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 38 KiB

+18
View File
@@ -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
View File
@@ -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>
+4 -23
View File
@@ -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 },
+205 -12
View File
@@ -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 ? (
+2 -2
View File
@@ -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;
+19
View File
@@ -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>;
+36
View File
@@ -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);
} }
+85
View File
@@ -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"];
}
}
}
+2 -2
View File
@@ -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
+5
View File
@@ -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) {
+103
View File
@@ -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
}
}
+4 -4
View File
@@ -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 '';
+320
View File
@@ -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))]
}
+128
View File
@@ -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
}
+18
View File
@@ -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)
}
+369
View File
@@ -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()
}
}
+176
View File
@@ -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
View File
@@ -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
} }
+14 -12
View File
@@ -17,10 +17,11 @@ import (
// Profile is one operating configuration. A user typically keeps a few: // Profile is one operating configuration. A user typically keeps a few:
// "Home", "Portable", "SOTA Pic du Midi", "/MM cruise"… // "Home", "Portable", "SOTA Pic du Midi", "/MM cruise"…
type Profile struct { type Profile struct {
ID int64 `json:"id"` ID int64 `json:"id"`
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
+1 -1
View File
@@ -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
View File
@@ -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",