The watch list was a tab, and an operator working FT8 lives on the decodes one: a station they had asked to be told about turned up on a screen they were not looking at. The same answer is now docked in the widget strip, above the tabs, reduced to what is worth acting on — on the air and still needed, one row per band and mode, with the cluster's own NEW DXCC / NEW BAND / NEW SLOT badge and a click that tunes. Off by default. The "active and needed" answer costs a debounced query per visible slot, so it is written once (lib/watchlistSpots) and the tab uses it too. Auto-call: - It answers a new prefix, county, state, square or park. Those markers are orthogonal to the entity, they ranked as nothing-needed, and the engine sat through a never-worked WPX prefix calling CQ. New rung at the foot of the ladder, gated by the chase switches the badges use — which meant making those switches portable, since the backend cannot read localStorage. - It calls THROUGH a pileup. Giving up the moment the DX answered somebody else is precisely how a queue is not worked; the call and miss counters already bound the effort, and a station in mid-exchange is still never chosen as a new target. The PSK Reporter panel now follows the station auto-call is waiting for: the analysis takes a history query and a period or two to fill, so starting it when the DX comes free is starting it too late. Callbook lookup: a compound callsign with a page of its OWN keeps that page's location. QRZ files HP/WE9G under exactly that form, with the Panama square the station is operating from, and the rule that drops a home address from a portable call was throwing it away. The record's own country tells an operation's page from a home page. Changelog: entries may open with [NEW], drawn as a pill in the What's new dialog — a release is mostly fixes and the two or three genuinely new things should not have to be found by reading all of it.
689 lines
24 KiB
Go
689 lines
24 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
|
|
}
|
|
|
|
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()
|
|
}
|
|
}
|