chore: release v0.27.12
This commit is contained in:
@@ -0,0 +1,770 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
package autocall
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const me = "F4BPO"
|
||||
|
||||
// base is a slot boundary, so slot parity in the tests is the real arithmetic.
|
||||
var base = time.Date(2026, 9, 6, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
func at(period int) time.Time { return base.Add(time.Duration(period) * 15 * time.Second) }
|
||||
|
||||
func cq(call string, need Need, snr int, opts ...func(*Candidate)) Candidate {
|
||||
c := Candidate{
|
||||
Decode: Decode{Call: call, Band: "20m", Mode: "FT8", SNR: snr, CQ: true,
|
||||
Msg: "CQ " + call + " JN36", TRPeriod: 15, IsNew: true},
|
||||
Need: need,
|
||||
}
|
||||
for _, o := range opts {
|
||||
o(&c)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func watched(c *Candidate) { c.Watched = true }
|
||||
func worked(c *Candidate) { c.Worked = true }
|
||||
|
||||
// busy is a station in the middle of an exchange with somebody else.
|
||||
func busy(call string, need Need, snr int) Candidate {
|
||||
c := cq(call, need, snr)
|
||||
c.CQ = false
|
||||
c.Msg = "VP6D " + call + " JN36"
|
||||
return c
|
||||
}
|
||||
|
||||
// callsMe is a station answering us.
|
||||
func callsMe(call string, need Need, snr int) Candidate {
|
||||
c := cq(call, need, snr)
|
||||
c.CQ = false
|
||||
c.Msg = me + " " + call + " -12"
|
||||
return c
|
||||
}
|
||||
|
||||
func period(n int, decodes ...Candidate) Period {
|
||||
for i := range decodes {
|
||||
decodes[i].At = at(n)
|
||||
}
|
||||
return Period{Key: fmt.Sprintf("p%d", n), At: at(n), TRPeriod: 15, Decodes: decodes, MyCall: me}
|
||||
}
|
||||
|
||||
func on() *Engine { return New(Settings{Enabled: true}) }
|
||||
|
||||
// ── The ladder ────────────────────────────────────────────────────────────
|
||||
|
||||
func TestLadderOrder(t *testing.T) {
|
||||
// Every rung, in the order the operator asked for.
|
||||
order := []Candidate{
|
||||
cq("A", NeedDXCC, 0, watched), cq("B", NeedDXCC, 0),
|
||||
cq("C", NeedBand, 0, watched), cq("D", NeedBand, 0),
|
||||
cq("E", NeedMode, 0, watched), cq("F", NeedMode, 0),
|
||||
cq("G", NeedSlot, 0, watched), cq("H", NeedSlot, 0),
|
||||
cq("I", NeedNone, 0, watched),
|
||||
}
|
||||
for i := 1; i < len(order); i++ {
|
||||
if rank(order[i-1]) <= rank(order[i]) {
|
||||
t.Errorf("%s (%d) does not outrank %s (%d)",
|
||||
order[i-1].Call, rank(order[i-1]), order[i].Call, rank(order[i]))
|
||||
}
|
||||
}
|
||||
// A station with nothing needed and not watched is not called at all.
|
||||
if rank(cq("Z", NeedNone, 0)) != 0 {
|
||||
t.Error("a station with nothing to gain from it ranks above zero")
|
||||
}
|
||||
// And the pick agrees with the ladder, whatever order the period lists them.
|
||||
e := on()
|
||||
a := e.OnPeriod(period(0, order[7], order[3], order[0], order[5]))
|
||||
if a.Kind != DoReply || a.Decode.Call != "A" {
|
||||
t.Fatalf("picked %+v, want the watched new entity", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrongestWinsBetweenEquals(t *testing.T) {
|
||||
e := on()
|
||||
a := e.OnPeriod(period(0, cq("WEAK", NeedBand, -20), cq("LOUD", NeedBand, -5)))
|
||||
if a.Decode.Call != "LOUD" {
|
||||
t.Errorf("picked %q, want the strongest of two equal needs", a.Decode.Call)
|
||||
}
|
||||
}
|
||||
|
||||
// ── The busy station ──────────────────────────────────────────────────────
|
||||
|
||||
func TestBusyStationIsNeverCalled(t *testing.T) {
|
||||
e := on()
|
||||
// The new entity is answering a DXpedition; the new band is calling CQ.
|
||||
a := e.OnPeriod(period(0, busy("RARE", NeedDXCC, -3), cq("DL1XX", NeedBand, -15)))
|
||||
if a.Kind != DoReply || a.Decode.Call != "DL1XX" {
|
||||
t.Fatalf("called %+v — a station in mid-QSO cannot answer and must not be called", a)
|
||||
}
|
||||
// It is not banned: the moment it calls CQ it takes the slot back, once the
|
||||
// QSO in hand is over.
|
||||
e2 := on()
|
||||
if a := e2.OnPeriod(period(0, cq("RARE", NeedDXCC, -3))); a.Decode.Call != "RARE" {
|
||||
t.Errorf("the same station calling CQ was not called: %+v", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFinalFrameIsCallable(t *testing.T) {
|
||||
e := on()
|
||||
c := busy("RARE", NeedDXCC, -3)
|
||||
c.Msg = "IK2AAA RARE RR73" // one frame from being free
|
||||
if a := e.OnPeriod(period(0, c)); a.Kind != DoReply {
|
||||
t.Errorf("a station sending its last frame is free next period: %+v", a)
|
||||
}
|
||||
}
|
||||
|
||||
// ── The brakes ────────────────────────────────────────────────────────────
|
||||
|
||||
func TestAttemptsCapAtSevenAndFifteen(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
opt func(*Candidate)
|
||||
want int
|
||||
}{{"plain", func(*Candidate) {}, 7}, {"watched", watched, 15}} {
|
||||
e := on()
|
||||
e.OnPeriod(period(0, cq("DX", NeedDXCC, -5, tc.opt)))
|
||||
tx := TXState{Transmitting: true, Msg: "DX " + me + " JN36"}
|
||||
for i := 1; i < tc.want; i++ {
|
||||
if a := e.NoteTX(tx); a.Kind != DoNothing {
|
||||
t.Fatalf("%s: gave up at call %d of %d", tc.name, i, tc.want)
|
||||
}
|
||||
}
|
||||
a := e.NoteTX(tx)
|
||||
if a.Kind != DoHalt {
|
||||
t.Errorf("%s: still calling after %d attempts", tc.name, tc.want)
|
||||
}
|
||||
if e.Target() != "" {
|
||||
t.Errorf("%s: target still held after giving up", tc.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnlyFreshCallsCount(t *testing.T) {
|
||||
e := on()
|
||||
e.OnPeriod(period(0, cq("DX", NeedDXCC, -5)))
|
||||
// The rest of the exchange is not a call: without this the seven were spent
|
||||
// on one QSO in progress.
|
||||
for _, msg := range []string{"DX " + me + " -12", "DX " + me + " R-12", "DX " + me + " RR73"} {
|
||||
if a := e.NoteTX(TXState{Transmitting: true, Msg: msg}); a.Kind != DoNothing {
|
||||
t.Fatalf("%q ended the series", msg)
|
||||
}
|
||||
}
|
||||
if e.Status().Attempts != 0 {
|
||||
t.Errorf("attempts = %d after three QSO frames, want 0", e.Status().Attempts)
|
||||
}
|
||||
// A transmission aimed at somebody else counts for nothing either.
|
||||
e.NoteTX(TXState{Transmitting: true, Msg: "OTHER " + me + " JN36"})
|
||||
if e.Status().Attempts != 0 {
|
||||
t.Errorf("a call to another station was counted against the target")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissesOnlyCountTheStationsOwnPeriods(t *testing.T) {
|
||||
e := on()
|
||||
e.OnPeriod(period(0, cq("DX", NeedDXCC, -5))) // learns nothing yet
|
||||
e.OnPeriod(period(2, cq("DX", NeedDXCC, -5))) // seen: it transmits on even periods
|
||||
// Its listening periods say nothing about it. Ten of them must not add up
|
||||
// to a give-up.
|
||||
for _, p := range []int{3, 5, 7, 9, 11} {
|
||||
if a := e.OnPeriod(period(p)); a.Kind != DoNothing {
|
||||
t.Fatalf("gave up during the station's own listening period %d", p)
|
||||
}
|
||||
}
|
||||
if e.Status().Misses != 0 {
|
||||
t.Errorf("misses = %d over five listening periods, want 0", e.Status().Misses)
|
||||
}
|
||||
// Absent from two of its transmit periods: still holding.
|
||||
e.OnPeriod(period(4))
|
||||
e.OnPeriod(period(6))
|
||||
if e.Target() == "" {
|
||||
t.Fatal("gave up after two misses, the limit is three")
|
||||
}
|
||||
if a := e.OnPeriod(period(8)); a.Kind != DoHalt {
|
||||
t.Errorf("still holding after three missed transmit periods: %+v", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOneMissPerPeriodEvenIfTheHandlerRunsTwice(t *testing.T) {
|
||||
e := on()
|
||||
e.OnPeriod(period(0, cq("DX", NeedDXCC, -5)))
|
||||
e.OnPeriod(period(2, cq("DX", NeedDXCC, -5)))
|
||||
p := period(4)
|
||||
e.OnPeriod(p)
|
||||
e.OnPeriod(p)
|
||||
e.OnPeriod(p)
|
||||
if e.Status().Misses != 1 {
|
||||
t.Errorf("misses = %d after one period handled three times, want 1", e.Status().Misses)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClockBackstopReleasesAStuckTarget(t *testing.T) {
|
||||
e := on()
|
||||
e.OnPeriod(period(0, cq("DX", NeedDXCC, -5)))
|
||||
// Decoded every one of its periods and never answering, with the decoder
|
||||
// never reporting a transmission: no counter can advance.
|
||||
for p := 2; p <= 16; p += 2 {
|
||||
e.OnPeriod(period(p, cq("DX", NeedDXCC, -5)))
|
||||
}
|
||||
if a := e.OnPeriod(period(18, cq("DX", NeedDXCC, -5))); a.Kind != DoHalt {
|
||||
t.Errorf("a target held past MaxHold with no counter moving was never released: %+v", a)
|
||||
}
|
||||
}
|
||||
|
||||
// ── After a series ────────────────────────────────────────────────────────
|
||||
|
||||
func TestAReleasedStationYieldsToAnythingBetter(t *testing.T) {
|
||||
e := on()
|
||||
e.OnPeriod(period(0, cq("DX", NeedBand, -5)))
|
||||
tx := TXState{Transmitting: true, Msg: "DX " + me + " JN36"}
|
||||
for i := 0; i < 7; i++ {
|
||||
e.NoteTX(tx)
|
||||
}
|
||||
if e.Target() != "" {
|
||||
t.Fatal("still holding after seven calls")
|
||||
}
|
||||
// It is still there, and so is a new entity: the entity takes the slot.
|
||||
a := e.OnPeriod(period(2, cq("DX", NeedBand, -5), cq("RARE", NeedDXCC, -20)))
|
||||
if a.Decode.Call != "RARE" {
|
||||
t.Errorf("picked %q, want the higher priority over the station just released", a.Decode.Call)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAReleasedStationIsCalledAgainWhenNothingBetterIsOnTheAir(t *testing.T) {
|
||||
e := on()
|
||||
e.OnPeriod(period(0, cq("DX", NeedBand, -5)))
|
||||
tx := TXState{Transmitting: true, Msg: "DX " + me + " JN36"}
|
||||
for i := 0; i < 7; i++ {
|
||||
e.NoteTX(tx)
|
||||
}
|
||||
a := e.OnPeriod(period(2, cq("DX", NeedBand, -5)))
|
||||
if a.Kind != DoReply || a.Decode.Call != "DX" {
|
||||
t.Errorf("nothing better on the air and the station was not called again: %+v", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAStationIsParkedAfterItsRounds(t *testing.T) {
|
||||
e := on()
|
||||
tx := TXState{Transmitting: true, Msg: "DX " + me + " JN36"}
|
||||
for round := 1; round <= 3; round++ {
|
||||
a := e.OnPeriod(period(round*2, cq("DX", NeedBand, -5)))
|
||||
if a.Kind != DoReply {
|
||||
t.Fatalf("round %d: not called (%+v)", round, a)
|
||||
}
|
||||
for i := 0; i < 7; i++ {
|
||||
e.NoteTX(tx)
|
||||
}
|
||||
}
|
||||
// Three series of seven is twenty-one calls. That is the end of it for this
|
||||
// session — the whole point of the exercise is that it cannot reach fifty.
|
||||
if a := e.OnPeriod(period(20, cq("DX", NeedBand, -5))); a.Kind != DoNothing {
|
||||
t.Errorf("a fourth series was started: %+v", a)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Handing over ──────────────────────────────────────────────────────────
|
||||
|
||||
func TestFinishedQSOMovesToTheNextPriority(t *testing.T) {
|
||||
e := on()
|
||||
e.OnPeriod(period(0, cq("DX", NeedDXCC, -5)))
|
||||
done := callsMe("DX", NeedDXCC, -5)
|
||||
done.Msg = me + " DX RR73"
|
||||
a := e.OnPeriod(period(2, done, cq("NEXT", NeedBand, -10)))
|
||||
if a.Kind != DoReply || a.Decode.Call != "NEXT" {
|
||||
t.Errorf("after the QSO ended: %+v, want the next priority in the same period", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFinishedQSOWithNoPriorityAnswersWhoeverIsCallingUs(t *testing.T) {
|
||||
e := on()
|
||||
e.OnPeriod(period(0, cq("DX", NeedDXCC, -5)))
|
||||
done := callsMe("DX", NeedDXCC, -5)
|
||||
done.Msg = me + " DX RR73"
|
||||
// Two stations calling us, nothing needed from either: the strongest wins.
|
||||
weak := callsMe("WEAK", NeedNone, -18)
|
||||
weak.Watched = true
|
||||
loud := callsMe("LOUD", NeedNone, -4)
|
||||
loud.Watched = true
|
||||
a := e.OnPeriod(period(2, done, weak, loud))
|
||||
if a.Kind != DoReply || a.Decode.Call != "LOUD" {
|
||||
t.Errorf("answered %+v, want the strongest of the stations calling us", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlreadyWorkedIsNeverCalled(t *testing.T) {
|
||||
e := on()
|
||||
a := e.OnPeriod(period(0, cq("DX", NeedDXCC, -5, worked)))
|
||||
if a.Kind != DoNothing {
|
||||
t.Errorf("called a station already in the log on this band and mode: %+v", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplayedHistoryIsNeverCalled(t *testing.T) {
|
||||
e := on()
|
||||
old := cq("DX", NeedDXCC, -5)
|
||||
old.IsNew = false
|
||||
if a := e.OnPeriod(period(0, old)); a.Kind != DoNothing {
|
||||
t.Errorf("answered a replayed decode: %+v", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoCallStartsOverATransmissionInProgress(t *testing.T) {
|
||||
e := on()
|
||||
p := period(0, cq("DX", NeedDXCC, -5))
|
||||
p.TX = TXState{Transmitting: true}
|
||||
if a := e.OnPeriod(p); a.Kind != DoNothing {
|
||||
t.Errorf("started a call while the decoder was transmitting: %+v", a)
|
||||
}
|
||||
}
|
||||
|
||||
// ── The "call this station" field ─────────────────────────────────────────
|
||||
|
||||
func TestOnlyCallsThatStation(t *testing.T) {
|
||||
e := New(Settings{Enabled: true, Only: "VP6D"})
|
||||
a := e.OnPeriod(period(0, cq("RARE", NeedDXCC, -5), cq("VP6D", NeedSlot, -20)))
|
||||
if a.Kind != DoReply || a.Decode.Call != "VP6D" {
|
||||
t.Fatalf("picked %+v, want the station named in the field", a)
|
||||
}
|
||||
// Same brakes, then a hard stop: there is nothing else it was asked to do.
|
||||
tx := TXState{Transmitting: true, Msg: "VP6D " + me + " JN36"}
|
||||
for i := 0; i < 7; i++ {
|
||||
e.NoteTX(tx)
|
||||
}
|
||||
if !e.Status().Stopped {
|
||||
t.Error("an explicit target ran out of attempts and the feature did not stop")
|
||||
}
|
||||
if a := e.OnPeriod(period(2, cq("VP6D", NeedSlot, -20))); a.Kind != DoNothing {
|
||||
t.Errorf("kept calling after the stop: %+v", a)
|
||||
}
|
||||
e.Reset()
|
||||
if a := e.OnPeriod(period(4, cq("VP6D", NeedSlot, -20))); a.Kind != DoReply {
|
||||
t.Errorf("the operator restarted it and nothing happened: %+v", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisabledDoesNothingAtAll(t *testing.T) {
|
||||
e := New(Settings{})
|
||||
if a := e.OnPeriod(period(0, cq("DX", NeedDXCC, 0))); a.Kind != DoNothing {
|
||||
t.Errorf("switched off and still calling: %+v", a)
|
||||
}
|
||||
if a := e.NoteTX(TXState{Transmitting: true, Msg: "DX " + me + " JN36"}); a.Kind != DoNothing {
|
||||
t.Errorf("switched off and still counting: %+v", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnlyStopsOnceThatStationIsWorked(t *testing.T) {
|
||||
e := New(Settings{Enabled: true, Only: "VP6D"})
|
||||
e.OnPeriod(period(0, cq("VP6D", NeedDXCC, -10)))
|
||||
done := callsMe("VP6D", NeedDXCC, -10)
|
||||
done.Msg = me + " VP6D RR73"
|
||||
e.OnPeriod(period(2, done))
|
||||
// The station is still on the air calling CQ. It has been worked: an
|
||||
// explicit request is for one QSO, not for the whole evening.
|
||||
if a := e.OnPeriod(period(4, cq("VP6D", NeedDXCC, -10))); a.Kind != DoNothing {
|
||||
t.Errorf("called the named station again after working it: %+v", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAStationJustWorkedIsNotCalledBackWhileTheLogCatchesUp(t *testing.T) {
|
||||
e := on()
|
||||
e.OnPeriod(period(0, cq("DX", NeedDXCC, -5)))
|
||||
done := callsMe("DX", NeedDXCC, -5)
|
||||
done.Msg = me + " DX RR73"
|
||||
e.OnPeriod(period(2, done))
|
||||
// Still flagged as a new entity — the QSO is not in the log yet — and still
|
||||
// calling CQ. It must not be answered again.
|
||||
if a := e.OnPeriod(period(4, cq("DX", NeedDXCC, -5))); a.Kind != DoNothing {
|
||||
t.Errorf("called back a station worked two periods ago: %+v", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChaseListTakesSeveralCallsigns(t *testing.T) {
|
||||
// Typed the way an operator types a list: commas, spaces, or both.
|
||||
for _, field := range []string{"VP6D 3Y0J", "vp6d,3y0j", "VP6D, 3Y0J", " VP6D ;3Y0J "} {
|
||||
if got := onlyList(field); len(got) != 2 || got[0] != "VP6D" || got[1] != "3Y0J" {
|
||||
t.Errorf("%q parsed as %v, want [VP6D 3Y0J]", field, got)
|
||||
}
|
||||
}
|
||||
|
||||
e := New(Settings{Enabled: true, Only: "VP6D, 3Y0J"})
|
||||
// Nothing off the list is called, however rare it is.
|
||||
if a := e.OnPeriod(period(0, cq("RARE", NeedDXCC, -1))); a.Kind != DoNothing {
|
||||
t.Errorf("called a station that is not on the chase list: %+v", a)
|
||||
}
|
||||
// Between two listed stations the ladder still decides: the new entity over
|
||||
// the new slot, whatever their order in the period.
|
||||
a := e.OnPeriod(period(2, cq("3Y0J", NeedSlot, -1), cq("VP6D", NeedDXCC, -22)))
|
||||
if a.Kind != DoReply || a.Decode.Call != "VP6D" {
|
||||
t.Fatalf("picked %+v, want the new entity of the two listed", a)
|
||||
}
|
||||
// Working one of them leaves the other callable — the list is a hunt, not a
|
||||
// single request.
|
||||
done := callsMe("VP6D", NeedDXCC, -22)
|
||||
done.Msg = me + " VP6D RR73"
|
||||
a = e.OnPeriod(period(4, done, cq("3Y0J", NeedSlot, -1)))
|
||||
if a.Kind != DoReply || a.Decode.Call != "3Y0J" {
|
||||
t.Errorf("after working VP6D: %+v, want the other station on the list", a)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Two decoders at once (the split view) ─────────────────────────────────
|
||||
|
||||
func onInst(inst string, p Period) Period {
|
||||
p.Instance = inst
|
||||
for i := range p.Decodes {
|
||||
p.Decodes[i].Instance = inst
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func TestTheOtherReceiverCannotBreakTheQSOInProgress(t *testing.T) {
|
||||
e := on()
|
||||
// Calling a new band on receiver A.
|
||||
if a := e.OnPeriod(onInst("A", period(0, cq("DL1XX", NeedBand, -10)))); a.Decode.Call != "DL1XX" {
|
||||
t.Fatalf("first call went to %+v", a)
|
||||
}
|
||||
// Receiver B now hears a new ENTITY — a better catch by every rule. It must
|
||||
// still not be called: one station at a time, and the QSO in hand is the
|
||||
// one already under way.
|
||||
if a := e.OnPeriod(onInst("B", period(1, cq("RARE", NeedDXCC, -1)))); a.Kind != DoNothing {
|
||||
t.Errorf("the other receiver started a second QSO: %+v", a)
|
||||
}
|
||||
// And B's periods count no misses against A's target: the station is not
|
||||
// absent from B, it was never on that band.
|
||||
e.OnPeriod(onInst("B", period(3, cq("RARE", NeedDXCC, -1))))
|
||||
e.OnPeriod(onInst("B", period(5, cq("RARE", NeedDXCC, -1))))
|
||||
e.OnPeriod(onInst("B", period(7, cq("RARE", NeedDXCC, -1))))
|
||||
if e.Status().Misses != 0 || e.Target() != "DL1XX" {
|
||||
t.Errorf("misses = %d, target = %q — the other receiver's periods were counted",
|
||||
e.Status().Misses, e.Target())
|
||||
}
|
||||
// B transmitting its own QSO is not us calling DL1XX either.
|
||||
for i := 0; i < 9; i++ {
|
||||
e.NoteTX(TXState{Transmitting: true, Instance: "B", Msg: "DL1XX " + me + " JN36"})
|
||||
}
|
||||
if e.Status().Attempts != 0 {
|
||||
t.Errorf("attempts = %d from the other receiver's transmissions, want 0", e.Status().Attempts)
|
||||
}
|
||||
// A's own transmissions do count.
|
||||
e.NoteTX(TXState{Transmitting: true, Instance: "A", Msg: "DL1XX " + me + " JN36"})
|
||||
if e.Status().Attempts != 1 {
|
||||
t.Errorf("attempts = %d after one call from the calling receiver, want 1", e.Status().Attempts)
|
||||
}
|
||||
// Once the QSO is over, the other receiver's station is free to be taken.
|
||||
done := callsMe("DL1XX", NeedBand, -10)
|
||||
done.Msg = me + " DL1XX RR73"
|
||||
e.OnPeriod(onInst("A", period(9, done)))
|
||||
if a := e.OnPeriod(onInst("B", period(11, cq("RARE", NeedDXCC, -1)))); a.Decode.Call != "RARE" {
|
||||
t.Errorf("after the QSO ended, the other receiver was still locked out: %+v", a)
|
||||
}
|
||||
}
|
||||
+73
-2
@@ -9,6 +9,7 @@ package cat
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
@@ -240,15 +241,81 @@ func (m *Manager) freqOffsetHz() int64 {
|
||||
// display trick: the readout would say 144 and every spot click, band change and
|
||||
// memory recall would send the rig somewhere 116 MHz away.
|
||||
func (m *Manager) SetFrequency(hz int64) error {
|
||||
real := hz
|
||||
if off := m.freqOffsetHz(); off != 0 && hz > off {
|
||||
hz -= off
|
||||
}
|
||||
return m.exec(func(b Backend) error { return b.SetFrequency(hz) })
|
||||
err := m.exec(func(b Backend) error { return b.SetFrequency(hz) })
|
||||
if err == nil {
|
||||
m.noteCommandedFreq(real)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// noteCommandedFreq publishes a frequency the radio has just acknowledged,
|
||||
// without waiting for the next poll to come round and read it back.
|
||||
//
|
||||
// The wait is what this is about. A rigctl client — WSJT-X above all — sets a
|
||||
// frequency and then READS it back before it believes it is there, and until
|
||||
// then it will not decode, transmit or even update its own dial. Everything
|
||||
// answering "f" here comes from the last poll, so the answer was the OLD
|
||||
// frequency for as long as a poll cycle takes; on a rig reached over the
|
||||
// internet, where one cycle is many round trips, a band change from WSJT-X took
|
||||
// ten seconds to be believed while the radio itself had moved instantly.
|
||||
//
|
||||
// Only when NOT split. In split the two frequencies mean different VFOs and a
|
||||
// guess about which one just moved is how a client ends up writing the transmit
|
||||
// frequency onto the dial — the poll is left to settle that case.
|
||||
func (m *Manager) noteCommandedFreq(hz int64) {
|
||||
if hz <= 0 {
|
||||
return
|
||||
}
|
||||
m.mu.Lock()
|
||||
st := m.state
|
||||
if !st.Connected || st.Split || st.FreqHz == hz {
|
||||
m.mu.Unlock()
|
||||
return
|
||||
}
|
||||
st.FreqHz = hz
|
||||
st.Band = BandFromHz(hz)
|
||||
st.UpdatedAt = time.Now()
|
||||
m.state = st
|
||||
m.mu.Unlock()
|
||||
m.emitState()
|
||||
}
|
||||
|
||||
// SetMode dispatches a SetMode call to the CAT goroutine.
|
||||
func (m *Manager) SetMode(mode string) error {
|
||||
return m.exec(func(b Backend) error { return b.SetMode(mode) })
|
||||
err := m.exec(func(b Backend) error { return b.SetMode(mode) })
|
||||
if err == nil {
|
||||
m.noteCommandedMode(mode)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// noteCommandedMode is the mode half of noteCommandedFreq, and exists for the
|
||||
// same client readback.
|
||||
//
|
||||
// "DATA" is deliberately not published. A backend reports data mode under the
|
||||
// operator's own digital mode (FT8, JS8, RTTY…), and that name is what a QSO is
|
||||
// logged with — a plain "DATA" standing in for a poll cycle is a mode nobody
|
||||
// works, in a field that ends up in an ADIF file. The poll is a fraction of a
|
||||
// second away and knows the real name.
|
||||
func (m *Manager) noteCommandedMode(mode string) {
|
||||
if mode == "" || strings.EqualFold(mode, "DATA") {
|
||||
return
|
||||
}
|
||||
m.mu.Lock()
|
||||
st := m.state
|
||||
if !st.Connected || st.Mode == mode {
|
||||
m.mu.Unlock()
|
||||
return
|
||||
}
|
||||
st.Mode = mode
|
||||
st.UpdatedAt = time.Now()
|
||||
m.state = st
|
||||
m.mu.Unlock()
|
||||
m.emitState()
|
||||
}
|
||||
|
||||
// SetPTT dispatches a transmit on/off request to the CAT goroutine.
|
||||
@@ -704,6 +771,10 @@ type IcomController interface {
|
||||
SetVOXGain(int) error
|
||||
SetAntiVOX(int) error
|
||||
SetPower(bool) error // turn the transceiver on/off (manual — never auto on connect)
|
||||
// RecallBandStack moves the VFO to what the radio's own band stacking
|
||||
// register holds — the operator's last frequency and mode on that band.
|
||||
// Returns the frequency landed on.
|
||||
RecallBandStack(band, reg int) (int64, error)
|
||||
}
|
||||
|
||||
// ScopeSweep is one complete spectrum-scope sweep reassembled from the Icom's
|
||||
|
||||
@@ -425,3 +425,49 @@ func indexPreamble(buf []byte, from int) int {
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// ── Band stacking registers (CI-V 0x1A sub 0x01) ──────────────────────────
|
||||
//
|
||||
// Every modern Icom remembers the last few frequency/mode pairs used on each
|
||||
// band, and its front-panel band key walks through them. That is why pressing
|
||||
// [14] on the radio lands on the FT8 watering hole rather than on a number
|
||||
// somebody chose in software: the register is the operator's OWN last visit.
|
||||
//
|
||||
// The frame is a READ — it asks the rig what a register holds and changes
|
||||
// nothing — so a rig that does not know the command answers NG and the caller
|
||||
// is exactly where it started.
|
||||
//
|
||||
// → 1A 01 <band> <reg>
|
||||
// ← 1A 01 <band> <reg> <freq 5 BCD, LE> <mode> <filter> <data mode> …
|
||||
//
|
||||
// Anything past the data-mode byte (duplex, tone, DV squelch on the VHF rigs)
|
||||
// is not read here: the question is where the operator last was, and the answer
|
||||
// to that is the frequency and the mode.
|
||||
const SubBandStack = 0x01
|
||||
|
||||
// BandStack is one register's contents.
|
||||
type BandStack struct {
|
||||
FreqHz int64
|
||||
Mode byte
|
||||
Data bool // the data-mode flag that goes with Mode
|
||||
}
|
||||
|
||||
// DecodeBandStack reads the payload of a 0x1A 0x01 reply, i.e. everything after
|
||||
// the command byte. ok is false for a frame that is not the register asked for,
|
||||
// which is what a desynchronised read looks like.
|
||||
func DecodeBandStack(data []byte, band, reg byte) (BandStack, bool) {
|
||||
if len(data) < 9 || data[0] != SubBandStack || data[1] != band || data[2] != reg {
|
||||
return BandStack{}, false
|
||||
}
|
||||
hz, ok := BCDToFreq(data[3:8])
|
||||
if !ok || hz <= 0 {
|
||||
return BandStack{}, false
|
||||
}
|
||||
bs := BandStack{FreqHz: hz, Mode: data[8]}
|
||||
// Filter then data mode. A rig that stops at the filter byte is not an
|
||||
// error — it is a register without a data flag, so the flag stays false.
|
||||
if len(data) >= 11 {
|
||||
bs.Data = data[10] != 0
|
||||
}
|
||||
return bs, true
|
||||
}
|
||||
|
||||
@@ -204,3 +204,36 @@ func TestBCDToFreqRejectsNonDecimal(t *testing.T) {
|
||||
t.Error("a short but valid BCD frame must still decode")
|
||||
}
|
||||
}
|
||||
|
||||
// A band stacking register reply, byte for byte as the rigs send it:
|
||||
// 1A 01 <band> <reg> <freq 5 LE-BCD> <mode> <filter> <data>. The payload here
|
||||
// is everything after the command byte, which is what Decoded.Data holds.
|
||||
func TestDecodeBandStack(t *testing.T) {
|
||||
// 14.074.000 MHz, USB, FIL1, data mode on — the FT8 stack on 20 m.
|
||||
frame := []byte{0x01, 0x05, 0x03, 0x00, 0x40, 0x07, 0x14, 0x00, ModeUSB, 0x01, 0x01}
|
||||
bs, ok := DecodeBandStack(frame, 0x05, 0x03)
|
||||
if !ok {
|
||||
t.Fatal("a well-formed register was rejected")
|
||||
}
|
||||
if bs.FreqHz != 14_074_000 {
|
||||
t.Errorf("freq = %d, want 14074000", bs.FreqHz)
|
||||
}
|
||||
if bs.Mode != ModeUSB || !bs.Data {
|
||||
t.Errorf("mode = 0x%02X data = %v, want USB + data", bs.Mode, bs.Data)
|
||||
}
|
||||
// The register ASKED FOR is part of the answer: a reply about another one is
|
||||
// a desynchronised read, not a frequency to send the radio to.
|
||||
if _, ok := DecodeBandStack(frame, 0x05, 0x01); ok {
|
||||
t.Error("a reply for register 3 was accepted as register 1")
|
||||
}
|
||||
if _, ok := DecodeBandStack(frame, 0x03, 0x03); ok {
|
||||
t.Error("a reply about 40 m was accepted as 20 m")
|
||||
}
|
||||
// A register without the data-mode byte is a shorter frame, not a bad one.
|
||||
if bs, ok := DecodeBandStack(frame[:10], 0x05, 0x03); !ok || bs.FreqHz != 14_074_000 || bs.Data {
|
||||
t.Errorf("short register = %+v ok=%v, want the frequency with no data flag", bs, ok)
|
||||
}
|
||||
if _, ok := DecodeBandStack([]byte{0x01, 0x05, 0x03}, 0x05, 0x03); ok {
|
||||
t.Error("a truncated frame was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -594,6 +594,15 @@ func (b *IcomSerial) SetMode(mode string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return b.setModeBytes(mode, code, data)
|
||||
}
|
||||
|
||||
// setModeBytes is SetMode once the mode is already a CI-V byte and a data flag.
|
||||
// Split out for the band-stacking recall, which gets both FROM the radio and
|
||||
// must not go back through an ADIF name to reach them: a register holding CW-R
|
||||
// or LSB would come back as plain CW or as whatever the band convention says,
|
||||
// i.e. not the mode the operator left there.
|
||||
func (b *IcomSerial) setModeBytes(mode string, code byte, data bool) error {
|
||||
// Set the base mode (keeping the rig's current filter by sending only the
|
||||
// mode byte), then set the data-mode flag for digital modes.
|
||||
if err := b.execIdempotent("set mode "+mode, civ.CmdSetMode, code); err != nil {
|
||||
@@ -2188,3 +2197,59 @@ func (b *IcomSerial) TXAudioSender() (func([]byte) error, error) {
|
||||
}
|
||||
return nil, fmt.Errorf("this rig takes transmit audio through its USB sound card, not the CAT link")
|
||||
}
|
||||
|
||||
// ── Band stacking registers ───────────────────────────────────────────────
|
||||
|
||||
// RecallBandStack puts the VFO where the operator last was on a band, by asking
|
||||
// the radio rather than by holding an opinion about it.
|
||||
//
|
||||
// The console's band buttons used to send a frequency chosen in software — a
|
||||
// reasonable middle-of-the-band number, and never where anybody actually
|
||||
// operates. The radio already knows better: every band key press it has ever
|
||||
// had is remembered in that band's stacking registers, so register 1 is the
|
||||
// last place used on that band, and cycling through 2 and 3 walks back through
|
||||
// the ones before it — CW where CW was worked, and the FT8 frequency where FT8
|
||||
// was worked, without either being written down anywhere.
|
||||
//
|
||||
// Reads the register, then sets frequency and mode from it. Returns the
|
||||
// frequency it landed on, so the caller can say where it went; a register the
|
||||
// rig will not read leaves the radio untouched and returns an error, which is
|
||||
// what makes the caller's fallback to a plain frequency safe.
|
||||
func (b *IcomSerial) RecallBandStack(band, reg int) (int64, error) {
|
||||
if b.port == nil {
|
||||
return 0, fmt.Errorf("not connected")
|
||||
}
|
||||
if band <= 0 || reg < 1 || reg > 3 {
|
||||
return 0, fmt.Errorf("icom: band stack %d/%d is not a register", band, reg)
|
||||
}
|
||||
bb, rb := civ.ByteToBCD(band), civ.ByteToBCD(reg)
|
||||
if err := b.write(civ.CmdExtra, civ.SubBandStack, bb, rb); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
f, err := b.recv(icomReadTimeout, func(d civ.Decoded) bool {
|
||||
return d.Cmd == civ.CmdExtra && len(d.Data) >= 2 && d.Data[0] == civ.SubBandStack
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
bs, ok := civ.DecodeBandStack(f.Data, bb, rb)
|
||||
if !ok {
|
||||
// Logged with the raw frame: the register layout has a tail that differs
|
||||
// between models, and a rig that answers something we cannot read is the
|
||||
// one thing worth seeing here.
|
||||
applog.Printf("icom: band stack %d/%d — cannot read the register from % X", band, reg, f.Data)
|
||||
return 0, fmt.Errorf("icom: band stacking register %d/%d not understood", band, reg)
|
||||
}
|
||||
if err := b.SetFrequency(bs.FreqHz); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
// The mode is best-effort. Landing on the right frequency in the wrong mode
|
||||
// is a nuisance; refusing the whole recall over it would send the operator
|
||||
// back to a button that does less.
|
||||
if bs.Mode != 0 {
|
||||
if err := b.setModeBytes(civ.ModeToADIF(bs.Mode, bs.Data), bs.Mode, bs.Data); err != nil {
|
||||
applog.Printf("icom: band stack %d/%d — frequency set, mode 0x%02X refused: %v", band, reg, bs.Mode, err)
|
||||
}
|
||||
}
|
||||
return bs.FreqHz, nil
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ package geo
|
||||
|
||||
import (
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -118,3 +119,65 @@ func NeighbourGrids(lat, lon float64, ring int) []string {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// GridsWithin returns every Maidenhead square whose centre lies within km of
|
||||
// (lat, lon), nearest first, at most max of them.
|
||||
//
|
||||
// NeighbourGrids answers "the ring around here", which is the right shape for a
|
||||
// few hundred kilometres and the wrong one past that: a ring is a square, so
|
||||
// asking for 2000 km through it means 1369 squares, most of them further away
|
||||
// than the ones it left out. This measures instead, and the count then follows
|
||||
// the AREA asked for rather than the corner of a box.
|
||||
//
|
||||
// Nearest first because the caller has to be able to trim: these become one
|
||||
// broker subscription each, and when there are more than can be afforded, the
|
||||
// squares to keep are the close ones.
|
||||
func GridsWithin(lat, lon, km float64, max int) []string {
|
||||
if km <= 0 || max <= 0 {
|
||||
return nil
|
||||
}
|
||||
// One square is 1° of latitude and 2° of longitude. Sweep a box big enough
|
||||
// to hold the circle — a degree of latitude is ~111 km everywhere, and a
|
||||
// degree of longitude never MORE than that, so this cannot cut the circle.
|
||||
steps := int(km/111.0) + 1
|
||||
type cand struct {
|
||||
grid string
|
||||
d float64
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
out := []cand{}
|
||||
for dLat := -steps; dLat <= steps; dLat++ {
|
||||
for dLon := -2 * steps; dLon <= 2*steps; dLon++ {
|
||||
la := lat + float64(dLat)
|
||||
lo := lon + float64(dLon)*2
|
||||
if la > 90 || la < -90 {
|
||||
continue
|
||||
}
|
||||
g := LatLonToGrid(la, lo)
|
||||
if seen[g] {
|
||||
continue
|
||||
}
|
||||
// Measured to the square's own centre, not to the sample point, so
|
||||
// two samples landing in one square agree about how far it is.
|
||||
cLat, cLon, ok := GridToLatLon(g)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
d := HaversineKm(lat, lon, cLat, cLon)
|
||||
if d > km {
|
||||
continue
|
||||
}
|
||||
seen[g] = true
|
||||
out = append(out, cand{g, d})
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].d < out[j].d })
|
||||
if len(out) > max {
|
||||
out = out[:max]
|
||||
}
|
||||
grids := make([]string, len(out))
|
||||
for i, c := range out {
|
||||
grids[i] = c.grid
|
||||
}
|
||||
return grids
|
||||
}
|
||||
|
||||
+31
-5
@@ -140,8 +140,17 @@ func New(cfg Config) *Watcher {
|
||||
// same measurement is 0.2 to 1.2 — the load follows distance, which is what the
|
||||
// feed is actually about, and it is the same for every operator.
|
||||
func (w *Watcher) topics() []string {
|
||||
bands := w.cfg.Bands
|
||||
// One subscription per band per square multiplies, and past a point the
|
||||
// cheaper trade is to take every band from those squares and drop the
|
||||
// unwanted ones here: four bands over six hundred squares is 2400
|
||||
// subscriptions, where "+" is 600 for maybe three times the messages —
|
||||
// which are then filtered locally, as they already are for everything else.
|
||||
if len(bands) > 1 && len(bands)*len(w.cfg.RxGrids) > 1000 {
|
||||
bands = []string{"+"}
|
||||
}
|
||||
out := []string{}
|
||||
for _, b := range w.cfg.Bands {
|
||||
for _, b := range bands {
|
||||
if len(w.cfg.RxGrids) == 0 {
|
||||
out = append(out, "pskr/filter/v2/"+b+"/#")
|
||||
continue
|
||||
@@ -181,13 +190,30 @@ func (w *Watcher) Start() error {
|
||||
|
||||
opts.OnConnect = func(c mqtt.Client) {
|
||||
w.cfg.Logf("pskr: connected to %s", w.cfg.Broker)
|
||||
for _, topic := range w.topics() {
|
||||
if tok := c.Subscribe(topic, 0, w.handle); tok.Wait() && tok.Error() != nil {
|
||||
w.cfg.Logf("pskr: subscribe %s failed: %v", topic, tok.Error())
|
||||
topics := w.topics()
|
||||
// In batches, not one at a time. Each Subscribe waits for its own
|
||||
// acknowledgement, which is fine for the nine squares this started with
|
||||
// and is minutes of waiting for the six hundred a 2000 km radius asks
|
||||
// for — during which the feed is only partly subscribed and the panel
|
||||
// looks broken.
|
||||
const batch = 100
|
||||
subbed := 0
|
||||
for i := 0; i < len(topics); i += batch {
|
||||
end := i + batch
|
||||
if end > len(topics) {
|
||||
end = len(topics)
|
||||
}
|
||||
filters := make(map[string]byte, end-i)
|
||||
for _, t := range topics[i:end] {
|
||||
filters[t] = 0
|
||||
}
|
||||
if tok := c.SubscribeMultiple(filters, w.handle); tok.Wait() && tok.Error() != nil {
|
||||
w.cfg.Logf("pskr: subscribing to %d topics failed: %v", len(filters), tok.Error())
|
||||
continue
|
||||
}
|
||||
w.cfg.Logf("pskr: watching %s", topic)
|
||||
subbed += len(filters)
|
||||
}
|
||||
w.cfg.Logf("pskr: watching %d topics (%d bands × %d receiver squares)", subbed, len(w.cfg.Bands), max(len(w.cfg.RxGrids), 1))
|
||||
}
|
||||
opts.OnConnectionLost = func(_ mqtt.Client, err error) {
|
||||
w.mu.Lock()
|
||||
|
||||
@@ -0,0 +1,863 @@
|
||||
// Package pskrtgt answers one question about one station: can they hear me?
|
||||
//
|
||||
// It is the other way round from internal/pskr. That watcher asks what is
|
||||
// happening AROUND HERE — reports collected near the operator, whoever sent
|
||||
// them — and it is the right shape for finding a band opening or a new entity.
|
||||
// This one starts from a callsign the operator wants to work and gathers the
|
||||
// evidence about that path, in both directions:
|
||||
//
|
||||
// - did the DX decode MY call, and how long ago
|
||||
// - who NEAR ME did the DX decode (the path is open at my end)
|
||||
// - who near the DX decoded ME (the path is open at his end, even when he
|
||||
// uploads nothing himself)
|
||||
// - how many stations he is decoding right now (the pileup I am up against)
|
||||
// - where in his receive passband those decodes land, so a caller can pick a
|
||||
// slot he is not already covered on
|
||||
//
|
||||
// Nothing here is persisted and nothing is inferred from a QSO: it is a sliding
|
||||
// window of PSK Reporter reports, and when the window empties the answer goes
|
||||
// back to "not known", which is the honest answer.
|
||||
//
|
||||
// The window is FIVE minutes. An FT8 cycle is fifteen seconds, so that is
|
||||
// twenty chances for a path to show itself — short enough that "he decoded you"
|
||||
// still means now, long enough that one missed cycle does not erase it.
|
||||
package pskrtgt
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
mqtt "github.com/eclipse/paho.mqtt.golang"
|
||||
)
|
||||
|
||||
// DefaultBroker is PSK Reporter's public MQTT endpoint, TLS. Same one the
|
||||
// band-opening watcher uses — two connections to it, because the two want
|
||||
// opposite slices of the feed and neither can be filtered out of the other's.
|
||||
const DefaultBroker = "tls://mqtt.pskreporter.info:1884"
|
||||
|
||||
const (
|
||||
// window is how far back a report still counts.
|
||||
//
|
||||
// TEN minutes. Five was chosen as "recent enough to still mean now", and on
|
||||
// the air it meant half the evidence: PSK Reporter's uploaders batch their
|
||||
// reports, many of them every five minutes, so a five-minute window catches
|
||||
// roughly one upload cycle per station. Side by side with DXHunter on the
|
||||
// same DX, the same second: 18 decodes here against 27 there, and a station
|
||||
// missing from "from your area" that was simply six minutes old.
|
||||
//
|
||||
// It is a window on ONE station's activity, not on the band: ten minutes of
|
||||
// a DX working a pileup is still what he is doing now.
|
||||
window = 10 * time.Minute
|
||||
// pileupWindow is the tighter one for "how many stations is he working
|
||||
// through". A station he decoded four minutes ago has very likely moved on,
|
||||
// and counting it inflates the only number an operator uses to decide
|
||||
// whether it is worth calling at all.
|
||||
pileupWindow = 2 * time.Minute
|
||||
|
||||
// The passband histogram: 60 Hz bins from 200 Hz to 4000 Hz. Above 4 kHz
|
||||
// there is essentially no FT8, and drawing the empty space made the strip
|
||||
// look broken rather than empty.
|
||||
binHz = 60
|
||||
lowHz = 200
|
||||
highHz = 4000
|
||||
)
|
||||
|
||||
// Scope decides how much of the feed is subscribed to.
|
||||
type Scope string
|
||||
|
||||
const (
|
||||
// ScopeTarget subscribes to three filters: what the DX transmits, what he
|
||||
// receives, and who hears the operator. A handful of messages a second, and
|
||||
// the REST backfill fills the window the moment the target changes.
|
||||
ScopeTarget Scope = "target"
|
||||
// ScopeBand subscribes to the whole band's FTx traffic. Switching target is
|
||||
// then instant with no backfill, at the cost of every message on the band —
|
||||
// hundreds a second when 20 m is busy.
|
||||
ScopeBand Scope = "band"
|
||||
)
|
||||
|
||||
// spot is one PSK Reporter reception report, as the v2 payload carries it.
|
||||
type spot struct {
|
||||
Freq int64 `json:"f"`
|
||||
Mode string `json:"md"`
|
||||
SNR int `json:"rp"`
|
||||
TxCall string `json:"sc"`
|
||||
TxGrid string `json:"sl"`
|
||||
RxCall string `json:"rc"`
|
||||
RxGrid string `json:"rl"`
|
||||
Band string `json:"b"`
|
||||
// at is stamped on arrival. The payload's own timestamps differ between
|
||||
// versions of the feed, and everything here is measured in minutes.
|
||||
at time.Time
|
||||
}
|
||||
|
||||
// Entry is one station in one of the lists the panel shows.
|
||||
type Entry struct {
|
||||
Call string `json:"call"`
|
||||
Grid string `json:"grid"`
|
||||
SNR int `json:"snr"`
|
||||
OffsetHz int `json:"offset_hz"` // audio offset from the operator's dial
|
||||
AgeSec int `json:"age_sec"`
|
||||
}
|
||||
|
||||
// Bin is one 60 Hz slice of the DX's receive passband.
|
||||
type Bin struct {
|
||||
OffsetHz int `json:"offset_hz"`
|
||||
Count int `json:"count"`
|
||||
AvgSNR float64 `json:"avg_snr"`
|
||||
}
|
||||
|
||||
// Analysis is the whole snapshot the panel draws, recomputed on demand.
|
||||
type Analysis struct {
|
||||
Target string `json:"target"`
|
||||
Mode string `json:"mode,omitempty"`
|
||||
// Enabled is the operator's switch; Online is whether the broker is
|
||||
// actually connected. A panel that says nothing has to be able to say WHY.
|
||||
Enabled bool `json:"enabled"`
|
||||
Online bool `json:"online"`
|
||||
// Spots is everything in the window, the sign that the feed is alive even
|
||||
// when every counter below is legitimately zero.
|
||||
Spots int `json:"spots"`
|
||||
|
||||
// HeMe is the answer to the question. The rest is what to do when it is no.
|
||||
HeMe bool `json:"he_me"`
|
||||
HeMeSeconds int `json:"he_me_seconds"`
|
||||
HeMeSNR int `json:"he_me_snr"`
|
||||
HeMeOffset int `json:"he_me_offset_hz"`
|
||||
|
||||
// TargetUploads distinguishes "he is not hearing anybody" from "his software
|
||||
// tells PSK Reporter nothing" — without it, a silent panel reads as a dead
|
||||
// band when it may be a full one.
|
||||
TargetUploads bool `json:"target_uploads"`
|
||||
TargetGrid string `json:"target_grid,omitempty"`
|
||||
|
||||
// Near the DX: stations in his square that decoded the operator. This is
|
||||
// what still works when he uploads nothing himself.
|
||||
NearHimCount int `json:"near_him_count"`
|
||||
NearHimTop []Entry `json:"near_him_top"`
|
||||
|
||||
// Near the operator: stations in his own field that the DX decoded.
|
||||
FromMyAreaCount int `json:"from_my_area_count"`
|
||||
FromMyAreaTop []Entry `json:"from_my_area_top"`
|
||||
PathOpen bool `json:"path_open"`
|
||||
|
||||
// Who heard the DX, worldwide and locally.
|
||||
HeardByCount int `json:"heard_by_count"`
|
||||
HeardNearMe int `json:"heard_near_me"`
|
||||
HeardNearMeTop []Entry `json:"heard_near_me_top"`
|
||||
|
||||
// The pileup: everyone he decoded (window), and the recent slice of it.
|
||||
DecodedByCount int `json:"decoded_by_count"`
|
||||
DecodedByTop []Entry `json:"decoded_by_top"`
|
||||
DecodedByCalls []string `json:"decoded_by_calls"`
|
||||
PileupCount int `json:"pileup_count"`
|
||||
|
||||
// His receive passband, and a slot in it that nobody is using.
|
||||
DialHz int64 `json:"dial_hz"`
|
||||
CeilingHz int `json:"ceiling_hz"`
|
||||
DecodesInWindow int `json:"decodes_in_window"`
|
||||
Bins []Bin `json:"bins"`
|
||||
SuggestedOffset int `json:"suggested_offset"`
|
||||
}
|
||||
|
||||
// Config is what the watcher needs from the application.
|
||||
type Config struct {
|
||||
Broker string
|
||||
Scope Scope
|
||||
// MyCall and MyGrid are the operator's. Both matter: the callsign is what
|
||||
// "he decoded you" is looked up by, and the grid decides what counts as
|
||||
// "near me" — its first two characters, a Maidenhead FIELD, which is a few
|
||||
// hundred kilometres rather than a whole continent.
|
||||
MyCall string
|
||||
MyGrid string
|
||||
// Continent resolves a callsign to EU/NA/AS/… It is only a FALLBACK, for an
|
||||
// operator whose grid is not set: without a grid there is nothing to compare
|
||||
// squares with, and a continent is better than nothing. Injected so this
|
||||
// package does not pull in the country file.
|
||||
Continent func(call string) string
|
||||
Logf func(string, ...any)
|
||||
}
|
||||
|
||||
// Watcher owns the MQTT connection and the window.
|
||||
type Watcher struct {
|
||||
mu sync.Mutex
|
||||
cfg Config
|
||||
client mqtt.Client
|
||||
|
||||
target string // the callsign being analysed, upper case
|
||||
mode string // FT8 / FT4 — the target's mode, for the band-scope topic
|
||||
band string // band tag currently subscribed to under ScopeBand
|
||||
dialHz int64 // the operator's dial, for audio offsets
|
||||
|
||||
// subs is what we are subscribed to right now, so a target change can take
|
||||
// the old filters down without guessing at their shape.
|
||||
subs []string
|
||||
spots []spot
|
||||
|
||||
// backfilled remembers the target the REST history was fetched for, so the
|
||||
// panel's polling cannot re-fetch it every second. PSK Reporter's query API
|
||||
// answers that with a rate limit, and rightly.
|
||||
backfilled string
|
||||
}
|
||||
|
||||
func New(cfg Config) *Watcher {
|
||||
if cfg.Broker == "" {
|
||||
cfg.Broker = DefaultBroker
|
||||
}
|
||||
if cfg.Scope == "" {
|
||||
cfg.Scope = ScopeTarget
|
||||
}
|
||||
if cfg.Logf == nil {
|
||||
cfg.Logf = func(string, ...any) {}
|
||||
}
|
||||
return &Watcher{cfg: cfg}
|
||||
}
|
||||
|
||||
// Watch points the analysis at a callsign. Connects on the first call, so an
|
||||
// operator who never opens the panel never opens a socket.
|
||||
//
|
||||
// Called repeatedly with the same target — the panel re-asserts it as the
|
||||
// operator works — so everything expensive here is guarded on an actual change.
|
||||
func (w *Watcher) Watch(target, mode string, dialHz int64) error {
|
||||
target = strings.ToUpper(strings.TrimSpace(target))
|
||||
mode = strings.ToUpper(strings.TrimSpace(mode))
|
||||
if mode == "" {
|
||||
mode = "FT8"
|
||||
}
|
||||
if target == "" {
|
||||
w.Stop()
|
||||
return nil
|
||||
}
|
||||
|
||||
w.mu.Lock()
|
||||
changed := target != w.target || mode != w.mode
|
||||
w.target, w.mode = target, mode
|
||||
if dialHz > 0 {
|
||||
w.dialHz = dialHz
|
||||
}
|
||||
band := bandTag(w.dialHz)
|
||||
bandChanged := band != "" && band != w.band
|
||||
// Set BEFORE any connect: the subscription is built from it, and a first
|
||||
// connect that found it empty would subscribe to every band at once under
|
||||
// the band-wide scope — the one case where that is expensive.
|
||||
if band != "" {
|
||||
w.band = band
|
||||
}
|
||||
client := w.client
|
||||
w.mu.Unlock()
|
||||
|
||||
if client == nil || !client.IsConnected() {
|
||||
c, err := w.connect()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w.mu.Lock()
|
||||
w.client, client = c, c
|
||||
w.mu.Unlock()
|
||||
// connect() subscribes on its own OnConnect handler; anything below
|
||||
// would only repeat it.
|
||||
changed = false
|
||||
bandChanged = false
|
||||
}
|
||||
if !changed && !bandChanged {
|
||||
return nil
|
||||
}
|
||||
|
||||
w.mu.Lock()
|
||||
// The window belongs to the target it was collected for. Keeping it across a
|
||||
// change would answer the new question with the old station's evidence.
|
||||
if changed {
|
||||
w.spots = w.spots[:0]
|
||||
}
|
||||
w.mu.Unlock()
|
||||
|
||||
if err := w.resubscribe(client); err != nil {
|
||||
return err
|
||||
}
|
||||
if changed && w.cfg.Scope == ScopeTarget {
|
||||
// Under the band-wide subscription the window is already full of the new
|
||||
// target's reports; under the narrow one it is empty, and the REST query
|
||||
// is what makes the panel useful in the first fifteen seconds instead of
|
||||
// after five minutes.
|
||||
go w.backfill(target, mode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resubscribe replaces every filter with the ones the current target and scope
|
||||
// want. Takes the old ones down first: a target change that only ADDED filters
|
||||
// would leave the previous station's reports arriving for ever.
|
||||
func (w *Watcher) resubscribe(c mqtt.Client) error {
|
||||
w.mu.Lock()
|
||||
old := w.subs
|
||||
topics := w.topicsLocked()
|
||||
w.subs = topics
|
||||
target, scope := w.target, w.cfg.Scope
|
||||
w.mu.Unlock()
|
||||
|
||||
if len(old) > 0 {
|
||||
if tok := c.Unsubscribe(old...); tok.Wait() && tok.Error() != nil {
|
||||
w.cfg.Logf("pskr target: unsubscribe failed: %v", tok.Error())
|
||||
}
|
||||
}
|
||||
if len(topics) == 0 {
|
||||
return nil
|
||||
}
|
||||
filters := make(map[string]byte, len(topics))
|
||||
for _, t := range topics {
|
||||
filters[t] = 0
|
||||
}
|
||||
if tok := c.SubscribeMultiple(filters, w.handle); tok.Wait() && tok.Error() != nil {
|
||||
return fmt.Errorf("pskr target: subscribe: %w", tok.Error())
|
||||
}
|
||||
w.cfg.Logf("pskr target: watching %s (%s scope, %d filters)", target, scope, len(topics))
|
||||
return nil
|
||||
}
|
||||
|
||||
// topicsLocked builds the subscription list. The v2 topic is
|
||||
//
|
||||
// pskr/filter/v2/<band>/<mode>/<tx call>/<rx call>/<tx grid>/<rx grid>/<tx dxcc>/<rx dxcc>
|
||||
//
|
||||
// so both directions of one callsign are addressable at the broker, which is
|
||||
// the whole reason the narrow scope costs almost nothing.
|
||||
func (w *Watcher) topicsLocked() []string {
|
||||
if w.target == "" {
|
||||
return nil
|
||||
}
|
||||
if w.cfg.Scope == ScopeBand {
|
||||
band := w.band
|
||||
if band == "" {
|
||||
band = "+"
|
||||
}
|
||||
return []string{"pskr/filter/v2/" + band + "/" + w.mode + "/#"}
|
||||
}
|
||||
out := []string{
|
||||
// What he is transmitting: who is hearing him.
|
||||
"pskr/filter/v2/+/" + w.mode + "/" + w.target + "/#",
|
||||
// What he is receiving: the pileup, and whether the operator is in it.
|
||||
"pskr/filter/v2/+/" + w.mode + "/+/" + w.target + "/#",
|
||||
}
|
||||
// Who hears the OPERATOR. Only some of those receivers are near the DX, and
|
||||
// those are the ones that answer "can I be heard over there" on a DX who
|
||||
// uploads nothing himself. Left out when the callsign is not configured
|
||||
// rather than subscribing to a filter with an empty level in it.
|
||||
if my := strings.ToUpper(strings.TrimSpace(w.cfg.MyCall)); my != "" {
|
||||
out = append(out, "pskr/filter/v2/+/"+w.mode+"/"+my+"/#")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (w *Watcher) connect() (mqtt.Client, error) {
|
||||
opts := mqtt.NewClientOptions().
|
||||
AddBroker(w.cfg.Broker).
|
||||
SetClientID(fmt.Sprintf("opslog-tgt-%d", time.Now().UnixNano())).
|
||||
SetCleanSession(true).
|
||||
SetAutoReconnect(true).
|
||||
SetConnectRetry(true).
|
||||
SetConnectRetryInterval(30 * time.Second).
|
||||
SetConnectTimeout(15 * time.Second).
|
||||
SetOrderMatters(false)
|
||||
// Re-subscribe on every connect, reconnects included: the session is clean,
|
||||
// so the broker remembers nothing and a dropped link would otherwise come
|
||||
// back up subscribed to nothing at all — a panel that goes quiet for ever
|
||||
// while still saying "online".
|
||||
opts.OnConnect = func(c mqtt.Client) {
|
||||
w.mu.Lock()
|
||||
w.subs = nil
|
||||
target, mode := w.target, w.mode
|
||||
w.mu.Unlock()
|
||||
if err := w.resubscribe(c); err != nil {
|
||||
w.cfg.Logf("pskr target: %v", err)
|
||||
}
|
||||
if target != "" && w.cfg.Scope == ScopeTarget {
|
||||
go w.backfill(target, mode)
|
||||
}
|
||||
}
|
||||
opts.OnConnectionLost = func(_ mqtt.Client, err error) {
|
||||
w.cfg.Logf("pskr target: connection lost: %v (will retry)", err)
|
||||
}
|
||||
c := mqtt.NewClient(opts)
|
||||
tok := c.Connect()
|
||||
if !tok.WaitTimeout(15*time.Second) || tok.Error() != nil {
|
||||
err := tok.Error()
|
||||
if err == nil {
|
||||
err = fmt.Errorf("timeout")
|
||||
}
|
||||
return nil, fmt.Errorf("pskr target: connect %s: %w", w.cfg.Broker, err)
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (w *Watcher) handle(_ mqtt.Client, m mqtt.Message) {
|
||||
var s spot
|
||||
if err := json.Unmarshal(m.Payload(), &s); err != nil {
|
||||
return
|
||||
}
|
||||
if s.TxCall == "" || s.RxCall == "" {
|
||||
return
|
||||
}
|
||||
s.TxCall = strings.ToUpper(s.TxCall)
|
||||
s.RxCall = strings.ToUpper(s.RxCall)
|
||||
s.TxGrid = strings.ToUpper(s.TxGrid)
|
||||
s.RxGrid = strings.ToUpper(s.RxGrid)
|
||||
s.at = time.Now()
|
||||
w.mu.Lock()
|
||||
w.spots = append(w.spots, s)
|
||||
w.mu.Unlock()
|
||||
}
|
||||
|
||||
// Stop drops the target and the connection. The window goes with it: it is
|
||||
// evidence about a station nobody is asking about any more.
|
||||
func (w *Watcher) Stop() {
|
||||
w.mu.Lock()
|
||||
c := w.client
|
||||
w.client, w.target, w.band, w.subs, w.backfilled = nil, "", "", nil, ""
|
||||
w.spots = nil
|
||||
w.mu.Unlock()
|
||||
if c != nil {
|
||||
c.Disconnect(250)
|
||||
}
|
||||
}
|
||||
|
||||
// SetDial updates the frequency audio offsets are measured against.
|
||||
func (w *Watcher) SetDial(hz int64) {
|
||||
if hz <= 0 {
|
||||
return
|
||||
}
|
||||
w.mu.Lock()
|
||||
w.dialHz = hz
|
||||
w.mu.Unlock()
|
||||
}
|
||||
|
||||
// SetOperator refreshes the operator's own callsign and grid. Called when the
|
||||
// station profile changes: every "near me" answer is measured from these, and a
|
||||
// stale pair would quietly measure them from somebody else's station.
|
||||
func (w *Watcher) SetOperator(call, grid string) {
|
||||
w.mu.Lock()
|
||||
w.cfg.MyCall = strings.ToUpper(strings.TrimSpace(call))
|
||||
w.cfg.MyGrid = strings.ToUpper(strings.TrimSpace(grid))
|
||||
w.mu.Unlock()
|
||||
}
|
||||
|
||||
// Snapshot recomputes the analysis from the window.
|
||||
func (w *Watcher) Snapshot() Analysis {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
cutoff := now.Add(-window)
|
||||
kept := w.spots[:0]
|
||||
for _, s := range w.spots {
|
||||
if s.at.After(cutoff) {
|
||||
kept = append(kept, s)
|
||||
}
|
||||
}
|
||||
w.spots = kept
|
||||
|
||||
a := Analysis{
|
||||
Target: w.target,
|
||||
Mode: w.mode,
|
||||
Online: w.client != nil && w.client.IsConnected(),
|
||||
DialHz: w.dialHz,
|
||||
Spots: len(w.spots),
|
||||
}
|
||||
if w.target == "" {
|
||||
return a
|
||||
}
|
||||
|
||||
myCall := strings.ToUpper(strings.TrimSpace(w.cfg.MyCall))
|
||||
myField := ""
|
||||
if g := strings.ToUpper(strings.TrimSpace(w.cfg.MyGrid)); len(g) >= 2 {
|
||||
myField = g[:2]
|
||||
}
|
||||
myCont := ""
|
||||
if myField == "" && myCall != "" && w.cfg.Continent != nil {
|
||||
myCont = strings.ToUpper(w.cfg.Continent(myCall))
|
||||
}
|
||||
|
||||
// His square, taken from any report where he was transmitting. It is what
|
||||
// "near him" is measured against, so without it that whole answer is
|
||||
// unavailable rather than approximated.
|
||||
for i := range w.spots {
|
||||
if w.spots[i].TxCall == w.target && len(w.spots[i].TxGrid) >= 4 {
|
||||
a.TargetGrid = w.spots[i].TxGrid[:4]
|
||||
}
|
||||
}
|
||||
|
||||
entry := func(call, grid string, s *spot) Entry {
|
||||
off := 0
|
||||
if w.dialHz > 0 {
|
||||
off = int(s.Freq - w.dialHz)
|
||||
}
|
||||
return Entry{Call: call, Grid: grid, SNR: s.SNR, OffsetHz: off,
|
||||
AgeSec: int(now.Sub(s.at).Seconds())}
|
||||
}
|
||||
// One entry per station, overwritten as newer reports arrive, so a station
|
||||
// calling every cycle counts once and shows its latest report.
|
||||
heardBy := map[string]Entry{}
|
||||
heardNearMe := map[string]Entry{}
|
||||
fromMyArea := map[string]Entry{}
|
||||
decodedBy := map[string]Entry{}
|
||||
nearHim := map[string]Entry{}
|
||||
pileup := map[string]struct{}{}
|
||||
pileupCutoff := now.Add(-pileupWindow)
|
||||
|
||||
type acc struct {
|
||||
n int
|
||||
sum float64
|
||||
}
|
||||
bins := map[int]*acc{}
|
||||
var lastHeMe *spot
|
||||
|
||||
near := func(theirGrid, call string) bool {
|
||||
if myField != "" {
|
||||
return strings.HasPrefix(strings.ToUpper(theirGrid), myField)
|
||||
}
|
||||
if myCont != "" && w.cfg.Continent != nil {
|
||||
return strings.ToUpper(w.cfg.Continent(call)) == myCont
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
for i := range w.spots {
|
||||
s := &w.spots[i]
|
||||
|
||||
// He transmitted: somebody heard him.
|
||||
if s.TxCall == w.target {
|
||||
e := entry(s.RxCall, s.RxGrid, s)
|
||||
heardBy[s.RxCall] = e
|
||||
if near(s.RxGrid, s.RxCall) {
|
||||
heardNearMe[s.RxCall] = e
|
||||
}
|
||||
}
|
||||
|
||||
// The operator transmitted and a station in the DX's own square heard
|
||||
// it. That is a path to his region, proved without his help.
|
||||
if a.TargetGrid != "" && myCall != "" && s.TxCall == myCall &&
|
||||
strings.HasPrefix(s.RxGrid, a.TargetGrid) {
|
||||
nearHim[s.RxCall] = entry(s.RxCall, s.RxGrid, s)
|
||||
}
|
||||
|
||||
// He received: this is the pileup, the passband, and the answer.
|
||||
if s.RxCall == w.target {
|
||||
a.DecodesInWindow++
|
||||
if s.TxCall == myCall {
|
||||
if lastHeMe == nil || s.at.After(lastHeMe.at) {
|
||||
lastHeMe = s
|
||||
}
|
||||
continue // the operator is not part of his own pileup
|
||||
}
|
||||
decodedBy[s.TxCall] = entry(s.TxCall, s.TxGrid, s)
|
||||
if s.at.After(pileupCutoff) {
|
||||
pileup[s.TxCall] = struct{}{}
|
||||
}
|
||||
if near(s.TxGrid, s.TxCall) {
|
||||
fromMyArea[s.TxCall] = entry(s.TxCall, s.TxGrid, s)
|
||||
}
|
||||
if w.dialHz > 0 {
|
||||
off := int(s.Freq - w.dialHz)
|
||||
if off >= lowHz && off <= highHz {
|
||||
edge := (off / binHz) * binHz
|
||||
b := bins[edge]
|
||||
if b == nil {
|
||||
b = &acc{}
|
||||
bins[edge] = b
|
||||
}
|
||||
b.n++
|
||||
b.sum += float64(s.SNR)
|
||||
if off > a.CeilingHz {
|
||||
a.CeilingHz = off
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if lastHeMe != nil {
|
||||
a.HeMe = true
|
||||
a.HeMeSeconds = int(now.Sub(lastHeMe.at).Seconds())
|
||||
a.HeMeSNR = lastHeMe.SNR
|
||||
if w.dialHz > 0 {
|
||||
a.HeMeOffset = int(lastHeMe.Freq - w.dialHz)
|
||||
}
|
||||
}
|
||||
a.TargetUploads = a.DecodesInWindow > 0
|
||||
a.HeardByCount = len(heardBy)
|
||||
a.HeardNearMe = len(heardNearMe)
|
||||
a.HeardNearMeTop = top(heardNearMe, 5)
|
||||
a.FromMyAreaCount = len(fromMyArea)
|
||||
a.FromMyAreaTop = top(fromMyArea, 5)
|
||||
a.PathOpen = a.FromMyAreaCount > 0
|
||||
a.NearHimCount = len(nearHim)
|
||||
a.NearHimTop = top(nearHim, 5)
|
||||
a.DecodedByCount = len(decodedBy)
|
||||
a.DecodedByTop = top(decodedBy, 10)
|
||||
a.DecodedByCalls = make([]string, 0, len(decodedBy))
|
||||
for c := range decodedBy {
|
||||
a.DecodedByCalls = append(a.DecodedByCalls, c)
|
||||
}
|
||||
sort.Strings(a.DecodedByCalls)
|
||||
a.PileupCount = len(pileup)
|
||||
|
||||
a.Bins = make([]Bin, 0, len(bins))
|
||||
for edge, b := range bins {
|
||||
avg := 0.0
|
||||
if b.n > 0 {
|
||||
avg = b.sum / float64(b.n)
|
||||
}
|
||||
a.Bins = append(a.Bins, Bin{OffsetHz: edge, Count: b.n, AvgSNR: avg})
|
||||
}
|
||||
sort.Slice(a.Bins, func(i, j int) bool { return a.Bins[i].OffsetHz < a.Bins[j].OffsetHz })
|
||||
a.SuggestedOffset = suggestOffset(a.Bins, a.CeilingHz)
|
||||
return a
|
||||
}
|
||||
|
||||
// top returns the freshest entries from a per-callsign map, newest first.
|
||||
func top(m map[string]Entry, limit int) []Entry {
|
||||
out := make([]Entry, 0, len(m))
|
||||
for _, e := range m {
|
||||
out = append(out, e)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].AgeSec < out[j].AgeSec })
|
||||
if len(out) > limit {
|
||||
out = out[:limit]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// suggestOffset picks an audio slot to call on: the middle of the widest run of
|
||||
// empty bins below the ceiling.
|
||||
//
|
||||
// Below the CEILING, not below 4000 Hz. The ceiling is the highest offset he
|
||||
// has actually decoded, and it is the only evidence available about how wide
|
||||
// his receiver is set — plenty of stations run 2500 Hz. Suggesting 3400 Hz to
|
||||
// somebody whose passband stops at 2700 is advice to transmit into a filter.
|
||||
func suggestOffset(bins []Bin, ceiling int) int {
|
||||
if ceiling < 1000 {
|
||||
return 0
|
||||
}
|
||||
used := map[int]bool{}
|
||||
for _, b := range bins {
|
||||
if b.Count > 0 {
|
||||
used[b.OffsetHz] = true
|
||||
// The neighbours too: FT8 is 50 Hz wide and the bins are 60, so a
|
||||
// signal on a bin edge covers the next one as surely as its own.
|
||||
used[b.OffsetHz-binHz] = true
|
||||
used[b.OffsetHz+binHz] = true
|
||||
}
|
||||
}
|
||||
bestStart, bestLen := -1, 0
|
||||
start, run := -1, 0
|
||||
// From 1000 Hz up: below that is where every default transmit offset sits,
|
||||
// so it is the most crowded part of the passband and the least useful
|
||||
// advice.
|
||||
for edge := 1020; edge+binHz <= ceiling; edge += binHz {
|
||||
if used[edge] {
|
||||
start, run = -1, 0
|
||||
continue
|
||||
}
|
||||
if start < 0 {
|
||||
start = edge
|
||||
}
|
||||
run++
|
||||
if run > bestLen {
|
||||
bestStart, bestLen = start, run
|
||||
}
|
||||
}
|
||||
if bestStart < 0 || bestLen < 2 {
|
||||
return 0
|
||||
}
|
||||
return bestStart + bestLen*binHz/2
|
||||
}
|
||||
|
||||
// bandTag names the band a dial frequency is on, in PSK Reporter's own
|
||||
// vocabulary ("20m"). Only used by the band-wide scope, to subscribe to one
|
||||
// band instead of all of them.
|
||||
func bandTag(hz int64) string {
|
||||
khz := hz / 1000
|
||||
switch {
|
||||
case khz >= 1800 && khz <= 2000:
|
||||
return "160m"
|
||||
case khz >= 3500 && khz <= 4000:
|
||||
return "80m"
|
||||
case khz >= 5250 && khz <= 5450:
|
||||
return "60m"
|
||||
case khz >= 7000 && khz <= 7300:
|
||||
return "40m"
|
||||
case khz >= 10100 && khz <= 10150:
|
||||
return "30m"
|
||||
case khz >= 14000 && khz <= 14350:
|
||||
return "20m"
|
||||
case khz >= 18068 && khz <= 18168:
|
||||
return "17m"
|
||||
case khz >= 21000 && khz <= 21450:
|
||||
return "15m"
|
||||
case khz >= 24890 && khz <= 24990:
|
||||
return "12m"
|
||||
case khz >= 28000 && khz <= 29700:
|
||||
return "10m"
|
||||
case khz >= 50000 && khz <= 54000:
|
||||
return "6m"
|
||||
case khz >= 70000 && khz <= 70500:
|
||||
return "4m"
|
||||
case khz >= 144000 && khz <= 148000:
|
||||
return "2m"
|
||||
case khz >= 430000 && khz <= 440000:
|
||||
return "70cm"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ── REST backfill ─────────────────────────────────────────────────────────
|
||||
//
|
||||
// The narrow subscription starts empty, and five minutes of waiting is not an
|
||||
// answer to "should I call this station now". PSK Reporter's query API hands
|
||||
// back the last quarter hour in one request, so the window is populated before
|
||||
// the first cycle finishes.
|
||||
//
|
||||
// Fetched ONCE per target. The panel polls every second, and a query per poll
|
||||
// is what gets an application rate-limited off the service for everyone.
|
||||
|
||||
type pskrReport struct {
|
||||
Sender string `xml:"senderCallsign,attr"`
|
||||
SenderGrid string `xml:"senderLocator,attr"`
|
||||
Receiver string `xml:"receiverCallsign,attr"`
|
||||
ReceiverGrid string `xml:"receiverLocator,attr"`
|
||||
Frequency string `xml:"frequency,attr"`
|
||||
SNR string `xml:"sNR,attr"`
|
||||
Mode string `xml:"mode,attr"`
|
||||
FlowStartSecs string `xml:"flowStartSeconds,attr"`
|
||||
}
|
||||
|
||||
type pskrReports struct {
|
||||
XMLName xml.Name `xml:"receptionReports"`
|
||||
Reports []pskrReport `xml:"receptionReport"`
|
||||
}
|
||||
|
||||
// backfill fetches the last quarter hour for a target, in BOTH directions.
|
||||
//
|
||||
// Two queries, because the panel asks two questions and the service answers
|
||||
// them separately: what the target RECEIVED (his pileup, the passband, whether
|
||||
// he decoded us) and what he TRANSMITTED (who is hearing him, and how much of
|
||||
// that is near us). The live feed fills both eventually; a target picked ten
|
||||
// seconds ago has neither, and with the narrow subscription there is nothing in
|
||||
// the window at all until his own uploader next reports.
|
||||
//
|
||||
// Fetched ONCE per target. The panel polls every second, and a query per poll
|
||||
// is what gets an application rate-limited off the service for everyone.
|
||||
func (w *Watcher) backfill(target, mode string) {
|
||||
w.mu.Lock()
|
||||
if w.backfilled == target {
|
||||
w.mu.Unlock()
|
||||
return
|
||||
}
|
||||
w.backfilled = target
|
||||
w.mu.Unlock()
|
||||
|
||||
got := 0
|
||||
for _, dir := range []struct{ param, what string }{
|
||||
{"receiverCallsign", "decoded by him"},
|
||||
{"senderCallsign", "who is hearing him"},
|
||||
} {
|
||||
q := url.Values{}
|
||||
q.Set(dir.param, target)
|
||||
q.Set("mode", mode)
|
||||
q.Set("flowStartSeconds", strconv.Itoa(-900))
|
||||
q.Set("nolocator", "0")
|
||||
// The pskquery5 endpoint rather than retrieve.pskreporter.info: this is
|
||||
// the one DXHunter has been using against the live service, and a
|
||||
// backfill that silently returns nothing is worse than none at all.
|
||||
req, err := http.NewRequest("GET", "https://pskreporter.info/cgi-bin/pskquery5.pl?"+q.Encode(), nil)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
req.Header.Set("User-Agent", "OpsLog (PSK Reporter target analysis)")
|
||||
resp, err := (&http.Client{Timeout: 15 * time.Second}).Do(req)
|
||||
if err != nil {
|
||||
w.cfg.Logf("pskr target: history for %s (%s) unavailable: %v", target, dir.what, err)
|
||||
continue
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
// 503 is the service saying "too often". Worth a line, because the
|
||||
// panel then fills at the live feed's pace and looks slow for no
|
||||
// visible reason.
|
||||
w.cfg.Logf("pskr target: history for %s (%s) refused (HTTP %d)", target, dir.what, resp.StatusCode)
|
||||
resp.Body.Close()
|
||||
continue
|
||||
}
|
||||
var rr pskrReports
|
||||
err = xml.NewDecoder(resp.Body).Decode(&rr)
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
got += w.absorb(target, rr.Reports)
|
||||
}
|
||||
if got > 0 {
|
||||
w.cfg.Logf("pskr target: %d recent reports for %s from the history queries", got, target)
|
||||
}
|
||||
}
|
||||
|
||||
// absorb adds fetched reports to the window, skipping what the live feed has
|
||||
// already delivered. Without the check the same report arrives twice — once by
|
||||
// MQTT, once by query — and every count that is not per-callsign doubles: the
|
||||
// decode total, and the bars of the passband.
|
||||
func (w *Watcher) absorb(target string, reports []pskrReport) int {
|
||||
now := time.Now()
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
// Still the same target? The operator may have moved on while this was in
|
||||
// flight, and dropping a stale answer into the window would attribute one
|
||||
// station's pileup to another.
|
||||
if w.target != target {
|
||||
return 0
|
||||
}
|
||||
type key struct {
|
||||
tx, rx string
|
||||
hz int64
|
||||
}
|
||||
seen := make(map[key]bool, len(w.spots))
|
||||
for i := range w.spots {
|
||||
seen[key{w.spots[i].TxCall, w.spots[i].RxCall, w.spots[i].Freq}] = true
|
||||
}
|
||||
added := 0
|
||||
for _, r := range reports {
|
||||
hz, _ := strconv.ParseInt(r.Frequency, 10, 64)
|
||||
snr, _ := strconv.Atoi(r.SNR)
|
||||
k := key{strings.ToUpper(r.Sender), strings.ToUpper(r.Receiver), hz}
|
||||
if hz == 0 || seen[k] {
|
||||
continue
|
||||
}
|
||||
at := now
|
||||
if secs, err := strconv.ParseInt(r.FlowStartSecs, 10, 64); err == nil {
|
||||
switch {
|
||||
case secs > 1_000_000_000:
|
||||
at = time.Unix(secs, 0) // an absolute time
|
||||
case secs < 0:
|
||||
at = now.Add(time.Duration(secs) * time.Second) // an age in seconds
|
||||
}
|
||||
}
|
||||
// Stamped with its REAL age, so it ages out of the window on its own and
|
||||
// a quarter-hour-old decode is never read as "he heard you just now".
|
||||
if at.Before(now.Add(-window)) {
|
||||
continue
|
||||
}
|
||||
seen[k] = true
|
||||
w.spots = append(w.spots, spot{
|
||||
Freq: hz, Mode: strings.ToUpper(r.Mode), SNR: snr,
|
||||
TxCall: k.tx, TxGrid: strings.ToUpper(r.SenderGrid),
|
||||
RxCall: k.rx, RxGrid: strings.ToUpper(r.ReceiverGrid),
|
||||
at: at,
|
||||
})
|
||||
added++
|
||||
}
|
||||
return added
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package pskrtgt
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// feed builds a watcher with a window already populated, so the analysis can be
|
||||
// pinned without a broker.
|
||||
func feed(target string, spots ...spot) *Watcher {
|
||||
w := New(Config{MyCall: "F4BPO", MyGrid: "JN36BQ"})
|
||||
w.target, w.mode, w.dialHz = target, "FT8", 14_074_000
|
||||
w.spots = spots
|
||||
return w
|
||||
}
|
||||
|
||||
func rep(tx, txGrid, rx, rxGrid string, snr int, offset int, ago time.Duration) spot {
|
||||
return spot{
|
||||
TxCall: tx, TxGrid: txGrid, RxCall: rx, RxGrid: rxGrid,
|
||||
SNR: snr, Freq: 14_074_000 + int64(offset), at: time.Now().Add(-ago),
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeardYouIsTheOperatorsOwnCallOnly(t *testing.T) {
|
||||
w := feed("YI5RLS",
|
||||
rep("F4BPO", "JN36", "YI5RLS", "LM43", -14, 1200, 20*time.Second),
|
||||
rep("F4XYZ", "JN36", "YI5RLS", "LM43", -8, 900, 30*time.Second),
|
||||
)
|
||||
a := w.Snapshot()
|
||||
if !a.HeMe {
|
||||
t.Fatal("the DX decoded the operator and the panel says he did not")
|
||||
}
|
||||
if a.HeMeSNR != -14 || a.HeMeOffset != 1200 {
|
||||
t.Errorf("he_me = %d dB @ %d Hz, want -14 dB @ 1200 Hz", a.HeMeSNR, a.HeMeOffset)
|
||||
}
|
||||
// The operator is not part of the pileup he is calling into: counting
|
||||
// yourself as competition is how a "1 caller" band looks contested.
|
||||
if a.PileupCount != 1 {
|
||||
t.Errorf("pileup = %d, want 1 (the other station only)", a.PileupCount)
|
||||
}
|
||||
// F4XYZ shares the operator's Maidenhead field, so the path from this
|
||||
// region is demonstrably open.
|
||||
if !a.PathOpen || a.FromMyAreaCount != 1 {
|
||||
t.Errorf("from my area = %d (open=%v), want 1 open", a.FromMyAreaCount, a.PathOpen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNearHimNeedsHisSquareAndTheOperatorsCall(t *testing.T) {
|
||||
// He transmits (so his square is known), and a station in that square hears
|
||||
// the operator. He himself has decoded nobody.
|
||||
w := feed("YI5RLS",
|
||||
rep("YI5RLS", "LM43", "OH5CX", "KP30", -3, 0, 40*time.Second),
|
||||
rep("F4BPO", "JN36", "YI9XY", "LM43CC", -19, 1500, 25*time.Second),
|
||||
)
|
||||
a := w.Snapshot()
|
||||
if a.TargetGrid != "LM43" {
|
||||
t.Fatalf("his square = %q, want LM43", a.TargetGrid)
|
||||
}
|
||||
if a.NearHimCount != 1 || len(a.NearHimTop) != 1 || a.NearHimTop[0].Call != "YI9XY" {
|
||||
t.Errorf("near him = %d %v, want the one receiver in his square", a.NearHimCount, a.NearHimTop)
|
||||
}
|
||||
// He uploads nothing: the panel must be able to say so, or an operator
|
||||
// reads an empty panel as a closed band.
|
||||
if a.TargetUploads {
|
||||
t.Error("he received nothing in the window, yet the panel claims he uploads")
|
||||
}
|
||||
if a.HeMe {
|
||||
t.Error("nobody reported HIM decoding the operator")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindowDropsWhatIsTooOld(t *testing.T) {
|
||||
// Inside the window: a report from six minutes ago is still evidence. Five
|
||||
// minutes was too short — measured against DXHunter on the same station at
|
||||
// the same moment, it hid a third of the decodes and a co-area station.
|
||||
w := feed("YI5RLS",
|
||||
rep("F4BPO", "JN36", "YI5RLS", "LM43", -14, 1200, 6*time.Minute),
|
||||
)
|
||||
if a := w.Snapshot(); !a.HeMe {
|
||||
t.Errorf("a six-minute-old report was dropped from a ten-minute window: %+v", a)
|
||||
}
|
||||
// Past it, it goes.
|
||||
w = feed("YI5RLS",
|
||||
rep("F4BPO", "JN36", "YI5RLS", "LM43", -14, 1200, 11*time.Minute),
|
||||
)
|
||||
if a := w.Snapshot(); a.HeMe || a.Spots != 0 {
|
||||
t.Errorf("an eleven-minute-old report survived: %+v", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestOffsetAvoidsTheOccupiedBinsAndTheCeiling(t *testing.T) {
|
||||
// Busy from 1000 to 1500 Hz, empty from 1560 to 2400, ceiling 2400.
|
||||
bins := []Bin{}
|
||||
for hz := 1020; hz <= 1500; hz += binHz {
|
||||
bins = append(bins, Bin{OffsetHz: hz, Count: 3})
|
||||
}
|
||||
bins = append(bins, Bin{OffsetHz: 2400, Count: 1})
|
||||
got := suggestOffset(bins, 2400)
|
||||
if got < 1620 || got > 2340 {
|
||||
t.Errorf("suggested %d Hz, want somewhere in the empty 1560-2400 run", got)
|
||||
}
|
||||
// A passband that stops low must not produce advice above it: transmitting
|
||||
// past the DX's filter is the one outcome worse than picking a busy slot.
|
||||
if got := suggestOffset(bins, 1500); got != 0 {
|
||||
t.Errorf("suggested %d Hz with a 1500 Hz ceiling and no room, want none", got)
|
||||
}
|
||||
if got := suggestOffset(nil, 0); got != 0 {
|
||||
t.Errorf("suggested %d Hz with no data at all, want none", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTopicsFollowTheScope(t *testing.T) {
|
||||
w := New(Config{MyCall: "F4BPO", Scope: ScopeTarget})
|
||||
w.target, w.mode, w.band = "YI5RLS", "FT8", "20m"
|
||||
got := w.topicsLocked()
|
||||
want := []string{
|
||||
"pskr/filter/v2/+/FT8/YI5RLS/#",
|
||||
"pskr/filter/v2/+/FT8/+/YI5RLS/#",
|
||||
"pskr/filter/v2/+/FT8/F4BPO/#",
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("narrow scope = %v, want %v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Errorf("filter %d = %q, want %q", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
|
||||
w.cfg.Scope = ScopeBand
|
||||
if got := w.topicsLocked(); len(got) != 1 || got[0] != "pskr/filter/v2/20m/FT8/#" {
|
||||
t.Errorf("band scope = %v, want the one band-wide filter", got)
|
||||
}
|
||||
}
|
||||
@@ -262,7 +262,18 @@ func (s *Server) serve(c net.Conn) {
|
||||
return
|
||||
}
|
||||
req := strings.TrimSpace(line)
|
||||
started := time.Now()
|
||||
resp, quit := s.handle(req)
|
||||
// A command the radio took a visible time to accept is worth a line of its
|
||||
// own, always — not behind the trace switch below.
|
||||
//
|
||||
// This is the shape every "WSJT-X takes ten seconds to change band" report
|
||||
// has: the client is waiting on us, we are waiting on the rig, and the log
|
||||
// showed neither. Which command, and how long, is the whole diagnosis —
|
||||
// and one second is already far outside anything a healthy link does.
|
||||
if took := time.Since(started); took > time.Second && req != "" {
|
||||
s.log("rigctld: %q took %s — the radio was slow to answer, the client waited that long", req, took.Round(10*time.Millisecond))
|
||||
}
|
||||
// The whole exchange, when tracing is on.
|
||||
//
|
||||
// Only PTT transitions were ever recorded, so when JTDX aborted a
|
||||
@@ -362,6 +373,21 @@ func (s *Server) handle(line string) (resp string, quit bool) {
|
||||
if len(args) < 1 {
|
||||
return rprt(-1), false
|
||||
}
|
||||
// Only touch the radio on a CHANGE, the same rule set_ptt above follows.
|
||||
//
|
||||
// WSJT-X restates the mode on every band change and after every transmit,
|
||||
// almost always the mode the rig is already in. On a native backend that
|
||||
// is not free: an Icom set_mode is the mode frame, the data-mode frame and
|
||||
// a readback to check the rig honoured them, each a round trip — over a
|
||||
// remote CI-V link that is seconds, spent to arrive where we already were,
|
||||
// with the client blocked on the answer the whole time.
|
||||
//
|
||||
// Compared in the CLIENT's own vocabulary — what "m" would report against
|
||||
// what it just asked for — so nothing it can observe changes. A rig whose
|
||||
// mode we do not know yet (empty) is never assumed.
|
||||
if cur := s.rig.Mode(); cur != "" && adifToHamlib(cur) == adifToHamlib(hamlibToADIF(args[0])) {
|
||||
return rprt(0), false
|
||||
}
|
||||
if err := s.rig.SetMode(hamlibToADIF(args[0])); err != nil {
|
||||
s.log("rigctld: set_mode %q failed: %v", args[0], err)
|
||||
return rprt(-9), false
|
||||
|
||||
@@ -332,3 +332,31 @@ func TestModeMapping(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A mode the rig is already in must not reach it. WSJT-X restates the mode on
|
||||
// every band change, and on a native backend each restatement is several CI-V
|
||||
// round trips with the client blocked on the answer.
|
||||
func TestSetModeSkipsWhenUnchanged(t *testing.T) {
|
||||
cases := []struct {
|
||||
rig string // what the rig reports (ADIF)
|
||||
ask string // what the client asks for (hamlib)
|
||||
want int // times the radio should be touched
|
||||
}{
|
||||
{"CW", "CW", 0},
|
||||
{"SSB", "USB", 0}, // "SSB" is reported as USB to a client
|
||||
{"FT8", "PKTUSB", 0}, // data rides on USB; both name it PKTUSB
|
||||
{"USB", "PKTUSB", 1}, // out of data mode into it: a real change
|
||||
{"CW", "USB", 1}, // a real change
|
||||
{"", "USB", 1}, // mode unknown: never assume
|
||||
}
|
||||
for _, c := range cases {
|
||||
f := &fakeRig{mode: c.rig, freq: 14074000}
|
||||
s := New(4532, f, nil)
|
||||
if got, _ := s.handle("M " + c.ask); got != "RPRT 0\n" {
|
||||
t.Fatalf("set_mode %q on a rig in %q = %q", c.ask, c.rig, got)
|
||||
}
|
||||
if n := len(f.setModes); n != c.want {
|
||||
t.Errorf("rig in %q, client asked %q: rig touched %d times, want %d", c.rig, c.ask, n, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user