The detection shipped reading whatever the operator's cluster nodes happened to carry. On VHF that is a few hundred skimmers, nearly all of them on HF: a 6 m opening carrying 869 stations reached OpsLog as a handful of spots or none, and Nexus flagged it on the same PC while OpsLog stayed silent. internal/pskr subscribes to pskr/filter/v2/<band>/# on PSK Reporter's MQTT broker. Every ordinary station running WSJT-X reports what it decodes, so the difference is two orders of magnitude rather than a threshold. The feed's shape suits us exactly: BOTH grids are in each message, so distance and bearing are arithmetic — no lookup, and no DXCC-centre approximation, which is what made the cluster path's bearings coarse. The geometry is injected from app.go so it stays the same arithmetic the cluster path uses; two answers to one question is how a bearing quietly becomes wrong. Volume was the design constraint, not the protocol. Six metres open is thousands of messages a minute and this runs on some very old PCs, so nothing is kept or persisted in the watcher: each message is parsed, measured and handed on or dropped, and the detector's existing window does the deciding. And the part that was the real bug: enabling the watch now ARRANGES ITS OWN SOURCES. It adds the two RBN nodes when missing and brings the feed up. A feature that silently depends on sources nobody can know are needed does not look unconfigured, it looks broken. Matched on host and port, not name, so an operator who renamed theirs does not get a duplicate — which the detector would read as twice as many stations, and announce an opening that is not there. Turning it off leaves the nodes alone: they may have been wanted for their own sake, and removing a node someone is using is worse than leaving one they are not.
88 lines
2.8 KiB
Go
88 lines
2.8 KiB
Go
package main
|
|
|
|
// Band-opening announcements — the app-side glue for internal/bandopen.
|
|
//
|
|
// The detector needs nothing OpsLog does not already compute: the cluster event
|
|
// worker enriches every spot with the great-circle distance and bearing from
|
|
// the operator's grid before this is called. So watching for sporadic E costs
|
|
// one function call per spot and no new data source.
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"sync"
|
|
|
|
"hamlog/internal/applog"
|
|
"hamlog/internal/bandopen"
|
|
"hamlog/internal/cluster"
|
|
|
|
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
|
)
|
|
|
|
type bandOpenState struct {
|
|
mu sync.Mutex
|
|
det *bandopen.Detector
|
|
last []bandopen.Opening // most recent first, for the UI
|
|
}
|
|
|
|
const maxRememberedOpenings = 20
|
|
|
|
// detectBandOpening feeds one spot to the detector and announces a hit.
|
|
func (a *App) detectBandOpening(s cluster.Spot) {
|
|
// No operator grid = no distance and no bearing on the spot, and the whole
|
|
// detection rests on those two. Say nothing rather than guess.
|
|
if !a.opSet || s.DistanceKm <= 0 || !bandopen.Watched(s.Band) {
|
|
return
|
|
}
|
|
a.bandOpen.mu.Lock()
|
|
if a.bandOpen.det == nil {
|
|
a.bandOpen.det = bandopen.New(bandopen.DefaultConfig())
|
|
}
|
|
op := a.bandOpen.det.Add(bandopen.Spot{
|
|
Call: s.DXCall, Band: s.Band, DistKm: s.DistanceKm,
|
|
Bearing: s.ShortPath, At: s.ReceivedAt,
|
|
}, a.opLat)
|
|
if op != nil {
|
|
a.bandOpen.last = append([]bandopen.Opening{*op}, a.bandOpen.last...)
|
|
if len(a.bandOpen.last) > maxRememberedOpenings {
|
|
a.bandOpen.last = a.bandOpen.last[:maxRememberedOpenings]
|
|
}
|
|
}
|
|
a.bandOpen.mu.Unlock()
|
|
if op != nil {
|
|
a.announceOpening(*op)
|
|
}
|
|
}
|
|
|
|
// announceOpening logs and pushes one detection. Shared by both feeds — the
|
|
// cluster path here and the PSK Reporter path in bandopen_sources.go — so an
|
|
// opening reads the same however it was noticed.
|
|
func (a *App) announceOpening(op bandopen.Opening) {
|
|
applog.Printf("bandopen: %s opening — %d stations, ~%d km, %s%s (%s)",
|
|
op.Band, op.Calls, op.MedianKm, op.Sector(),
|
|
map[bool]string{true: "", false: " — UNUSUAL for the season"}[op.InSeason],
|
|
strings.Join(op.Examples, " "))
|
|
if a.ctx != nil {
|
|
wruntime.EventsEmit(a.ctx, "bandopen:detected", op)
|
|
}
|
|
}
|
|
|
|
// GetBandOpenings returns the openings seen this session, newest first. The UI
|
|
// polls this so a detection is still visible after its toast has gone.
|
|
func (a *App) GetBandOpenings() []bandopen.Opening {
|
|
a.bandOpen.mu.Lock()
|
|
defer a.bandOpen.mu.Unlock()
|
|
out := make([]bandopen.Opening, len(a.bandOpen.last))
|
|
copy(out, a.bandOpen.last)
|
|
return out
|
|
}
|
|
|
|
// BandOpeningSummary is the one-line form used in the toast and the log.
|
|
func BandOpeningSummary(o bandopen.Opening) string {
|
|
s := fmt.Sprintf("%s open — %d stations ~%d km, %s", strings.ToUpper(o.Band), o.Calls, o.MedianKm, o.Sector())
|
|
if !o.InSeason {
|
|
s += " (unusual for the season)"
|
|
}
|
|
return s
|
|
}
|