310 lines
8.2 KiB
Go
310 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 guesses a spot's mode from its comment and frequency. Cluster spots
|
|
// don't carry a mode field, so we read common tags (FT8/FT4/CW/RTTY/…) then fall
|
|
// back to the digital watering holes and the band-plan CW/phone split.
|
|
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"
|
|
case strings.Contains(c, "CW"):
|
|
return "CW"
|
|
case strings.Contains(c, "SSB") || strings.Contains(c, "USB") || strings.Contains(c, "LSB") || strings.Contains(c, "PH"):
|
|
return "SSB"
|
|
}
|
|
khz := float64(freqHz) / 1000
|
|
// FT8 watering holes (…074) and FT4 (…080/…140) as a fallback.
|
|
for _, f := range []float64{1840, 3573, 7074, 10136, 14074, 18100, 21074, 24915, 28074, 50313} {
|
|
if khz >= f-1 && khz <= f+3 {
|
|
return "FT8"
|
|
}
|
|
}
|
|
// Band-plan CW segments (bottom of each band).
|
|
switch {
|
|
case khz >= 1810 && khz <= 1840,
|
|
khz >= 3500 && khz <= 3570,
|
|
khz >= 7000 && khz <= 7040,
|
|
khz >= 10100 && khz <= 10130,
|
|
khz >= 14000 && khz <= 14070,
|
|
khz >= 18068 && khz <= 18095,
|
|
khz >= 21000 && khz <= 21070,
|
|
khz >= 24890 && khz <= 24910,
|
|
khz >= 28000 && khz <= 28070:
|
|
return "CW"
|
|
}
|
|
return "SSB"
|
|
}
|