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