feat: Cluster alert implemented
This commit is contained in:
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user