An operator waited over a minute and the button never appeared; opening Settings and pressing Cancel made it appear at once. That is the whole diagnosis — Cancel was the only thing that re-read the option. GetChaseNew returned the cached atomic, which exists for the MQTT goroutine and is false until startup has read the setting. The UI asked while the database was still opening, was told "off", believed it, and never asked again. It now reads the setting, like the grid-chasing binding beside it. The frontend asks again at the two moments this class of race resolves: when GetStartupStatus returns, and when the first logbook load succeeds — the seam that already re-reads the connection label for exactly this reason, with a comment saying so. The open/closed state was already remembered per machine; it is now in PORTABLE_KEYS with the other widget toggles, so it travels with data/ like the rotor and amplifier panels rather than being the one that does not.
288 lines
10 KiB
Go
288 lines
10 KiB
Go
package main
|
|
|
|
// Chase New — a widget listing the stations PSK Reporter is hearing NEAR HERE
|
|
// that are new against the log.
|
|
//
|
|
// The feed is the one the band-opening watch and the grid store already use, so
|
|
// this costs no extra subscription when either is on: it reads messages that
|
|
// were arriving and being discarded. Measured on the live broker, one ring of
|
|
// neighbour squares is 0.2 to 1.2 messages a second.
|
|
//
|
|
// Two things about the data decide the shape of everything below:
|
|
//
|
|
// - PSK Reporter is DIGITAL ONLY. This can never show a new entity on CW or
|
|
// SSB, and the panel says so rather than letting an operator conclude the
|
|
// band is dead when it is full of CW.
|
|
// - A report says "X was heard BY Y". The watcher already drops anything
|
|
// collected further than NearKm from the operator (internal/pskr), so what
|
|
// arrives here is a station being heard in this region — not a world map.
|
|
//
|
|
// "New" is NOT decided here. It goes through ClusterSpotStatuses, the same
|
|
// function the DX cluster grid uses, because two definitions of new is how the
|
|
// two panels quietly start disagreeing about the same callsign.
|
|
|
|
import (
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"hamlog/internal/applog"
|
|
"hamlog/internal/pskr"
|
|
)
|
|
|
|
// keyChaseNew turns the widget on. Deliberately NOT nested under "chase grids":
|
|
// chasing squares and chasing entities are different wants, and an operator may
|
|
// have one without the other. They only share the feed, which either one starts.
|
|
const keyChaseNew = "cluster.chase_new"
|
|
|
|
// chaseNewMax bounds the panel. A list nobody can read to the bottom is not more
|
|
// information, and the oldest rows are the least likely to still be on the air.
|
|
const chaseNewMax = 200
|
|
|
|
// chaseSeenTTL is how long the same station stays de-duplicated on one band and
|
|
// mode. PSK Reporter re-reports a calling station every cycle — without this the
|
|
// panel would be one operator repeated fifty times.
|
|
const chaseSeenTTL = 10 * time.Minute
|
|
|
|
// ChaseNewSpot is one station worth looking at, as the widget shows it.
|
|
type ChaseNewSpot struct {
|
|
Call string `json:"call"`
|
|
Band string `json:"band"`
|
|
Mode string `json:"mode"`
|
|
FreqHz int64 `json:"freq_hz"`
|
|
Grid string `json:"grid"`
|
|
Country string `json:"country,omitempty"`
|
|
Cont string `json:"cont,omitempty"`
|
|
DistKm int `json:"dist_km"`
|
|
Bearing int `json:"bearing"`
|
|
// Status is the entity-level verdict from the cluster's own vocabulary:
|
|
// new | new-band | new-mode | new-slot. Empty when the row is here for a
|
|
// prefix or a square instead.
|
|
Status string `json:"status,omitempty"`
|
|
NewPfx bool `json:"new_pfx,omitempty"`
|
|
NewGrid bool `json:"new_grid,omitempty"`
|
|
LoTW bool `json:"lotw,omitempty"`
|
|
At string `json:"at"` // RFC3339, stamped on receipt
|
|
}
|
|
|
|
// chaseNewStore holds what the widget shows. Written from the MQTT goroutine,
|
|
// read by the UI poll, so everything is behind one mutex — the work per message
|
|
// is a handful of map lookups and this must never become the reason the broker's
|
|
// buffer backs up.
|
|
type chaseNewStore struct {
|
|
mu sync.Mutex
|
|
spots []ChaseNewSpot // newest last
|
|
seen map[string]time.Time // "CALL|BAND|MODE" → when it was last shown
|
|
}
|
|
|
|
func newChaseNewStore() *chaseNewStore {
|
|
return &chaseNewStore{seen: make(map[string]time.Time, 512)}
|
|
}
|
|
|
|
// put adds a spot unless the same station on the same band and mode is already
|
|
// on the list. Returns false when it was a duplicate.
|
|
func (s *chaseNewStore) put(sp ChaseNewSpot, now time.Time) bool {
|
|
key := sp.Call + "|" + sp.Band + "|" + sp.Mode
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if last, ok := s.seen[key]; ok && now.Sub(last) < chaseSeenTTL {
|
|
return false
|
|
}
|
|
s.seen[key] = now
|
|
s.spots = append(s.spots, sp)
|
|
if len(s.spots) > chaseNewMax {
|
|
s.spots = s.spots[len(s.spots)-chaseNewMax:]
|
|
}
|
|
// The de-duplication map is the only thing here that grows without a natural
|
|
// bound, so it is swept when it gets large rather than on every message.
|
|
if len(s.seen) > 4*chaseNewMax {
|
|
for k, t := range s.seen {
|
|
if now.Sub(t) >= chaseSeenTTL {
|
|
delete(s.seen, k)
|
|
}
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// list returns the spots newer than ttl, newest first.
|
|
func (s *chaseNewStore) list(ttl time.Duration, now time.Time) []ChaseNewSpot {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
out := make([]ChaseNewSpot, 0, len(s.spots))
|
|
for _, sp := range s.spots {
|
|
at, err := time.Parse(time.RFC3339, sp.At)
|
|
if err == nil && ttl > 0 && now.Sub(at) > ttl {
|
|
continue
|
|
}
|
|
out = append(out, sp)
|
|
}
|
|
sort.SliceStable(out, func(i, j int) bool { return out[i].At > out[j].At })
|
|
return out
|
|
}
|
|
|
|
func (s *chaseNewStore) clear() {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.spots = nil
|
|
s.seen = make(map[string]time.Time, 512)
|
|
}
|
|
|
|
// chaseModes is what the panel will show. PSK Reporter reports everything its
|
|
// receivers decode, and most of it is not a contact waiting to happen:
|
|
//
|
|
// - WSPR is a beacon. Nobody answers a WSPR transmission, so a "new entity on
|
|
// WSPR" is a path report, not a station to work — and on a quiet band it
|
|
// would be most of the list.
|
|
// - JT65, JT9, FST4W, Q65 and the rest are real but rare enough that they
|
|
// would only dilute what an operator scans.
|
|
//
|
|
// The list is the modes an operator actually calls on. Deliberately not a
|
|
// setting: a widget with its own mode list is a second place to get the answer
|
|
// wrong, and this one is short enough to read.
|
|
var chaseModes = map[string]bool{
|
|
"FT8": true, "FT4": true, "FT2": true, "PSK31": true, "RTTY": true,
|
|
}
|
|
|
|
// chaseNewEnabled reads the option. Called per message, so it reads the cached
|
|
// atomic rather than the settings store.
|
|
func (a *App) chaseNewEnabled() bool { return a.chaseNewOn.Load() }
|
|
|
|
// chaseBandAllowed reports whether a band is one the operator uses.
|
|
//
|
|
// PSK Reporter carries every band its receivers listen on, including microwave
|
|
// segments nobody in the region is equipped for. A 13 cm decode is not an
|
|
// opportunity for a station with no 13 cm — it is a row in the way.
|
|
//
|
|
// The answer is the operator's own band list (Settings → Modes & bands), read
|
|
// once and cached: this is consulted per message, and re-reading a JSON setting
|
|
// at that rate on the MQTT goroutine is exactly what must not happen.
|
|
func (a *App) chaseBandAllowed(band string) bool {
|
|
a.chaseBandsMu.RLock()
|
|
m := a.chaseBands
|
|
a.chaseBandsMu.RUnlock()
|
|
if m == nil {
|
|
return true // list not loaded yet — better to show than to swallow
|
|
}
|
|
return m[strings.ToLower(strings.TrimSpace(band))]
|
|
}
|
|
|
|
// refreshChaseBands re-reads the operator's band list into the cache. Called
|
|
// when the widget is switched on and whenever the lists are saved.
|
|
func (a *App) refreshChaseBands() {
|
|
s, _ := a.GetListsSettings()
|
|
m := make(map[string]bool, len(s.Bands))
|
|
for _, b := range s.Bands {
|
|
if b = strings.ToLower(strings.TrimSpace(b)); b != "" {
|
|
m[b] = true
|
|
}
|
|
}
|
|
a.chaseBandsMu.Lock()
|
|
a.chaseBands = m
|
|
a.chaseBandsMu.Unlock()
|
|
}
|
|
|
|
// refreshChaseNew re-reads the option into the atomic the feed consults.
|
|
func (a *App) refreshChaseNew() {
|
|
on := a.settingOr(keyChaseNew, "") == "1"
|
|
a.chaseNewOn.Store(on)
|
|
if on {
|
|
// Read the band list here rather than per message. Saving the lists calls
|
|
// back into this, so a band ticked in Settings applies to the next decode.
|
|
a.refreshChaseBands()
|
|
}
|
|
if !on && a.chaseNew != nil {
|
|
// Drop the list rather than leave it on screen: it would go stale with no
|
|
// feed behind it, and a frozen list of "new" stations is worse than none.
|
|
a.chaseNew.clear()
|
|
}
|
|
}
|
|
|
|
// feedChaseNew turns one PSK Reporter decode into a widget row, or drops it.
|
|
//
|
|
// Runs on the MQTT goroutine. The cheap tests come first — the option, then the
|
|
// de-duplication — so a station already listed costs one map lookup and nothing
|
|
// else.
|
|
func (a *App) feedChaseNew(sp pskr.Spot) {
|
|
if !a.chaseNewEnabled() || a.chaseNew == nil {
|
|
return
|
|
}
|
|
call := strings.ToUpper(strings.TrimSpace(sp.Call))
|
|
if call == "" {
|
|
return
|
|
}
|
|
band := strings.ToLower(strings.TrimSpace(sp.Band))
|
|
mode := strings.ToUpper(strings.TrimSpace(sp.Mode))
|
|
// Both tests before the status lookup: they are map reads on short keys and
|
|
// they throw away most of the feed, so nothing further down pays for a band
|
|
// this station cannot work or a mode nobody answers.
|
|
if !chaseModes[mode] || !a.chaseBandAllowed(band) {
|
|
return
|
|
}
|
|
|
|
// The same verdict the cluster grid computes, from the same cached index:
|
|
// map lookups per spot, no query.
|
|
st := a.ClusterSpotStatuses([]SpotQuery{{Call: call, Band: band, Mode: mode}})
|
|
if len(st) == 0 {
|
|
return
|
|
}
|
|
s := st[0]
|
|
isNew := s.Status == "new" || s.Status == "new-band" ||
|
|
s.Status == "new-mode" || s.Status == "new-slot" || s.NewPfx || s.NewGrid
|
|
if !isNew {
|
|
return
|
|
}
|
|
|
|
now := time.Now()
|
|
row := ChaseNewSpot{
|
|
Call: call, Band: band, Mode: mode, FreqHz: sp.FreqHz,
|
|
Grid: sp.Grid, Country: s.Country, Cont: s.Continent,
|
|
DistKm: sp.DistKm, Bearing: sp.Bearing,
|
|
Status: s.Status, NewPfx: s.NewPfx, NewGrid: s.NewGrid, LoTW: s.LoTW,
|
|
At: now.UTC().Format(time.RFC3339),
|
|
}
|
|
// The grid the watcher gives is the transmitter's own, straight off the air —
|
|
// better than anything we could look up, so it is kept even when the status
|
|
// index had one.
|
|
if row.Grid == "" {
|
|
row.Grid = s.Grid
|
|
}
|
|
a.chaseNew.put(row, now)
|
|
}
|
|
|
|
// GetChaseNewSpots returns what the widget should show, newest first, aged out
|
|
// with the same spot lifetime the cluster and band maps use — one setting for
|
|
// "how long is a spot worth looking at", not three.
|
|
func (a *App) GetChaseNewSpots() []ChaseNewSpot {
|
|
if a.chaseNew == nil || !a.chaseNewEnabled() {
|
|
return []ChaseNewSpot{}
|
|
}
|
|
ttl := time.Duration(a.GetSpotTTLMinutes()) * time.Minute
|
|
return a.chaseNew.list(ttl, time.Now())
|
|
}
|
|
|
|
// GetChaseNew reports whether the widget is on.
|
|
//
|
|
// Reads the SETTING, not the cached atomic. The atomic exists for the MQTT
|
|
// goroutine and is false until startup has read the option — so a UI that asked
|
|
// this while the database was still opening was told "off", believed it, and
|
|
// never asked again. The toolbar button only appeared after opening Settings
|
|
// and closing it, which is what re-read it.
|
|
func (a *App) GetChaseNew() bool { return a.settingOr(keyChaseNew, "") == "1" }
|
|
|
|
// SetChaseNew turns the widget on or off and brings the feed up or down with it.
|
|
func (a *App) SetChaseNew(on bool) error {
|
|
v := "0"
|
|
if on {
|
|
v = "1"
|
|
}
|
|
a.setSetting(keyChaseNew, v)
|
|
a.refreshChaseNew()
|
|
// The feed is shared: startBandOpenSources decides whether it is still needed
|
|
// by anything else, so turning this off does not cut the grid store loose.
|
|
a.startBandOpenFeed()
|
|
applog.Printf("chase new: %v", on)
|
|
return nil
|
|
}
|