Files
OpsLog/pskchase.go
T
rouggy 94cba2592e feat(chase-new): drop the frequency column, the beacons and the bands you don't have
Three cuts, all of them removing things nobody could act on.

The frequency column is gone. Clicking the row tunes the rig to it, so the
number was there to be read and never used. It is still carried on the spot —
the click is what needs it — and it is in the row's tooltip. The country takes
the space.

Modes are limited to FT8, FT4, FT2, PSK31 and RTTY. WSPR is the one worth
spelling out: it is a beacon, nobody answers a WSPR transmission, so a "new
entity on WSPR" is a path report and not a station to work — and on a quiet
band it would have been most of the list.

Bands are limited to the operator's own list. PSK Reporter carries every band
its receivers listen on, and a 13 cm decode is not an opportunity for a station
with no 13 cm.

Both tests run before the status lookup and both read a cached map: they throw
away most of the feed, so nothing downstream pays for it. The band list is
re-read when the lists are saved, so ticking a band in Settings applies to the
next decode rather than the next restart — which would have looked like the
filter not working.
2026-08-14 15:41:37 +02:00

282 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.
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
}