ZD8GB — watch-listed, six streams a period, two of them RR73 — was refused as "parked" for the rest of the session. Parking answers "it will not answer, stop wasting the evening on it", which is a fair verdict about a station the LOG picked out and the wrong one about a station the OPERATOR named: a DXpedition running a pileup takes more than two series of calls to get through to, which is exactly why it is on the list. The rest between series still applies, so it cannot monopolise the transmitter — it simply never becomes ineligible. Send to (right-click) now refuses a service with no credentials and says which are missing. The upload runs on its own goroutine and reports into the QSL Manager's console, which is not open when the command came from the QSO list, so an upload to an unconfigured service looked exactly like one that worked. The toast is also raised only once the backend has accepted the request, and Cloudlog / Wavelog and HamQTH name themselves in it.
1256 lines
47 KiB
Go
1256 lines
47 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"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
"unicode"
|
|
)
|
|
|
|
// Need is what the log still wants from a station, worst to best.
|
|
type Need int
|
|
|
|
const (
|
|
NeedNone Need = iota
|
|
// NeedExtra: the ENTITY has nothing left to give on this band and mode, but
|
|
// the station carries something else that has never been worked — a WPX
|
|
// prefix, a square, a US county or state, a park. Those are what the cluster
|
|
// calls the orthogonal markers, and an operator who ticked them in the chase
|
|
// settings is hunting them: leaving them at nothing-needed meant auto-call
|
|
// watched a never-worked prefix call CQ and did nothing.
|
|
//
|
|
// The lowest rung, deliberately. It is worth calling when nothing better is
|
|
// on the air, and never worth leaving a new band for.
|
|
NeedExtra
|
|
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"
|
|
case NeedExtra:
|
|
return "extra"
|
|
}
|
|
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
|
|
// Extra names what NeedExtra is about — "prefix", "county", "square" — so the
|
|
// decision line says why the station was worth a call. Empty otherwise.
|
|
Extra string
|
|
// Unconfirmed says the need above exists ONLY because a contact was never
|
|
// confirmed — the operator hunts "new + unconfirmed", the band is in the log
|
|
// and the QSL is not. A real need outranks it; see rank.
|
|
Unconfirmed bool
|
|
// Hidden is true when the decodes panel's own filters are keeping this
|
|
// station off the screen. The operator's filters are the control: what is
|
|
// not shown is not called — see Settings.OnScreenOnly.
|
|
Hidden 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
|
|
// OnScreenOnly restricts the calling to what the decodes panel is actually
|
|
// showing.
|
|
//
|
|
// The filters an operator sets while reading the band — CQ only, LoTW only,
|
|
// the new-category chips, continents, a minimum report, the search box — now
|
|
// bind the transmitter too: a station filtered off the screen is not called.
|
|
// It replaces a separate "LoTW users only" setting, which said one thing
|
|
// while the chip above the list said another.
|
|
//
|
|
// The panel publishes what it is showing; when it is publishing nothing —
|
|
// the tab was never opened this session — there is nothing to restrict and
|
|
// the ladder decides alone.
|
|
OnScreenOnly bool
|
|
}
|
|
|
|
// 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
|
|
// Soft asks the decoder to finish the over it is sending and only then stop
|
|
// transmitting, rather than cutting the carrier where it stands.
|
|
//
|
|
// It matters for exactly one brake. The attempts cap trips WHILE the seventh
|
|
// call is going out — that is what counts it — so a hard halt cuts that
|
|
// transmission a second in, which on the air is a half-sent call and on the
|
|
// screen is an auto-call that "starts and stops". Nothing is saved by
|
|
// stopping it: the frame is already half gone.
|
|
//
|
|
// Every other brake fires on a station that is not being transmitted to (it
|
|
// has vanished, or the clock ran out between overs), and there a hard halt
|
|
// is right: it is the one that also clears the decoder's own auto-sequence.
|
|
Soft bool
|
|
}
|
|
|
|
// 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
|
|
// now is the time of the last period seen. Every deadline in here is measured
|
|
// against it rather than against the wall clock: the periods ARE the clock —
|
|
// they arrive stamped — and mixing the two gave a rest that expired against
|
|
// one reading and a hold that ran against another.
|
|
now time.Time
|
|
// waiting is the station the engine WOULD call and cannot, because it is in
|
|
// a QSO with somebody else. Kept so the toolbar can say so: an auto-call
|
|
// deliberately holding its fire looks exactly like one with nothing to do,
|
|
// and from the outside there was no way to tell them apart.
|
|
waiting string
|
|
// answered says the target has replied to us at least once — the exchange is
|
|
// under way and nothing may take its place.
|
|
answered bool
|
|
// 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
|
|
// trace, when set, receives one line per period: what was on the air, why
|
|
// each station was refused, and what was decided. Off unless the operator
|
|
// asks for it — a line per period is a line every fifteen seconds, all
|
|
// night.
|
|
trace func(string, ...any)
|
|
// greyed is every station the OPERATOR stopped a call to.
|
|
//
|
|
// Halt is a verdict, not a pause: the operator watched the engine call this
|
|
// station and said no. Answering that by releasing the target and picking
|
|
// the same station again the next period — which is what clearing the state
|
|
// did — is the machine overruling them, and it is what Halt exists to
|
|
// prevent. It holds until auto-call is switched off and on again, which is
|
|
// the one gesture that plainly means "start over".
|
|
greyed map[string]bool
|
|
// 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{},
|
|
greyed: 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"`
|
|
// Waiting: wanted, decoded, and in a QSO with somebody else.
|
|
Waiting string `json:"waiting"`
|
|
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,
|
|
Waiting: e.waiting}
|
|
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, e.greyed = map[string]bool{}, map[string]bool{}
|
|
}
|
|
|
|
// Halt is the operator stopping the call in progress.
|
|
//
|
|
// The station is set aside for the session — see greyed — and the target is
|
|
// released. Returns the callsign so the caller can say what happened; empty
|
|
// when nothing was being called, where Halt is just a halt.
|
|
func (e *Engine) Halt() string {
|
|
if e.target == nil {
|
|
return ""
|
|
}
|
|
call := strings.ToUpper(e.target.Call)
|
|
e.greyed[call] = true
|
|
e.release(call, true)
|
|
// Not counted as a series: the brakes are about a station that will not
|
|
// answer, and this one was never given the chance. It will not be called
|
|
// again anyway.
|
|
e.rounds[call]--
|
|
if e.rounds[call] < 0 {
|
|
e.rounds[call] = 0
|
|
}
|
|
delete(e.rested, call)
|
|
return call
|
|
}
|
|
|
|
// SetTrace turns the per-period trace on (a non-nil function) or off (nil).
|
|
func (e *Engine) SetTrace(fn func(string, ...any)) { e.trace = fn }
|
|
|
|
// Greylisted reports how many stations the operator has stopped this session.
|
|
func (e *Engine) Greylisted() int { return len(e.greyed) }
|
|
|
|
// 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.
|
|
//
|
|
// A WATCHED callsign outranks everything that is not watched. That is what a
|
|
// watch list means: the operator has named this station, and the machine's
|
|
// opinion of what the log needs does not get to overrule it.
|
|
//
|
|
// It did before, one rung at a time — watched new band above plain new band,
|
|
// but a watched station with nothing needed at the very bottom. On a band full
|
|
// of stations that ARE needed, that station was never reached: a watched
|
|
// DXpedition sat there all evening while the engine worked Brazilians. The
|
|
// list is not a tie-breaker, it is the answer.
|
|
//
|
|
// Below that line the old order stands, and for the same reasons: what is
|
|
// needed first, and a real need above one that exists only because a QSL never
|
|
// came — an unconfirmed new BAND still outranks a real new SLOT, because the
|
|
// band is the bigger prize whatever state it is in.
|
|
//
|
|
// 100+ every watched callsign, ordered among themselves by the same rule
|
|
// 18 DXCC 16 DXCC unconf
|
|
// 14 band 12 band unconf
|
|
// 10 mode 8 mode unconf
|
|
// 6 slot 4 slot unconf
|
|
// 0 nothing to call for
|
|
//
|
|
// needLabel is what the candidate is worth, in words: the rung, or the name of
|
|
// the orthogonal marker when that is the whole reason for the call.
|
|
func needLabel(c Candidate) string {
|
|
if c.Need == NeedExtra && c.Extra != "" {
|
|
return "new " + c.Extra
|
|
}
|
|
return c.Need.String()
|
|
}
|
|
|
|
func rank(c Candidate) int {
|
|
r := int(c.Need) * 4 // slot 4, mode 8, band 12, DXCC 16
|
|
if c.Need != NeedNone && !c.Unconfirmed {
|
|
r += 2
|
|
}
|
|
if c.Watched {
|
|
// Above every unwatched station, whatever the log makes of either.
|
|
return 100 + 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)
|
|
}
|
|
|
|
// messages splits a decoded line into the messages it carries.
|
|
//
|
|
// One transmission can hold two. MSHV answers several callers at once — its
|
|
// own multi-answer transmission, not SuperFox — and the decoder prints them as
|
|
// one line:
|
|
//
|
|
// YV5ALI RR73; F4BPO <HK0/PY8WW> -08
|
|
//
|
|
// The second half is a report to F4BPO. Reading only the start of the line, the
|
|
// engine saw a station working YV5ALI, decided it could not answer us, and
|
|
// dropped the target — at the exact moment the DX was answering. Each segment
|
|
// is its own message and names its own recipient first.
|
|
func messages(msg string) []string {
|
|
return strings.Split(strings.ToUpper(strings.TrimSpace(msg)), ";")
|
|
}
|
|
|
|
// bare strips the angle brackets a decoder puts round a compressed callsign,
|
|
// and upper-cases what is left. "<HP/WE9G>" and "HP/WE9G" are one station, and
|
|
// every comparison in here has to agree about that.
|
|
func bare(call string) string {
|
|
return strings.Trim(strings.ToUpper(strings.TrimSpace(call)), "<>")
|
|
}
|
|
|
|
// addressedTo reports whether one message segment is addressed to a callsign.
|
|
// The recipient is the first token, sometimes bracketed when the sender has
|
|
// compressed a non-standard call.
|
|
func addressedTo(part, call string) bool {
|
|
toks := strings.Fields(part)
|
|
if len(toks) == 0 || call == "" {
|
|
return false
|
|
}
|
|
return bare(toks[0]) == bare(call)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
parts := messages(c.Msg)
|
|
if len(parts) == 0 {
|
|
return false // nothing to read: assume it is busy rather than call blind
|
|
}
|
|
for _, part := range parts {
|
|
toks := strings.Fields(part)
|
|
if len(toks) == 0 {
|
|
continue
|
|
}
|
|
if addressedTo(part, 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
|
|
}
|
|
for _, part := range messages(c.Msg) {
|
|
if addressedTo(part, myCall) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// 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
|
|
}
|
|
// Every segment: a fox says goodbye to one caller and hello to the next
|
|
// in the same transmission.
|
|
for _, part := range messages(c.Msg) {
|
|
toks := strings.Fields(part)
|
|
if len(toks) < 2 || !addressedTo(part, 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{}
|
|
}
|
|
if !p.At.IsZero() {
|
|
e.now = p.At
|
|
}
|
|
// 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)
|
|
|
|
// HAS IT ANSWERED US? Settled first, before any of the checks below can
|
|
// decide to leave it.
|
|
//
|
|
// The reply lands in the same period the ladder is re-read, and that
|
|
// period used to be judged with answered still false — so a station
|
|
// answering our CQ-call was dropped for a better-ranked one in the exact
|
|
// period it came back to us. Watched from the shack: mid-exchange with
|
|
// V31MA, report sent, and OpsLog switched to a station calling on the
|
|
// other side of the screen. A QSO in progress outranks the ladder.
|
|
if seen := bestOf(p.Decodes, tc); seen != nil && callingUs(*seen, p.MyCall) {
|
|
e.answered = true
|
|
}
|
|
|
|
// 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)
|
|
// AND NOTHING ELSE THIS PERIOD. The next station is picked on the next
|
|
// one, fifteen seconds later, and it is still there.
|
|
//
|
|
// His RR73 is decoded while we are between overs, so the decoder is
|
|
// about to send our own 73 in the slot that is opening. Answering
|
|
// somebody else now takes that slot: the reply switches the DX call
|
|
// mid-sequence and the 73 never goes out whole — an operator watching
|
|
// saw both transmissions in one period, and the station at the other
|
|
// end never got the frame that closes the contact.
|
|
//
|
|
// This used to hand straight on "because the slot is the resource".
|
|
// The slot is worth less than the QSO it would spoil.
|
|
return Action{Kind: DoNothing,
|
|
Reason: fmt.Sprintf("QSO with %s finished — leaving the next slot for our 73", 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 e.trace != nil {
|
|
e.trace("period %s holding %s calls=%d/%d misses=%d/%d%s",
|
|
p.Key, tc, e.attempts, e.maxAttempts(t), e.misses, e.set.Misses,
|
|
map[bool]string{true: " (transmitting)", false: ""}[p.TX.Transmitting])
|
|
}
|
|
// SOMETHING BETTER HAS COME ON THE AIR.
|
|
//
|
|
// A target is held until it answers or a brake releases it, and that is
|
|
// right once an exchange has started — but before it has, holding means
|
|
// spending the next two minutes on a new slot while the watched
|
|
// DXpedition calls CQ three rows above it. Which is what an operator
|
|
// sees: the first CQ ignored, then the second one answered because the
|
|
// other call had run its course.
|
|
//
|
|
// So while the target has NOT answered us, a strictly better station
|
|
// takes its place. "Better" is the ladder, which does not flicker: a
|
|
// station's need does not change from period to period, so this cannot
|
|
// oscillate between two of them — only a genuinely higher rung wins.
|
|
if !e.answered {
|
|
if a, ok := e.preempt(p); ok {
|
|
return a
|
|
}
|
|
}
|
|
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
|
|
e.answered = true
|
|
// The exchange is under way, so the hold clock starts again from
|
|
// here: it exists to end a call nobody answers, and this one has
|
|
// been answered. Without this a QSO that began at the end of a
|
|
// long wait could be halted mid-exchange by a backstop that was
|
|
// counting the wait.
|
|
e.heldSince = p.At
|
|
return Action{}
|
|
}
|
|
// IT IS WORKING SOMEBODY ELSE, AND WE GO ON CALLING.
|
|
//
|
|
// This used to stop, on the reasoning that a station in a QSO cannot
|
|
// hear the call. That is how a pileup is NOT worked: a DX with a
|
|
// queue answers one caller per period, and the only way to be the
|
|
// next one is to keep calling while it works the others. An operator
|
|
// watched exactly that — D44TWO finishing a contact, the call
|
|
// started, the DX coming back to somebody else, and the engine
|
|
// giving up on a station that was about to be free.
|
|
//
|
|
// The effort is already bounded: seven calls, fifteen for a watched
|
|
// station, and the miss counter for one that goes off the air. And
|
|
// nothing is wasted while it is busy — a better station may still
|
|
// take the slot, because it has not answered us (see preempt).
|
|
//
|
|
// The callable test still governs the CHOICE of a target, where it
|
|
// belongs: a reply to a decode in mid-exchange is one WSJT-X and
|
|
// JTDX may refuse outright.
|
|
if e.trace != nil && !callable(*seen, p.MyCall) {
|
|
e.trace("period %s %s is working %s — carrying on calling", p.Key, tc, addressee(seen.Msg))
|
|
}
|
|
return Action{}
|
|
}
|
|
|
|
// Absent. Only its OWN transmit periods count against it — and never one
|
|
// of ours: our transmission is the reason it is not there.
|
|
if p.TX.Transmitting {
|
|
return Action{}
|
|
}
|
|
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
|
|
}
|
|
return e.pick(p, "")
|
|
}
|
|
|
|
// pick chooses the best station on the air and answers it.
|
|
//
|
|
// NEVER over a transmission in progress, and that guard belongs HERE because
|
|
// there are two ways in. A reply is not a request the decoder queues: WSJT-X
|
|
// acts on it at once, drops the exchange it is in and starts calling the new
|
|
// station — so a reply sent while our own 73 was going out cut that 73 about a
|
|
// second in, and the station we had just worked never got it. Reported from the
|
|
// air, and the sequence is exactly this one: his RRR is decoded, the QSO reads
|
|
// as finished, and the next station is picked in the same instant.
|
|
//
|
|
// The slot is worth having, but not at the price of the QSO in hand. The next
|
|
// period is a fifteen-second wait and everything worth calling is still there.
|
|
func (e *Engine) pick(p Period, why string) Action {
|
|
if e.trace != nil {
|
|
e.tracePick(p)
|
|
}
|
|
if p.TX.Transmitting {
|
|
if why != "" {
|
|
return Action{Kind: DoNothing, Reason: why + " — holding until the transmission ends"}
|
|
}
|
|
return Action{}
|
|
}
|
|
only := onlyList(e.set.Only)
|
|
var best *Candidate
|
|
var bestRank int
|
|
// The best station that is wanted, decoded, and working somebody else. It
|
|
// is not a candidate — it cannot answer — but it is the reason the engine
|
|
// is silent, and the operator is entitled to know that.
|
|
waiting, waitingRank := "", 0
|
|
for i := range p.Decodes {
|
|
c := p.Decodes[i]
|
|
if ok, why := e.judge(c, p, only); !ok {
|
|
if why == "busy" {
|
|
if r := rank(c); r > waitingRank {
|
|
waiting, waitingRank = strings.ToUpper(c.Call), r
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
r := rank(c)
|
|
if best == nil || better(c, r, *best, bestRank, p.MyCall) {
|
|
cc := c
|
|
best, bestRank = &cc, r
|
|
}
|
|
}
|
|
e.waiting = waiting
|
|
if best == nil {
|
|
if waiting != "" {
|
|
return Action{Kind: DoNothing,
|
|
Reason: fmt.Sprintf("waiting for %s — it is working somebody else", waiting)}
|
|
}
|
|
if why != "" {
|
|
return Action{Kind: DoNothing, Reason: why + " — nothing else worth calling"}
|
|
}
|
|
return Action{}
|
|
}
|
|
e.waiting = ""
|
|
e.target, e.heldSince, e.targetInst = best, p.At, best.Instance
|
|
// ITS SLOT IS KNOWN FROM THE DECODE THAT MADE IT A CANDIDATE.
|
|
//
|
|
// This used to start at -1, meaning "parity unknown", and with parity
|
|
// unknown the miss counter has nothing to filter on: every period without
|
|
// the station counted, INCLUDING the one we spend transmitting to it, where
|
|
// it cannot be decoded by definition. An operator answering a CQ saw the
|
|
// counter at two misses out of three before the station had had a single
|
|
// chance to come back — one bad period from being given up on.
|
|
e.attempts, e.misses, e.lastMiss = 0, 0, ""
|
|
e.txSlot = slotOf(p.At, periodSecs(p, *best))
|
|
e.answered = false
|
|
e.waiting = ""
|
|
reason := fmt.Sprintf("calling %s (%s%s)", best.Call, watchedTag(*best), needLabel(*best))
|
|
if why != "" {
|
|
reason = why + " — " + reason
|
|
}
|
|
return Action{Kind: DoReply, Decode: best.Decode, Reason: reason}
|
|
}
|
|
|
|
// tracePick writes the one line that says what this period held and what it
|
|
// was worth. Counts by refusal, then the best few candidates in rank order —
|
|
// enough to see WHY a station on the screen was not the one called.
|
|
func (e *Engine) tracePick(p Period) {
|
|
only := onlyList(e.set.Only)
|
|
refused := map[string]int{}
|
|
// The callsigns too, not only the counts. "worked=2" answers "how many" and
|
|
// leaves "which" — and which is the whole question when an operator is
|
|
// looking at one station on the screen and asking why it was passed over.
|
|
who := map[string][]string{}
|
|
type scored struct {
|
|
c Candidate
|
|
r int
|
|
}
|
|
var ok []scored
|
|
for i := range p.Decodes {
|
|
if good, why := e.judge(p.Decodes[i], p, only); good {
|
|
ok = append(ok, scored{p.Decodes[i], rank(p.Decodes[i])})
|
|
} else {
|
|
refused[why]++
|
|
if len(who[why]) < 4 {
|
|
who[why] = append(who[why], p.Decodes[i].Call)
|
|
}
|
|
}
|
|
}
|
|
sort.Slice(ok, func(i, j int) bool { return ok[i].r > ok[j].r })
|
|
best := make([]string, 0, 3)
|
|
for i := 0; i < len(ok) && i < 3; i++ {
|
|
best = append(best, fmt.Sprintf("%s(%s%s,%d dB,r%d)",
|
|
ok[i].c.Call, watchedTag(ok[i].c), needLabel(ok[i].c), ok[i].c.SNR, ok[i].r))
|
|
}
|
|
why := make([]string, 0, len(refused))
|
|
for _, k := range []string{"worked", "nothing-needed", "busy", "resting", "halted", "parked", "hidden", "not-chased", "replay", "self"} {
|
|
if n := refused[k]; n > 0 {
|
|
names := strings.Join(who[k], ",")
|
|
if n > len(who[k]) {
|
|
names += ",…"
|
|
}
|
|
why = append(why, fmt.Sprintf("%s=%d(%s)", k, n, names))
|
|
}
|
|
}
|
|
e.trace("period %s rx=%q decodes=%d callable=%d [%s] refused[%s]%s",
|
|
p.Key, p.Instance, len(p.Decodes), len(ok), strings.Join(best, " "),
|
|
strings.Join(why, " "),
|
|
map[bool]string{true: " (transmitting)", false: ""}[p.TX.Transmitting])
|
|
}
|
|
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 {
|
|
ok, _ := e.judge(c, p, only)
|
|
return ok
|
|
}
|
|
|
|
// judge is eligible() with the reason attached, in one word.
|
|
//
|
|
// The reason exists for the log. An auto-call that calls nobody is the hardest
|
|
// thing to argue with — the band is full, the panel shows a dozen stations
|
|
// wanted, and nothing goes out — and "twenty-three decodes, nineteen already
|
|
// worked, three greyed, one resting" answers it in a line where a boolean
|
|
// cannot.
|
|
func (e *Engine) judge(c Candidate, p Period, only []string) (bool, string) {
|
|
call := strings.ToUpper(strings.TrimSpace(c.Call))
|
|
if call == "" || call == strings.ToUpper(p.MyCall) {
|
|
return false, "self"
|
|
}
|
|
if !c.IsNew {
|
|
return false, "replay" // 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.
|
|
switch {
|
|
case !inList(only, call):
|
|
return false, "not-chased"
|
|
case e.greyed[call]:
|
|
return false, "halted"
|
|
case e.done[call] || c.Worked:
|
|
return false, "worked"
|
|
}
|
|
return true, ""
|
|
}
|
|
if c.Worked || e.done[call] {
|
|
return false, "worked"
|
|
}
|
|
if e.greyed[call] {
|
|
return false, "halted"
|
|
}
|
|
// A STATION CALLING US is answered, needed or not.
|
|
//
|
|
// It has heard us, it is waiting, and the QSO is one over away — refusing it
|
|
// because the log has nothing to gain is how an operator finishes a contact,
|
|
// sees two stations calling them on the next sequence, and watches the
|
|
// machine ignore both. Everything below this point is about choosing whom to
|
|
// CALL; a caller is not chosen, it is already there.
|
|
//
|
|
// The refusals above still apply — worked on this band and mode, the QSO
|
|
// already made, a station the operator stopped. Those are answers about this
|
|
// station, not about the log's appetite.
|
|
if callingUs(c, p.MyCall) {
|
|
return true, ""
|
|
}
|
|
// "LoTW users only" is a rule about STRANGERS. It cannot overrule the watch
|
|
// list: naming a callsign is the operator saying they want that station, and
|
|
// a DXpedition that uploads nowhere is exactly the kind of station one puts
|
|
// on the list. Reported from the air — a watched J38DX, plainly calling CQ,
|
|
// never called all evening because it is not in the LoTW user file.
|
|
// FILTERED OFF THE SCREEN. The operator's own filters, applied to the
|
|
// transmitter: what is not shown is not called.
|
|
if e.set.OnScreenOnly && c.Hidden {
|
|
return false, "hidden"
|
|
}
|
|
if rank(c) == 0 {
|
|
return false, "nothing-needed"
|
|
}
|
|
// A WATCHED callsign is never parked.
|
|
//
|
|
// Parking is the answer to "it will not answer, stop wasting the evening on
|
|
// it" — a fair verdict about a station the LOG picked out, and the wrong one
|
|
// about a station the OPERATOR did. A DXpedition running a pileup takes more
|
|
// than two series of calls to get through to, which is precisely why it was
|
|
// put on the list; watched ZD8GB was refused for the rest of the session
|
|
// after fourteen unanswered calls, while it went on transmitting six streams
|
|
// a period.
|
|
//
|
|
// The rest between series still applies, so it does not monopolise the
|
|
// transmitter — it simply never becomes ineligible.
|
|
if e.rounds[call] >= e.set.MaxRounds && !c.Watched {
|
|
return false, "parked" // its series are spent for the session
|
|
}
|
|
// RESTING. A series that ended in a brake is followed by a real pause,
|
|
// whatever else is on the air — the exception used to be "unless nothing
|
|
// better is decoding", and for the station everybody is chasing that is
|
|
// every period: seven calls, a halt, and the same station called again four
|
|
// seconds later. From the outside that is not a rest, it is a stutter.
|
|
if until, ok := e.rested[call]; ok && p.At.Before(until) {
|
|
return false, "resting"
|
|
}
|
|
// Mid-exchange with somebody else: it cannot answer us — see callable.
|
|
if !callable(c, p.MyCall) {
|
|
return false, "busy"
|
|
}
|
|
return true, ""
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// preempt hands the slot to a better station, if one is on the air.
|
|
func (e *Engine) preempt(p Period) (Action, bool) {
|
|
if e.target == nil || p.TX.Transmitting {
|
|
// Mid-over: the reply would switch the decoder's call in the middle of
|
|
// a transmission. It waits for the gap, like every other call.
|
|
return Action{}, false
|
|
}
|
|
held := rank(*e.target)
|
|
heldSeen := bestOf(p.Decodes, strings.ToUpper(e.target.Call)) != nil
|
|
only := onlyList(e.set.Only)
|
|
best, bestRank := (*Candidate)(nil), held
|
|
for i := range p.Decodes {
|
|
c := p.Decodes[i]
|
|
if strings.EqualFold(c.Call, e.target.Call) || !e.eligible(c, p, only) {
|
|
continue
|
|
}
|
|
// Strictly better takes over. So does an EQUAL rung when the station we
|
|
// are calling is not even on the air this period: the seven calls are
|
|
// going into the void while a station of the same value is calling CQ
|
|
// three rows above it, which is what an operator sees and cannot explain.
|
|
r := rank(c)
|
|
switch {
|
|
case r > bestRank:
|
|
case r == bestRank && !heldSeen && best == nil:
|
|
case best != nil && r == bestRank && better(c, r, *best, bestRank, p.MyCall):
|
|
default:
|
|
continue
|
|
}
|
|
{
|
|
cc := c
|
|
best, bestRank = &cc, r
|
|
}
|
|
}
|
|
if best == nil {
|
|
return Action{}, false
|
|
}
|
|
was := strings.ToUpper(e.target.Call)
|
|
e.drop()
|
|
e.target, e.heldSince, e.targetInst = best, p.At, best.Instance
|
|
return Action{Kind: DoReply, Decode: best.Decode,
|
|
Reason: fmt.Sprintf("%s (%s%s) takes over from %s — nothing had been answered yet",
|
|
best.Call, watchedTag(*best), needLabel(*best), was)}, true
|
|
}
|
|
|
|
// drop lets the target go with no verdict attached: not worked, not given up
|
|
// on, not rested. It is simply not the station to be calling right now, and it
|
|
// is picked again like any other the moment it is.
|
|
func (e *Engine) drop() {
|
|
e.target, e.attempts, e.misses, e.txSlot, e.lastMiss = nil, 0, 0, -1, ""
|
|
e.targetInst, e.answered = "", false
|
|
}
|
|
|
|
// addressee is who a message is being sent to — the first token, which is the
|
|
// callsign being answered — the first segment of a multi-answer line, which is
|
|
// the QSO it is finishing.
|
|
func addressee(msg string) string {
|
|
parts := messages(msg)
|
|
if len(parts) == 0 {
|
|
return "someone else"
|
|
}
|
|
toks := strings.Fields(parts[0])
|
|
if len(toks) == 0 {
|
|
return "someone else"
|
|
}
|
|
return bare(toks[0])
|
|
}
|
|
|
|
// 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, e.answered = "", false
|
|
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]++
|
|
at := e.now
|
|
if at.IsZero() {
|
|
at = time.Now()
|
|
}
|
|
e.rested[call] = at.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 := bare(t.Call)
|
|
msg := strings.ToUpper(strings.TrimSpace(tx.Msg))
|
|
switch {
|
|
case msg != "":
|
|
toks := strings.Fields(msg)
|
|
// bare(): a decoder compresses a non-standard callsign into angle
|
|
// brackets — "<HP/WE9G> F4BPO JN36" is a call to HP/WE9G — and comparing
|
|
// the raw token left the counter at 0/15 while the operator watched call
|
|
// after call go out. Every brake downstream of it was dead too.
|
|
if len(toks) < 3 || bare(toks[0]) != tc {
|
|
return Action{}
|
|
}
|
|
if !isGrid(toks[2]) {
|
|
// A report, an R-report, RR73: we are INSIDE the exchange, not
|
|
// opening it. It does not count as a call — and it settles that
|
|
// nothing may take this station's place, which matters when the
|
|
// decoder is answering somebody the engine never picked (the
|
|
// operator double-clicked a row) and no reply has been decoded yet.
|
|
e.answered = true
|
|
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 bare(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, Soft: true,
|
|
Reason: fmt.Sprintf("%s called %d times without an answer — stopping after this over", 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
|
|
}
|