771 lines
26 KiB
Go
771 lines
26 KiB
Go
// Package autocall answers FT8/FT4 decodes without the operator clicking them.
|
|
//
|
|
// THIS KEYS THE TRANSMITTER ON ITS OWN, which is why the decision lives here,
|
|
// in one place, as a function of state that can be read, argued with and
|
|
// tested — rather than spread through a panel that only runs while its tab is
|
|
// open. Everything below is written as a series of refusals: a call goes out
|
|
// only when nothing says it should not.
|
|
//
|
|
// ── What it is for ────────────────────────────────────────────────────────
|
|
// On a busy band the operator cannot read twenty decodes, judge each against
|
|
// the log and click the right one inside a fifteen-second slot. This does that
|
|
// part: it ranks what is on the air by what the log still needs, calls the best
|
|
// one, and — the harder half — knows when to STOP calling it.
|
|
//
|
|
// ── The ladder ────────────────────────────────────────────────────────────
|
|
// A watched callsign outranks the same need from anybody else, at every level:
|
|
//
|
|
// WL new DXCC > new DXCC > WL new band > new band > WL new mode > new mode
|
|
// > WL new slot > new slot > watched with nothing needed
|
|
//
|
|
// Watching a callsign is the operator saying "this one matters more than the
|
|
// rule", so it lifts a station by one rung rather than jumping the whole
|
|
// ladder: a watched new-slot must not outrank a new entity that will not come
|
|
// back.
|
|
//
|
|
// ── Why it stops ──────────────────────────────────────────────────────────
|
|
// The failure that matters is not calling the wrong station, it is calling one
|
|
// station for ever. Four independent brakes, any of which releases the target:
|
|
//
|
|
// - attempts: 7 calls, or 15 for a watched callsign
|
|
// - misses: 3 periods IN WHICH IT TRANSMITS with no decode of it
|
|
// - the clock: a target held longer than MaxHold is dropped whatever the
|
|
// counters say, because a counter that never advances never trips
|
|
// - rounds: a callsign released this way is rested, and after MaxRounds
|
|
// series it is parked for the session
|
|
//
|
|
// A released station is not banned. It can be picked again once rested, but
|
|
// only while nothing of HIGHER rank is callable — which is what keeps "seven
|
|
// calls, then straight back to the same station" from being fifty calls.
|
|
//
|
|
// ── Who is callable ───────────────────────────────────────────────────────
|
|
// A station in the middle of an exchange with somebody else is never targeted.
|
|
// It cannot answer, and WSJT-X and JTDX ignore a reply to it anyway; only the
|
|
// station calling CQ, calling US, or sending its last frame is worth a slot.
|
|
package autocall
|
|
|
|
import (
|
|
"fmt"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
"unicode"
|
|
)
|
|
|
|
// Need is what the log still wants from a station, worst to best.
|
|
type Need int
|
|
|
|
const (
|
|
NeedNone Need = iota
|
|
NeedSlot // entity worked on this band and in this mode, never together
|
|
NeedMode // entity never worked in this mode
|
|
NeedBand // entity never worked on this band
|
|
NeedDXCC // entity never worked at all
|
|
)
|
|
|
|
func (n Need) String() string {
|
|
switch n {
|
|
case NeedDXCC:
|
|
return "DXCC"
|
|
case NeedBand:
|
|
return "band"
|
|
case NeedMode:
|
|
return "mode"
|
|
case NeedSlot:
|
|
return "slot"
|
|
}
|
|
return "-"
|
|
}
|
|
|
|
// Decode is one line from the decoder, carrying both what the decision needs
|
|
// and what a reply has to send back untouched.
|
|
type Decode struct {
|
|
Call string
|
|
Band string
|
|
Mode string
|
|
Msg string
|
|
SNR int
|
|
At time.Time
|
|
TRPeriod int // slot length in seconds; 0 means "work it out from the mode"
|
|
Instance string
|
|
CQ bool
|
|
|
|
// Replay of the decoder's own line. WSJT-X matches a Reply against its
|
|
// decode list field for field, so these are passed through unread.
|
|
Ms uint32
|
|
DT float64
|
|
AudioHz int64
|
|
ModeRaw string
|
|
MsgRaw string
|
|
LowConf bool
|
|
|
|
// IsNew is false for a decode the sender replayed from its history. Shown
|
|
// in the panel, never answered: the station may have gone hours ago.
|
|
IsNew bool
|
|
}
|
|
|
|
// Candidate is a decode with the log's verdict on it attached.
|
|
type Candidate struct {
|
|
Decode
|
|
Need Need
|
|
Watched bool
|
|
// Worked is "already in the log on this band AND mode". Nothing is gained
|
|
// by calling it again — and while chasing confirmations it is the trap that
|
|
// makes the same station be called all evening, because an unconfirmed QSO
|
|
// leaves the entity flagged as still wanted.
|
|
Worked bool
|
|
}
|
|
|
|
// TXState is what the decoding application says it is doing.
|
|
type TXState struct {
|
|
Transmitting bool
|
|
Enabled bool // its own Enable Tx: nothing we send transmits while false
|
|
DXCall string
|
|
Msg string
|
|
Instance string
|
|
}
|
|
|
|
// Settings are the operator's.
|
|
type Settings struct {
|
|
Enabled bool
|
|
// Only is the "chase these callsigns" field: one or more, separated by
|
|
// spaces or commas. Filled, nothing else is ever called — the ladder still
|
|
// orders the ones listed, so "VP6D 3Y0J" calls whichever of the two is on
|
|
// the air and takes the better catch when both are. Wildcards are the watch
|
|
// list's job; this is a hunt list for right now.
|
|
//
|
|
// The brakes are unchanged, and hitting one stops the feature rather than
|
|
// moving on to somebody else — an explicit request deserves an explicit
|
|
// restart.
|
|
Only string
|
|
// Attempts before a target is released, and the larger allowance for a
|
|
// watched callsign.
|
|
Attempts int
|
|
WatchedAttempts int
|
|
// Misses is how many of the station's OWN transmit periods may pass with no
|
|
// decode of it before it is given up on.
|
|
Misses int
|
|
// Rest is how long a released callsign waits before it can be picked again,
|
|
// and MaxRounds how many such series it gets before being parked for the
|
|
// session.
|
|
Rest time.Duration
|
|
MaxRounds int
|
|
// MaxHold is the wall-clock backstop on one target.
|
|
MaxHold time.Duration
|
|
}
|
|
|
|
// Defaults are what the operator gets before touching anything.
|
|
func Defaults() Settings {
|
|
return Settings{
|
|
Attempts: 7, WatchedAttempts: 15, Misses: 3,
|
|
Rest: 2 * time.Minute, MaxRounds: 3, MaxHold: 4 * time.Minute,
|
|
}
|
|
}
|
|
|
|
func (s Settings) withDefaults() Settings {
|
|
d := Defaults()
|
|
if s.Attempts <= 0 {
|
|
s.Attempts = d.Attempts
|
|
}
|
|
if s.WatchedAttempts <= 0 {
|
|
s.WatchedAttempts = d.WatchedAttempts
|
|
}
|
|
if s.Misses <= 0 {
|
|
s.Misses = d.Misses
|
|
}
|
|
if s.Rest <= 0 {
|
|
s.Rest = d.Rest
|
|
}
|
|
if s.MaxRounds <= 0 {
|
|
s.MaxRounds = d.MaxRounds
|
|
}
|
|
if s.MaxHold <= 0 {
|
|
s.MaxHold = d.MaxHold
|
|
}
|
|
return s
|
|
}
|
|
|
|
// ActionKind is what the caller must do about the decision.
|
|
type ActionKind int
|
|
|
|
const (
|
|
DoNothing ActionKind = iota
|
|
DoReply // answer this decode
|
|
DoHalt // stop the decoder transmitting
|
|
)
|
|
|
|
// Action is the decision, with the reason in plain words. The reason is not
|
|
// decoration: it is the only way an operator can tell an auto-call that is
|
|
// working from one that is stuck, and it goes to the log line by line.
|
|
type Action struct {
|
|
Kind ActionKind
|
|
Decode Decode
|
|
Reason string
|
|
}
|
|
|
|
// Engine holds the state between periods. Not safe for concurrent use; the
|
|
// caller owns the serialisation, which in OpsLog is the UDP event loop.
|
|
type Engine struct {
|
|
set Settings
|
|
|
|
// target is the station being called, held as the decode last seen of it so
|
|
// a reply always carries a fresh timestamp.
|
|
target *Candidate
|
|
// targetInst is the receiver the target was picked on, so the brakes are
|
|
// counted against ITS periods and its transmissions.
|
|
targetInst string
|
|
// heldSince is when this station BECAME the target, and is never refreshed
|
|
// while it stays one. It was, and that quietly disabled the clock backstop:
|
|
// a station decoded every period for ever kept pushing its own deadline
|
|
// forward, which is exactly the case the backstop exists for.
|
|
heldSince time.Time
|
|
attempts int
|
|
misses int
|
|
lastMiss string // period already counted, so one period counts once
|
|
txSlot int // which of the two slots the target transmits in; -1 unknown
|
|
stopped bool // an explicit "Only" target gave up: needs a restart
|
|
stoppedOn string
|
|
|
|
// rested says when a released callsign may be considered again, and rounds
|
|
// how many series it has already had this session.
|
|
rested map[string]time.Time
|
|
rounds map[string]int
|
|
// done is every station the exchange was completed with this session.
|
|
//
|
|
// The log is the authority on what is worked, and it is SLOW: the QSO is
|
|
// logged when the operator (or the watcher) gets to it, several periods
|
|
// later. In between, the station we have just worked is still flagged as a
|
|
// new entity and is still on the air sending its 73 — which read as a
|
|
// perfectly good station to call, and the engine called it straight back.
|
|
done map[string]bool
|
|
}
|
|
|
|
func New(s Settings) *Engine {
|
|
return &Engine{set: s.withDefaults(), txSlot: -1,
|
|
rested: map[string]time.Time{}, rounds: map[string]int{}, done: map[string]bool{}}
|
|
}
|
|
|
|
// SetSettings swaps the settings in place. A target already being called is
|
|
// kept: the operator adjusting a limit is not asking to abandon the QSO in
|
|
// flight, and switching the feature off is a separate call.
|
|
func (e *Engine) SetSettings(s Settings) { e.set = s.withDefaults() }
|
|
|
|
// Target is the callsign being called, for the panel. Empty when idle.
|
|
func (e *Engine) Target() string {
|
|
if e.target == nil {
|
|
return ""
|
|
}
|
|
return e.target.Call
|
|
}
|
|
|
|
// Status is what the panel shows: who, how far into the brakes, and whether
|
|
// the engine has given up and is waiting for the operator.
|
|
type Status struct {
|
|
Target string `json:"target"`
|
|
Attempts int `json:"attempts"`
|
|
Max int `json:"max"`
|
|
Misses int `json:"misses"`
|
|
MaxMiss int `json:"max_miss"`
|
|
Stopped bool `json:"stopped"`
|
|
StoppedOn string `json:"stopped_on"`
|
|
}
|
|
|
|
func (e *Engine) Status() Status {
|
|
st := Status{Misses: e.misses, MaxMiss: e.set.Misses, Stopped: e.stopped, StoppedOn: e.stoppedOn}
|
|
if e.target != nil {
|
|
st.Target = e.target.Call
|
|
st.Attempts = e.attempts
|
|
st.Max = e.maxAttempts(*e.target)
|
|
}
|
|
return st
|
|
}
|
|
|
|
// Reset clears everything except the settings — the operator's Halt, a profile
|
|
// change, or switching the feature off and on again.
|
|
func (e *Engine) Reset() {
|
|
e.target, e.attempts, e.misses, e.txSlot = nil, 0, 0, -1
|
|
e.targetInst = ""
|
|
e.lastMiss, e.stopped, e.stoppedOn = "", false, ""
|
|
e.rested, e.rounds = map[string]time.Time{}, map[string]int{}
|
|
e.done = map[string]bool{}
|
|
}
|
|
|
|
// Take hands the operator's own click to the engine: the station they picked
|
|
// becomes the target, with the counters restarted. Everything after it — the
|
|
// brakes, the hand-off at the end of the QSO — then applies as usual.
|
|
func (e *Engine) Take(c Candidate) {
|
|
e.target, e.targetInst = &c, c.Instance
|
|
e.heldSince = c.At
|
|
e.attempts, e.misses, e.txSlot, e.lastMiss = 0, 0, -1, ""
|
|
e.stopped, e.stoppedOn = false, ""
|
|
}
|
|
|
|
// maxAttempts is the allowance for one target: larger for a watched callsign,
|
|
// because that is the operator saying this one is worth the extra slots.
|
|
func (e *Engine) maxAttempts(c Candidate) int {
|
|
if c.Watched {
|
|
return e.set.WatchedAttempts
|
|
}
|
|
return e.set.Attempts
|
|
}
|
|
|
|
// rank is the ladder, as one number. Watched lifts a station by one rung, and
|
|
// a watched station with nothing needed sits at the bottom rather than being
|
|
// ineligible: the operator asked for that callsign.
|
|
//
|
|
// 9 WL DXCC 8 DXCC 7 WL band 6 band
|
|
// 5 WL mode 4 mode 3 WL slot 2 slot 1 watched, nothing needed
|
|
// 0 nothing to call for
|
|
func rank(c Candidate) int {
|
|
if c.Need == NeedNone {
|
|
if c.Watched {
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|
|
r := int(c.Need) * 2 // slot 2, mode 4, band 6, DXCC 8
|
|
if c.Watched {
|
|
r++
|
|
}
|
|
return r
|
|
}
|
|
|
|
// onlyList splits the chase field. Space or comma, either way: an operator
|
|
// typing a list types it the way they think of it, and a field that silently
|
|
// ignores half of what was entered is worse than one that refuses it.
|
|
func onlyList(field string) []string {
|
|
out := []string{}
|
|
for _, tok := range strings.FieldsFunc(strings.ToUpper(field), func(r rune) bool {
|
|
return r == ',' || r == ';' || unicode.IsSpace(r)
|
|
}) {
|
|
if tok = strings.TrimSpace(tok); tok != "" {
|
|
out = append(out, tok)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func inList(list []string, call string) bool {
|
|
for _, c := range list {
|
|
if c == call {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
var gridRe = regexp.MustCompile(`^[A-R]{2}[0-9]{2}([A-X]{2})?$`)
|
|
|
|
// isGrid is the grid test with the exchange tokens taken out first.
|
|
//
|
|
// "RR73" IS a valid Maidenhead square by the pattern — two letters in A-R, two
|
|
// digits — and it is also the second most common thing to find in that position
|
|
// of a message. Counting it as a fresh call spent an attempt on every QSO the
|
|
// engine actually completed, which is precisely backwards: a station that
|
|
// answers should cost nothing.
|
|
func isGrid(tok string) bool {
|
|
switch tok {
|
|
case "RR73", "RRR", "73", "RR", "R":
|
|
return false
|
|
}
|
|
return gridRe.MatchString(tok)
|
|
}
|
|
|
|
// callable reports whether a station can be answered RIGHT NOW.
|
|
//
|
|
// A station in mid-exchange is committed to somebody else: it will not answer,
|
|
// and WSJT-X and JTDX refuse to act on a reply to it at all — so calling it is
|
|
// at best a wasted slot and at worst the operator watching an auto-call that
|
|
// appears to do nothing. CQ, a message addressed to us, and the final frame of
|
|
// somebody else's QSO (the station is free from the next period) are the three
|
|
// states worth a call.
|
|
func callable(c Candidate, myCall string) bool {
|
|
if c.CQ {
|
|
return true
|
|
}
|
|
toks := strings.Fields(strings.ToUpper(strings.TrimSpace(c.Msg)))
|
|
if len(toks) == 0 {
|
|
return false // nothing to read: assume it is busy rather than call blind
|
|
}
|
|
if myCall != "" && toks[0] == strings.ToUpper(myCall) {
|
|
return true // it is calling us
|
|
}
|
|
switch toks[len(toks)-1] {
|
|
case "RR73", "RRR", "73":
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// callingUs is the stronger half of callable: the station has our callsign in
|
|
// its message, so it has heard us and is waiting for an answer.
|
|
func callingUs(c Candidate, myCall string) bool {
|
|
if myCall == "" || c.CQ {
|
|
return false
|
|
}
|
|
toks := strings.Fields(strings.ToUpper(strings.TrimSpace(c.Msg)))
|
|
return len(toks) > 0 && toks[0] == strings.ToUpper(myCall)
|
|
}
|
|
|
|
// finished reports that the exchange with this station is over: it sent us the
|
|
// last frame. Read from ITS message rather than from our own transmit state,
|
|
// which says only what we did.
|
|
func finished(decodes []Candidate, call, myCall string) bool {
|
|
if myCall == "" {
|
|
return false
|
|
}
|
|
me := strings.ToUpper(myCall)
|
|
call = strings.ToUpper(call)
|
|
for _, c := range decodes {
|
|
if strings.ToUpper(c.Call) != call {
|
|
continue
|
|
}
|
|
toks := strings.Fields(strings.ToUpper(strings.TrimSpace(c.Msg)))
|
|
if len(toks) < 2 || toks[0] != me {
|
|
continue
|
|
}
|
|
switch toks[len(toks)-1] {
|
|
case "RR73", "RRR", "73":
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// slotOf is which of the two alternating slots a moment falls in. FT8 stations
|
|
// transmit in one and listen in the other, so a station is legitimately absent
|
|
// half the time — counting that as a miss dropped a perfectly workable station
|
|
// after two unlucky periods.
|
|
func slotOf(at time.Time, trSec int) int {
|
|
if trSec <= 0 {
|
|
trSec = 15
|
|
}
|
|
return int((at.UTC().Unix() / int64(trSec)) % 2)
|
|
}
|
|
|
|
// Period is one slot's worth of decodes, with the state of the world around it.
|
|
type Period struct {
|
|
// Instance is the receiver this period came from. With two decoders running
|
|
// — the split view, two bands — their slots are separate: a station absent
|
|
// from the OTHER receiver's period says nothing about the one being called
|
|
// on this one, and counting it as a miss dropped a target that was being
|
|
// decoded perfectly well.
|
|
Instance string
|
|
// Key identifies the period, so a handler that runs twice for one slot
|
|
// cannot count the same miss twice.
|
|
Key string
|
|
At time.Time
|
|
TRPeriod int
|
|
Decodes []Candidate
|
|
TX TXState
|
|
MyCall string
|
|
}
|
|
|
|
// OnPeriod is the decision, taken once per receive period.
|
|
func (e *Engine) OnPeriod(p Period) Action {
|
|
if !e.set.Enabled {
|
|
return Action{}
|
|
}
|
|
// ONE station at a time, on the receiver it is being called on.
|
|
//
|
|
// This is the guarantee with two decoders running: while a target is held,
|
|
// a period from any OTHER receiver is not even looked at. It cannot start a
|
|
// second QSO over the top of the one in progress, however much better the
|
|
// station it hears is, and its periods count no missed periods against a
|
|
// target that was never on its band.
|
|
if e.target != nil && e.targetInst != "" && p.Instance != "" && p.Instance != e.targetInst {
|
|
return Action{}
|
|
}
|
|
|
|
// ── An existing target ────────────────────────────────────────────────
|
|
if e.target != nil {
|
|
t := *e.target
|
|
tc := strings.ToUpper(t.Call)
|
|
|
|
// The exchange is over — either the station sent us its last frame, or
|
|
// the log now holds it. Hand straight on to the next station in the same
|
|
// period rather than waiting for the next one: the slot is the resource.
|
|
if finished(p.Decodes, tc, p.MyCall) || workedNow(p.Decodes, tc) {
|
|
e.release(tc, false)
|
|
// Straight on to the next station, list or no list: pick() is what
|
|
// knows the chase list, and with one on it the answer is simply the
|
|
// next callsign there — a list of two DXpeditions must not stop
|
|
// after the first.
|
|
return e.pick(p, fmt.Sprintf("QSO with %s finished", tc))
|
|
}
|
|
|
|
// The clock backstop. Every counter below depends on decodes arriving in
|
|
// a particular shape; this one does not depend on anything.
|
|
if !e.heldSince.IsZero() && p.At.Sub(e.heldSince) > e.set.MaxHold {
|
|
e.giveUp(tc, t)
|
|
return Action{Kind: DoHalt, Reason: fmt.Sprintf("%s held for %s with nothing to show for it", tc, e.set.MaxHold)}
|
|
}
|
|
|
|
if seen := bestOf(p.Decodes, tc); seen != nil {
|
|
// The decode is refreshed so a reply carries a current timestamp; the
|
|
// hold clock is NOT — see heldSince.
|
|
e.target = seen
|
|
e.misses, e.lastMiss = 0, ""
|
|
e.txSlot = slotOf(p.At, periodSecs(p, *seen))
|
|
// In QSO: the decoder is sequencing the exchange on its own and the
|
|
// attempt counter has done its job. Interrupting it with another
|
|
// reply is how two transmissions land in one slot.
|
|
if callingUs(*seen, p.MyCall) {
|
|
e.attempts = 0
|
|
}
|
|
return Action{}
|
|
}
|
|
|
|
// Absent. Only its OWN transmit periods count against it.
|
|
if e.txSlot >= 0 && slotOf(p.At, periodSecs(p, t)) != e.txSlot {
|
|
return Action{}
|
|
}
|
|
if e.lastMiss == p.Key {
|
|
return Action{}
|
|
}
|
|
e.lastMiss = p.Key
|
|
e.misses++
|
|
if e.misses >= e.set.Misses {
|
|
e.giveUp(tc, t)
|
|
return Action{Kind: DoHalt, Reason: fmt.Sprintf("%s not decoded for %d of its own periods", tc, e.set.Misses)}
|
|
}
|
|
return Action{}
|
|
}
|
|
|
|
// ── No target ─────────────────────────────────────────────────────────
|
|
if e.stopped {
|
|
return Action{} // an explicit target gave up; the operator restarts it
|
|
}
|
|
// Never start a call over a transmission in progress: the reply would land
|
|
// in a slot the decoder is already using.
|
|
if p.TX.Transmitting {
|
|
return Action{}
|
|
}
|
|
return e.pick(p, "")
|
|
}
|
|
|
|
// pick chooses the best station on the air and answers it.
|
|
func (e *Engine) pick(p Period, why string) Action {
|
|
only := onlyList(e.set.Only)
|
|
var best *Candidate
|
|
var bestRank int
|
|
// bestRank of everything eligible, rested or not: a station that is resting
|
|
// may only be re-picked while nothing better is on the air, and that is the
|
|
// comparison.
|
|
topRank := 0
|
|
for i := range p.Decodes {
|
|
c := p.Decodes[i]
|
|
if !e.eligible(c, p, only) {
|
|
continue
|
|
}
|
|
if r := rank(c); r > topRank {
|
|
topRank = r
|
|
}
|
|
}
|
|
for i := range p.Decodes {
|
|
c := p.Decodes[i]
|
|
if !e.eligible(c, p, only) {
|
|
continue
|
|
}
|
|
r := rank(c)
|
|
if resting, ok := e.rested[strings.ToUpper(c.Call)]; ok && p.At.Before(resting) {
|
|
// Rested: allowed back only when it is the best thing there is.
|
|
// Anything of higher rank takes the slot instead.
|
|
if r < topRank {
|
|
continue
|
|
}
|
|
}
|
|
if best == nil || better(c, r, *best, bestRank, p.MyCall) {
|
|
cc := c
|
|
best, bestRank = &cc, r
|
|
}
|
|
}
|
|
if best == nil {
|
|
if why != "" {
|
|
return Action{Kind: DoNothing, Reason: why + " — nothing else worth calling"}
|
|
}
|
|
return Action{}
|
|
}
|
|
e.target, e.heldSince, e.targetInst = best, p.At, best.Instance
|
|
e.attempts, e.misses, e.txSlot, e.lastMiss = 0, 0, -1, ""
|
|
reason := fmt.Sprintf("calling %s (%s%s)", best.Call, watchedTag(*best), best.Need)
|
|
if why != "" {
|
|
reason = why + " — " + reason
|
|
}
|
|
return Action{Kind: DoReply, Decode: best.Decode, Reason: reason}
|
|
}
|
|
|
|
func watchedTag(c Candidate) string {
|
|
if c.Watched {
|
|
return "watched "
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// eligible is every refusal that applies before a station is even ranked.
|
|
func (e *Engine) eligible(c Candidate, p Period, only []string) bool {
|
|
call := strings.ToUpper(strings.TrimSpace(c.Call))
|
|
if call == "" || call == strings.ToUpper(p.MyCall) {
|
|
return false
|
|
}
|
|
if !c.IsNew {
|
|
return false // replayed history: the station may be hours gone
|
|
}
|
|
if len(only) > 0 {
|
|
// The named stations, and each only until the QSO with it is made: an
|
|
// explicit request is not a standing order to work the same station all
|
|
// evening. The others on the list stay callable.
|
|
return inList(only, call) && !e.done[call] && !c.Worked
|
|
}
|
|
if c.Worked || e.done[call] {
|
|
return false
|
|
}
|
|
if rank(c) == 0 {
|
|
return false
|
|
}
|
|
if e.rounds[call] >= e.set.MaxRounds {
|
|
return false // parked for the session
|
|
}
|
|
// Mid-exchange with somebody else: it cannot answer us — see callable.
|
|
return callable(c, p.MyCall)
|
|
}
|
|
|
|
// better orders two eligible stations: the need first, then the one already
|
|
// calling us, then a CQ over a station about to be free, then the strongest.
|
|
func better(a Candidate, ra int, b Candidate, rb int, myCall string) bool {
|
|
if ra != rb {
|
|
return ra > rb
|
|
}
|
|
if x, y := callingUs(a, myCall), callingUs(b, myCall); x != y {
|
|
return x
|
|
}
|
|
if a.CQ != b.CQ {
|
|
return a.CQ
|
|
}
|
|
return a.SNR > b.SNR
|
|
}
|
|
|
|
// release clears the target. gaveUp marks it as a series that ended in a brake
|
|
// rather than in a QSO.
|
|
func (e *Engine) release(call string, gaveUp bool) {
|
|
e.target, e.attempts, e.misses, e.txSlot, e.lastMiss = nil, 0, 0, -1, ""
|
|
e.targetInst = ""
|
|
if !gaveUp {
|
|
// A finished QSO is not a failed series — the callsign keeps its rounds —
|
|
// but it IS finished: nothing more is wanted from this station today,
|
|
// whatever the log still says while it catches up.
|
|
delete(e.rounds, call)
|
|
e.done[call] = true
|
|
}
|
|
}
|
|
|
|
func (e *Engine) giveUp(call string, t Candidate) {
|
|
e.release(call, true)
|
|
e.rounds[call]++
|
|
e.rested[call] = time.Now().Add(e.set.Rest)
|
|
// An explicit "call this station" that runs out of attempts stops the
|
|
// feature instead of moving on. There is nothing else it was asked to do.
|
|
if strings.TrimSpace(e.set.Only) != "" {
|
|
e.stopped, e.stoppedOn = true, call
|
|
}
|
|
}
|
|
|
|
// NoteTX counts what actually goes on the air, and is the ONLY place attempts
|
|
// are counted or capped.
|
|
//
|
|
// Counted here rather than where the reply is sent, because those are different
|
|
// things: a reply may be refused by the decoder, and the decoder transmits
|
|
// several times per QSO on its own. Capped here too — the period handler used
|
|
// to cap as well, and a target could sit at twenty-four calls while none of its
|
|
// conditions were met. One place that counts and stops cannot overshoot.
|
|
//
|
|
// A FRESH call only: the third token of an FT8 call is a grid, where the rest
|
|
// of the exchange carries a report, R-report or 73. Without that, the answer to
|
|
// "how many times have we called this station" counted the whole QSO.
|
|
func (e *Engine) NoteTX(tx TXState) Action {
|
|
if !e.set.Enabled || e.target == nil || !tx.Transmitting {
|
|
return Action{}
|
|
}
|
|
// The OTHER decoder transmitting is not us calling this station: in a split
|
|
// view both are on the air, and counting both spent the seven calls in half
|
|
// the time — on a target the other receiver had never heard of.
|
|
if e.targetInst != "" && tx.Instance != "" && tx.Instance != e.targetInst {
|
|
return Action{}
|
|
}
|
|
t := *e.target
|
|
tc := strings.ToUpper(t.Call)
|
|
msg := strings.ToUpper(strings.TrimSpace(tx.Msg))
|
|
switch {
|
|
case msg != "":
|
|
toks := strings.Fields(msg)
|
|
if len(toks) < 3 || toks[0] != tc || !isGrid(toks[2]) {
|
|
return Action{}
|
|
}
|
|
default:
|
|
// JTDX reports no transmit message. The DX call is all there is, so
|
|
// every transmit period aimed at the target counts — more generous, and
|
|
// far better than a counter frozen at zero for ever.
|
|
if strings.ToUpper(strings.TrimSpace(tx.DXCall)) != tc {
|
|
return Action{}
|
|
}
|
|
}
|
|
e.attempts++
|
|
if e.attempts < e.maxAttempts(t) {
|
|
return Action{}
|
|
}
|
|
max := e.maxAttempts(t)
|
|
e.giveUp(tc, t)
|
|
return Action{Kind: DoHalt, Reason: fmt.Sprintf("%s called %d times without an answer", tc, max)}
|
|
}
|
|
|
|
// bestOf returns the most callable decode of one station in a period.
|
|
//
|
|
// A station running several streams at once — a DXpedition answering four
|
|
// callers — appears several times in one period: a report to one, RR73 to
|
|
// another, a CQ to the band. Judging it on whichever line came first reads a
|
|
// station that is free right now as busy.
|
|
func bestOf(decodes []Candidate, call string) *Candidate {
|
|
var best *Candidate
|
|
for i := range decodes {
|
|
c := decodes[i]
|
|
if strings.ToUpper(c.Call) != call {
|
|
continue
|
|
}
|
|
if best == nil || (c.CQ && !best.CQ) || (c.CQ == best.CQ && c.SNR > best.SNR) {
|
|
cc := c
|
|
best = &cc
|
|
}
|
|
}
|
|
return best
|
|
}
|
|
|
|
// workedNow reports that the log has caught up with a station mid-series — the
|
|
// QSO was logged, by this or by another hand.
|
|
func workedNow(decodes []Candidate, call string) bool {
|
|
for _, c := range decodes {
|
|
if strings.ToUpper(c.Call) == call && c.Worked {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func periodSecs(p Period, c Candidate) int {
|
|
if c.TRPeriod > 0 {
|
|
return c.TRPeriod
|
|
}
|
|
if p.TRPeriod > 0 {
|
|
return p.TRPeriod
|
|
}
|
|
return 15
|
|
}
|
|
|
|
// TargetInstance names the receiver the current target is being called on, and
|
|
// the callsign. Both empty when idle.
|
|
func (e *Engine) TargetInstance() (instance, call string) {
|
|
if e.target == nil {
|
|
return "", ""
|
|
}
|
|
return e.targetInst, e.target.Call
|
|
}
|