Measured against DXHunter on the same station at the same second: 18 decodes here against 27 there. - The window was five minutes. PSK Reporter's uploaders batch their reports, most of them every five, so a five-minute window catches about one upload cycle per station. Ten, as DXHunter has always had behind a label that says four. - The history query ran once per target and only on the narrow feed. It now runs in both scopes and again every five minutes while a station is watched, which is the cadence the uploaders keep. - It asked only what the target RECEIVED. Both directions now, so "who is hearing him" starts full too. - The suggested call offset looked for a run of empty slots and said nothing when there was none — exactly the case it exists for: a hundred decodes across a 2800 Hz passband leave no gap. Failing a gap it names the quietest slot, ties to the higher offset, never above the ceiling. Chase new: the receiver squares now follow the radius that was asked for (it was one fixed ring whatever the setting said, so raising it bought nothing), and the panel says what the feed is doing rather than leaving an empty list to speak for itself. A change of hunt empties it: its rows are verdicts reached under the old rule and nothing re-judged them. Its own band selection, because what a station CAN work and what is worth watching tonight are different questions.
377 lines
14 KiB
Go
377 lines
14 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"
|
|
|
|
// keyChaseNewBandsOff lists the bands the panel is NOT to show, comma
|
|
// separated.
|
|
//
|
|
// Stored as the EXCLUSIONS rather than the selection, so that an empty setting
|
|
// — every operator who has never opened this — means "show them all", and so
|
|
// that a band added to the station's list later appears here on its own. Stored
|
|
// the other way round, a selection saved today would silently hide 4 m the day
|
|
// a transverter arrives.
|
|
const keyChaseNewBandsOff = "cluster.chase_new_bands_off"
|
|
|
|
// ChaseNewBands is the band vocabulary of the panel's own filter: HF through
|
|
// 70 cm, in the order an operator reads a band plan.
|
|
//
|
|
// A fixed list, not the station's: the two answer different questions. The
|
|
// station list says what this station can work at all, and still applies —
|
|
// this one says what is worth WATCHING right now, which changes with the hour
|
|
// and the season, not with the shack.
|
|
var ChaseNewBands = []string{
|
|
"160m", "80m", "60m", "40m", "30m", "20m", "17m", "15m", "12m", "10m",
|
|
"6m", "4m", "2m", "70cm",
|
|
}
|
|
|
|
// 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() }
|
|
|
|
// GetChaseNewBands returns the bands the panel is set to SHOW — every band in
|
|
// the vocabulary that has not been switched off.
|
|
func (a *App) GetChaseNewBands() []string {
|
|
off := map[string]bool{}
|
|
for _, b := range splitCSV(a.settingOr(keyChaseNewBandsOff, "")) {
|
|
off[strings.ToLower(strings.TrimSpace(b))] = true
|
|
}
|
|
out := make([]string, 0, len(ChaseNewBands))
|
|
for _, b := range ChaseNewBands {
|
|
if !off[b] {
|
|
out = append(out, b)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// SetChaseNewBands takes the bands to SHOW and stores the complement.
|
|
//
|
|
// Nothing is filtered out of the store retroactively: the rows already
|
|
// collected stay until they age out, and the next message is the one that
|
|
// obeys. A panel that emptied itself on a settings click would look like it had
|
|
// lost the feed.
|
|
func (a *App) SetChaseNewBands(show []string) {
|
|
want := map[string]bool{}
|
|
for _, b := range show {
|
|
want[strings.ToLower(strings.TrimSpace(b))] = true
|
|
}
|
|
var off []string
|
|
for _, b := range ChaseNewBands {
|
|
if !want[b] {
|
|
off = append(off, b)
|
|
}
|
|
}
|
|
a.setSetting(keyChaseNewBandsOff, strings.Join(off, ","))
|
|
a.refreshChaseNewBands()
|
|
applog.Printf("chase new: showing %d of %d bands", len(ChaseNewBands)-len(off), len(ChaseNewBands))
|
|
}
|
|
|
|
// refreshChaseNewBands re-reads the panel's own band filter into the cache the
|
|
// feed consults. Called at startup, and whenever the selection is saved.
|
|
func (a *App) refreshChaseNewBands() {
|
|
off := map[string]bool{}
|
|
for _, b := range splitCSV(a.settingOr(keyChaseNewBandsOff, "")) {
|
|
if b = strings.ToLower(strings.TrimSpace(b)); b != "" {
|
|
off[b] = true
|
|
}
|
|
}
|
|
a.chaseNewBandsOff.Store(off)
|
|
}
|
|
|
|
// chaseNewBandShown is the per-message half: a map read on the MQTT goroutine.
|
|
func (a *App) chaseNewBandShown(band string) bool {
|
|
v := a.chaseNewBandsOff.Load()
|
|
if v == nil {
|
|
return true // nothing loaded yet — better to show than to swallow
|
|
}
|
|
off, ok := v.(map[string]bool)
|
|
if !ok {
|
|
return true
|
|
}
|
|
return !off[strings.ToLower(strings.TrimSpace(band))]
|
|
}
|
|
|
|
// 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.
|
|
// Two band tests, and they answer two questions: can this station work the
|
|
// band at all (its own list), and does the operator want to WATCH it right
|
|
// now (this panel's own filter). A station with 2 m equipment that is
|
|
// chasing HF tonight needs the second one.
|
|
if !chaseModes[mode] || !a.chaseBandAllowed(band) || !a.chaseNewBandShown(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
|
|
}
|