diff --git a/internal/pskr/pskr.go b/internal/pskr/pskr.go index ce2b2dc..6ce679f 100644 --- a/internal/pskr/pskr.go +++ b/internal/pskr/pskr.go @@ -319,6 +319,12 @@ type Status struct { LastErr string `json:"last_err,omitempty"` Broker string `json:"broker"` Bands []string `json:"bands"` + // What the feed is actually filtered on, so a panel showing nothing can say + // WHY instead of leaving the operator to guess: the radius in force, and how + // many receiver squares it came to. A radius raised in Preferences that + // never reached the subscription is invisible without these two. + NearKm int `json:"near_km"` + Squares int `json:"squares"` } func (w *Watcher) Status() Status { @@ -327,6 +333,7 @@ func (w *Watcher) Status() Status { st := Status{ Running: w.running, Received: w.received, LastAt: w.lastAt, LastErr: w.lastErr, Broker: w.cfg.Broker, + NearKm: w.cfg.NearKm, Squares: len(w.cfg.RxGrids), } st.Bands = append(st.Bands, w.cfg.Bands...) return st diff --git a/internal/pskrtgt/pskrtgt.go b/internal/pskrtgt/pskrtgt.go index 4f6d256..4499fb6 100644 --- a/internal/pskrtgt/pskrtgt.go +++ b/internal/pskrtgt/pskrtgt.go @@ -62,6 +62,12 @@ const ( // whether it is worth calling at all. pileupWindow = 2 * time.Minute + // backfillEvery is how often the history query is asked again for a target + // that is still being watched. Five minutes is PSK Reporter's own courtesy + // interval for repeating a query, and it happens to be the period most + // uploaders batch on. + backfillEvery = 5 * time.Minute + // The passband histogram: 60 Hz bins from 200 Hz to 4000 Hz. Above 4 kHz // there is essentially no FT8, and drawing the empty space made the strip // look broken rather than empty. @@ -202,10 +208,12 @@ type Watcher struct { subs []string spots []spot - // backfilled remembers the target the REST history was fetched for, so the - // panel's polling cannot re-fetch it every second. PSK Reporter's query API - // answers that with a rate limit, and rightly. - backfilled string + // backfilled remembers the target the REST history was fetched for and when, + // so the panel's polling cannot re-fetch it every second — PSK Reporter's + // query API answers that with a rate limit, and rightly — while a target + // held for a while still gets a fresh look every few minutes. + backfilled string + backfilledAt time.Time } func New(cfg Config) *Watcher { @@ -282,11 +290,12 @@ func (w *Watcher) Watch(target, mode string, dialHz int64) error { if err := w.resubscribe(client); err != nil { return err } - if changed && w.cfg.Scope == ScopeTarget { - // Under the band-wide subscription the window is already full of the new - // target's reports; under the narrow one it is empty, and the REST query - // is what makes the panel useful in the first fifteen seconds instead of - // after five minutes. + if changed { + // In BOTH scopes. The band-wide subscription was assumed to arrive with + // the target's reports already in the window — true only once it has been + // running a while, and false in the case that matters: the operator picks + // a station a minute after opening the panel and sees one decode where + // another program, running for an hour, shows four. go w.backfill(target, mode) } return nil @@ -377,7 +386,7 @@ func (w *Watcher) connect() (mqtt.Client, error) { if err := w.resubscribe(c); err != nil { w.cfg.Logf("pskr target: %v", err) } - if target != "" && w.cfg.Scope == ScopeTarget { + if target != "" { go w.backfill(target, mode) } } @@ -449,7 +458,15 @@ func (w *Watcher) SetOperator(call, grid string) { // Snapshot recomputes the analysis from the window. func (w *Watcher) Snapshot() Analysis { + // Due another look at the history? Checked here because this is what the + // panel calls every second; the query itself is rate-limited inside + // backfill, so this cannot turn into a request per poll. w.mu.Lock() + if t, m := w.target, w.mode; t != "" && time.Since(w.backfilledAt) >= backfillEvery { + w.mu.Unlock() + go w.backfill(t, m) + w.mu.Lock() + } defer w.mu.Unlock() now := time.Now() @@ -633,34 +650,42 @@ func top(m map[string]Entry, limit int) []Entry { return out } -// suggestOffset picks an audio slot to call on: the middle of the widest run of -// empty bins below the ceiling. +// suggestOffset picks an audio slot to call on. // // Below the CEILING, not below 4000 Hz. The ceiling is the highest offset he // has actually decoded, and it is the only evidence available about how wide // his receiver is set — plenty of stations run 2500 Hz. Suggesting 3400 Hz to // somebody whose passband stops at 2700 is advice to transmit into a filter. +// +// Two passes, and the second is the one that matters on a busy DX. Looking for +// an empty run alone answered "nowhere" exactly when the answer was most +// wanted: a hundred decodes across a 2800 Hz passband leave no run of clear +// bins at all, and the panel drew a full histogram with no advice under it. +// Failing a real gap, the quietest slot is still better than the one the +// operator would have picked by eye. func suggestOffset(bins []Bin, ceiling int) int { if ceiling < 1000 { return 0 } - used := map[int]bool{} + count := make(map[int]int, len(bins)) + busy := make(map[int]bool, len(bins)*3) for _, b := range bins { + count[b.OffsetHz] = b.Count if b.Count > 0 { - used[b.OffsetHz] = true // The neighbours too: FT8 is 50 Hz wide and the bins are 60, so a // signal on a bin edge covers the next one as surely as its own. - used[b.OffsetHz-binHz] = true - used[b.OffsetHz+binHz] = true + busy[b.OffsetHz], busy[b.OffsetHz-binHz], busy[b.OffsetHz+binHz] = true, true, true } } - bestStart, bestLen := -1, 0 - start, run := -1, 0 // From 1000 Hz up: below that is where every default transmit offset sits, - // so it is the most crowded part of the passband and the least useful - // advice. - for edge := 1020; edge+binHz <= ceiling; edge += binHz { - if used[edge] { + // so it is the most crowded part of the passband and the least useful advice. + const low = 1020 + high := (ceiling / binHz) * binHz + + // Pass 1 — the widest clear run, and call from its middle. + bestStart, bestLen, start, run := -1, 0, -1, 0 + for edge := low; edge <= high; edge += binHz { + if busy[edge] { start, run = -1, 0 continue } @@ -672,10 +697,32 @@ func suggestOffset(bins []Bin, ceiling int) int { bestStart, bestLen = start, run } } - if bestStart < 0 || bestLen < 2 { + if bestStart >= 0 && bestLen >= 2 { + return bestStart + bestLen*binHz/2 + } + + // Pass 2 — no clear run: the least busy slot. Walked from the top down, so + // a tie goes to the higher offset, which is the less crowded half of any + // passband and the half a pile-up leaves alone. + quietest, fewest := -1, 1<<30 + for edge := high; edge >= low; edge -= binHz { + if c := count[edge]; c < fewest { + quietest, fewest = edge, c + } + } + if quietest < 0 { return 0 } - return bestStart + bestLen*binHz/2 + // Never past the ceiling: the top bin CONTAINS it, so its middle can sit + // beyond the highest offset he has been shown to decode — which is the one + // thing this function exists to avoid. + if quietest+binHz/2 > ceiling { + quietest -= binHz + } + if quietest < low { + return 0 + } + return quietest + binHz/2 } // bandTag names the band a dial frequency is on, in PSK Reporter's own @@ -755,11 +802,19 @@ type pskrReports struct { // is what gets an application rate-limited off the service for everyone. func (w *Watcher) backfill(target, mode string) { w.mu.Lock() - if w.backfilled == target { + // Once per target, then no more often than the refresh interval. + // + // The live feed alone lags by design: PSK Reporter's uploaders batch their + // reports, most of them every five minutes, so between two batches the + // window only holds what happened to have been sent. Asking the history + // again at that same cadence keeps it as full as a program that has been + // subscribed for an hour — which is the whole of the difference an operator + // sees when comparing the two side by side. + if w.backfilled == target && time.Since(w.backfilledAt) < backfillEvery { w.mu.Unlock() return } - w.backfilled = target + w.backfilled, w.backfilledAt = target, time.Now() w.mu.Unlock() got := 0 diff --git a/internal/pskrtgt/pskrtgt_test.go b/internal/pskrtgt/pskrtgt_test.go index 5d3465a..1ac04eb 100644 --- a/internal/pskrtgt/pskrtgt_test.go +++ b/internal/pskrtgt/pskrtgt_test.go @@ -99,10 +99,12 @@ func TestSuggestOffsetAvoidsTheOccupiedBinsAndTheCeiling(t *testing.T) { if got < 1620 || got > 2340 { t.Errorf("suggested %d Hz, want somewhere in the empty 1560-2400 run", got) } - // A passband that stops low must not produce advice above it: transmitting - // past the DX's filter is the one outcome worse than picking a busy slot. - if got := suggestOffset(bins, 1500); got != 0 { - t.Errorf("suggested %d Hz with a 1500 Hz ceiling and no room, want none", got) + // A passband with no gap at all still gets an answer — the quietest slot, + // which is what an operator would look for by eye. What it must never do is + // advise ABOVE the ceiling: transmitting past the DX's filter is the one + // outcome worse than picking a busy slot. + if got := suggestOffset(bins, 1500); got <= 1000 || got > 1500 { + t.Errorf("suggested %d Hz with a full 1500 Hz passband, want a slot inside it", got) } if got := suggestOffset(nil, 0); got != 0 { t.Errorf("suggested %d Hz with no data at all, want none", got) @@ -132,3 +134,25 @@ func TestTopicsFollowTheScope(t *testing.T) { t.Errorf("band scope = %v, want the one band-wide filter", got) } } + +// The case reported from the air: a busy DX, 103 decodes across a 2805 Hz +// passband, and the panel drew the whole histogram with no advice under it. +func TestACrowdedPassbandStillGetsAnAnswer(t *testing.T) { + // Every bin from 1020 to 2800 occupied — no clear run anywhere, and the + // quietest slot is the answer. + bins := []Bin{} + for hz := 1020; hz <= 2760; hz += binHz { + n := 5 + if hz == 2400 { // one slot noticeably quieter than the rest + n = 1 + } + bins = append(bins, Bin{OffsetHz: hz, Count: n}) + } + got := suggestOffset(bins, 2805) + if got == 0 { + t.Fatal("no advice on a full passband — this is exactly when it is wanted") + } + if got < 2400 || got > 2460 { + t.Errorf("suggested %d Hz, want the quietest slot around 2400", got) + } +} diff --git a/pskchase.go b/pskchase.go index 7d80950..0e6ed7c 100644 --- a/pskchase.go +++ b/pskchase.go @@ -36,6 +36,28 @@ import ( // 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 @@ -149,6 +171,69 @@ var chaseModes = map[string]bool{ // 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 @@ -217,7 +302,11 @@ func (a *App) feedChaseNew(sp pskr.Spot) { // 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) { + // 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 }