chore: release v0.26.3

This commit is contained in:
2026-08-21 23:28:55 +02:00
parent 0cd243fe00
commit d48420485f
31 changed files with 1908 additions and 164 deletions
+158 -51
View File
@@ -794,6 +794,10 @@ type App struct {
catFlexDVKDax bool // raise the Flex transmit DAX button around a voice message
catFlexDecodeSpots bool // push WSJT-X decodes (heard stations) to the panadapter
catFlexDecodeSecs int // decode spot display duration (seconds)
// The panadapter palette, parsed once and re-read when it is saved. Guarded
// because the cluster read loop touches it on every spot.
spotColorsMu sync.Mutex
spotColorsCache *SpotColors
// catSig / catShareSig fingerprint the last APPLIED link and share
// configuration, so re-applying an identical one is a no-op instead of a
// reconnection. Empty at startup, which is what makes the first call connect.
@@ -803,19 +807,21 @@ type App struct {
liveFreqHz int64 // last freq/band/mode the UI reported (fallback when CAT is off)
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
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
awardSnapBuild sync.Mutex // serialises BUILDING it — see awardSnapshot
awardSnapCap int // rows the last build produced, the capacity hint for the next
awardSnap []qso.QSO // light-scanned + enriched logbook snapshot reused across award computations
awardSnapRev string // logbook revision the snapshot was built at ("" = none)
awardSnapUsed time.Time // last read — the snapshot is dropped once it goes cold (see awardSnapshotJanitor)
webpub webPublisher // log-to-website publishing: debounce timer, periodic ticker, last result
bandOpen bandOpenState // sporadic-E / band-opening detector over the spot stream
dataDir string // <exeDir>/data — holds config.json, logs, cty.dat
livePublishTimer *time.Timer // debounced live-status publish on activity change
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
awardSnapBuild sync.Mutex // serialises BUILDING it — see awardSnapshot
awardSnapCap int // rows the last build produced, the capacity hint for the next
awardSnap []qso.QSO // light-scanned + enriched logbook snapshot reused across award computations
awardSnapRev string // logbook revision the snapshot was built at ("" = none)
awardSnapUsed time.Time // last read — the snapshot is dropped once it goes cold (see awardSnapshotJanitor)
workedRefMu sync.Mutex // guards the worked-reference sets
workedRef workedRefIndex // per-award references already in the log — see awardnew.go
webpub webPublisher // log-to-website publishing: debounce timer, periodic ticker, last result
bandOpen bandOpenState // sporadic-E / band-opening detector over the spot stream
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
@@ -4734,6 +4740,9 @@ func (a *App) invalidateAwardStats() {
a.qsoNumMu.Lock()
a.qsoNumbers = nil
a.qsoNumMu.Unlock()
// Reference lists, award definitions and bulk edits all land here, and any of
// them can change what counts as already worked.
a.invalidateWorkedRefs()
// Bulk QSO changes (import, delete, bulk edit) also land here — refresh the
// worked-index so alert "needed" checks stay accurate. Async: never block the
// mutation, and it's a single lightweight query.
@@ -5113,9 +5122,14 @@ func (a *App) materializeAwardRefs(q qso.QSO) {
return
}
ac := a.newAwardMatCtx()
if err := a.qso.SetAwardRefs(a.ctx, q.ID, a.awardRefsJSONFor(ac, q)); err != nil {
refs := a.awardRefsJSONFor(ac, q)
if err := a.qso.SetAwardRefs(a.ctx, q.ID, refs); err != nil {
applog.Printf("award_refs: store for qso %d failed: %v", q.ID, err)
}
// Keep the NEW-badge sets in step with the contact just logged, rather than
// letting the revision change discard them while the operator types the next
// callsign. Off the logging path: it re-matches the QSO against every award.
go a.noteWorkedQSO(q)
}
// materializeAwardRefsForIDs recomputes award_refs for a specific set of QSOs
@@ -8681,11 +8695,20 @@ func (a *App) clusterEventWorker() {
// panadapter tunes AND switches mode — a DX cluster line carries no mode,
// so without this the radio only moved frequency.
if a.catFlexSpots && a.cat != nil {
mode := alerts.InferMode(s.Comment, s.FreqHz)
// Coloured by what the spot IS, which is the only thing worth knowing
// at a glance on a waterfall. Resolved from the same index the cluster
// grid uses, so a spot is never one colour on screen and another on
// the radio — and the index is cached, so this costs a map lookup.
status := a.spotStatusName(s.DXCall, s.Band, mode, s.POTARef)
col := a.spotColorFor(status)
a.cat.SendSpot(cat.SpotInfo{
FreqHz: s.FreqHz,
Callsign: s.DXCall,
Mode: alerts.InferMode(s.Comment, s.FreqHz),
Comment: s.Comment,
FreqHz: s.FreqHz,
Callsign: s.DXCall,
Mode: mode,
Comment: spotComment(s.Comment, status),
Color: col.Text,
BackgroundColor: col.Bg,
})
}
}
@@ -14850,10 +14873,14 @@ func (a *App) reloadCAT() {
// Native FlexRadio (SmartSDR) TCP API — no OmniRig needed.
fb := cat.NewFlex(s.FlexHost, s.FlexPort, s.FlexSpots)
// Clicking one of our spots on the panadapter fills the entry form.
fb.OnSpotClick = func(call string, hz int64) {
fb.OnSpotClick = func(call string, hz int64, mode string) {
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "flex:spot_clicked", map[string]any{"call": call, "freq_hz": hz})
}
// A spot clicked ON the panadapter deserves the same zoom as one
// clicked in the band map: the operator did the same thing and is
// looking at the same signal, and only the click's origin differs.
a.FlexZoomForSpot(mode, hz)
}
a.cat.Start(fb)
case "xiegu":
@@ -16512,11 +16539,17 @@ func newMotorClient(s UltrabeamSettings) motorAntenna {
}
return steppirAdapter{c: steppir.New(tr), freqMin: s.FreqMinMHz, freqMax: s.FreqMaxMHz}
}
// Ultrabeam is TCP only.
if strings.TrimSpace(s.Host) == "" {
// Ultrabeam: serial or TCP, same as the SteppIR. The controller has an RS232
// port, so a plain FTDI cable is the direct route; the Ethernet adapter is
// for putting the antenna at the far end of a link.
tr := ultrabeam.Transport{Mode: s.Transport, Host: strings.TrimSpace(s.Host), Port: s.Port, COM: strings.TrimSpace(s.COM), Baud: s.Baud}
if tr.Mode == "serial" && tr.COM == "" {
return nil
}
return ubAdapter{c: ultrabeam.New(s.Host, s.Port), freqMin: s.FreqMinMHz, freqMax: s.FreqMaxMHz}
if tr.Mode != "serial" && tr.Host == "" {
return nil
}
return ubAdapter{c: ultrabeam.New(tr), freqMin: s.FreqMinMHz, freqMax: s.FreqMaxMHz}
}
// startUltrabeam stops any existing client and starts a fresh one if the
@@ -18795,12 +18828,19 @@ type SpotQuery struct {
// SpotStatus is the per-tuple result. Status is one of:
//
// "new" — entity never worked
// "new-band" — entity worked but never on this band
// "new-mode" — band worked, but this MODE never worked on the entity (any band)
// "new-slot" — band & mode each worked before, but not this band+mode together
// "worked" exact band+mode already in the log
// "" couldn't resolve the entity (no cty.dat match)
// "new" — entity never worked
// "new-band-mode" — entity worked, but neither this band NOR this mode
// "new-band" — entity worked in this mode, never on this band
// "new-mode" — band worked, but this MODE never worked on the entity (any band)
// "new-slot" — band & mode each worked before, but not this band+mode together
// "worked" — exact band+mode already in the log
// "" — couldn't resolve the entity (no cty.dat match)
//
// They are EXCLUSIVE and tested in that order, from most wanted to least. The
// distinction that matters and used to be missing: an entity that is new on
// both counts is not the same catch as one that is new on the band alone, and
// neither is a "slot" — a slot is the pair still missing when each half is
// already in the log.
type SpotStatus struct {
Call string `json:"call"`
Band string `json:"band"`
@@ -19193,6 +19233,88 @@ func (a *App) lookupDecodeGrid(call string) string {
return a.decodeGridsOld[call]
}
// spotEntityStatus decides ONE spot's status from what the entity already has
// in the log. Pure, and separate from the query that feeds it, because the
// ORDER of these tests is the whole meaning of the answer and it deserves to be
// read — and tested — without a database.
//
// Most wanted first, exclusive:
//
// new the entity has never been worked at all
// new-band-mode worked, but neither this band nor this mode
// new-band worked in this mode, never on this band
// new-mode worked on this band, never in this mode (on any band)
// new-slot both halves already in the log, never this pair
// worked this exact band and mode
//
// new-band-mode is NOT a slot, and that is the distinction that was missing:
// a slot is the pair still missing when each half is already worked. A station
// that is new on both counts is a better catch than either, and it used to be
// reported as a plain "new band".
func spotEntityStatus(entityWorked, haveBand, haveMode, haveSlot bool, mode string) string {
if !entityWorked {
return "new"
}
// Without a mode nothing below "band" can be told apart from worked, and
// claiming "new" on a guess is the one mistake that costs a QSO.
if mode == "" {
if !haveBand {
return "new-band"
}
return "worked"
}
switch {
case !haveBand && !haveMode:
return "new-band-mode"
case !haveBand:
return "new-band"
case !haveMode:
return "new-mode"
case !haveSlot:
return "new-slot"
}
return "worked"
}
// spotStatusName resolves ONE spot to the palette key it should be drawn with.
//
// The entity status first — it is the headline — then the orthogonal markers,
// which only speak when the entity says nothing new: a park is worth a colour
// on a station you have already worked, and worth nothing on one you have not,
// where "new entity" is the better thing to say.
func (a *App) spotStatusName(call, band, mode, potaRef string) string {
if a.qso == nil {
return "none"
}
st := a.ClusterSpotStatuses([]SpotQuery{{Call: call, Band: band, Mode: mode, POTARef: potaRef}})
if len(st) == 0 {
return "none"
}
r := st[0]
if a.profiles != nil {
if p, err := a.profiles.Active(a.ctx); err == nil &&
strings.EqualFold(strings.TrimSpace(p.Callsign), strings.TrimSpace(call)) {
return "my-call"
}
}
switch r.Status {
case "new", "new-band-mode", "new-band", "new-mode", "new-slot":
return r.Status
}
switch {
case r.NewPOTA:
return "new-pota"
case r.NewCounty:
return "new-county"
case r.NewPfx:
return "new-pfx"
}
if r.Status == "worked" {
return "worked"
}
return "none"
}
func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
out := make([]SpotStatus, len(spots))
if a.qso == nil {
@@ -19357,33 +19479,18 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
out[i].Status = "new"
continue
}
if _, b := e.Bands[out[i].Band]; !b {
out[i].Status = "new-band"
continue
}
// Without a mode we can't distinguish the rest from "worked";
// the safer default is "worked" so we never falsely claim "new".
if out[i].Mode == "" {
out[i].Status = "worked"
continue
}
// Band already worked. If this MODE was never worked on the entity (any
// band) → new-mode. If the mode was worked elsewhere but not on THIS
// band+mode → new-slot. Otherwise → worked. The check mode goes through
// the same normaliser as the slot map (digital grouping).
// The check mode goes through the same normaliser as the slot map
// (digital grouping), and is resolved before the band test because
// "new band" and "new mode" are now asked TOGETHER.
checkMode := out[i].Mode
if normMode != nil {
checkMode = normMode(checkMode)
}
if _, m := e.Modes[checkMode]; !m {
out[i].Status = "new-mode"
continue
}
if _, ok := e.Slots[out[i].Band][checkMode]; !ok {
out[i].Status = "new-slot"
continue
}
out[i].Status = "worked"
_, haveBand := e.Bands[out[i].Band]
_, haveMode := e.Modes[checkMode]
_, haveSlot := e.Slots[out[i].Band][checkMode]
out[i].Status = spotEntityStatus(true, haveBand, haveMode, haveSlot, out[i].Mode)
}
return out
}