clublog.org/watch.php, per entry: the DXpedition flag, OQRS, LiveStream, the log's QSO total and the last-24h rate — the fields the schema has carried since phase 1. Refreshed on DXHunter's own cadence (hourly for expeditions, six-hourly for the rest), two at a time with a breath between requests: the application API key is shared by every install, so a hundred-entry list must read as a trickle at ClubLog's end. Nothing to configure — OpsLog's own key, already used for cty and Most Wanted, serves. A starred pattern is asked about by its base (ClubLog has no log for VK9*). The card gains the 24h rate beside the QSO total and the Live link to the expedition's stream.
294 lines
9.9 KiB
Go
294 lines
9.9 KiB
Go
package main
|
|
|
|
// Watchlist bindings — the DXHunter watchlist concept as an OpsLog tab.
|
|
// The store lives in internal/watchlist (global watchlist.json, DXHunter's own
|
|
// schema); this file is the Wails boundary plus the two places the list meets
|
|
// the rest of the app: the spot stream (MarkSeen + alert) and the logbook (the
|
|
// worked-today answer contest entries are judged by).
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"hamlog/internal/applog"
|
|
"hamlog/internal/qso"
|
|
"hamlog/internal/watchlist"
|
|
|
|
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
|
)
|
|
|
|
// The auto-contest pattern, DXHunter's contest_prefix: while non-empty, any
|
|
// spotted callsign CONTAINING it is added to the watchlist as a contest entry
|
|
// by itself — a special-event fleet (HB9WWA, DL0WWA, F4WWA…) is collected as
|
|
// it appears instead of typed in one by one. Held in an atomic so the spot
|
|
// pipeline never touches the settings store per spot.
|
|
func (a *App) GetWatchlistContestPattern() string {
|
|
if v := a.watchPattern.Load(); v != nil {
|
|
return v.(string)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// SetWatchlistContestPattern stores the pattern (global, like the list itself).
|
|
// Emptying it stops the auto-add; entries already collected stay — deleting an
|
|
// operator's list because a setting changed is DXHunter behaviour this port
|
|
// deliberately drops.
|
|
func (a *App) SetWatchlistContestPattern(p string) {
|
|
p = strings.ToUpper(strings.TrimSpace(p))
|
|
a.watchPattern.Store(p)
|
|
a.setSettingGlobal(keyWatchlistContestPattern, p)
|
|
}
|
|
|
|
// WatchlistEntries returns the list for the tab.
|
|
func (a *App) WatchlistEntries() []watchlist.Entry {
|
|
if a.watchlist == nil {
|
|
return nil
|
|
}
|
|
return a.watchlist.Entries()
|
|
}
|
|
|
|
// WatchlistAdd adds a callsign or prefix; contest entries are judged per UTC day.
|
|
func (a *App) WatchlistAdd(callsign string, contest bool) error {
|
|
if a.watchlist == nil {
|
|
return fmt.Errorf("watchlist not initialized")
|
|
}
|
|
return a.watchlist.Add(callsign, contest)
|
|
}
|
|
|
|
// WatchlistRemove deletes an entry.
|
|
func (a *App) WatchlistRemove(callsign string) error {
|
|
if a.watchlist == nil {
|
|
return fmt.Errorf("watchlist not initialized")
|
|
}
|
|
return a.watchlist.Remove(callsign)
|
|
}
|
|
|
|
// WatchlistSetNotify arms the existing alert path (sound + toast) for an entry.
|
|
func (a *App) WatchlistSetNotify(callsign string, on bool) error {
|
|
if a.watchlist == nil {
|
|
return fmt.Errorf("watchlist not initialized")
|
|
}
|
|
return a.watchlist.SetNotify(callsign, on)
|
|
}
|
|
|
|
// WatchlistSetContest flips the per-entry contest rule.
|
|
func (a *App) WatchlistSetContest(callsign string, on bool) error {
|
|
if a.watchlist == nil {
|
|
return fmt.Errorf("watchlist not initialized")
|
|
}
|
|
return a.watchlist.SetContest(callsign, on)
|
|
}
|
|
|
|
// WatchlistSlotQuery asks whether one spot's slot is worked — against the whole
|
|
// log for a normal entry, against TODAY (UTC) for a contest one.
|
|
type WatchlistSlotQuery struct {
|
|
Call string `json:"call"`
|
|
Band string `json:"band"`
|
|
Mode string `json:"mode"`
|
|
Contest bool `json:"contest"`
|
|
}
|
|
|
|
// WatchlistWorkedSlots answers a batch of slot questions for the tab.
|
|
//
|
|
// Normal entries read the CLUSTER's own slot set — the same cached
|
|
// WorkedCallSlotKeys index that colours the grid — so the watchlist can never
|
|
// contradict the cluster about the same spot. That index normalises the mode
|
|
// exactly as the operator configured (digital grouping on → FT4 counts as FT8's
|
|
// class; off → exact mode), and the first version of this ignored that: it
|
|
// asked the alerts' raw-mode index with a CLASS name, matched nothing, and
|
|
// showed a fully-worked DXpedition as all Needed.
|
|
//
|
|
// Contest entries read TODAY's contacts instead — the midnight-UTC reset is
|
|
// the query's date bound, nothing stored, nothing to reset — normalised through
|
|
// the same function so the two answers use one grammar.
|
|
func (a *App) WatchlistWorkedSlots(queries []WatchlistSlotQuery) []bool {
|
|
out := make([]bool, len(queries))
|
|
if a.qso == nil || len(queries) == 0 {
|
|
return out
|
|
}
|
|
idx := a.clusterStatusMaps()
|
|
norm := func(m string) string {
|
|
m = strings.ToUpper(strings.TrimSpace(m))
|
|
if idx.normMode != nil {
|
|
m = idx.normMode(m)
|
|
}
|
|
return m
|
|
}
|
|
slotKey := func(call, band, mode string) string {
|
|
up := strings.ToUpper(strings.TrimSpace(call))
|
|
b := strings.ToLower(strings.TrimSpace(band))
|
|
if m := norm(mode); m != "" {
|
|
return up + "|" + b + "|" + m
|
|
}
|
|
return up + "|" + b
|
|
}
|
|
// A spot whose mode the band plan could only call "DATA" (an F/H DXpedition
|
|
// off the standard dials, a comment with no mode) cannot be matched exactly:
|
|
// "DATA" is in nobody's log. Such a spot is judged at digital-CLASS grain —
|
|
// worked if ANY digital mode of that call is logged on that band. Claiming
|
|
// NEW MODE because the label differs from FT8 was the reported bug.
|
|
generic := func(m string) bool {
|
|
switch strings.ToUpper(strings.TrimSpace(m)) {
|
|
case "", "DATA", "DIG", "DIGI", "DIGITAL":
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
digKey := func(call, band string) string {
|
|
return strings.ToUpper(strings.TrimSpace(call)) + "|" + strings.ToLower(strings.TrimSpace(band)) + "|DIG"
|
|
}
|
|
needToday := false
|
|
for _, q := range queries {
|
|
if q.Contest {
|
|
needToday = true
|
|
break
|
|
}
|
|
}
|
|
today := map[string]bool{}
|
|
if needToday {
|
|
midnight := time.Now().UTC().Truncate(24 * time.Hour)
|
|
rows, err := a.qso.SlotsSince(a.ctx, midnight)
|
|
if err == nil {
|
|
for _, r := range rows {
|
|
today[slotKey(r.Callsign, r.Band, r.Mode)] = true
|
|
today[digKey(r.Callsign, r.Band)] = qso.ModeClass(r.Mode) == "DIG" || today[digKey(r.Callsign, r.Band)]
|
|
}
|
|
}
|
|
}
|
|
for i, q := range queries {
|
|
if q.Contest {
|
|
if generic(q.Mode) {
|
|
out[i] = today[digKey(q.Call, q.Band)]
|
|
} else {
|
|
out[i] = today[slotKey(q.Call, q.Band, q.Mode)]
|
|
}
|
|
} else if generic(q.Mode) {
|
|
if idx.workedCallSlotsDig != nil {
|
|
_, out[i] = idx.workedCallSlotsDig[digKey(q.Call, q.Band)]
|
|
}
|
|
} else if idx.workedCallSlots != nil {
|
|
_, out[i] = idx.workedCallSlots[slotKey(q.Call, q.Band, q.Mode)]
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// watchSpot runs one live spot through the watchlist: last-seen bookkeeping and
|
|
// the alert, through the SAME event the alert rules fire — the frontend already
|
|
// knows how to toast and sound it, and a second notification path would be a
|
|
// second thing to misconfigure.
|
|
func (a *App) watchSpot(dxCall, band, mode, country, comment string, freqHz int64) {
|
|
if a.watchlist == nil {
|
|
return
|
|
}
|
|
entry, notify, ok := a.watchlist.MarkSeen(dxCall)
|
|
if !ok {
|
|
// Not watched yet — the auto-contest pattern may claim it. Contains, not
|
|
// prefix: the event string sits anywhere in these calls (HB9WWA, F4WWA/P).
|
|
if p := a.GetWatchlistContestPattern(); p != "" &&
|
|
strings.Contains(strings.ToUpper(dxCall), p) {
|
|
if err := a.watchlist.Add(dxCall, true); err == nil {
|
|
applog.Printf("watchlist: auto-added %s (contest pattern %q)", dxCall, p)
|
|
entry, notify, ok = a.watchlist.MarkSeen(dxCall)
|
|
if a.ctx != nil {
|
|
wruntime.EventsEmit(a.ctx, "watchlist:changed")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if !ok || !notify || a.ctx == nil {
|
|
return
|
|
}
|
|
// Already worked? Then nothing to announce. Judged HERE, before the sound —
|
|
// the report was an alert ringing for a slot the tab showed as Worked a
|
|
// moment later, because the frontend's verdict arrives on a debounce while
|
|
// the alert used to fire on the raw spot. Same verdict as the tab: exact
|
|
// slot for named modes, digital class for a generic DATA spot, today-only
|
|
// for a contest entry.
|
|
if e, found := a.watchlist.Get(entry); found {
|
|
if a.WatchlistWorkedSlots([]WatchlistSlotQuery{{Call: dxCall, Band: band, Mode: mode, Contest: e.IsContest}})[0] {
|
|
return
|
|
}
|
|
}
|
|
// Throttled per entry: a DXpedition lights up every skimmer on the planet,
|
|
// and forty alerts a minute for one station is a alarm nobody keeps on.
|
|
a.watchAlertMu.Lock()
|
|
last := a.watchAlertAt[entry]
|
|
now := time.Now()
|
|
if now.Sub(last) < 2*time.Minute {
|
|
a.watchAlertMu.Unlock()
|
|
return
|
|
}
|
|
a.watchAlertAt[entry] = now
|
|
a.watchAlertMu.Unlock()
|
|
wruntime.EventsEmit(a.ctx, "alert:fired", map[string]any{
|
|
"rule": "Watchlist " + entry,
|
|
"call": strings.ToUpper(strings.TrimSpace(dxCall)),
|
|
"band": band,
|
|
"mode": mode,
|
|
"freq_hz": freqHz,
|
|
"country": country,
|
|
"comment": comment,
|
|
"sound": true,
|
|
"visual": true,
|
|
})
|
|
}
|
|
|
|
// startWatchlistClubLog begins the expedition enrichment: every entry's
|
|
// ClubLog block (expedition flag, OQRS, LiveStream, QSO totals and 24 h rate)
|
|
// is refreshed on a rolling schedule — hourly for expeditions, six-hourly for
|
|
// the rest, DXHunter's own cadence. OpsLog's application API key is used, so
|
|
// there is nothing to configure.
|
|
//
|
|
// Two at a time with a breath between requests: the key is shared by every
|
|
// OpsLog install, and a hundred-entry watchlist must read as a trickle at
|
|
// ClubLog's end, not a burst.
|
|
func (a *App) startWatchlistClubLog() {
|
|
if a.watchlist == nil {
|
|
return
|
|
}
|
|
go func() {
|
|
client := &http.Client{Timeout: 30 * time.Second}
|
|
// Let the app finish starting before the first network pass.
|
|
time.Sleep(15 * time.Second)
|
|
for {
|
|
stale := a.watchlist.StaleEntries(1*time.Hour, 6*time.Hour)
|
|
changed := false
|
|
for _, call := range stale {
|
|
// The pattern's BASE is what ClubLog knows: VK9* has no log.
|
|
base := strings.TrimSuffix(call, "*")
|
|
if base == "" {
|
|
continue
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
d, err := watchlist.FetchWatch(ctx, client, clublogAppAPIKey, base)
|
|
cancel()
|
|
if err != nil {
|
|
applog.Printf("watchlist clublog: %s: %v", base, err)
|
|
continue
|
|
}
|
|
if a.watchlist.ApplyClubLog(call, d) {
|
|
changed = true
|
|
if d.IsExpedition {
|
|
applog.Printf("watchlist clublog: %s is a DXpedition (%d QSOs, %d/24h, OQRS=%v)",
|
|
base, func() int {
|
|
if d.ClubLogInfo != nil {
|
|
return d.ClubLogInfo.TotalQSOs
|
|
}
|
|
return 0
|
|
}(), d.QSOs24h(), d.HasOQRS)
|
|
}
|
|
}
|
|
time.Sleep(400 * time.Millisecond)
|
|
}
|
|
if changed && a.ctx != nil {
|
|
wruntime.EventsEmit(a.ctx, "watchlist:changed")
|
|
}
|
|
time.Sleep(10 * time.Minute)
|
|
}
|
|
}()
|
|
}
|