chore: release v0.26.3
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user