package main // Auto-call — the wiring around internal/autocall. // // The DECISION is in that package, alone and tested. This file does the three // things it cannot do for itself: cut the decode stream into periods, tell it // what the log still needs from each station, and carry out what it decides. // // It lives in the backend rather than in the panel because it keys a // transmitter: it must behave identically whether the FT decodes tab is open, // behind another tab, or the window is minimised — and because every rule it // applies is then a Go test rather than something only the air can check. import ( "fmt" "strconv" "strings" "time" wruntime "github.com/wailsapp/wails/v2/pkg/runtime" "hamlog/internal/applog" "hamlog/internal/autocall" ) const ( keyAutoCallOn = "autocall.enabled" keyAutoCallOnly = "autocall.only" keyAutoCallAttempts = "autocall.attempts" keyAutoCallWatched = "autocall.watched_attempts" keyAutoCallMisses = "autocall.misses" keyAutoCallRounds = "autocall.max_rounds" keyAutoCallRestMin = "autocall.rest_min" keyAutoCallOnScreen = "autocall.on_screen_only" keyAutoCallTrace = "autocall.trace" ) // AutoCallSettings is the panel's shape. Durations are in minutes because that // is what the operator is asked for. type AutoCallSettings struct { Enabled bool `json:"enabled"` Only string `json:"only"` // Attempts / WatchedAttempts: how many calls one station gets before it is // released. The larger allowance is for a callsign on the watch list. Attempts int `json:"attempts"` WatchedAttempts int `json:"watched_attempts"` // Misses: periods in which the station itself transmits, with no decode of // it, before it is given up on. Misses int `json:"misses"` // MaxRounds: how many series of calls one station gets in a session, and // RestMin the pause between two of them. MaxRounds int `json:"max_rounds"` RestMin int `json:"rest_min"` // OnScreenOnly: call only what the decodes panel is showing, so its filters // steer the transmitter as well as the eye. OnScreenOnly bool `json:"on_screen_only"` // Trace writes one line per period to the log: what was on the air, why // each station was refused, and what was decided. For diagnosing "it is not // calling anything" — and it is a line every fifteen seconds, so it is off // unless asked for. Trace bool `json:"trace"` } func (a *App) GetAutoCallSettings() AutoCallSettings { d := autocall.Defaults() num := func(key string, def int) int { n, err := strconv.Atoi(strings.TrimSpace(a.settingOr(key, ""))) if err != nil || n <= 0 { return def } return n } return AutoCallSettings{ // Never on from a stored value alone — see startAutoCall. Enabled: a.settingOr(keyAutoCallOn, "0") == "1", Only: strings.ToUpper(strings.TrimSpace(a.settingOr(keyAutoCallOnly, ""))), Attempts: num(keyAutoCallAttempts, d.Attempts), WatchedAttempts: num(keyAutoCallWatched, d.WatchedAttempts), // On by default: the filters are in front of the operator, and a station // they have hidden is one they have said they do not want. OnScreenOnly: a.settingOr(keyAutoCallOnScreen, "1") == "1", Trace: a.settingOr(keyAutoCallTrace, "0") == "1", Misses: num(keyAutoCallMisses, d.Misses), MaxRounds: num(keyAutoCallRounds, d.MaxRounds), RestMin: num(keyAutoCallRestMin, int(d.Rest/time.Minute)), } } func (a *App) SaveAutoCallSettings(s AutoCallSettings) error { a.setSetting(keyAutoCallOn, map[bool]string{true: "1", false: "0"}[s.Enabled]) a.setSetting(keyAutoCallOnly, strings.ToUpper(strings.TrimSpace(s.Only))) a.setSetting(keyAutoCallOnScreen, map[bool]string{true: "1", false: "0"}[s.OnScreenOnly]) a.setSetting(keyAutoCallTrace, map[bool]string{true: "1", false: "0"}[s.Trace]) for key, v := range map[string]int{ keyAutoCallAttempts: s.Attempts, keyAutoCallWatched: s.WatchedAttempts, keyAutoCallMisses: s.Misses, keyAutoCallRounds: s.MaxRounds, keyAutoCallRestMin: s.RestMin, } { if v > 0 { a.setSetting(key, strconv.Itoa(v)) } } a.applyAutoCall() applog.Printf("autocall: %v (only=%q, %d/%d calls, %d misses, %d rounds)", s.Enabled, s.Only, s.Attempts, s.WatchedAttempts, s.Misses, s.MaxRounds) return nil } // SetAutoCallOnly is the chase-list field in the decodes toolbar. // // Its own binding rather than a settings round-trip: the toolbar knows one // field, and handing back a whole struct it never read is how a Preferences // window left open somewhere quietly reverts a limit that was just changed. func (a *App) SetAutoCallOnly(list string) error { s := a.GetAutoCallSettings() s.Only = list return a.SaveAutoCallSettings(s) } // SetAutoCall is the toolbar switch above the decodes. func (a *App) SetAutoCall(on bool) error { s := a.GetAutoCallSettings() s.Enabled = on return a.SaveAutoCallSettings(s) } // autoCallEngine returns the engine, built on first use. func (a *App) autoCallEngine() *autocall.Engine { a.acMu.Lock() defer a.acMu.Unlock() if a.ac == nil { a.ac = autocall.New(a.autoCallSettings()) } return a.ac } func (a *App) autoCallSettings() autocall.Settings { s := a.GetAutoCallSettings() return autocall.Settings{ Enabled: s.Enabled, Only: s.Only, OnScreenOnly: s.OnScreenOnly, Attempts: s.Attempts, WatchedAttempts: s.WatchedAttempts, Misses: s.Misses, MaxRounds: s.MaxRounds, Rest: time.Duration(s.RestMin) * time.Minute, } } // applyAutoCall pushes the settings into the engine, and clears its state when // the feature is switched off — an operator turning it off is entitled to have // it forget the station it was calling, not resume it half an hour later. func (a *App) applyAutoCall() { e := a.autoCallEngine() s := a.autoCallSettings() e.SetSettings(s) if a.GetAutoCallSettings().Trace { e.SetTrace(func(f string, args ...any) { applog.Printf("autocall: "+f, args...) }) } else { e.SetTrace(nil) } if !s.Enabled { e.Reset() a.acMu.Lock() a.acPeriod, a.acBuf = nil, nil a.acMu.Unlock() } a.emitAutoCall() } // ResetAutoCall is the operator's restart after the engine gave up on an // explicit target: it clears every verdict, including the grey list. func (a *App) ResetAutoCall() { a.autoCallEngine().Reset() a.emitAutoCall() } // HaltAutoCall is the Halt button while a call is in progress. // // It does NOT clear the engine's state, which is what Halt used to do: that // wiped the rests and the rounds along with everything else, so the station the // operator had just stopped was eligible again in the same second and the next // period called it straight back. func (a *App) HaltAutoCall() { call := a.autoCallEngine().Halt() if call != "" { a.acMu.Lock() a.acReason = fmt.Sprintf("%s stopped by the operator — set aside until auto-call is switched off and on", call) a.acMu.Unlock() applog.Printf("autocall: %s", a.acReason) } a.emitAutoCall() } // AutoCallStatus is what the toolbar shows. type AutoCallStatus struct { Enabled bool `json:"enabled"` // Only is the chase list, carried in the status so the field in the decodes // toolbar and the one in Preferences are never two versions of the truth: // whichever is typed into, both show it. Only string `json:"only"` Target string `json:"target"` Calls int `json:"calls"` Max int `json:"max"` Misses int `json:"misses"` MaxMiss int `json:"max_miss"` Stopped bool `json:"stopped"` // Greylisted counts the stations the operator has stopped this session, so // the toolbar can say why a station on the air is never called. Greylisted int `json:"greylisted"` // Reason is the last decision in plain words. An auto-call that is doing // nothing on purpose looks exactly like one that is broken. Reason string `json:"reason"` } func (a *App) GetAutoCallStatus() AutoCallStatus { st := a.autoCallEngine().Status() a.acMu.Lock() reason := a.acReason a.acMu.Unlock() set := a.GetAutoCallSettings() return AutoCallStatus{ Enabled: set.Enabled, Only: set.Only, Target: st.Target, Calls: st.Attempts, Max: st.Max, Misses: st.Misses, MaxMiss: st.MaxMiss, Stopped: st.Stopped, Greylisted: a.autoCallEngine().Greylisted(), Reason: reason, } } func (a *App) emitAutoCall() { if a.ctx == nil { return } wruntime.EventsEmit(a.ctx, "autocall:status", a.GetAutoCallStatus()) } // TakeAutoCallTarget adopts the station the operator has just clicked, so a // manual pick gets the same watchdogs as an automatic one — the click is the // choice of station, not a decision to call it for ever. func (a *App) TakeAutoCallTarget(call, band, mode string) { if !a.GetAutoCallSettings().Enabled { return } call = strings.ToUpper(strings.TrimSpace(call)) if call == "" { return } a.autoCallEngine().Take(autocall.Candidate{ Decode: autocall.Decode{Call: call, Band: band, Mode: mode, At: time.Now().UTC(), IsNew: true}, Need: a.autoCallNeed(call, band, mode), Watched: a.autoCallWatched(call), }) a.emitAutoCall() } // ── The decode stream, cut into periods ─────────────────────────────────── // acDecode is one decode held until its period is complete. type acDecode struct { d autocall.Decode tx bool // the decode is our own transmission echoed back } // autoCallFeed takes one decode from the UDP loop. // // Decodes arrive one datagram at a time and a decision needs the whole period: // the best station in it, and whether the target was there at all. They are // therefore buffered under the period they belong to, and the period is judged // when the next one starts — or, if the band goes quiet, by the sweeper below, // which is what makes "not decoded for three of its periods" reachable when the // answer is that nothing is being decoded at all. func (a *App) autoCallFeed(d autocall.Decode) { if !a.GetAutoCallSettings().Enabled { return } inst := d.Instance key := acPeriodKey(d.At, d.TRPeriod) a.acMu.Lock() if a.acPeriod == nil { a.acPeriod, a.acAt, a.acTR, a.acBuf = map[string]string{}, map[string]time.Time{}, map[string]int{}, map[string][]acDecode{} a.acFed = map[string]time.Time{} } if prev := a.acPeriod[inst]; prev != "" && prev != key { prevAt, prevTR, buf := a.acAt[inst], a.acTR[inst], a.acBuf[inst] a.acPeriod[inst], a.acAt[inst], a.acTR[inst] = key, d.At, d.TRPeriod a.acBuf[inst] = []acDecode{{d: d}} a.acMu.Unlock() a.autoCallJudge(inst, prev, prevAt, prevTR, buf) return } a.acPeriod[inst], a.acAt[inst], a.acTR[inst] = key, d.At, d.TRPeriod a.acFed[inst] = time.Now() a.acBuf[inst] = append(a.acBuf[inst], acDecode{d: d}) a.acMu.Unlock() } // acQuiet is how long a period is left open after its LAST decode arrives. // // This is the whole timing budget of the feature. A decoder finishes a period // and sends its decodes about a second before the next slot opens, so the // answer has to be back before that boundary — a reply that arrives after it // makes the decoder start its call several seconds into the slot, which is what // an operator sees as "it calls late" and what a station on the other end sees // as a message it cannot decode. // // It was a whole slot plus four seconds, measured from the DECODE'S OWN // TIMESTAMP — the start of the period, not the moment it arrived — so the // answer left about four seconds INTO the next slot, every time. // // 800 ms: long enough for a busy period's decodes to arrive together (measured // in bursts of a few hundred milliseconds), short enough to answer inside the // same second they landed. const acQuiet = 800 * time.Millisecond // autoCallSweep closes the periods nothing has closed for us. Called on a timer. // // Per receiver, because with two decoders one may fall silent while the other // is busy — and it is the silent one's period that has to close for a missed // period to be counted at all. func (a *App) autoCallSweep() { if !a.GetAutoCallSettings().Enabled { return } type due struct { inst, key string at time.Time tr int buf []acDecode } var ready []due a.acMu.Lock() for inst, key := range a.acPeriod { if key == "" { continue } tr := a.acTR[inst] if tr <= 0 { tr = 15 } // Measured from when the last decode ARRIVED, not from the period it // belongs to: a decode is stamped with the start of its own slot, so // waiting "a slot plus four seconds" from that stamp is waiting until // the middle of the NEXT slot. See acQuiet. if time.Since(a.acFed[inst]) < acQuiet { continue } ready = append(ready, due{inst, key, a.acAt[inst], tr, a.acBuf[inst]}) delete(a.acPeriod, inst) delete(a.acBuf, inst) } a.acMu.Unlock() for _, d := range ready { a.autoCallJudge(d.inst, d.key, d.at, d.tr, d.buf) } } // autoCallSilence is the empty period. With the band dead, no decode ever // arrives to close the next one, and the target's absence would never be // counted — so a period with nothing in it is still a period. // // Only for the receiver the target is being called on: an idle second decoder // has no periods to miss. func (a *App) autoCallSilence() { if !a.GetAutoCallSettings().Enabled { return } inst, target := a.autoCallEngine().TargetInstance() if target == "" { return } a.acMu.Lock() quiet := a.acPeriod[inst] == "" && time.Since(a.acLastJudge) > 20*time.Second tr := a.acTR[inst] a.acMu.Unlock() if !quiet { return } now := time.Now().UTC() a.autoCallJudge(inst, acPeriodKey(now, tr), now, tr, nil) } func acPeriodKey(at time.Time, trSec int) string { if trSec <= 0 { trSec = 15 } return fmt.Sprintf("%d", at.UTC().Unix()/int64(trSec)) } // autoCallJudge resolves what the log needs from each station in the period, // runs the decision, and carries it out. func (a *App) autoCallJudge(inst, key string, at time.Time, tr int, buf []acDecode) { a.acMu.Lock() a.acLastJudge = time.Now() a.acMu.Unlock() // One status call for the whole period. It reads a cached worked-index, but // it is still per-callsign work and a busy period is thirty of them. seen := map[string]bool{} var q []SpotQuery var uniq []acDecode for _, dd := range buf { k := dd.d.Call + "|" + dd.d.Band + "|" + dd.d.Mode if seen[k] { // The same station twice in one period is one candidate, judged on // its most callable line — the engine's bestOf does that, so both // decodes are kept; only the status lookup is deduplicated. uniq = append(uniq, dd) continue } seen[k] = true uniq = append(uniq, dd) q = append(q, SpotQuery{Call: dd.d.Call, Band: dd.d.Band, Mode: dd.d.Mode}) } status := map[string]SpotStatus{} for _, st := range a.ClusterSpotStatuses(q) { status[st.Call+"|"+st.Band+"|"+st.Mode] = st } cands := make([]autocall.Candidate, 0, len(uniq)) for _, dd := range uniq { st := status[dd.d.Call+"|"+dd.d.Band+"|"+dd.d.Mode] c := candidateOf(dd.d, st) c.Watched = a.autoCallWatched(dd.d.Call) c.Hidden = a.autoCallHidden(dd.d.Call) cands = append(cands, c) } tx := a.autoCallTX() act := a.autoCallEngine().OnPeriod(autocall.Period{ Instance: inst, Key: key, At: at, TRPeriod: tr, Decodes: cands, TX: tx, MyCall: a.opCall, }) a.autoCallDo(act) } // candidateOf turns one decode and the log's verdict on it into a candidate. // // Split out and kept pure because ONE line of it was wrong for weeks and // nothing could catch it: the entity's verdict was read as the station's. func candidateOf(d autocall.Decode, st SpotStatus) autocall.Candidate { return autocall.Candidate{ Decode: d, // The ENTITY's verdict decides what is still needed… Need: autoCallNeedOf(st.Status), // …and THIS CALLSIGN on this band and mode decides whether calling it // would be a duplicate. // // Status was used for both. "worked" there means the COUNTRY is in the // log on this band and mode, so on a band where the operator has most of // them, nearly every station on the air was refused as already worked — // a trace of one evening shows twenty decodes out of twenty-one turned // away that way, the watched DXpedition among them. Worked: st.WorkedSlot, // The need exists only because a QSL never came: worth chasing, and worth // less than the same need never worked at all. Unconfirmed: st.UnconfStatus, } } // autoCallDo carries out a decision and records it. func (a *App) autoCallDo(act autocall.Action) { if act.Reason != "" { a.acMu.Lock() a.acReason = act.Reason a.acMu.Unlock() applog.Printf("autocall: %s", act.Reason) } switch act.Kind { case autocall.DoReply: d := act.Decode if err := a.AnswerDecode(d.Instance, d.Ms, d.SNR, d.DT, d.AudioHz, d.ModeRaw, d.MsgRaw, d.LowConf); err != nil { applog.Printf("autocall: the call to %s could not be sent: %v", d.Call, err) } case autocall.DoHalt: // Soft: let the over finish, then stop transmitting. Hard: stop now. // The engine decides — see Action.Soft. if err := a.HaltDecodeTx("", act.Soft); err != nil { applog.Printf("autocall: halt failed: %v", err) } } if act.Kind != autocall.DoNothing || act.Reason != "" { a.emitAutoCall() } } // autoCallNoteTX is the attempt counter, fed from the decoder's own status. // // ONCE PER TRANSMIT PERIOD. Status arrives every second and says "transmitting" // throughout the over, so counting each one would spend the whole allowance of // seven calls inside a single fifteen-second slot — the counter has to measure // transmissions, not seconds of carrier. func (a *App) autoCallNoteTX(tx autocall.TXState) { if !a.GetAutoCallSettings().Enabled { return } a.acMu.Lock() // Per receiver as well as per period: in a split view both decoders report // their own transmissions, and one key for both would let the second one's // carrier swallow the first one's count. key := tx.Instance + "|" + acPeriodKey(time.Now().UTC(), a.acTR[tx.Instance]) if a.acTXPeriod == key { a.acMu.Unlock() return } a.acTXPeriod = key a.acMu.Unlock() a.autoCallDo(a.autoCallEngine().NoteTX(tx)) } // autoCallTX is the last transmit state reported, as the engine wants it. func (a *App) autoCallTX() autocall.TXState { a.acMu.Lock() defer a.acMu.Unlock() return a.acTX } func (a *App) autoCallSetTX(tx autocall.TXState) { a.acMu.Lock() a.acTX = tx a.acMu.Unlock() } // SetAutoCallVisible is the decodes panel saying what it is SHOWING. // // The panel owns the filters and therefore owns the answer: reimplementing them // here would give the screen and the transmitter two definitions of the same // word, which is how they end up disagreeing. It sends the callsigns that // survive its filters, and the engine calls nothing else. // // An empty list with active=false means "no filtering in force" — the panel was // closed, or has never been opened this session — and the ladder decides alone. func (a *App) SetAutoCallVisible(calls []string, active bool) { set := make(map[string]bool, len(calls)) for _, c := range calls { if c = strings.ToUpper(strings.TrimSpace(c)); c != "" { set[c] = true } } a.acMu.Lock() a.acVisible, a.acVisibleOn = set, active a.acMu.Unlock() } // autoCallHidden reports whether the panel's filters are keeping a station off // the screen. Unknown when nothing is being published: not hidden. func (a *App) autoCallHidden(call string) bool { a.acMu.Lock() defer a.acMu.Unlock() if !a.acVisibleOn { return false } return !a.acVisible[strings.ToUpper(strings.TrimSpace(call))] } // autoCallNeedOf maps the cluster's own status vocabulary onto the ladder. One // vocabulary for both, so a station that reads NEW BAND in the decodes list is // the same NEW BAND the auto-call ranks — two answers to one question is how // the panel and the caller quietly start disagreeing. func autoCallNeedOf(status string) autocall.Need { switch status { case "new": return autocall.NeedDXCC case "new-band-mode", "new-band": // New on both counts is at least a new band, and it is the better catch // of the two — it must not fall below a plain new band. return autocall.NeedBand case "new-mode": return autocall.NeedMode case "new-slot": return autocall.NeedSlot } return autocall.NeedNone } func (a *App) autoCallNeed(call, band, mode string) autocall.Need { st := a.ClusterSpotStatuses([]SpotQuery{{Call: call, Band: band, Mode: mode}}) if len(st) == 0 { return autocall.NeedNone } return autoCallNeedOf(st[0].Status) } // autoCallWatched asks the watch list, which is the same list the spot alerts // and the cluster colouring use. func (a *App) autoCallWatched(call string) bool { if a.watchlist == nil { return false } _, ok := a.watchlist.Match(strings.ToUpper(strings.TrimSpace(call))) return ok } // startAutoCall arms the engine at launch. // // The stored "on" is honoured, and the engine starts with NO target: a program // that came up already calling a station chosen before the last shutdown is not // something an operator can be expected to anticipate. func (a *App) startAutoCall() { a.applyAutoCall() go a.autoCallLoop() } func (a *App) autoCallLoop() { // A quarter of a second. The sweeper is what closes a period, so its tick is // part of the same budget as acQuiet: a two-second tick added up to two // seconds of its own to every answer, which is most of the margin there is. // The work per tick is a map read. t := time.NewTicker(250 * time.Millisecond) defer t.Stop() for range t.C { if a.ctx == nil { return } a.autoCallSweep() a.autoCallSilence() } }