Files
OpsLog/internal/alerts/alerts.go
T
rouggy 9c7983b13e fix(alerts): one band plan, not two that disagree
A spot at 10131.5 showed as DATA in the cluster list and raised an alert
announcing SSB. Two tables answered the same question: the frontend knew
10.130-10.150 is the 30 m data segment, while the alert engine knew a CW
range that stopped at 10.130 and ended with a bare 'return SSB' for
everything else.

That fall-through was the fault. A band plan that answers 'SSB' for
anything it does not recognise is not a band plan, it is a default wearing
one — and it fed the alert text, the FlexRadio spot colours and the mode
the radio is told to switch to.

The Go table is now transcribed from the frontend's, segment for segment,
and returns nothing where it knows nothing. The order matters and is now
load-bearing — the FT8 and FT4 watering holes are listed BEFORE the wide
data blocks they sit inside — so there is a test that fails if anybody
sorts the table by frequency and quietly turns every FT8 hole into DATA.

WPM counts as CW while we are here: nothing else is reported in words per
minute, and RBN puts it on every line.
2026-08-26 21:09:52 +02:00

302 lines
8.2 KiB
Go

// Package alerts evaluates incoming DX-cluster spots against user-defined rules
// and reports which rules fire, so the app can notify the operator (sound /
// visual / e-mail) when a wanted station is spotted — like Log4OM's Alert
// Management. Rules are persisted as JSON (global, machine-local).
package alerts
import (
"encoding/json"
"os"
"regexp"
"strconv"
"strings"
"sync"
"time"
)
// Rule is one alert definition. Every filter dimension is optional: an empty
// list/string matches ANY value, and the dimensions are ANDed together, so a
// rule with Countries=[France] and Bands=[20m] fires only for French stations
// on 20m. Calls / SpotterCall accept wildcards (IW3*, */P).
type Rule struct {
ID string `json:"id"`
Name string `json:"name"`
Enabled bool `json:"enabled"`
// DX filters.
Calls []string `json:"calls,omitempty"` // wildcard patterns on the DX call
Countries []string `json:"countries,omitempty"` // DXCC entity names
Continents []string `json:"continents,omitempty"` // AF/AN/AS/EU/NA/OC/SA
// Band / mode filters.
Bands []string `json:"bands,omitempty"`
Modes []string `json:"modes,omitempty"`
// Spotter (origin) filters.
SpotterCall string `json:"spotter_call,omitempty"` // wildcard
SpotterContinents []string `json:"spotter_continents,omitempty"`
SpotterCountries []string `json:"spotter_countries,omitempty"`
// Actions.
Sound bool `json:"sound"`
Visual bool `json:"visual"`
Email bool `json:"email"`
// Throttling: minutes to wait before re-alerting the same callsign. 0 = only
// once per session, -1 = always, >0 = that many minutes.
AgainAfterMin int `json:"again_after_min"`
// Skip a spot whose DX call is already worked on this band+mode.
SkipWorked bool `json:"skip_worked"`
}
// Spot is the subset of a cluster spot the engine matches against.
type Spot struct {
DXCall string
Band string
Mode string // inferred (see InferMode)
Country string // DXCC entity name
Continent string
Spotter string
SpotterCountry string
SpotterContinent string
}
// Store persists the rule set as a JSON file.
type Store struct {
mu sync.Mutex
path string
rules []Rule
// last fired time per (ruleID|call), for the AgainAfter throttle.
lastFired map[string]time.Time
}
// Open loads the store (empty when the file is absent).
func Open(path string) (*Store, error) {
s := &Store{path: path, lastFired: map[string]time.Time{}}
b, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return s, nil
}
return nil, err
}
if len(b) > 0 {
_ = json.Unmarshal(b, &s.rules) // corrupt file → start empty
}
return s, nil
}
func (s *Store) save() error {
b, err := json.MarshalIndent(s.rules, "", " ")
if err != nil {
return err
}
tmp := s.path + ".tmp"
if err := os.WriteFile(tmp, b, 0o644); err != nil {
return err
}
return os.Rename(tmp, s.path)
}
// List returns a copy of all rules.
func (s *Store) List() []Rule {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]Rule, len(s.rules))
copy(out, s.rules)
return out
}
func newID() string { return strconv.FormatInt(time.Now().UnixNano(), 36) }
// Save upserts a rule (creating an id when empty) and returns it.
func (s *Store) Save(r Rule) (Rule, error) {
s.mu.Lock()
defer s.mu.Unlock()
if strings.TrimSpace(r.ID) == "" {
r.ID = newID()
s.rules = append(s.rules, r)
} else {
found := false
for i := range s.rules {
if s.rules[i].ID == r.ID {
s.rules[i] = r
found = true
break
}
}
if !found {
s.rules = append(s.rules, r)
}
}
return r, s.save()
}
// Delete removes a rule by id.
func (s *Store) Delete(id string) error {
s.mu.Lock()
defer s.mu.Unlock()
for i := range s.rules {
if s.rules[i].ID == id {
s.rules = append(s.rules[:i], s.rules[i+1:]...)
return s.save()
}
}
return nil
}
// Match is a rule that fired for a spot (returned by Evaluate).
type Match struct {
Rule Rule
Spot Spot
}
// Evaluate returns every enabled rule that matches the spot AND isn't currently
// throttled. It records the fire time for matched rules so the AgainAfter window
// is honoured. workedFn (may be nil) reports whether the DX call is already
// worked on this band+mode — used by rules with SkipWorked.
func (s *Store) Evaluate(sp Spot, now time.Time, workedFn func(call, band, mode string) bool) []Match {
s.mu.Lock()
defer s.mu.Unlock()
var out []Match
for _, r := range s.rules {
if !r.Enabled || !ruleMatches(r, sp) {
continue
}
if r.SkipWorked && workedFn != nil && workedFn(sp.DXCall, sp.Band, sp.Mode) {
continue
}
key := r.ID + "|" + strings.ToUpper(sp.DXCall)
if r.AgainAfterMin >= 0 {
last, seen := s.lastFired[key]
if seen {
if r.AgainAfterMin == 0 {
continue // once per session
}
if now.Sub(last) < time.Duration(r.AgainAfterMin)*time.Minute {
continue
}
}
}
s.lastFired[key] = now
out = append(out, Match{Rule: r, Spot: sp})
}
return out
}
// ruleMatches reports whether every specified filter dimension matches the spot.
func ruleMatches(r Rule, sp Spot) bool {
if len(r.Calls) > 0 && !anyWildcard(r.Calls, sp.DXCall) {
return false
}
if len(r.Countries) > 0 && !containsFold(r.Countries, sp.Country) {
return false
}
if len(r.Continents) > 0 && !containsFold(r.Continents, sp.Continent) {
return false
}
if len(r.Bands) > 0 && !containsFold(r.Bands, sp.Band) {
return false
}
if len(r.Modes) > 0 && !containsFold(r.Modes, sp.Mode) {
return false
}
if strings.TrimSpace(r.SpotterCall) != "" && !matchWildcard(r.SpotterCall, sp.Spotter) {
return false
}
if len(r.SpotterContinents) > 0 && !containsFold(r.SpotterContinents, sp.SpotterContinent) {
return false
}
if len(r.SpotterCountries) > 0 && !containsFold(r.SpotterCountries, sp.SpotterCountry) {
return false
}
return true
}
func containsFold(list []string, v string) bool {
v = strings.TrimSpace(v)
if v == "" {
return false
}
for _, x := range list {
if strings.EqualFold(strings.TrimSpace(x), v) {
return true
}
}
return false
}
func anyWildcard(patterns []string, v string) bool {
for _, p := range patterns {
if matchWildcard(p, v) {
return true
}
}
return false
}
// matchWildcard does a case-insensitive full-string match where '*' matches any
// run of characters and '?' matches one (e.g. "IW3*", "*/P", "F?BPO").
func matchWildcard(pattern, v string) bool {
pattern = strings.TrimSpace(pattern)
v = strings.TrimSpace(v)
if pattern == "" {
return true
}
var b strings.Builder
b.WriteString("(?i)^")
for _, r := range pattern {
switch r {
case '*':
b.WriteString(".*")
case '?':
b.WriteString(".")
default:
b.WriteString(regexp.QuoteMeta(string(r)))
}
}
b.WriteString("$")
re, err := regexp.Compile(b.String())
if err != nil {
return strings.EqualFold(pattern, v)
}
return re.MatchString(v)
}
// InferMode works out a spot's mode from its comment, then from the band plan.
//
// Cluster lines carry no mode field, so this is all there is — and it feeds the
// alert rules, the FlexRadio panadapter colours and the spot the radio is told
// about. It used to end with a bare "return SSB" for anything its handful of CW
// ranges did not cover, which is how a 30 m FT8 spot at 10131.5 raised an alert
// announcing SSB while the cluster list beside it said DATA.
//
// The comment still wins when it names a mode: a spotter who wrote FT8 knows
// better than any table. Below that is the shared band plan (bandplan.go), and
// below THAT is nothing — an empty mode, which callers read as "unknown" rather
// than as a claim.
func InferMode(comment string, freqHz int64) string {
c := strings.ToUpper(comment)
switch {
case strings.Contains(c, "FT8"):
return "FT8"
case strings.Contains(c, "FT4"):
return "FT4"
case strings.Contains(c, "RTTY"):
return "RTTY"
case strings.Contains(c, "PSK"):
return "PSK"
case strings.Contains(c, "JS8"):
return "JS8"
// WPM is as good as the word CW: nothing else is reported in words per
// minute, and RBN spots carry it on every line.
case strings.Contains(c, "CW"), strings.Contains(c, "WPM"):
return "CW"
case strings.Contains(c, "SSB"), strings.Contains(c, "USB"),
strings.Contains(c, "LSB"), strings.Contains(c, "PH"):
return "SSB"
}
return modeFromFrequency(freqHz)
}