PSK Reporter tells you what is actually being decoded in your region, which is a larger set than what somebody chose to spot: nobody spots the FT8 caller running ten watts from a rare square. Almost all of it existed. The MQTT payload already carries frequency, mode, transmitter and both grids; the watcher already drops any report collected further than NearKm from the operator, which is exactly the question worth asking — the station is being heard HERE, not in Japan; and with grid chasing on the subscription is already every band, filtered at the broker by receiver square, measured at 0.2 to 1.2 messages a second. This reads messages that were arriving and being discarded. "New" is not decided here. Every spot goes through ClusterSpotStatuses, the same function the DX cluster grid uses and the same cached index, so the two panels cannot drift apart the way the county columns did. Cost per message is map lookups behind an option cached in an atomic, because the MQTT goroutine must never wait on the settings store. The option is its own, not nested under grid chasing: chasing squares and chasing entities are different wants, and the feed now has three consumers, any one of which brings it up and none of which cuts the others loose when it goes down. The panel says "digital modes only" in its footer. An empty list has to mean "nothing new on FT8/FT4/JS8 near you", not "the band is dead" — it will never show a new entity on CW.
221 lines
7.6 KiB
Go
221 lines
7.6 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)
|
|
}
|
|
|
|
// 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() }
|
|
|
|
// 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 && 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))
|
|
|
|
// 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.
|
|
func (a *App) GetChaseNew() bool { return a.chaseNewEnabled() }
|
|
|
|
// 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
|
|
}
|