chore: release v0.20.7

This commit is contained in:
2026-07-21 18:26:34 +02:00
parent 828f99b8ac
commit 24a5a0480d
15 changed files with 909 additions and 183 deletions
+96 -54
View File
@@ -19,6 +19,7 @@ import (
"sync/atomic"
"time"
"hamlog/internal/acom"
"hamlog/internal/adif"
"hamlog/internal/alerts"
"hamlog/internal/antgenius"
@@ -44,15 +45,15 @@ import (
"hamlog/internal/operating"
"hamlog/internal/pota"
"hamlog/internal/powergenius"
"hamlog/internal/spe"
"hamlog/internal/profile"
"hamlog/internal/qslcard"
"hamlog/internal/qso"
"hamlog/internal/rotator/pst"
"hamlog/internal/relaydev"
"hamlog/internal/rotator/pst"
"hamlog/internal/rotgenius"
"hamlog/internal/settings"
"hamlog/internal/solar"
"hamlog/internal/spe"
"hamlog/internal/steppir"
"hamlog/internal/uls"
"hamlog/internal/ultrabeam"
@@ -446,8 +447,8 @@ type App struct {
// wcbm is an in-memory "CALL|BAND|MODE" worked-index so the alert engine never
// queries the DB per cluster spot (an FT8 firehose would swamp a remote MySQL).
// Loaded once, appended to on each log, rebuilt after bulk changes.
wcbm map[string]struct{}
wcbmMu sync.RWMutex
wcbm map[string]struct{}
wcbmMu sync.RWMutex
pota *pota.Cache
uls *uls.Store // US callsign→county/grid (offline FCC ULS), lazily opened
awardRefs *awardref.Repo
@@ -466,6 +467,7 @@ type App struct {
antgenius *antgenius.Client // Antenna Genius (4O3A) switch (TCP); nil when disabled
pgxl *powergenius.Client // PowerGenius XL (4O3A) amp fan control (TCP); nil when disabled
spe *spe.Client // SPE Expert amplifier (serial/TCP); nil when disabled or not SPE
acom *acom.Client // ACOM 500S/600S/700S/1200S/2020S amplifier (serial/TCP); nil when disabled or not ACOM
audioMgr *audio.Manager
qsoRec *audio.Recorder // continuous QSO recorder (rolling pre-roll)
solar *solar.Manager // live space-weather (SFI/SSN/A/K) for the header + QSO stamping
@@ -481,27 +483,27 @@ type App struct {
alertStore *alerts.Store // DX-cluster spot alert rules (global JSON)
startupProfile string // --profile <name> from the command line (activate at startup)
dvkRecSlot int // slot currently being recorded (DVKStartRecord → DVKStopRecord)
dvkPttKeyed bool // we keyed PTT for a voice message; unkey when it ends
startupProfile string // --profile <name> from the command line (activate at startup)
dvkRecSlot int // slot currently being recorded (DVKStartRecord → DVKStopRecord)
dvkPttKeyed bool // we keyed PTT for a voice message; unkey when it ends
pttMu sync.Mutex
udpLogMu sync.Mutex // serialises UDP auto-log so concurrent packets can't both pass the dedup check
adifMonMu sync.Mutex // guards the ADIF-monitor config (file list + per-file read offsets)
relayAutoMu sync.Mutex // serialises relay auto-control evaluation
relayAutoLast map[string]bool // deviceID|relay → last applied on/off, so we only switch on a real change
relayAutoOn atomic.Bool // cached "auto-control enabled" so the CAT hot path skips work when off
relayDrvMu sync.Mutex // guards the cached relay drivers below
udpLogMu sync.Mutex // serialises UDP auto-log so concurrent packets can't both pass the dedup check
adifMonMu sync.Mutex // guards the ADIF-monitor config (file list + per-file read offsets)
relayAutoMu sync.Mutex // serialises relay auto-control evaluation
relayAutoLast map[string]bool // deviceID|relay → last applied on/off, so we only switch on a real change
relayAutoOn atomic.Bool // cached "auto-control enabled" so the CAT hot path skips work when off
relayDrvMu sync.Mutex // guards the cached relay drivers below
relayDrv map[string]cachedRelay // deviceID → live driver (reused across polls; stateful boards can't be reopened per call)
pttPort serial.Port // open serial port while PTT (RTS/DTR) is asserted
pttKeyedMethod string // "cat" | "rts" | "dtr" while keyed; "" when idle
pttGen int64 // bumped on every key; a delayed unkey only fires if unchanged (guards against a stale release cutting a new transmission)
startupErr string // captured for surfacing to the frontend
dbPath string // active database file (may be a user-chosen location)
logDb *sql.DB // QSO logbook connection — MySQL when the shared backend is enabled, else == db (local SQLite)
dbBackend string // "sqlite" | "mysql" — the logbook backend actually opened at startup
dbBackendErr string // non-empty when a configured MySQL backend failed and we fell back to SQLite
offlineQ *offlineq.Queue // ADIF outbox: QSOs logged while the DB was unreachable
offlineMode bool // last write failed because the DB was unreachable
pttPort serial.Port // open serial port while PTT (RTS/DTR) is asserted
pttKeyedMethod string // "cat" | "rts" | "dtr" while keyed; "" when idle
pttGen int64 // bumped on every key; a delayed unkey only fires if unchanged (guards against a stale release cutting a new transmission)
startupErr string // captured for surfacing to the frontend
dbPath string // active database file (may be a user-chosen location)
logDb *sql.DB // QSO logbook connection — MySQL when the shared backend is enabled, else == db (local SQLite)
dbBackend string // "sqlite" | "mysql" — the logbook backend actually opened at startup
dbBackendErr string // non-empty when a configured MySQL backend failed and we fell back to SQLite
offlineQ *offlineq.Queue // ADIF outbox: QSOs logged while the DB was unreachable
offlineMode bool // last write failed because the DB was unreachable
catFlexSpots bool // push cluster spots to the FlexRadio panadapter
catFlexDecodeSpots bool // push WSJT-X decodes (heard stations) to the panadapter
@@ -511,11 +513,13 @@ type App struct {
liveBand string
liveMode string
livePublishTimer *time.Timer // debounced live-status publish on activity change
liveLastQSOAt time.Time // when this operator last logged a NEW contact — drives online/offline
awardSnapMu sync.Mutex // guards the award QSO snapshot
awardSnap []qso.QSO // light-scanned + enriched logbook snapshot reused across award computations
awardSnapRev string // logbook revision the snapshot was built at ("" = none)
dataDir string // <exeDir>/data — holds config.json, logs, cty.dat
liveLastQSOAt time.Time // when this operator last logged a NEW contact — drives online/offline
liveTableMu sync.Mutex // guards liveTableFor
liveTableFor *sql.DB // logbook whose live_status DDL has been ensured (once per connection, not per call)
awardSnapMu sync.Mutex // guards the award QSO snapshot
awardSnap []qso.QSO // light-scanned + enriched logbook snapshot reused across award computations
awardSnapRev string // logbook revision the snapshot was built at ("" = none)
dataDir string // <exeDir>/data — holds config.json, logs, cty.dat
// shuttingDown gates beforeClose re-entry: the first user attempt to
// close fires shutdown tasks (backup, future LoTW upload, ...) while
@@ -859,9 +863,9 @@ func (a *App) startup(ctx context.Context) {
applog.Printf("startup: logbook backend = %s", backend)
a.logDb = logbookConn
a.qso = qso.NewRepo(logbookConn)
a.backfillAwardRefsOnce() // one-time: materialise award_refs for pre-existing QSOs
go a.rebuildWorkedIndex() // in-memory worked-index for per-spot alert checks
go a.adifMonitorLoop() // watch external ADIF files (fldigi, N1MM…) for new QSOs
a.backfillAwardRefsOnce() // one-time: materialise award_refs for pre-existing QSOs
go a.rebuildWorkedIndex() // in-memory worked-index for per-spot alert checks
go a.adifMonitorLoop() // watch external ADIF files (fldigi, N1MM…) for new QSOs
a.relayAutoOn.Store(a.GetRelayAuto().Enabled) // prime the relay auto-control hot-path flag
// cty.dat for offline DXCC / country resolution. Cached on disk; first
@@ -1955,7 +1959,7 @@ func (a *App) AddQSO(q qso.QSO) (id int64, err error) {
if err == nil {
q.ID = id
a.noteWorked(q.Callsign, q.Band, q.Mode) // keep the alert worked-index fresh (in-memory)
a.noteLiveQSO() // multi-op: flip this operator back "online" (publishes async)
a.noteLiveQSO() // multi-op: flip this operator back "online" (publishes async)
// Announce the log RIGHT AWAY so the grid/UI refresh at once and the entry
// form clears immediately — the operator is not made to wait on the DB.
wruntime.EventsEmit(a.ctx, "qso:logged", id)
@@ -4334,8 +4338,8 @@ func (a *App) ExportAwardForCatalog(code string, version int) (string, error) {
return "", fmt.Errorf("load references: %w", err)
}
entry := struct {
Def award.Def `json:"def"`
References []awardref.Ref `json:"references,omitempty"`
Def award.Def `json:"def"`
References []awardref.Ref `json:"references,omitempty"`
}{Def: def, References: refs}
b, err := json.MarshalIndent(entry, "", " ")
if err != nil {
@@ -6309,7 +6313,7 @@ type clusterQueue struct {
mu sync.Mutex
cond *sync.Cond
buf []clusterEvent
head int // index of the next event to pop (waste reclaimed by compaction)
head int // index of the next event to pop (waste reclaimed by compaction)
closed bool
}
@@ -11342,7 +11346,6 @@ func (a *App) SaveRotatorSettings(s RotatorSettings) error {
return nil
}
// RotatorHeading is the live antenna heading for the status bar.
type RotatorHeading struct {
Enabled bool `json:"enabled"`
@@ -11461,14 +11464,14 @@ func boolStr(b bool) string {
// StationDevice is one configured relay board for the Station Control tab.
type StationDevice struct {
ID string `json:"id"`
Type string `json:"type"` // "webswitch" | "kmtronic" | "denkovi" | "usbrelay"
Name string `json:"name"`
Host string `json:"host"` // IP for network boards; FTDI serial for denkovi; COM port for usbrelay
User string `json:"user,omitempty"` // KMTronic HTTP auth (blank = none)
Pass string `json:"pass,omitempty"`
Channels int `json:"channels,omitempty"` // usbrelay only: number of relays (1/2/4/8/16)
Labels []string `json:"labels"` // per-relay label (index 0 = relay 1)
ID string `json:"id"`
Type string `json:"type"` // "webswitch" | "kmtronic" | "denkovi" | "usbrelay"
Name string `json:"name"`
Host string `json:"host"` // IP for network boards; FTDI serial for denkovi; COM port for usbrelay
User string `json:"user,omitempty"` // KMTronic HTTP auth (blank = none)
Pass string `json:"pass,omitempty"`
Channels int `json:"channels,omitempty"` // usbrelay only: number of relays (1/2/4/8/16)
Labels []string `json:"labels"` // per-relay label (index 0 = relay 1)
}
// deviceRelayCount is the relay count for a configured device — fixed by type,
@@ -11741,12 +11744,12 @@ type motorAntenna interface {
// convention, so commands pass straight through).
type ubAdapter struct{ c *ultrabeam.Client }
func (a ubAdapter) Start() error { return a.c.Start() }
func (a ubAdapter) Stop() { a.c.Stop() }
func (a ubAdapter) SetFrequency(k, d int) error { return a.c.SetFrequency(k, d) }
func (a ubAdapter) SetDirection(d int) error { return a.c.SetDirection(d) }
func (a ubAdapter) Retract() error { return a.c.Retract() }
func (a ubAdapter) LastSetKHz() int { return a.c.LastSetKHz() }
func (a ubAdapter) Start() error { return a.c.Start() }
func (a ubAdapter) Stop() { a.c.Stop() }
func (a ubAdapter) SetFrequency(k, d int) error { return a.c.SetFrequency(k, d) }
func (a ubAdapter) SetDirection(d int) error { return a.c.SetDirection(d) }
func (a ubAdapter) Retract() error { return a.c.Retract() }
func (a ubAdapter) LastSetKHz() int { return a.c.LastSetKHz() }
func (a ubAdapter) SetElement(n, mm int) error { return a.c.ModifyElement(n, mm) }
func (a ubAdapter) ReadElements() ([]int, error) { return a.c.ReadElements() }
func (a ubAdapter) Elements() []int {
@@ -12269,11 +12272,12 @@ func (a *App) AntGeniusDeselect(port int) error {
// ── PowerGenius XL (4O3A) amplifier fan control (TCP, default port 9008) ─────
// PGXLSettings is the JSON shape for the Hardware → Amplifier panel. It covers
// the 4O3A PowerGenius XL (TCP) and the SPE Expert amps (USB serial or an
// RS232-to-Ethernet bridge). The type name is kept for binding compatibility.
// the 4O3A PowerGenius XL (TCP), the SPE Expert amps and the ACOM S-series amps
// (both: USB serial or an RS232-to-Ethernet bridge). The type name is kept for
// binding compatibility.
type PGXLSettings struct {
Enabled bool `json:"enabled"`
Type string `json:"type"` // "pgxl" | "spe13" | "spe15" | "spe2k"
Type string `json:"type"` // "pgxl" | "spe13" | "spe15" | "spe2k" | "acom500" | "acom600" | "acom700" | "acom1200" | "acom2020"
Transport string `json:"transport"` // "tcp" | "serial"
Host string `json:"host"` // TCP transport
Port int `json:"port"` // TCP transport
@@ -12354,6 +12358,10 @@ func (a *App) startPGXL() {
go a.spe.Stop()
a.spe = nil
}
if a.acom != nil {
go a.acom.Stop()
a.acom = nil
}
s, err := a.GetPGXLSettings()
if err != nil || !s.Enabled {
return
@@ -12366,13 +12374,18 @@ func (a *App) startPGXL() {
_ = a.pgxl.Start()
return
}
// SPE Expert — USB serial or an RS232-to-Ethernet bridge.
// SPE Expert / ACOM — USB serial or an RS232-to-Ethernet bridge.
if s.Transport == "serial" && strings.TrimSpace(s.ComPort) == "" {
return
}
if s.Transport == "tcp" && strings.TrimSpace(s.Host) == "" {
return
}
if model, ok := strings.CutPrefix(s.Type, "acom"); ok {
a.acom = acom.New(acom.Config{Model: model + "S", Transport: s.Transport, ComPort: s.ComPort, Baud: s.Baud, Host: s.Host, Port: s.Port})
_ = a.acom.Start()
return
}
a.spe = spe.New(spe.Config{Transport: s.Transport, ComPort: s.ComPort, Baud: s.Baud, Host: s.Host, Port: s.Port})
_ = a.spe.Start()
}
@@ -12413,6 +12426,35 @@ func (a *App) SPESetPowerLevel(level string) error {
return a.spe.SetPowerLevel(level)
}
// GetACOMStatus returns the ACOM amplifier state for the UI poll.
func (a *App) GetACOMStatus() acom.Status {
if a.acom == nil {
return acom.Status{}
}
return a.acom.GetStatus()
}
// ACOMSetOperate puts the ACOM amp in OPERATE (true) or STANDBY (false) — the
// protocol has explicit commands for each, unlike the SPE's toggle key.
func (a *App) ACOMSetOperate(on bool) error {
if a.acom == nil {
return fmt.Errorf("ACOM amplifier not connected — enable it in Settings → Amplifier")
}
return a.acom.Operate(on)
}
// ACOMSetPower turns the ACOM amp on (true, a DTR/RTS pulse — serial only, needs
// the power-on pins wired in the cable) or off (false, the OFF data command).
func (a *App) ACOMSetPower(on bool) error {
if a.acom == nil {
return fmt.Errorf("ACOM amplifier not connected — enable it in Settings → Amplifier")
}
if on {
return a.acom.PowerOn()
}
return a.acom.PowerOff()
}
// GetPGXLStatus returns the amp's fan/connection state for the UI poll.
func (a *App) GetPGXLStatus() powergenius.Status {
if a.pgxl == nil {