chore: release v0.26.0

This commit is contained in:
2026-08-20 17:53:55 +02:00
parent 47992b5f03
commit 1e507225dd
44 changed files with 3197 additions and 240 deletions
+291 -50
View File
@@ -694,16 +694,21 @@ type App struct {
pskr *pskr.Watcher
// Self-spot throttle: when and on what frequency we last announced ourselves.
// Held in memory only — a restart legitimately re-announces the station.
selfSpotMu sync.Mutex
selfSpotAt time.Time
selfSpotHz int64
pota *pota.Cache
uls *uls.Store // US callsign→county/grid (offline FCC ULS), lazily opened
awardRefs *awardref.Repo
qslTemplates *qslcard.Repo
operating *operating.Repo
udp *udp.Manager
udpRepo *udp.Repo
selfSpotMu sync.Mutex
selfSpotAt time.Time
selfSpotHz int64
pota *pota.Cache
uls *uls.Store // US callsign→county/grid (offline FCC ULS), lazily opened
awardRefs *awardref.Repo
qslTemplates *qslcard.Repo
operating *operating.Repo
udp *udp.Manager
udpRepo *udp.Repo
// Program id of the last decoding application that reported its status.
// Halt Tx is routed by id, and the panel's Halt button must work even when
// nothing is transmitting at that instant — so the id is remembered from
// every Status rather than read off a live transmission.
lastTxID atomic.Value // string
extsvc *extsvc.Manager
winkeyer *winkeyer.Manager
clublog *clublog.Manager
@@ -4290,6 +4295,13 @@ func (a *App) insertPOTAQSOs(entries []pota.HunterQSO) int {
added++
}
}
// Bulk insert, so the caches are dropped ONCE at the end rather than per row.
// invalidateAwardStats is the right hammer here: a raw a.qso.Add leaves the
// cluster worked-index, the award snapshot AND the QSO numbering all holding
// a pre-import answer, and this import inserts into the MIDDLE of the order.
if added > 0 {
a.invalidateAwardStats()
}
return added
}
@@ -6667,6 +6679,127 @@ func (a *App) BandSlotQSOs(callsign string, dxccNum int, band, modeClass string)
return out, nil
}
// FlexTXOnBand moves the FlexRadio's transmitter to the slice listening on the
// given band, and focuses it.
//
// Why this exists: two decoding instances on two slices — say 20 m on slice A
// and 80 m on slice B — both feed OpsLog, and answering a decode routes the
// Reply to the right INSTANCE. But the instance keys one radio, and the radio
// transmits on whichever slice carries the TX flag. Answer an 80 m decode with
// TX still on the 20 m slice and the exchange goes out on the wrong band, or
// nowhere at all.
//
// A no-op that reports success on any other backend: only a Flex has slices, and
// a caller that has to ask what radio is attached before answering a decode is a
// caller that will get it wrong somewhere.
func (a *App) FlexTXOnBand(band string) error {
if a.cat == nil {
return nil
}
st, ok := a.cat.FlexState()
if !ok || len(st.Slices) == 0 {
return nil // not a Flex, or no slices yet — nothing to move
}
want := strings.ToLower(strings.TrimSpace(band))
if want == "" {
return nil
}
// The whole band is examined BEFORE anything moves. Taking the first slice
// that matches was wrong the moment two slices share a band — which is
// exactly what split is: an RX slice and a TX slice, same band, a few kHz
// apart. The loop would find the RX one first, see no TX flag on it, and move
// the transmitter onto it — collapsing the split into simplex, and with it
// the panel's split indicator.
//
// If any slice on this band already transmits, there is nothing to do: the
// radio is already set up for this band, split or not, and it is not this
// function's business to rearrange it.
var target *cat.FlexSliceInfo
for i := range st.Slices {
s := &st.Slices[i]
if strings.ToLower(s.Band) != want {
continue
}
if s.TX {
return nil
}
if target == nil {
target = s
}
}
if target == nil {
// No slice on that band. Silent: the operator may simply be answering a
// decode from a receiver that is not this radio.
return nil
}
{
s := target
applog.Printf("flex: moving TX to slice %s (%s) to answer a %s decode", s.Letter, s.Band, band)
// TX only. NOT SetActiveSlice — that sets a persistent PIN whose whole
// contract is "the operator chose this slice in OpsLog", overriding the
// radio's own active flag until the slice closes. Calling it from here
// made every click on a decode silently re-pin the operating slice, and
// split — which is built around whichever slice is active — then created
// a new slice instead of pairing with the existing one, every time.
//
// Where the transmitter goes and which slice the operator is working from
// are two different questions. This answers only the first.
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetTXSlice(s.Index) })
}
}
// SendDecodeFreeText puts a message in the decoding application's free-text box
// and, with send, transmits it at once.
//
// The one transmit primitive that behaves the same across the whole family,
// because it matches against no decode. It does NOT start a QSO — FT8 free text
// is 13 characters and does not set the DX call, so no sequencing follows — and
// it is offered as "send this text", never as a way to call a station.
func (a *App) SendDecodeFreeText(instance, text string, send bool) error {
if a.udp == nil {
return fmt.Errorf("udp not initialized")
}
if strings.TrimSpace(instance) == "" {
instance = a.lastTxInstance()
}
err := a.udp.SendFreeText(instance, text, send)
if err != nil {
applog.Printf("udp: free text on %q failed: %v", instance, err)
}
return err
}
// GridSquares returns the 4-character Maidenhead squares in the log, with
// whether each is confirmed.
//
// modeClass scopes it the way the rest of the app does: "DIGI", "CW", "PHONE",
// or "" for every mode. "DIGI" is the one this was built for — a map of the
// squares worked on FT8/FT4 answers "where have I actually been heard" in a way
// no list of callsigns does.
//
// Confirmed means LoTW, a card or eQSL — the same three the award engine counts,
// so a square cannot be green here and unconfirmed in the Awards panel.
func (a *App) GridSquares(modeClass string) ([]qso.GridSquare, error) {
if a.qso == nil {
return nil, fmt.Errorf("db not initialized")
}
want := strings.ToUpper(strings.TrimSpace(modeClass))
keep := func(mode string) bool {
switch want {
case "", "ALL":
return true
case "FTX":
// The FT family alone, which is narrower than digital and usually the
// honest answer beside an FTx panel: a square worked on RTTY in a
// contest is not a square worked on FT8.
return ftxModes[strings.ToUpper(strings.TrimSpace(mode))]
default:
return award.ModeClass(mode) == want
}
}
return a.qso.GridSquares(a.ctx, keep)
}
// SetCompactMode toggles a tiny always-on-top window that exposes just the
// QSO entry — useful when running on a single screen alongside WSJT-X,
// JT-Alert or the cluster.
@@ -8288,6 +8421,20 @@ func (a *App) clusterEventWorker() {
}
s := *ev.spot
if a.dxcc != nil {
// The SPOTTER's continent, resolved here rather than in the per-call
// status cache. That cache is keyed by call|band|mode, and one DX call
// is spotted by skimmers all over the world within the same minute —
// so all of them collapsed onto whichever spotter happened to arrive
// first, and the continent filter passed every one of them. It is a
// property of the spot and now travels with it.
//
// The skimmer suffix goes first: RBN reports as "VU2OY-#" and cluster
// nodes as "DL1ABC-2", which the prefix matcher cannot resolve.
if sp := skimmerBase(s.Spotter); sp != "" {
if m, ok := a.dxcc.Lookup(sp); ok {
s.SpotterContinent = m.Continent
}
}
if m, ok := a.dxcc.Lookup(s.DXCall); ok && m.Entity != nil {
s.Country = m.Entity.Name
s.Continent = m.Continent
@@ -8339,6 +8486,19 @@ func (a *App) clusterEventWorker() {
}
}
// skimmerBase strips the reporting suffix from a spotter callsign: RBN skimmers
// announce as "VU2OY-#" and cluster nodes as "DL1ABC-2". The DXCC prefix matcher
// sees an unknown callsign and gives up on those, which is how the spotter's
// continent came back empty for every RBN spot. A real callsign never contains a
// hyphen, so cutting at the first one is safe.
func skimmerBase(spotter string) string {
s := strings.TrimSpace(spotter)
if i := strings.IndexByte(s, '-'); i > 0 {
s = s[:i]
}
return s
}
func (a *App) evaluateAlerts(s cluster.Spot) {
if a.alertStore == nil {
return
@@ -8352,10 +8512,15 @@ func (a *App) evaluateAlerts(s cluster.Spot) {
Spotter: s.Spotter,
}
// The spotter's country/continent (cty.dat) — resolved lazily for Origin rules.
if a.dxcc != nil && s.Spotter != "" {
if m, ok := a.dxcc.Lookup(s.Spotter); ok && m.Entity != nil {
sp.SpotterCountry = m.Entity.Name
sp.SpotterContinent = m.Continent
// Through skimmerBase for the same reason the cluster filter needs it: an
// Origin rule on "spotter continent = EU" never matched an RBN spot, because
// "DL1ABC-#" resolves to nothing.
if a.dxcc != nil {
if base := skimmerBase(s.Spotter); base != "" {
if m, ok := a.dxcc.Lookup(base); ok && m.Entity != nil {
sp.SpotterCountry = m.Entity.Name
sp.SpotterContinent = m.Continent
}
}
}
matches := a.alertStore.Evaluate(sp, time.Now(), a.isWorkedBandMode)
@@ -8411,10 +8576,13 @@ func (a *App) noteWorked(call, band, mode string) {
return
}
a.wcbmMu.Lock()
if a.wcbm == nil {
a.wcbm = map[string]struct{}{}
// nil means "not built yet", and isWorkedBandMode reads that as its cue to
// load the whole log. Seeding a one-entry map here would look built and
// answer "never worked" for everything else. The row is already inserted, so
// the later rebuild picks this contact up anyway.
if a.wcbm != nil {
a.wcbm[wcbmKey(call, band, mode)] = struct{}{}
}
a.wcbm[wcbmKey(call, band, mode)] = struct{}{}
a.wcbmMu.Unlock()
// The cluster worked-index snapshot is now stale (this call/slot just became
// worked) — drop it so the next spot batch recolours with the new QSO.
@@ -12633,6 +12801,14 @@ func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) {
// log. This insert bypasses AddQSO entirely, which is why UDP-logged contacts
// came out unnumbered while hand-logged ones did not.
a.noteQSONumbered(id, q.QSODate)
// And for the same reason, the worked indexes have to be told here too.
// AddQSO does this; bypassing it left the cluster/decode status snapshot
// holding its pre-QSO answer for the rest of the session — so a station
// worked through WSJT-X / JTDX / MSHV went on wearing NEW GRID, NEW CALL and
// NEW PFX after the QSO was in the log. Reported on RA3Y (KO73), still badged
// NEW GRID two minutes after the contact. Hand-logged contacts never showed
// it, which is exactly what made it look like a display bug.
a.noteWorked(q.Callsign, q.Band, q.Mode)
a.noteLiveQSO() // multi-op: flip this operator back "online" (publishes async)
// Announce the log AT ONCE so the grid / ON-AIR badge / stations-on-air widget
// refresh immediately, then run the DB-heavy enrichment off the critical path
@@ -12693,14 +12869,19 @@ func (a *App) consumeUDPEvents() {
// switch because a Status carries BOTH a DX call and a transmit message,
// and the switch below takes only one branch.
//
// Sent on EVERY Status, not only when there is a transmit message: MSHV
// and older JTDX builds stop before tx_message in the Status payload, and
// the panel still has to be able to say who is being called and whether
// the carrier is up. A Status always carries de_call, so that is the test.
// Sent on EVERY Status, not only when there is a transmit message: the
// field is EMPTY between overs — that is its normal state, not a sender
// that withholds it — and the panel still has to say who is being called
// and whether the carrier is up. A Status always carries de_call, so that
// is the test.
if ev.DECall != "" || ev.TxMessage != "" {
if ev.ProgramID != "" {
a.lastTxID.Store(ev.ProgramID)
}
wruntime.EventsEmit(a.ctx, "udp:tx_state", map[string]any{
"msg": ev.TxMessage,
"transmitting": ev.Transmitting,
"tx_enabled": ev.TxEnabled,
"de_call": ev.DECall,
"dx_call": ev.DXCall,
"mode": ev.Mode,
@@ -12744,9 +12925,13 @@ func (a *App) consumeUDPEvents() {
"dt": ev.DecodeDT,
"audio_hz": ev.DecodeAudioHz,
// Carried so a click can answer the station: WSJT-X matches a
// Reply against its own decode list, field for field.
// Reply against its own decode list, field for field. mode_raw is
// the mode as the Decode sent it (the "~"/"+" marker) — "mode"
// above is the resolved name, which the match rejects.
"ms": ev.DecodeMs,
"low_conf": ev.DecodeLowConf,
"mode_raw": ev.DecodeModeRaw,
"msg_raw": ev.DecodeMsgRaw,
})
// A WSJT-X decode (heard station). Render it on the FlexRadio
// panadapter when the option is on; green + SNR comment, auto-expiring
@@ -18219,6 +18404,12 @@ type SpotStatus struct {
// carries the SPOTTER's grid at best, never the DX's.
Grid string `json:"grid,omitempty"`
NewGrid bool `json:"new_grid"`
// GridState splits NewGrid into its two meanings: "new" (never worked under
// the operator's scope) and "unconf" (worked, awaiting a confirmation). Empty
// when there is nothing to chase. They are different decisions — a QSO to
// make against a QSL to chase — and one badge for both is what made a station
// worked an hour ago still read as NEW GRID.
GridState string `json:"grid_state,omitempty"`
// SpotterContinent is the continent of the station that SENT the spot, not of
// the DX. It answers a different question — "is anyone near me hearing this?"
// — which is what makes it worth filtering on: a JA spot on 20 m tells a
@@ -18256,10 +18447,17 @@ type clusterStatusCache struct {
// callCounties holds callsign → "STATE,County" for stations already logged
// with a county, so a spot shows the county the entry panel showed rather
// than the one derived from the licence ZIP. See qso.CallCounties.
callCounties map[string]string
workedPOTA map[string]struct{}
workedPfx map[string]struct{}
workedGrids map[string]struct{} // "GRID|MODE", mode normalised like the rest
callCounties map[string]string
workedPOTA map[string]struct{}
workedPfx map[string]struct{}
// workedGrids answers "is this square already mine" under EVERY band-and-mode
// scope, keyed by qso.GridKey and valued by whether the square is confirmed.
// One index for all six scopes and both hunting modes: the operator switches
// between them freely, and rebuilding the log index on each toggle would make
// a dropdown take seconds.
workedGrids map[string]bool
gridScope gridScope // the scope and hunt the cache was built under —
gridHunt string // a change to either rebuilds it
normMode func(string) string // nil unless digital-mode grouping is on
groupDigital bool // settings the maps were built under —
sameSlot bool // a change rebuilds the snapshot
@@ -18274,12 +18472,21 @@ func (a *App) clusterStatusMaps() *clusterStatusCache {
groupDigital := a.groupDigitalSlots()
sameSlot := a.clusterWorkedSameSlot()
slotHighlight := a.clusterSlotHighlight()
// Read BEFORE taking the lock: settingOr goes to the settings store, and
// holding the status mutex across that is how two subsystems deadlock.
gs := a.GetGridScopeSettings()
gridScopeNow := lookupGridScope(gs.Scope)
gridHuntNow := gs.Hunt
a.clusterStatusMu.Lock()
defer a.clusterStatusMu.Unlock()
if c := a.clusterStatusIdx; c != nil && c.groupDigital == groupDigital && c.sameSlot == sameSlot && c.slotHighlight == slotHighlight {
if c := a.clusterStatusIdx; c != nil && c.groupDigital == groupDigital && c.sameSlot == sameSlot &&
c.slotHighlight == slotHighlight && c.gridScope == gridScopeNow && c.gridHunt == gridHuntNow {
return c
}
c := &clusterStatusCache{groupDigital: groupDigital, sameSlot: sameSlot, slotHighlight: slotHighlight}
c := &clusterStatusCache{
groupDigital: groupDigital, sameSlot: sameSlot, slotHighlight: slotHighlight,
gridScope: gridScopeNow, gridHunt: gridHuntNow,
}
if a.qso == nil {
a.clusterStatusIdx = c
return c
@@ -18330,7 +18537,7 @@ func (a *App) clusterStatusMaps() *clusterStatusCache {
// One more DISTINCT scan when the snapshot is rebuilt, then pure map lookups
// per spot — the same shape as the county and POTA sets beside it, which is
// why grids cost nothing under an RBN firehose.
c.workedGrids, _ = a.qso.WorkedGridKeys(a.ctx, c.normMode)
c.workedGrids, _ = a.qso.GridWorkedIndex(a.ctx, gridClassesOf)
// Worked WPX prefixes, derived from the callsigns we already loaded — no
// extra query. Derived rather than read from the stored PFX column: that
// column is only filled when an import supplied it, and deriving keeps this
@@ -18404,6 +18611,37 @@ func (a *App) AnswerDecode(instance string, ms uint32, snr int, dt float64, audi
return err
}
// HaltDecodeTx tells the decoding application to stop transmitting — the same
// as its own Halt Tx button.
//
// autoTxOnly false stops the transmission mid-over; true lets the current over
// finish and only then disables auto-Tx. Both are real operator intentions: the
// first is "I called the wrong station", the second is "this QSO is done".
//
// instance may be empty, in which case the halt goes to whichever application
// last reported that it is transmitting — with one receiver, which is the
// normal case, that is simply the one running.
func (a *App) HaltDecodeTx(instance string, autoTxOnly bool) error {
if a.udp == nil {
return fmt.Errorf("udp not initialized")
}
if strings.TrimSpace(instance) == "" {
instance = a.lastTxInstance()
}
err := a.udp.SendHaltTx(instance, autoTxOnly)
if err != nil {
applog.Printf("udp: halt tx on %q failed: %v", instance, err)
}
return err
}
// lastTxInstance is the program id of the last decoding application heard from,
// or "" if none has reported yet.
func (a *App) lastTxInstance() string {
s, _ := a.lastTxID.Load().(string)
return s
}
// GetGridCacheStatus reports what the locator store holds.
func (a *App) GetGridCacheStatus() GridCacheStatus {
out := GridCacheStatus{Enabled: a.gridStore != nil}
@@ -18597,26 +18835,28 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
}
// The spotter's continent, and whether the DX uploads to LoTW. Both are
// in-memory lookups on tables already loaded, so they add nothing per spot.
//
// NOTE: the cluster list no longer filters on THIS copy — it is keyed by
// call|band|mode, so every spotter of one call shares it. The filter reads
// the spot's own SpotterContinent instead. Kept because the field is part
// of the published status shape.
if a.dxcc != nil && q.Spotter != "" {
// Strip the skimmer suffix first. RBN spotters report as "VU2OY-#" and
// a cluster node as "DL1ABC-2"; the prefix matcher sees an unknown
// callsign and gives up, so EVERY spot came back with no continent and
// the filter silently matched nothing. A real callsign never contains a
// hyphen, so cutting at the first one is safe.
sp := q.Spotter
if i := strings.IndexByte(sp, '-'); i > 0 {
sp = sp[:i]
}
if m, ok := a.dxcc.Lookup(sp); ok {
out[i].SpotterContinent = m.Continent
if sp := skimmerBase(q.Spotter); sp != "" {
if m, ok := a.dxcc.Lookup(sp); ok {
out[i].SpotterContinent = m.Continent
}
}
}
if a.lotwUsers != nil {
out[i].LoTW = a.lotwUsers.Lookup(q.Call).IsUser
}
// NEW GRID: the square this station announced in a CQ we decoded. The mode
// is part of the key, so the "group digital modes" option decides whether a
// grid worked on FT8 still counts as new on FT4 — one rule, no branch here.
// NEW GRID: the square this station announced on the UDP decode feed.
//
// This runs for CLUSTER spots as well as decodes — one function serves
// both — but a cluster line never carries the DX's grid, only the
// spotter's. So a spot is judged here exactly when the same callsign has
// also been heard on the decode feed, and stays unjudged otherwise. That
// is the honest answer: no grid known is not the same as no grid needed.
{
// Read through the accessor: the decode goroutine swaps these maps on
// rotation, so touching them unguarded is a race on the map header,
@@ -18624,13 +18864,14 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
g := a.lookupDecodeGrid(q.Call)
if g != "" {
out[i].Grid = g
cm := out[i].Mode
if normMode != nil && cm != "" {
cm = normMode(cm)
}
if _, done := idx.workedGrids[g+"|"+cm]; !done {
out[i].NewGrid = true
}
// The scope decides whether the band and the exact mode matter,
// and the hunt whether an unconfirmed square still counts as
// wanted. Both are the operator's settings — see gridscope.go.
// Two states, not one: never worked is a QSO to make, worked but
// unconfirmed is a QSL to chase. NewGrid stays the yes/no the
// filters read; GridState says WHICH, so the badge can differ.
out[i].GridState = gridStateFor(idx.workedGrids, idx.gridScope, idx.gridHunt, g, out[i].Band, out[i].Mode)
out[i].NewGrid = out[i].GridState != ""
}
}
// NEW COUNTY. Two sources, better one first: