fix(autocall): rest in overs, no phantom miss, and a readable auto readout

The rest between two series was two minutes — four overs on FT8, by
which time the DX has worked four other callers and half the time has
gone. It is now counted in the station's OWN overs and is one by default:
seven calls, one over listened through, and it goes again if the station
is still there. The deadline lands mid-cycle on purpose, so "sit out one
over" means one over rather than depending on a millisecond of clock
skew.

A period the target was DECODED in is never counted as a miss. A period
is judged more than once — decodes arrive in a burst and stragglers
follow — and a later judgement holds a partial view of it, not evidence
of absence: D2ACE answered in the very period the counter then read as a
miss.

The readout moved out of the Auto button and beside it. "Auto D2ACE 1/7
·1/3" read as one number, and the control changed width every period.
The callsign is now the biggest thing on the row, the calls and the
missed periods each carry a label, the miss count appears only once there
is one, and a station being waited for gets its own amber chip.
This commit is contained in:
2026-09-06 14:36:32 +02:00
parent f56630d7d6
commit ed66e9394b
8 changed files with 176 additions and 56 deletions
+49 -11
View File
@@ -169,11 +169,17 @@ type Settings struct {
// 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
// RestPeriods is how many of the station's OWN OVERS a released callsign sits
// out before it can be picked again, and MaxRounds how many such series it
// gets before being parked for the session.
//
// Counted in overs, not in minutes, because that is the unit the thing is
// happening in: an operator who has called a DX seven times without an answer
// listens through one of its transmissions and calls again if it is still
// there. Two minutes is four overs on FT8 — the DX has worked four other
// callers by then, and half the time it has gone.
RestPeriods int
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
@@ -195,7 +201,7 @@ type Settings struct {
func Defaults() Settings {
return Settings{
Attempts: 7, WatchedAttempts: 15, Misses: 3,
Rest: 2 * time.Minute, MaxRounds: 3, MaxHold: 4 * time.Minute,
RestPeriods: 1, MaxRounds: 3, MaxHold: 4 * time.Minute,
}
}
@@ -210,8 +216,8 @@ func (s Settings) withDefaults() Settings {
if s.Misses <= 0 {
s.Misses = d.Misses
}
if s.Rest <= 0 {
s.Rest = d.Rest
if s.RestPeriods <= 0 {
s.RestPeriods = d.RestPeriods
}
if s.MaxRounds <= 0 {
s.MaxRounds = d.MaxRounds
@@ -284,6 +290,12 @@ type Engine struct {
heldSince time.Time
attempts int
misses int
// seenKey is the period in which the target was last DECODED. A period is
// judged more than once — the decodes arrive in bursts and stragglers follow —
// and a later judgement holds a partial view of it, not evidence of absence:
// the station answered in that very period and the counter still read one
// miss out of three.
seenKey string
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
@@ -735,7 +747,7 @@ func (e *Engine) OnPeriod(p Period) Action {
// 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.misses, e.lastMiss, e.seenKey = 0, "", p.Key
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
@@ -780,6 +792,10 @@ func (e *Engine) OnPeriod(p Period) Action {
if p.TX.Transmitting {
return Action{}
}
// Nor a period it was already decoded in: see seenKey.
if e.seenKey == p.Key {
return Action{}
}
if e.txSlot >= 0 && slotOf(p.At, periodSecs(p, t)) != e.txSlot {
return Action{}
}
@@ -1094,7 +1110,7 @@ func (e *Engine) preempt(p Period) (Action, bool) {
// 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
e.targetInst, e.answered, e.seenKey = "", false, ""
}
// addressee is who a message is being sent to — the first token, which is the
@@ -1133,7 +1149,7 @@ func (e *Engine) giveUp(call string, t Candidate) {
if at.IsZero() {
at = time.Now()
}
e.rested[call] = at.Add(e.set.Rest)
e.rested[call] = at.Add(restFor(e.set.RestPeriods, candidateSecs(t)))
// 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) != "" {
@@ -1235,6 +1251,28 @@ func workedNow(decodes []Candidate, call string) bool {
return false
}
// restFor turns a number of the station's own overs into a deadline.
//
// A station transmits every OTHER slot, so one of its overs is two T/R periods —
// and the extra half-cycle is what makes the answer stable: without it the
// deadline lands exactly on the station's next transmission, where a
// millisecond of clock skew decides whether it is called or passed over. Landing
// it mid-cycle makes "sit out one over" mean one over, every time.
func restFor(overs, trSec int) time.Duration {
if overs <= 0 {
return time.Duration(trSec) * time.Second // no rest: its next over will do
}
return time.Duration(overs*2*trSec+trSec) * time.Second
}
// candidateSecs is the station's own T/R period in seconds, defaulting to FT8's.
func candidateSecs(c Candidate) int {
if c.TRPeriod > 0 {
return c.TRPeriod
}
return 15
}
func periodSecs(p Period, c Candidate) int {
if c.TRPeriod > 0 {
return c.TRPeriod