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 }