package main // The data sources band-opening detection depends on, and the settings that // switch them on. // // The detection shipped reading whatever the operator's cluster nodes happened // to carry. That was a design mistake of the worst kind: the feature depended // on RBN feeds and a PSK Reporter subscription that nobody could know were // needed, so it looked broken rather than unconfigured. Nexus showed a 6 m // opening with 869 stations while OpsLog, on the same PC, showed nothing. // // So enabling the watch ARRANGES ITS OWN SOURCES: it adds the two RBN nodes if // they are missing and brings the PSK Reporter feed up. Turning it off leaves // the nodes alone — they may have been wanted for their own sake, and silently // removing a cluster node an operator is using would be worse than leaving one // they no longer need. import ( "strings" "hamlog/internal/applog" "hamlog/internal/bandopen" "hamlog/internal/cluster" "hamlog/internal/pskr" ) const ( keyBandOpenEnabled = "bandopen.enabled" keyBandOpenBands = "bandopen.bands" // comma-separated; empty = the default set ) // rbnNodes are the two Reverse Beacon Network endpoints the watch wants: CW and // digital are separate ports and carry different skimmers. var rbnNodes = []cluster.ServerConfig{ {Name: "RBN CW", Host: "telnet.reversebeacon.net", Port: 7000, Enabled: true}, {Name: "RBN FTx", Host: "telnet.reversebeacon.net", Port: 7001, Enabled: true}, } // BandOpenSettings is the panel's shape. type BandOpenSettings struct { Enabled bool `json:"enabled"` Bands []string `json:"bands"` // Available is every band that can be watched, so the UI does not carry its // own copy of a list that belongs to the detector. Available []string `json:"available"` } func (a *App) GetBandOpenSettings() BandOpenSettings { s := BandOpenSettings{ Enabled: a.settingOr(keyBandOpenEnabled, "") == "1", Bands: splitCSV(a.settingOr(keyBandOpenBands, "")), Available: pskr.Bands, } if len(s.Bands) == 0 { s.Bands = append(s.Bands, pskr.Bands...) } return s } func (a *App) SaveBandOpenSettings(s BandOpenSettings) error { a.setSetting(keyBandOpenEnabled, map[bool]string{true: "1", false: "0"}[s.Enabled]) a.setSetting(keyBandOpenBands, strings.Join(s.Bands, ",")) if s.Enabled { a.ensureRBNNodes() } a.startBandOpenFeed() return nil } // ensureRBNNodes adds the RBN endpoints when they are absent. // // Matched on host AND port rather than on name: an operator who renamed theirs // "Skimmers CW" has the node, and adding a second one pointed at the same // server would give them every spot twice — which the detector would read as // twice as many stations, i.e. an opening that is not there. func (a *App) ensureRBNNodes() { have, err := a.ListClusterServers() if err != nil { applog.Printf("bandopen: cannot read the cluster nodes (%v) — not adding RBN", err) return } for _, want := range rbnNodes { found := false for _, h := range have { if strings.EqualFold(strings.TrimSpace(h.Host), want.Host) && h.Port == want.Port { found = true break } } if found { continue } if _, err := a.SaveClusterServer(want); err != nil { applog.Printf("bandopen: could not add %s: %v", want.Name, err) continue } applog.Printf("bandopen: added cluster node %s (%s:%d) — the watch needs it", want.Name, want.Host, want.Port) } } // startBandOpenFeed brings the PSK Reporter subscription up or down to match // the setting. Called at startup and whenever the setting is saved. func (a *App) startBandOpenFeed() { if a.pskr != nil { a.pskr.Stop() a.pskr = nil } s := a.GetBandOpenSettings() if !s.Enabled { return } // Every spot is measured from the operator's position. Without one there is // nothing to measure, and a detector fed unmeasurable spots reports nothing // while looking like it is working. if !a.opSet { applog.Printf("bandopen: no station grid set — the opening watch needs one to measure a path") return } a.pskr = pskr.New(pskr.Config{ Bands: s.Bands, OpLat: a.opLat, OpLon: a.opLon, Geo: func(grid string) (int, int, bool) { lat, lon, ok := gridToLatLon(grid) if !ok { return 0, 0, false } // The same arithmetic the cluster path uses, so one spot cannot be // 2000 km away down one road and 2100 km down the other. d := int(haversineKm(a.opLat, a.opLon, lat, lon) + 0.5) b := int(initialBearingDeg(a.opLat, a.opLon, lat, lon) + 0.5) return d, b, true }, OnSpot: a.feedBandOpen, Logf: applog.Printf, }) if err := a.pskr.Start(); err != nil { applog.Printf("bandopen: PSK Reporter feed did not start: %v", err) } } // feedBandOpen hands one PSK Reporter decode to the detector. // // Called from the MQTT goroutine at up to thousands a minute when 6 m is open, // so it does the least possible: the detector's own window and de-duplication // by callsign are what turn that flood into one announcement. func (a *App) feedBandOpen(s pskr.Spot) { if !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.Call, Band: s.Band, DistKm: s.DistKm, Bearing: s.Bearing, At: s.At, }, 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) } } // GetPSKReporterStatus is what the settings panel polls. func (a *App) GetPSKReporterStatus() pskr.Status { if a.pskr == nil { return pskr.Status{Bands: pskr.Bands} } return a.pskr.Status() } // settingOr reads one key, falling back when the store is not up yet or the // value is blank. The settings store is a plain string key/value and every // caller does this by hand; two of them here earn the helper. func (a *App) settingOr(key, def string) string { if a.settings == nil { return def } v, _ := a.settings.Get(a.ctx, key) if strings.TrimSpace(v) == "" { return def } return v } func splitCSV(s string) []string { var out []string for _, p := range strings.Split(s, ",") { if p = strings.TrimSpace(p); p != "" { out = append(out, p) } } return out }