A decoder sends a period's decodes in a burst, and stragglers follow — a deep decode a second behind the rest. The sweeper judged the burst and CLEARED the buffer, so the straggler opened a fresh one under the same period key and was judged on its own: the ladder applied to a handful of late arrivals with the other thirty stations of that period nowhere in sight, and often after the reply to the burst had already put us on the air, where nothing can act on it at all. The buffer now outlives the judgement. A period stays open until a decode stamped with the NEXT slot arrives; a straggler appends to it and the period is judged again, whole. Judged once per period otherwise — acDirty says whether anything new has come in — and the same flag now answers the dead-band case that the cleared buffer used to stand for.
717 lines
26 KiB
Go
717 lines
26 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.acJudged, a.acDirty = 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{}
|
|
a.acJudged, a.acDirty = map[string]string{}, map[string]bool{}
|
|
}
|
|
if prev := a.acPeriod[inst]; prev != "" && prev != key {
|
|
prevAt, prevTR, buf := a.acAt[inst], a.acTR[inst], a.acBuf[inst]
|
|
// Only if something in it has NOT been judged. The buffer now outlives
|
|
// the judgement (see below), so without this the arrival of the next
|
|
// period would judge the previous one a second time on the very same
|
|
// decodes — and act on them, a slot late.
|
|
pending := a.acDirty[inst] || a.acJudged[inst] != prev
|
|
a.acPeriod[inst], a.acAt[inst], a.acTR[inst] = key, d.At, d.TRPeriod
|
|
a.acBuf[inst] = []acDecode{{d: d}}
|
|
a.acFed[inst], a.acDirty[inst] = time.Now(), true
|
|
a.acMu.Unlock()
|
|
// The previous period ends here whatever the sweeper was going to do: a
|
|
// decode stamped with the next slot is proof the old one is over.
|
|
if pending {
|
|
a.autoCallJudge(inst, prev, prevAt, prevTR, buf)
|
|
}
|
|
return
|
|
}
|
|
// APPENDED, never restarted. The buffer survives the period being judged,
|
|
// so a decode that arrives after the others belongs to the same period and
|
|
// is weighed against all of them.
|
|
//
|
|
// It used to be dropped and then judged on its own: the sweeper cleared the
|
|
// buffer, a straggler opened a "new" one under the same key, and the ladder
|
|
// was applied to whatever handful had come late — with the other thirty
|
|
// stations of that period nowhere in sight. A deep decode arriving a second
|
|
// after the burst is exactly the station worth calling.
|
|
a.acPeriod[inst], a.acAt[inst], a.acTR[inst] = key, d.At, d.TRPeriod
|
|
a.acFed[inst], a.acDirty[inst] = time.Now(), true
|
|
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
|
|
}
|
|
// Judged once per period, and again only when something new has come in
|
|
// for it. The period itself is kept open until a decode from the NEXT one
|
|
// arrives, so a straggler is judged with the whole period behind it.
|
|
if a.acJudged[inst] == key && !a.acDirty[inst] {
|
|
continue
|
|
}
|
|
ready = append(ready, due{inst, key, a.acAt[inst], tr, a.acBuf[inst]})
|
|
a.acJudged[inst], a.acDirty[inst] = key, false
|
|
}
|
|
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()
|
|
// Nothing pending for this receiver, and nothing judged for a while: the
|
|
// band has gone quiet under it.
|
|
quiet := !a.acDirty[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
|
|
}
|
|
|
|
chase := a.autoCallChase()
|
|
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, chase)
|
|
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.
|
|
// chaseExtras is what the operator hunts BESIDES entities — the cluster's
|
|
// orthogonal markers, from the same switches that decide whether the badges are
|
|
// shown at all (Settings → DX Cluster). One answer for the eye and the
|
|
// transmitter: a marker withdrawn from the screen is not one to call for.
|
|
type chaseExtras struct{ pota, grid, pfx, county, state bool }
|
|
|
|
func candidateOf(d autocall.Decode, st SpotStatus, ch chaseExtras) autocall.Candidate {
|
|
c := 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,
|
|
}
|
|
// NOTHING LEFT ON THE ENTITY, AND STILL WORTH A CALL.
|
|
//
|
|
// A prefix, a square, a county, a state, a park: never worked, orthogonal to
|
|
// the entity's verdict, and exactly what the operator ticked in the chase
|
|
// settings. They ranked at nothing-needed, so auto-call sat through a
|
|
// never-worked WPX prefix calling CQ and did not answer it.
|
|
//
|
|
// Only when the entity has nothing to add — a new band that is ALSO a new
|
|
// prefix is a new band, and says so.
|
|
if c.Need == autocall.NeedNone {
|
|
switch {
|
|
case ch.pfx && st.NewPfx:
|
|
c.Need, c.Extra, c.Unconfirmed = autocall.NeedExtra, "prefix", st.UnconfPfx
|
|
case ch.county && st.NewCounty:
|
|
c.Need, c.Extra, c.Unconfirmed = autocall.NeedExtra, "county", st.UnconfCty
|
|
case ch.state && st.NewState:
|
|
c.Need, c.Extra, c.Unconfirmed = autocall.NeedExtra, "state", st.UnconfState
|
|
case ch.grid && st.NewGrid:
|
|
c.Need, c.Extra, c.Unconfirmed = autocall.NeedExtra, "square", st.GridState == "unconf"
|
|
case ch.pota && st.NewPOTA:
|
|
c.Need, c.Extra = autocall.NeedExtra, "park"
|
|
}
|
|
}
|
|
return c
|
|
}
|
|
|
|
// autoCallChase reads the chase switches the cluster and the decode list use.
|
|
//
|
|
// Read once per period rather than per decode: they are settings-store reads,
|
|
// and the period loop runs over every station on the band.
|
|
func (a *App) autoCallChase() chaseExtras {
|
|
on := func(key string) bool {
|
|
if a.settings == nil {
|
|
return true
|
|
}
|
|
v, _ := a.settings.Get(a.ctx, "ui.opslog."+key)
|
|
return v != "0" // unset means on, as it does on the screen
|
|
}
|
|
return chaseExtras{
|
|
pota: on("chasePota"),
|
|
grid: on("chaseGrids"),
|
|
pfx: on("chasePfx"),
|
|
county: on("chaseCounty"),
|
|
state: on("chaseState"),
|
|
}
|
|
}
|
|
|
|
// 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()
|
|
}
|
|
}
|