Six faults from an evening on 60 m, all in the same family: the engine judging a station by what the log wants from it and forgetting what is already under way. - An exchange was abandoned mid-QSO. The reply lands in the same period the ladder is re-read, and that period was judged before the reply was taken into account, so a better-ranked caller took the slot from a station that had just come back to us. The answer is settled first now, and our own report counts as being inside the exchange too — which also protects a QSO the operator started by hand. - A station just picked started with misses against it. Its transmit slot was unknown until a second decode, and with the parity unknown every period counted, including the one spent transmitting to it. - The freed slot after "it is working somebody else" was thrown away: the period's decodes are in hand, so the next station is picked from them rather than fifteen seconds later. Never mid-over. - Auto-call is never armed from a stored setting — not at launch, not on a profile switch. It is the one feature that puts the station on the air by itself and OpsLog starts with Windows. - It says what it is waiting for: a wanted station in a QSO with somebody else now shows beside the Auto button instead of looking idle. - Switching profile left the previous logbook's verdicts on screen. The worked-index, chase-new and the frontend's cached verdicts are dropped when the logbook changes. FT decodes: distance column, a message addressed to you set whole in green (the station you are calling keeps a tint — most of what it sends goes to other people), badge order L / Wkd / WL, list cleared when the RIG changes band. Rotor: new world-map compass from EC1KD's design, with the Ultrabeam boom and second lobe restored and the compact form preserved; the classic dial is kept and Settings → Rotator chooses between them. Stop no longer flickers on a rotor standing still — movement was inferred from a degree, less than the jitter a controller reports at rest.
636 lines
22 KiB
Go
636 lines
22 KiB
Go
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"`
|
|
// Waiting: what it would call if that station were not in a QSO.
|
|
Waiting string `json:"waiting"`
|
|
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, Waiting: st.Waiting, 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 starts the engine's loop, with auto-call OFF.
|
|
//
|
|
// It is never on from a stored value. This is the one setting in the program
|
|
// that puts the station on the air by itself, and the operator who left it on
|
|
// last night is not necessarily the one at the desk now — nor necessarily at
|
|
// the desk at all: OpsLog starts with Windows, and a rig that powers up with it
|
|
// would begin calling into an empty shack, on whatever band the radio happens
|
|
// to be on, hours after anybody decided that was a good idea.
|
|
//
|
|
// Arming it is one click, and it is a click somebody has to make.
|
|
func (a *App) startAutoCall() {
|
|
a.disarmAutoCall("launch")
|
|
go a.autoCallLoop()
|
|
}
|
|
|
|
// disarmAutoCall switches auto-call off and writes that down.
|
|
//
|
|
// The setting is what the toolbar and the settings panel both read, so turning
|
|
// the engine off without storing it would show a lit switch over a silent
|
|
// transmitter — and the operator's next click, meaning "on", would send "off".
|
|
func (a *App) disarmAutoCall(why string) {
|
|
if a.settingOr(keyAutoCallOn, "0") == "1" {
|
|
applog.Printf("autocall: off at %s — it is never armed from a stored setting", why)
|
|
}
|
|
a.setSetting(keyAutoCallOn, "0")
|
|
a.applyAutoCall()
|
|
}
|
|
|
|
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()
|
|
}
|
|
}
|