feat: Cluster alert implemented

This commit is contained in:
2026-07-03 19:08:50 +02:00
parent 8740a4ba66
commit 3e199f9ab6
7 changed files with 823 additions and 0 deletions
+142
View File
@@ -18,6 +18,7 @@ import (
"time"
"hamlog/internal/adif"
"hamlog/internal/alerts"
"hamlog/internal/antgenius"
"hamlog/internal/applog"
"hamlog/internal/audio"
@@ -415,6 +416,8 @@ type App struct {
netActive []*qso.QSO // on-air QSO drafts (transient negative ids), check-in order
netSeq int64 // transient-id counter for on-air drafts (decrements: -1, -2, …)
alertStore *alerts.Store // DX-cluster spot alert rules (global JSON)
cwMu sync.Mutex // guards the CW decoder lifecycle
cwStop chan struct{} // stops the CW decoder capture loop; nil when off
cwDecoder *cwdecode.Decoder // live decoder (for retargeting the pitch)
@@ -764,6 +767,8 @@ func (a *App) startup(ctx context.Context) {
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "cluster:spot", s)
}
// Fire any matching alert rules (sound / visual / e-mail).
a.evaluateAlerts(s)
// Mirror the spot onto the FlexRadio panadapter when enabled. The
// Color is left to the backend default for now — status-based
// colouring can be filled in here later (new entity / worked / …).
@@ -851,6 +856,13 @@ func (a *App) startup(ctx context.Context) {
a.netStore = ns
}
// DX-cluster spot alert rules (global JSON).
if as, err := alerts.Open(filepath.Join(a.dataDir, "alerts.json")); err != nil {
applog.Printf("alerts: open failed: %v", err)
} else {
a.alertStore = as
}
// Ultrabeam antenna: connect in the background if enabled.
a.startUltrabeam()
// Antenna Genius switch: connect in the background if enabled.
@@ -4262,6 +4274,136 @@ func (a *App) toast(msg string) {
}
}
// ── DX-cluster spot alerts ─────────────────────────────────────────────────
const keyAlertEmailTo = "alerts.email_to" // where alert e-mails are sent (default: SMTP from)
// ListAlertRules returns every alert rule.
func (a *App) ListAlertRules() []alerts.Rule {
if a.alertStore == nil {
return nil
}
return a.alertStore.List()
}
// SaveAlertRule upserts a rule (empty id → create) and returns it.
func (a *App) SaveAlertRule(r alerts.Rule) (alerts.Rule, error) {
if a.alertStore == nil {
return alerts.Rule{}, fmt.Errorf("alert store unavailable")
}
return a.alertStore.Save(r)
}
// DeleteAlertRule removes a rule by id.
func (a *App) DeleteAlertRule(id string) error {
if a.alertStore == nil {
return fmt.Errorf("alert store unavailable")
}
return a.alertStore.Delete(id)
}
// GetAlertEmailTo returns the address alert e-mails go to (empty = use SMTP from).
func (a *App) GetAlertEmailTo() string {
if a.settings == nil {
return ""
}
v, _ := a.settings.GetGlobal(a.ctx, keyAlertEmailTo)
return v
}
// SetAlertEmailTo stores the alert e-mail recipient.
func (a *App) SetAlertEmailTo(addr string) error {
if a.settings == nil {
return fmt.Errorf("db not initialized")
}
return a.settings.SetGlobal(a.ctx, keyAlertEmailTo, strings.TrimSpace(addr))
}
// evaluateAlerts runs a spot through the rules and fires notifications for every
// match (visual/sound via a frontend event, e-mail via SMTP). Cheap when no
// rules exist; the worked-before check only runs for rules that opt into it and
// already matched, so it's off the hot path.
func (a *App) evaluateAlerts(s cluster.Spot) {
if a.alertStore == nil {
return
}
sp := alerts.Spot{
DXCall: s.DXCall,
Band: s.Band,
Mode: alerts.InferMode(s.Comment, s.FreqHz),
Country: s.Country,
Continent: s.Continent,
Spotter: s.Spotter,
}
// The spotter's country/continent (cty.dat) — resolved lazily for Origin rules.
if a.dxcc != nil && s.Spotter != "" {
if m, ok := a.dxcc.Lookup(s.Spotter); ok && m.Entity != nil {
sp.SpotterCountry = m.Entity.Name
sp.SpotterContinent = m.Continent
}
}
matches := a.alertStore.Evaluate(sp, time.Now(), a.isWorkedBandMode)
for _, mt := range matches {
if a.ctx != nil && (mt.Rule.Visual || mt.Rule.Sound) {
wruntime.EventsEmit(a.ctx, "alert:fired", map[string]any{
"rule": mt.Rule.Name,
"call": s.DXCall,
"band": s.Band,
"mode": sp.Mode,
"freq_hz": s.FreqHz,
"country": s.Country,
"comment": s.Comment,
"sound": mt.Rule.Sound,
"visual": mt.Rule.Visual,
})
}
if mt.Rule.Email {
go a.sendAlertEmail(mt.Rule, s, sp.Mode)
}
}
}
// isWorkedBandMode reports whether the exact callsign is already logged on the
// given band+mode (used by rules with "skip worked before").
func (a *App) isWorkedBandMode(call, band, mode string) bool {
if a.qso == nil || strings.TrimSpace(call) == "" {
return false
}
rows, err := a.qso.List(a.ctx, qso.ListFilter{Callsign: call, Band: band, Mode: mode, Limit: 5})
if err != nil {
return false
}
call = strings.ToUpper(strings.TrimSpace(call))
for _, q := range rows {
if strings.ToUpper(strings.TrimSpace(q.Callsign)) == call {
return true
}
}
return false
}
// sendAlertEmail notifies the operator by e-mail that a wanted station was
// spotted. Best effort; requires SMTP configured (E-mail settings).
func (a *App) sendAlertEmail(r alerts.Rule, s cluster.Spot, mode string) {
es, _ := a.GetEmailSettings()
to := strings.TrimSpace(a.GetAlertEmailTo())
if to == "" {
to = strings.TrimSpace(es.From)
}
if to == "" {
applog.Printf("alert: e-mail skipped (no recipient / SMTP from) for rule %q", r.Name)
return
}
subject := fmt.Sprintf("OpsLog alert: %s spotted on %s", s.DXCall, s.Band)
body := fmt.Sprintf("Rule: %s\n\nCall: %s\nBand: %s\nMode: %s\nFreq: %.1f kHz\nCountry: %s\nSpotter: %s\nComment: %s\n",
r.Name, s.DXCall, s.Band, mode, float64(s.FreqHz)/1000, s.Country, s.Spotter, s.Comment)
if err := email.Send(a.emailConfig(es), to, subject, body, ""); err != nil {
applog.Printf("alert: e-mail to %s failed: %v", to, err)
} else {
applog.Printf("alert: e-mailed %s (rule %q) to %s", s.DXCall, r.Name, to)
}
}
// sanitizeFilename makes a callsign safe for a filename (slashes etc.).
func sanitizeFilename(s string) string {
s = strings.ToUpper(strings.TrimSpace(s))