Compare commits

..
8 Commits
Author SHA1 Message Date
rouggy cccdb354f8 chore: release v0.27.13 2026-09-06 00:11:20 +02:00
rouggy 4321eae647 fix(watchlist widget): one line per spot
Callsign, band, mode, badge, age in reading order, and the widget widened
to 290 px so the badge and the age never squeeze the callsign. Two lines
made the panel twice as tall for the same three facts; the entry that
matched — which only differs when it is a prefix — moved into the row's
tooltip.
2026-09-06 00:10:21 +02:00
rouggy 03e71bfdf2 feat: docked watch-list panel, and auto-call learns the orthogonal markers
The watch list was a tab, and an operator working FT8 lives on the decodes
one: a station they had asked to be told about turned up on a screen they
were not looking at. The same answer is now docked in the widget strip,
above the tabs, reduced to what is worth acting on — on the air and still
needed, one row per band and mode, with the cluster's own NEW DXCC /
NEW BAND / NEW SLOT badge and a click that tunes. Off by default. The
"active and needed" answer costs a debounced query per visible slot, so it
is written once (lib/watchlistSpots) and the tab uses it too.

Auto-call:

- It answers a new prefix, county, state, square or park. Those markers
  are orthogonal to the entity, they ranked as nothing-needed, and the
  engine sat through a never-worked WPX prefix calling CQ. New rung at
  the foot of the ladder, gated by the chase switches the badges use —
  which meant making those switches portable, since the backend cannot
  read localStorage.
- It calls THROUGH a pileup. Giving up the moment the DX answered
  somebody else is precisely how a queue is not worked; the call and miss
  counters already bound the effort, and a station in mid-exchange is
  still never chosen as a new target.

The PSK Reporter panel now follows the station auto-call is waiting for:
the analysis takes a history query and a period or two to fill, so
starting it when the DX comes free is starting it too late.

Callbook lookup: a compound callsign with a page of its OWN keeps that
page's location. QRZ files HP/WE9G under exactly that form, with the
Panama square the station is operating from, and the rule that drops a
home address from a portable call was throwing it away. The record's own
country tells an operation's page from a home page.

Changelog: entries may open with [NEW], drawn as a pill in the What's new
dialog — a release is mostly fixes and the two or three genuinely new
things should not have to be found by reading all of it.
2026-09-06 00:08:55 +02:00
rouggy 23323c91e0 fix(autocall): the QSO in progress outranks the ladder
Six faults from an evening on 60 m, all in the same family: the engine
judging a station by what the log wants from it and forgetting what is
already under way.

- An exchange was abandoned mid-QSO. The reply lands in the same period
  the ladder is re-read, and that period was judged before the reply was
  taken into account, so a better-ranked caller took the slot from a
  station that had just come back to us. The answer is settled first now,
  and our own report counts as being inside the exchange too — which also
  protects a QSO the operator started by hand.
- A station just picked started with misses against it. Its transmit slot
  was unknown until a second decode, and with the parity unknown every
  period counted, including the one spent transmitting to it.
- The freed slot after "it is working somebody else" was thrown away: the
  period's decodes are in hand, so the next station is picked from them
  rather than fifteen seconds later. Never mid-over.
- Auto-call is never armed from a stored setting — not at launch, not on
  a profile switch. It is the one feature that puts the station on the
  air by itself and OpsLog starts with Windows.
- It says what it is waiting for: a wanted station in a QSO with somebody
  else now shows beside the Auto button instead of looking idle.
- Switching profile left the previous logbook's verdicts on screen. The
  worked-index, chase-new and the frontend's cached verdicts are dropped
  when the logbook changes.

FT decodes: distance column, a message addressed to you set whole in
green (the station you are calling keeps a tint — most of what it sends
goes to other people), badge order L / Wkd / WL, list cleared when the
RIG changes band.

Rotor: new world-map compass from EC1KD's design, with the Ultrabeam boom
and second lobe restored and the compact form preserved; the classic dial
is kept and Settings → Rotator chooses between them. Stop no longer
flickers on a rotor standing still — movement was inferred from a degree,
less than the jitter a controller reports at rest.
2026-09-05 23:02:31 +02:00
rouggy 8dbc4b7e62 feat(ui): the FT decodes panel, two DXHunter themes, and the maps
Decodes:
- one click SELECTS, two transmit. A single click handed the decode
  straight to WSJT-X as a reply, so brushing a row while reading the
  band started calling a station.
- the list empties for a receiver that changes band, and a receiver
  column appears when more than one is feeding one merged list.
- the period clock turns red while transmitting: it is the one thing on
  the screen that moves, so it is where the eye already is.
- a WL badge, after the LoTW "L" — one letter, always in the same
  place, so the column does not shift from row to row.
- the auto-call switch, its target and its count, and the chase list:
  naming the station you are waiting for is done while watching the
  band, not in a settings tree.

Themes: DXHunter's slate with its own blue, and the same slate with
OpsLog's orange. Counted across its sources rather than guessed from
one panel — blue is 132 uses to violet's 25, and the violet is the PSK
Reporter panel alone.

Watchlist: drawn as DXHunter draws it — the callsign in the interface
font rather than monospaced, which is the difference that shows with
the two windows side by side.

FT Map: arcs no longer run off the side of the map. The map shows one
world, and a path crossing the antimeridian was drawn past 180° into
the blank space beside it — from VK that is most of them.

Cluster: "superfox", "fox/hound" and "F/H" in a comment are read as
FT8. They are WSJT-X's DXpedition transmit modes, and the comment fell
through to the band plan and came out DATA — which then decided the
band+mode verdict.

A decoder is named by what it IS: Nexus sends its packets as "Tempo",
the engine inside it, and OpsLog showed a program nobody has heard of.
2026-09-05 21:46:06 +02:00
rouggy e4a5d42b85 feat(app): auto-call, PSK Reporter analysis and the decode band change
The Wails boundary for the two features above, plus the pieces that
belong to neither:

- decodes are cut into periods PER RECEIVER, and judged 0.8 s after the
  last one arrives rather than a slot and four seconds after the period
  they belong to. That stamp is the START of the slot, thirteen seconds
  before its decodes exist, so every answer left four seconds into the
  next slot and the decoder began its call late.

- WSJT-X colouring can grey out a station already worked on this band
  AND in this mode. Its own switch, off by default: the other three
  verdicts pick out a handful of decodes, this one can match most of a
  period on a full log.

- the watch list's contest auto-add takes a list of callsigns as well
  as a pattern. A pattern collects a fleet that shares a string; it
  cannot collect the station taking part under a callsign that says
  nothing about the event.
2026-09-05 21:45:51 +02:00
rouggy 470eaf5d80 fix(pskr): a ten-minute window, both directions, and always an answer
Measured against DXHunter on the same station at the same second: 18
decodes here against 27 there.

- The window was five minutes. PSK Reporter's uploaders batch their
  reports, most of them every five, so a five-minute window catches
  about one upload cycle per station. Ten, as DXHunter has always had
  behind a label that says four.

- The history query ran once per target and only on the narrow feed.
  It now runs in both scopes and again every five minutes while a
  station is watched, which is the cadence the uploaders keep.

- It asked only what the target RECEIVED. Both directions now, so
  "who is hearing him" starts full too.

- The suggested call offset looked for a run of empty slots and said
  nothing when there was none — exactly the case it exists for: a
  hundred decodes across a 2800 Hz passband leave no gap. Failing a
  gap it names the quietest slot, ties to the higher offset, never
  above the ceiling.

Chase new: the receiver squares now follow the radius that was asked
for (it was one fixed ring whatever the setting said, so raising it
bought nothing), and the panel says what the feed is doing rather than
leaving an empty list to speak for itself. A change of hunt empties it:
its rows are verdicts reached under the old rule and nothing re-judged
them. Its own band selection, because what a station CAN work and what
is worth watching tonight are different questions.
2026-09-05 21:45:39 +02:00
rouggy a41626955e fix(autocall): the entity's verdict is not the station's
Six faults, all found on the air this evening and all in the same
feature. The decision trace added here is what found the first one:
one line per period, saying what was on the air and why each station
was refused.

- "worked" was read from the ENTITY's status, which means the country
  is in the log on this band and mode. On 10 m, where most countries
  are, twenty decodes out of twenty-one were refused as worked — the
  watched DXpedition among them. The entity decides what is NEEDED;
  the callsign's own slot decides whether calling it is a duplicate.
  candidateOf() is split out and tested because one line was wrong for
  weeks and nothing could catch it.

- A multi-answer line was read only up to its first message. MSHV
  answers two stations in one transmission ("YV5ALI RR73; F4BPO
  <HK0/PY8WW> -08") and the second half was a report to us: the engine
  saw a station working somebody else and dropped the target at the
  moment the DX was answering. The decodes panel already read every
  segment.

- The slot after a QSO belongs to our own 73. Handing straight on to
  the next station took it, switching the DX call mid-sequence, and
  the frame that closes the contact never went out whole.

- A station CALLING US is answered whether or not the log wants
  anything from it. It was refused for having nothing to gain, so a
  QSO would end, two stations would call, and both were ignored.

- Callability was tested when a station was CHOSEN and never again
  while it was held: one picked on its CQ that then answered another
  caller went on being called for the whole seven attempts.

- A watched callsign now outranks every station that is not on the
  list. Lifted one rung at a time it sat at the bottom with nothing
  needed from it, and was never reached on a busy band — the opposite
  of what putting it on the list means.

Halt is now a verdict: the station is set aside for the session rather
than released, and the chase list does not override it. The attempts
cap lets the over finish instead of cutting the call that counted it,
and a rest is a rest. Only what the decodes list is SHOWING can be
called — the panel publishes the callsigns it shows, so there is one
definition of "shown" and not two.
2026-09-05 21:45:25 +02:00
33 changed files with 3786 additions and 623 deletions
+42 -5
View File
@@ -753,6 +753,9 @@ type App struct {
// is consulted once per message. // is consulted once per message.
chaseBandsMu sync.RWMutex chaseBandsMu sync.RWMutex
chaseBands map[string]bool chaseBands map[string]bool
// The Chase new panel's OWN band filter, as the set switched off. Read per
// message like the list above, so an atomic rather than a settings lookup.
chaseNewBandsOff atomic.Value // map[string]bool
// pskr is the PSK Reporter MQTT feed, up only while the opening watch is on. // pskr is the PSK Reporter MQTT feed, up only while the opening watch is on.
// It is the source that makes VHF detection work at all: the cluster and RBN // It is the source that makes VHF detection work at all: the cluster and RBN
// carry a handful of 6 m spots where PSK Reporter carries hundreds. // carry a handful of 6 m spots where PSK Reporter carries hundreds.
@@ -773,8 +776,12 @@ type App struct {
// FT8 window and an FT4 one do not even share a period length — and one // FT8 window and an FT4 one do not even share a period length — and one
// buffer for both cut every slot into fragments, each judged as a period of // buffer for both cut every slot into fragments, each judged as a period of
// its own. The engine then counted a missed period several times a slot. // its own. The engine then counted a missed period several times a slot.
acPeriod map[string]string acPeriod map[string]string
acAt map[string]time.Time acAt map[string]time.Time
// acFed is when the last decode of the open period ARRIVED, wall clock. The
// period's own timestamp cannot answer "has it gone quiet" — it is the start
// of the slot, thirteen seconds before its decodes exist.
acFed map[string]time.Time
acTR map[string]int acTR map[string]int
acBuf map[string][]acDecode acBuf map[string][]acDecode
acTX autocall.TXState acTX autocall.TXState
@@ -782,6 +789,10 @@ type App struct {
acLastJudge time.Time acLastJudge time.Time
// acTXPeriod is the last transmit period already counted — see autoCallNoteTX. // acTXPeriod is the last transmit period already counted — see autoCallNoteTX.
acTXPeriod string acTXPeriod string
// What the decodes panel is SHOWING, and whether it is publishing at all.
// The panel owns the filters; this is its answer, not a second copy of them.
acVisible map[string]bool
acVisibleOn bool
// Self-spot throttle: when and on what frequency we last announced ourselves. // Self-spot throttle: when and on what frequency we last announced ourselves.
// Held in memory only — a restart legitimately re-announces the station. // Held in memory only — a restart legitimately re-announces the station.
selfSpotMu sync.Mutex selfSpotMu sync.Mutex
@@ -1653,6 +1664,7 @@ func (a *App) startup(ctx context.Context) {
a.startGridCache() a.startGridCache()
a.chaseNew = newChaseNewStore() a.chaseNew = newChaseNewStore()
a.refreshChaseNew() a.refreshChaseNew()
a.refreshChaseNewBands()
// PSK Reporter. After the operator's grid is known: without it there is no // PSK Reporter. After the operator's grid is known: without it there is no
// distance to measure and no receiver squares to filter on, so it stays down. // distance to measure and no receiver squares to filter on, so it stays down.
a.startBandOpenFeed() a.startBandOpenFeed()
@@ -2591,7 +2603,20 @@ func (a *App) switchLogbook(p profile.Profile) error {
_ = old.Close() _ = old.Close()
} }
applog.Printf("logbook switched to %s for profile %q", backend, p.Name) applog.Printf("logbook switched to %s for profile %q", backend, p.Name)
// EVERY worked/new verdict belonged to the OLD logbook.
//
// The award matrices above are only half of it: the in-memory worked-index
// the alert engine reads, the chase-new list, and the frontend's own cache of
// resolved spot verdicts all still hold the previous log's answers. An
// operator switching to an empty profile and back found every decode on the
// screen badged NEW — the empty log's verdicts, cached under the full one,
// and nothing short of a restart cleared them.
if a.chaseNew != nil {
a.chaseNew.clear()
}
go a.rebuildWorkedIndex()
if a.ctx != nil { if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "spotstatus:invalidate")
wruntime.EventsEmit(a.ctx, "logbook:changed") wruntime.EventsEmit(a.ctx, "logbook:changed")
} }
return nil return nil
@@ -16692,10 +16717,13 @@ func (a *App) reloadAfterProfileSwitch() {
// Auto-call is per profile — attempts, the "call only" callsign, whether it // Auto-call is per profile — attempts, the "call only" callsign, whether it
// is on at all — and it TRANSMITS. A switch that left the previous profile's // is on at all — and it TRANSMITS. A switch that left the previous profile's
// settings running would have the wrong station calling on the wrong log. // settings running would have the wrong station calling on the wrong log.
//
// Off, exactly as at launch: a switch changes the callsign, the log and
// usually the antenna, and none of that is a state in which a transmitter
// should resume by itself because this profile's stored setting said on.
// Re-applied rather than restarted: the loop is one goroutine for the life // Re-applied rather than restarted: the loop is one goroutine for the life
// of the process, and applyAutoCall clears the target when the feature is // of the process.
// off in the profile just activated. a.disarmAutoCall("profile switch")
a.applyAutoCall()
a.autoCallEngine().Reset() a.autoCallEngine().Reset()
} }
@@ -20578,6 +20606,15 @@ func (a *App) SaveChaseSettings(cs ChaseSettings) error {
a.clusterStatusMu.Lock() a.clusterStatusMu.Lock()
a.clusterStatusIdx = nil a.clusterStatusIdx = nil
a.clusterStatusMu.Unlock() a.clusterStatusMu.Unlock()
// Chase new judges each decode AS IT ARRIVES and keeps only what was new at
// that moment, so its list is a set of verdicts reached under the old rule —
// and nothing in it would ever be re-judged. Switching to "new only" left
// rows listed that are merely unconfirmed; switching the other way could not
// bring back what had already been refused. Emptied, it refills from the
// live feed within a period or two, under the rule now in force.
if a.chaseNew != nil {
a.chaseNew.clear()
}
// The frontend caches resolved verdicts and only asks about unknown keys — // The frontend caches resolved verdicts and only asks about unknown keys —
// without this it kept judging half the screen under the OLD rules until a // without this it kept judging half the screen under the OLD rules until a
// restart, which read as the new mode simply not working. // restart, which read as the new mode simply not working.
+1
View File
@@ -20,6 +20,7 @@ var deniedCallHashes = map[string]struct{}{
"0741c9e394b42f43191899105553b47155ddc3026da12b5360701f9c181ff123": {}, "0741c9e394b42f43191899105553b47155ddc3026da12b5360701f9c181ff123": {},
"ab4926a3a0ab76d41b5b99cd3ad0683584970c341c29427c1dfa4b3c329ce415": {}, "ab4926a3a0ab76d41b5b99cd3ad0683584970c341c29427c1dfa4b3c329ce415": {},
"9d17c9c213a6cc89c12d7520bcf21c86a0cf43d17e82e74f33f3a77cb865d28a": {}, "9d17c9c213a6cc89c12d7520bcf21c86a0cf43d17e82e74f33f3a77cb865d28a": {},
"94ee059335e587e501cc4bf90613e0814f00a7b08bc7c648fd865a2af6a22cc2": {},
} }
// callDenied reports whether a callsign is on deniedCallHashes. The call is // callDenied reports whether a callsign is on deniedCallHashes. The call is
+223 -29
View File
@@ -31,6 +31,8 @@ const (
keyAutoCallMisses = "autocall.misses" keyAutoCallMisses = "autocall.misses"
keyAutoCallRounds = "autocall.max_rounds" keyAutoCallRounds = "autocall.max_rounds"
keyAutoCallRestMin = "autocall.rest_min" keyAutoCallRestMin = "autocall.rest_min"
keyAutoCallOnScreen = "autocall.on_screen_only"
keyAutoCallTrace = "autocall.trace"
) )
// AutoCallSettings is the panel's shape. Durations are in minutes because that // AutoCallSettings is the panel's shape. Durations are in minutes because that
@@ -49,6 +51,14 @@ type AutoCallSettings struct {
// RestMin the pause between two of them. // RestMin the pause between two of them.
MaxRounds int `json:"max_rounds"` MaxRounds int `json:"max_rounds"`
RestMin int `json:"rest_min"` RestMin int `json:"rest_min"`
// OnScreenOnly: call only what the decodes panel is showing, so its filters
// steer the transmitter as well as the eye.
OnScreenOnly bool `json:"on_screen_only"`
// Trace writes one line per period to the log: what was on the air, why
// each station was refused, and what was decided. For diagnosing "it is not
// calling anything" — and it is a line every fifteen seconds, so it is off
// unless asked for.
Trace bool `json:"trace"`
} }
func (a *App) GetAutoCallSettings() AutoCallSettings { func (a *App) GetAutoCallSettings() AutoCallSettings {
@@ -66,15 +76,21 @@ func (a *App) GetAutoCallSettings() AutoCallSettings {
Only: strings.ToUpper(strings.TrimSpace(a.settingOr(keyAutoCallOnly, ""))), Only: strings.ToUpper(strings.TrimSpace(a.settingOr(keyAutoCallOnly, ""))),
Attempts: num(keyAutoCallAttempts, d.Attempts), Attempts: num(keyAutoCallAttempts, d.Attempts),
WatchedAttempts: num(keyAutoCallWatched, d.WatchedAttempts), WatchedAttempts: num(keyAutoCallWatched, d.WatchedAttempts),
Misses: num(keyAutoCallMisses, d.Misses), // On by default: the filters are in front of the operator, and a station
MaxRounds: num(keyAutoCallRounds, d.MaxRounds), // they have hidden is one they have said they do not want.
RestMin: num(keyAutoCallRestMin, int(d.Rest/time.Minute)), OnScreenOnly: a.settingOr(keyAutoCallOnScreen, "1") == "1",
Trace: a.settingOr(keyAutoCallTrace, "0") == "1",
Misses: num(keyAutoCallMisses, d.Misses),
MaxRounds: num(keyAutoCallRounds, d.MaxRounds),
RestMin: num(keyAutoCallRestMin, int(d.Rest/time.Minute)),
} }
} }
func (a *App) SaveAutoCallSettings(s AutoCallSettings) error { func (a *App) SaveAutoCallSettings(s AutoCallSettings) error {
a.setSetting(keyAutoCallOn, map[bool]string{true: "1", false: "0"}[s.Enabled]) a.setSetting(keyAutoCallOn, map[bool]string{true: "1", false: "0"}[s.Enabled])
a.setSetting(keyAutoCallOnly, strings.ToUpper(strings.TrimSpace(s.Only))) a.setSetting(keyAutoCallOnly, strings.ToUpper(strings.TrimSpace(s.Only)))
a.setSetting(keyAutoCallOnScreen, map[bool]string{true: "1", false: "0"}[s.OnScreenOnly])
a.setSetting(keyAutoCallTrace, map[bool]string{true: "1", false: "0"}[s.Trace])
for key, v := range map[string]int{ for key, v := range map[string]int{
keyAutoCallAttempts: s.Attempts, keyAutoCallWatched: s.WatchedAttempts, keyAutoCallAttempts: s.Attempts, keyAutoCallWatched: s.WatchedAttempts,
keyAutoCallMisses: s.Misses, keyAutoCallRounds: s.MaxRounds, keyAutoCallMisses: s.Misses, keyAutoCallRounds: s.MaxRounds,
@@ -121,7 +137,7 @@ func (a *App) autoCallEngine() *autocall.Engine {
func (a *App) autoCallSettings() autocall.Settings { func (a *App) autoCallSettings() autocall.Settings {
s := a.GetAutoCallSettings() s := a.GetAutoCallSettings()
return autocall.Settings{ return autocall.Settings{
Enabled: s.Enabled, Only: s.Only, Enabled: s.Enabled, Only: s.Only, OnScreenOnly: s.OnScreenOnly,
Attempts: s.Attempts, WatchedAttempts: s.WatchedAttempts, Attempts: s.Attempts, WatchedAttempts: s.WatchedAttempts,
Misses: s.Misses, MaxRounds: s.MaxRounds, Misses: s.Misses, MaxRounds: s.MaxRounds,
Rest: time.Duration(s.RestMin) * time.Minute, Rest: time.Duration(s.RestMin) * time.Minute,
@@ -135,6 +151,11 @@ func (a *App) applyAutoCall() {
e := a.autoCallEngine() e := a.autoCallEngine()
s := a.autoCallSettings() s := a.autoCallSettings()
e.SetSettings(s) e.SetSettings(s)
if a.GetAutoCallSettings().Trace {
e.SetTrace(func(f string, args ...any) { applog.Printf("autocall: "+f, args...) })
} else {
e.SetTrace(nil)
}
if !s.Enabled { if !s.Enabled {
e.Reset() e.Reset()
a.acMu.Lock() a.acMu.Lock()
@@ -144,26 +165,48 @@ func (a *App) applyAutoCall() {
a.emitAutoCall() a.emitAutoCall()
} }
// ResetAutoCall is the operator's restart after it gave up — and what Halt // ResetAutoCall is the operator's restart after the engine gave up on an
// does, since halting means "not this station". // explicit target: it clears every verdict, including the grey list.
func (a *App) ResetAutoCall() { func (a *App) ResetAutoCall() {
a.autoCallEngine().Reset() a.autoCallEngine().Reset()
a.emitAutoCall() a.emitAutoCall()
} }
// HaltAutoCall is the Halt button while a call is in progress.
//
// It does NOT clear the engine's state, which is what Halt used to do: that
// wiped the rests and the rounds along with everything else, so the station the
// operator had just stopped was eligible again in the same second and the next
// period called it straight back.
func (a *App) HaltAutoCall() {
call := a.autoCallEngine().Halt()
if call != "" {
a.acMu.Lock()
a.acReason = fmt.Sprintf("%s stopped by the operator — set aside until auto-call is switched off and on", call)
a.acMu.Unlock()
applog.Printf("autocall: %s", a.acReason)
}
a.emitAutoCall()
}
// AutoCallStatus is what the toolbar shows. // AutoCallStatus is what the toolbar shows.
type AutoCallStatus struct { type AutoCallStatus struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
// Only is the chase list, carried in the status so the field in the decodes // Only is the chase list, carried in the status so the field in the decodes
// toolbar and the one in Preferences are never two versions of the truth: // toolbar and the one in Preferences are never two versions of the truth:
// whichever is typed into, both show it. // whichever is typed into, both show it.
Only string `json:"only"` Only string `json:"only"`
Target string `json:"target"` Target string `json:"target"`
// Waiting: what it would call if that station were not in a QSO.
Waiting string `json:"waiting"`
Calls int `json:"calls"` Calls int `json:"calls"`
Max int `json:"max"` Max int `json:"max"`
Misses int `json:"misses"` Misses int `json:"misses"`
MaxMiss int `json:"max_miss"` MaxMiss int `json:"max_miss"`
Stopped bool `json:"stopped"` Stopped bool `json:"stopped"`
// Greylisted counts the stations the operator has stopped this session, so
// the toolbar can say why a station on the air is never called.
Greylisted int `json:"greylisted"`
// Reason is the last decision in plain words. An auto-call that is doing // Reason is the last decision in plain words. An auto-call that is doing
// nothing on purpose looks exactly like one that is broken. // nothing on purpose looks exactly like one that is broken.
Reason string `json:"reason"` Reason string `json:"reason"`
@@ -177,9 +220,10 @@ func (a *App) GetAutoCallStatus() AutoCallStatus {
set := a.GetAutoCallSettings() set := a.GetAutoCallSettings()
return AutoCallStatus{ return AutoCallStatus{
Enabled: set.Enabled, Only: set.Only, Enabled: set.Enabled, Only: set.Only,
Target: st.Target, Calls: st.Attempts, Max: st.Max, Target: st.Target, Waiting: st.Waiting, Calls: st.Attempts, Max: st.Max,
Misses: st.Misses, MaxMiss: st.MaxMiss, Stopped: st.Stopped, Misses: st.Misses, MaxMiss: st.MaxMiss, Stopped: st.Stopped,
Reason: reason, Greylisted: a.autoCallEngine().Greylisted(),
Reason: reason,
} }
} }
@@ -234,6 +278,7 @@ func (a *App) autoCallFeed(d autocall.Decode) {
a.acMu.Lock() a.acMu.Lock()
if a.acPeriod == nil { if a.acPeriod == nil {
a.acPeriod, a.acAt, a.acTR, a.acBuf = map[string]string{}, map[string]time.Time{}, map[string]int{}, map[string][]acDecode{} a.acPeriod, a.acAt, a.acTR, a.acBuf = map[string]string{}, map[string]time.Time{}, map[string]int{}, map[string][]acDecode{}
a.acFed = map[string]time.Time{}
} }
if prev := a.acPeriod[inst]; prev != "" && prev != key { if prev := a.acPeriod[inst]; prev != "" && prev != key {
prevAt, prevTR, buf := a.acAt[inst], a.acTR[inst], a.acBuf[inst] prevAt, prevTR, buf := a.acAt[inst], a.acTR[inst], a.acBuf[inst]
@@ -244,10 +289,29 @@ func (a *App) autoCallFeed(d autocall.Decode) {
return return
} }
a.acPeriod[inst], a.acAt[inst], a.acTR[inst] = key, d.At, d.TRPeriod a.acPeriod[inst], a.acAt[inst], a.acTR[inst] = key, d.At, d.TRPeriod
a.acFed[inst] = time.Now()
a.acBuf[inst] = append(a.acBuf[inst], acDecode{d: d}) a.acBuf[inst] = append(a.acBuf[inst], acDecode{d: d})
a.acMu.Unlock() a.acMu.Unlock()
} }
// acQuiet is how long a period is left open after its LAST decode arrives.
//
// This is the whole timing budget of the feature. A decoder finishes a period
// and sends its decodes about a second before the next slot opens, so the
// answer has to be back before that boundary — a reply that arrives after it
// makes the decoder start its call several seconds into the slot, which is what
// an operator sees as "it calls late" and what a station on the other end sees
// as a message it cannot decode.
//
// It was a whole slot plus four seconds, measured from the DECODE'S OWN
// TIMESTAMP — the start of the period, not the moment it arrived — so the
// answer left about four seconds INTO the next slot, every time.
//
// 800 ms: long enough for a busy period's decodes to arrive together (measured
// in bursts of a few hundred milliseconds), short enough to answer inside the
// same second they landed.
const acQuiet = 800 * time.Millisecond
// autoCallSweep closes the periods nothing has closed for us. Called on a timer. // autoCallSweep closes the periods nothing has closed for us. Called on a timer.
// //
// Per receiver, because with two decoders one may fall silent while the other // Per receiver, because with two decoders one may fall silent while the other
@@ -273,10 +337,11 @@ func (a *App) autoCallSweep() {
if tr <= 0 { if tr <= 0 {
tr = 15 tr = 15
} }
// One slot plus a margin: decodes for a period keep arriving for a // Measured from when the last decode ARRIVED, not from the period it
// second or two after it ends, and judging early would count a station // belongs to: a decode is stamped with the start of its own slot, so
// as missing that is about to be listed. // waiting "a slot plus four seconds" from that stamp is waiting until
if time.Since(a.acAt[inst]) < time.Duration(tr)*time.Second+4*time.Second { // the middle of the NEXT slot. See acQuiet.
if time.Since(a.acFed[inst]) < acQuiet {
continue continue
} }
ready = append(ready, due{inst, key, a.acAt[inst], tr, a.acBuf[inst]}) ready = append(ready, due{inst, key, a.acAt[inst], tr, a.acBuf[inst]})
@@ -351,18 +416,18 @@ func (a *App) autoCallJudge(inst, key string, at time.Time, tr int, buf []acDeco
status[st.Call+"|"+st.Band+"|"+st.Mode] = st status[st.Call+"|"+st.Band+"|"+st.Mode] = st
} }
chase := a.autoCallChase()
cands := make([]autocall.Candidate, 0, len(uniq)) cands := make([]autocall.Candidate, 0, len(uniq))
for _, dd := range uniq { for _, dd := range uniq {
st := status[dd.d.Call+"|"+dd.d.Band+"|"+dd.d.Mode] st := status[dd.d.Call+"|"+dd.d.Band+"|"+dd.d.Mode]
cands = append(cands, autocall.Candidate{ c := candidateOf(dd.d, st, chase)
Decode: dd.d, c.Watched = a.autoCallWatched(dd.d.Call)
Need: autoCallNeedOf(st.Status), c.Hidden = a.autoCallHidden(dd.d.Call)
Watched: a.autoCallWatched(dd.d.Call), cands = append(cands, c)
Worked: st.Status == "worked",
})
} }
tx := a.autoCallTX() tx := a.autoCallTX()
act := a.autoCallEngine().OnPeriod(autocall.Period{ act := a.autoCallEngine().OnPeriod(autocall.Period{
Instance: inst, Key: key, At: at, TRPeriod: tr, Decodes: cands, TX: tx, Instance: inst, Key: key, At: at, TRPeriod: tr, Decodes: cands, TX: tx,
MyCall: a.opCall, MyCall: a.opCall,
@@ -370,6 +435,81 @@ func (a *App) autoCallJudge(inst, key string, at time.Time, tr int, buf []acDeco
a.autoCallDo(act) a.autoCallDo(act)
} }
// candidateOf turns one decode and the log's verdict on it into a candidate.
//
// Split out and kept pure because ONE line of it was wrong for weeks and
// nothing could catch it: the entity's verdict was read as the station's.
// chaseExtras is what the operator hunts BESIDES entities — the cluster's
// orthogonal markers, from the same switches that decide whether the badges are
// shown at all (Settings → DX Cluster). One answer for the eye and the
// transmitter: a marker withdrawn from the screen is not one to call for.
type chaseExtras struct{ pota, grid, pfx, county, state bool }
func candidateOf(d autocall.Decode, st SpotStatus, ch chaseExtras) autocall.Candidate {
c := autocall.Candidate{
Decode: d,
// The ENTITY's verdict decides what is still needed…
Need: autoCallNeedOf(st.Status),
// …and THIS CALLSIGN on this band and mode decides whether calling it
// would be a duplicate.
//
// Status was used for both. "worked" there means the COUNTRY is in the
// log on this band and mode, so on a band where the operator has most of
// them, nearly every station on the air was refused as already worked —
// a trace of one evening shows twenty decodes out of twenty-one turned
// away that way, the watched DXpedition among them.
Worked: st.WorkedSlot,
// The need exists only because a QSL never came: worth chasing, and worth
// less than the same need never worked at all.
Unconfirmed: st.UnconfStatus,
}
// NOTHING LEFT ON THE ENTITY, AND STILL WORTH A CALL.
//
// A prefix, a square, a county, a state, a park: never worked, orthogonal to
// the entity's verdict, and exactly what the operator ticked in the chase
// settings. They ranked at nothing-needed, so auto-call sat through a
// never-worked WPX prefix calling CQ and did not answer it.
//
// Only when the entity has nothing to add — a new band that is ALSO a new
// prefix is a new band, and says so.
if c.Need == autocall.NeedNone {
switch {
case ch.pfx && st.NewPfx:
c.Need, c.Extra, c.Unconfirmed = autocall.NeedExtra, "prefix", st.UnconfPfx
case ch.county && st.NewCounty:
c.Need, c.Extra, c.Unconfirmed = autocall.NeedExtra, "county", st.UnconfCty
case ch.state && st.NewState:
c.Need, c.Extra, c.Unconfirmed = autocall.NeedExtra, "state", st.UnconfState
case ch.grid && st.NewGrid:
c.Need, c.Extra, c.Unconfirmed = autocall.NeedExtra, "square", st.GridState == "unconf"
case ch.pota && st.NewPOTA:
c.Need, c.Extra = autocall.NeedExtra, "park"
}
}
return c
}
// autoCallChase reads the chase switches the cluster and the decode list use.
//
// Read once per period rather than per decode: they are settings-store reads,
// and the period loop runs over every station on the band.
func (a *App) autoCallChase() chaseExtras {
on := func(key string) bool {
if a.settings == nil {
return true
}
v, _ := a.settings.Get(a.ctx, "ui.opslog."+key)
return v != "0" // unset means on, as it does on the screen
}
return chaseExtras{
pota: on("chasePota"),
grid: on("chaseGrids"),
pfx: on("chasePfx"),
county: on("chaseCounty"),
state: on("chaseState"),
}
}
// autoCallDo carries out a decision and records it. // autoCallDo carries out a decision and records it.
func (a *App) autoCallDo(act autocall.Action) { func (a *App) autoCallDo(act autocall.Action) {
if act.Reason != "" { if act.Reason != "" {
@@ -385,9 +525,9 @@ func (a *App) autoCallDo(act autocall.Action) {
applog.Printf("autocall: the call to %s could not be sent: %v", d.Call, err) applog.Printf("autocall: the call to %s could not be sent: %v", d.Call, err)
} }
case autocall.DoHalt: case autocall.DoHalt:
// autoTxOnly=false: stop now. The whole point of a brake is that it does // Soft: let the over finish, then stop transmitting. Hard: stop now.
// not wait for the over in progress to finish. // The engine decides — see Action.Soft.
if err := a.HaltDecodeTx("", false); err != nil { if err := a.HaltDecodeTx("", act.Soft); err != nil {
applog.Printf("autocall: halt failed: %v", err) applog.Printf("autocall: halt failed: %v", err)
} }
} }
@@ -433,6 +573,38 @@ func (a *App) autoCallSetTX(tx autocall.TXState) {
a.acMu.Unlock() a.acMu.Unlock()
} }
// SetAutoCallVisible is the decodes panel saying what it is SHOWING.
//
// The panel owns the filters and therefore owns the answer: reimplementing them
// here would give the screen and the transmitter two definitions of the same
// word, which is how they end up disagreeing. It sends the callsigns that
// survive its filters, and the engine calls nothing else.
//
// An empty list with active=false means "no filtering in force" — the panel was
// closed, or has never been opened this session — and the ladder decides alone.
func (a *App) SetAutoCallVisible(calls []string, active bool) {
set := make(map[string]bool, len(calls))
for _, c := range calls {
if c = strings.ToUpper(strings.TrimSpace(c)); c != "" {
set[c] = true
}
}
a.acMu.Lock()
a.acVisible, a.acVisibleOn = set, active
a.acMu.Unlock()
}
// autoCallHidden reports whether the panel's filters are keeping a station off
// the screen. Unknown when nothing is being published: not hidden.
func (a *App) autoCallHidden(call string) bool {
a.acMu.Lock()
defer a.acMu.Unlock()
if !a.acVisibleOn {
return false
}
return !a.acVisible[strings.ToUpper(strings.TrimSpace(call))]
}
// autoCallNeedOf maps the cluster's own status vocabulary onto the ladder. One // autoCallNeedOf maps the cluster's own status vocabulary onto the ladder. One
// vocabulary for both, so a station that reads NEW BAND in the decodes list is // vocabulary for both, so a station that reads NEW BAND in the decodes list is
// the same NEW BAND the auto-call ranks — two answers to one question is how // the same NEW BAND the auto-call ranks — two answers to one question is how
@@ -471,18 +643,40 @@ func (a *App) autoCallWatched(call string) bool {
return ok return ok
} }
// startAutoCall arms the engine at launch. // startAutoCall starts the engine's loop, with auto-call OFF.
// //
// The stored "on" is honoured, and the engine starts with NO target: a program // It is never on from a stored value. This is the one setting in the program
// that came up already calling a station chosen before the last shutdown is not // that puts the station on the air by itself, and the operator who left it on
// something an operator can be expected to anticipate. // last night is not necessarily the one at the desk now — nor necessarily at
// the desk at all: OpsLog starts with Windows, and a rig that powers up with it
// would begin calling into an empty shack, on whatever band the radio happens
// to be on, hours after anybody decided that was a good idea.
//
// Arming it is one click, and it is a click somebody has to make.
func (a *App) startAutoCall() { func (a *App) startAutoCall() {
a.applyAutoCall() a.disarmAutoCall("launch")
go a.autoCallLoop() go a.autoCallLoop()
} }
// disarmAutoCall switches auto-call off and writes that down.
//
// The setting is what the toolbar and the settings panel both read, so turning
// the engine off without storing it would show a lit switch over a silent
// transmitter — and the operator's next click, meaning "on", would send "off".
func (a *App) disarmAutoCall(why string) {
if a.settingOr(keyAutoCallOn, "0") == "1" {
applog.Printf("autocall: off at %s — it is never armed from a stored setting", why)
}
a.setSetting(keyAutoCallOn, "0")
a.applyAutoCall()
}
func (a *App) autoCallLoop() { func (a *App) autoCallLoop() {
t := time.NewTicker(2 * time.Second) // A quarter of a second. The sweeper is what closes a period, so its tick is
// part of the same budget as acQuiet: a two-second tick added up to two
// seconds of its own to every answer, which is most of the margin there is.
// The work per tick is a map read.
t := time.NewTicker(250 * time.Millisecond)
defer t.Stop() defer t.Stop()
for range t.C { for range t.C {
if a.ctx == nil { if a.ctx == nil {
+81
View File
@@ -0,0 +1,81 @@
package main
import (
"testing"
"hamlog/internal/autocall"
)
// The entity's verdict is not the station's.
//
// From the air: on 10 m, where most countries are already in the log, the
// engine refused twenty decodes out of twenty-one as "worked" — a watched
// DXpedition calling CQ among them — because the ENTITY's status was read as
// the station's.
// allChased is the default: every orthogonal marker ticked.
var allChased = chaseExtras{pota: true, grid: true, pfx: true, county: true, state: true}
func TestCandidateWorkedIsTheCallsignNotTheEntity(t *testing.T) {
d := autocall.Decode{Call: "J38DX", Band: "10m", Mode: "FT8", IsNew: true}
// Grenada worked on 10 m FT8, this callsign never worked: nothing is needed
// from it, and calling it is NOT a duplicate.
c := candidateOf(d, SpotStatus{Status: "worked", WorkedSlot: false}, allChased)
if c.Worked {
t.Error("a station never worked was refused because its entity was")
}
if c.Need != autocall.NeedNone {
t.Errorf("need = %v on a worked entity, want none", c.Need)
}
// The same callsign already in the log on this band and mode IS a duplicate.
if c := candidateOf(d, SpotStatus{Status: "worked", WorkedSlot: true}, allChased); !c.Worked {
t.Error("a callsign already worked on this band and mode was not flagged")
}
// And a real need still carries through, with the unconfirmed distinction.
c = candidateOf(d, SpotStatus{Status: "new-band", UnconfStatus: true}, allChased)
if c.Need != autocall.NeedBand || !c.Unconfirmed {
t.Errorf("new-band unconfirmed came through as %v (unconf=%v)", c.Need, c.Unconfirmed)
}
}
// A station whose ENTITY has nothing left to give can still be the reason the
// operator is on the band: a WPX prefix, a county, a square, a park that has
// never been worked. Reported from the air — auto-call sat through a
// never-worked prefix calling CQ and did nothing.
func TestOrthogonalMarkersAreWorthACall(t *testing.T) {
d := autocall.Decode{Call: "BH2SWB", Band: "10m", Mode: "FT8", IsNew: true}
worked := SpotStatus{Status: "worked"}
for _, tc := range []struct {
what string
st SpotStatus
want string
}{
{"prefix", SpotStatus{Status: "worked", NewPfx: true}, "prefix"},
{"county", SpotStatus{Status: "worked", NewCounty: true}, "county"},
{"state", SpotStatus{Status: "worked", NewState: true}, "state"},
{"square", SpotStatus{Status: "worked", NewGrid: true}, "square"},
{"park", SpotStatus{Status: "worked", NewPOTA: true}, "park"},
} {
c := candidateOf(d, tc.st, allChased)
if c.Need != autocall.NeedExtra || c.Extra != tc.want {
t.Errorf("%s: need=%v extra=%q, want extra %q", tc.what, c.Need, c.Extra, tc.want)
}
// And not when the operator does not chase that kind of thing: the
// switch that withdraws the badge withdraws the call with it.
if c := candidateOf(d, tc.st, chaseExtras{}); c.Need != autocall.NeedNone {
t.Errorf("%s: called for a marker the operator does not chase", tc.what)
}
}
// It is the LOWEST rung: a real need on the entity still says what it is.
if c := candidateOf(d, SpotStatus{Status: "new-band", NewPfx: true}, allChased); c.Need != autocall.NeedBand {
t.Errorf("need = %v — a new band that is also a new prefix is a new band", c.Need)
}
// Nothing anywhere is still nothing.
if c := candidateOf(d, worked, allChased); c.Need != autocall.NeedNone {
t.Errorf("need = %v on a station with nothing to gain", c.Need)
}
}
+48
View File
@@ -1,4 +1,52 @@
[ [
{
"version": "0.27.13",
"date": "",
"en": [
"[NEW] Rotor widget redrawn: a night world map behind a square azimuth scale, an orange pointer following the mouse so a click lands where it is aimed, a yellow marker on the azimuth ordered until the antenna gets there, and quick turns in columns of six. The Ultrabeam boom and its second lobe are still drawn, and Station Control keeps the plain dial — dial only, since anything under it is pushed off the bottom of the row. Design contributed by EC1KD — thank you.",
"[NEW] Settings → Rotator now chooses the dial: the new world-map compass or the classic one. Both are kept — one reads across the shack, the other is the compact dial that was there before — and the choice applies to the docked widget and to Station Control at once.",
"Rotor widget: the Stop button no longer flickers on a rotor standing still. Movement was inferred from a one-degree change, which is less than the jitter a controller reports at rest.",
"[NEW] A docked watch-list panel, showing only what is ON THE AIR and still needed — one line per band and mode — callsign, band, mode, what it is worth, how long ago — freshest first, click to tune, with the clusters own NEW DXCC / NEW BAND / NEW SLOT badge on each row. The Watchlist tab is a tab, and an operator working FT8 lives on the decodes one: a station you asked to be told about was appearing on a screen you were not looking at. Turn it on with the bell in the toolbar.",
"[NEW] FT decodes: a distance column, next to the square it is computed from and in your own unit (km or miles). Rounded to whole units — a four-character grid is a square tens of kilometres wide and nothing finer is honest.",
"[NEW] The PSK Reporter panel switches to the station auto-call is WAITING for (the hourglass), while nothing is being called. Its analysis takes a history query and a period or two of live reports to fill, so starting it when the DX finally comes free is starting it too late — the wait is exactly what it should be spent on.",
"[NEW] Chase new: a band selection of its own, under the option (160 m to 70 cm). The stations band list still applies underneath — this one says what is worth WATCHING tonight.",
"Chase new: the panel says what the feed is doing — up or down, how many reports it has taken, how many receiver squares it is subscribed to. An empty list said nothing about whether anything was arriving at all.",
"FT decodes: a receiver column appears when more than one decoder feeds the merged list, and the list empties for a receiver that changes band — half a screen of stations no longer reachable is worse than an empty one.",
"FT decodes: a pink WL badge marks a station on your watch list, and the period clock turns red while transmitting. It is the one thing on the screen that moves, so “am I on the air” is readable from across the room.",
"FT decodes: a message addressed to YOU is set in green, whole and bold, with a green edge on the row — read from the far side of the shack. The station you are calling keeps a light tint and its callsign picked out in red: most of what it sends goes to other people, and colouring those lines the same way said you were in a QSO you were not in.",
"Cluster: “superfox”, “super fox”, “fox/hound” and “F/H” in a spot comment are read as FT8. They are WSJT-Xs DXpedition transmit modes, not modes of their own, and the comment fell through to the bands default.",
"Auto-call: what it calls, and in what order. It answers only what the decodes list is SHOWING — the filters above it (CQ only, LoTW only, the category chips, continents, minimum report, search) now steer the transmitter as well as the eye, and the separate “LoTW users only” option is gone. A new prefix, county, state, square or park is worth a call too, at the foot of the ladder and only for the kinds you chase (Settings → DX Cluster). A watched callsign outranks everything not on the list, a real need beats an unconfirmed one at the same level, and a better station may take over from one that has not answered yet — never from a QSO in progress.",
"Auto-call: it calls THROUGH a pileup. It used to give up the moment the station it was calling answered somebody else — which is precisely how a DX with a queue behaves, and the only way to be the next one is to keep calling while it works the others. Still bounded by the call and miss counters, and a station in mid-exchange is still never CHOSEN as a new target.",
"Auto-call: safety and control. It is always OFF at launch and after a profile switch, never armed from a stored setting — it is the one feature that puts the station on the air by itself, and OpsLog starts with Windows. Halt is now a verdict: the station is set aside for the session and never called again until auto-call is switched off and on. It says what it is waiting for, showing a wanted station that is working somebody else next to the Auto button. And it can log every decision (Settings → DXHunter): one line per period saying what was on the air and why each station was refused.",
"Auto-call: a round of fixes from on-air use. It no longer cuts your own 73 short, calls four to six seconds late, starts a call and drops it a second later, ignores a station calling you, misreads MSHVs two-answers-in-one-line, refuses nearly everything on the air because it read the entitys “worked” as the stations, or opens with two misses already counted against a station it has only just picked.",
"Switching profile no longer leaves the previous logbooks verdicts on the screen. Coming back from a profile with an empty log, every decode and spot stayed badged NEW until a restart: the worked-index, the chase-new list and the cached verdicts are now all dropped when the logbook changes.",
"Chase new fixes: the header counts both what the filters show and what the panel holds (“3 of 12 heard”), a stored filter set that hides everything is ignored, and switching between “new” and “new + unconfirmed” re-reads the list instead of leaving old verdicts on screen.",
"PSK Reporter panel fixes: the history is asked for in both feed scopes and refreshed every five minutes, so a target picked shortly after start-up no longer shows an empty page; the suggested transmit offset always has an answer when there is data; and the texts say ten minutes, which is the window actually used.",
"Callbook lookup: a compound callsign with a page of its OWN keeps that pages location. HP/WE9G is filed on QRZ under exactly that form, with the Panama address and square the station is operating from, and OpsLog was throwing it away — the rule that drops a home address from a portable call was applying to pages that describe the operation itself. The records own country tells the two apart. Cached lookups heal on the next read; QSOs already logged without a grid keep it empty."
],
"fr": [
"[NEW] Widget rotor redessiné : carte du monde nocturne derrière une échelle dazimut carrée, aiguille orange qui suit la souris pour quun clic parte où on vise, repère jaune sur lazimut demandé jusqu’à larrivée de lantenne, et directions rapides en colonnes de six. Le boom Ultrabeam et son deuxième lobe sont toujours tracés, et Station Control garde le cadran seul — rien en dessous, ce qui y était se retrouvait coupé en bas de la rangée. Design proposé par EC1KD — merci à lui.",
"[NEW] Réglages → Rotator permet de choisir le cadran : la nouvelle boussole carte du monde ou le cadran classique. Les deux sont conservés — lun se lit de loin, lautre est le cadran compact davant — et le choix sapplique aussitôt au widget docké comme à Station Control.",
"Widget rotor : le bouton Stop ne clignote plus sur un rotor à larrêt. Le mouvement était déduit dun changement dun degré, soit moins que le tremblement de lecture dun contrôleur au repos.",
"[NEW] Un panneau watchlist docké, qui ne montre que ce qui est EN LAIR et manque encore — une ligne par bande et mode — indicatif, bande, mode, ce que ça vaut, il y a combien de temps — le plus frais en haut, clic pour sy caler, avec le badge NEW DXCC / NEW BAND / NEW SLOT du cluster sur chaque ligne. Longlet Watchlist est un onglet, et en FT8 on vit sur celui des décodes : une station quon avait demandé à surveiller apparaissait sur un écran quon ne regardait pas. À activer avec la cloche dans la barre doutils.",
"[NEW] FT decodes : une colonne distance, à côté du locator dont elle est calculée et dans votre unité (km ou miles). Arrondie à lunité — un locator à quatre caractères est un carré de plusieurs dizaines de kilomètres, rien de plus fin ne serait honnête.",
"[NEW] Le panneau PSK Reporter bascule sur la station que lauto-call ATTEND (le sablier), tant que rien nest appelé. Son analyse met une requête dhistorique et une période ou deux à se remplir : la lancer quand le DX se libère enfin, cest la lancer trop tard — lattente sert précisément à ça.",
"[NEW] Chase new : sélection de bandes propre au panneau, sous loption (160 m à 70 cm). La liste de bandes de la station sapplique toujours en dessous — celle-ci dit ce quon veut SURVEILLER ce soir.",
"Chase new : le panneau indique l’état du flux — connecté ou non, nombre de rapports reçus, nombre de carrés de réception souscrits. Une liste vide ne disait rien sur ce qui arrivait vraiment.",
"FT decodes : une colonne récepteur apparaît quand plusieurs décodeurs alimentent la liste fusionnée, et la liste se vide pour un récepteur qui change de bande — un demi-écran de stations devenues inaccessibles est pire quun écran vide.",
"FT decodes : un badge WL rose marque une station de votre watchlist, et lhorloge de période passe au rouge en émission. Cest le seul élément qui bouge à l’écran, donc « suis-je en émission » se lit de loin.",
"FT decodes : un message qui VOUS est adressé saffiche en vert, en entier et en gras, avec un liseré vert sur la ligne — lisible de lautre bout du shack. La station que vous appelez garde une teinte légère et son indicatif en rouge : lessentiel de ce quelle émet sadresse à dautres, et colorer ces lignes pareil laissait croire à un QSO en cours.",
"Cluster : « superfox », « super fox », « fox/hound » et « F/H » dans un commentaire de spot sont lus comme du FT8. Ce sont les modes d’émission DXpédition de WSJT-X, pas des modes en soi, et le commentaire retombait sur le mode par défaut de la bande.",
"Auto-call : ce quil appelle, et dans quel ordre. Il ne répond qu’à ce que la liste des décodes AFFICHE — les filtres du dessus (CQ seul, LoTW seul, les pastilles de catégorie, les continents, le rapport minimum, la recherche) pilotent désormais l’émission autant que l’œil, et loption distincte « utilisateurs LoTW uniquement » disparaît. Un nouveau préfixe, comté, état, locator ou parc vaut aussi un appel, au dernier barreau de l’échelle et seulement pour ce que vous chassez (Réglages → DX Cluster). Un indicatif en watchlist passe devant tout ce qui ny est pas, un vrai besoin devant un besoin non confirmé de même niveau, et une meilleure station peut prendre la place dune autre qui na pas encore répondu — jamais celle dun QSO en cours.",
"Auto-call : il appelle À TRAVERS un pile-up. Il abandonnait dès que la station appelée répondait à quelquun dautre — or cest exactement ce que fait un DX avec une file, et le seul moyen d’être le suivant est de continuer à appeler pendant quil travaille les autres. Toujours borné par les compteurs dappels et de ratés, et une station en plein échange nest toujours jamais CHOISIE comme nouvelle cible.",
"Auto-call : sécurité et contrôle. Il est toujours ARRÊTÉ au lancement et après un changement de profil, jamais armé depuis un réglage enregistré — cest la seule fonction qui met la station en émission toute seule, et OpsLog démarre avec Windows. Halt devient un verdict : la station est mise de côté pour la session et nest plus appelée jusqu’à ce que lauto-call soit désactivé puis réactivé. Il dit ce quil attend, en affichant à côté du bouton Auto la station voulue qui travaille quelquun dautre. Et il peut journaliser chaque décision (Réglages → DXHunter) : une ligne par période disant ce qui était sur lair et pourquoi chaque station a été refusée.",
"Auto-call : série de corrections issues du trafic. Il ne coupe plus votre 73, nappelle plus avec quatre à six secondes de retard, ne lance plus un appel pour le couper une seconde après, ne laisse plus sans réponse une station qui vous appelle, lit correctement les doubles réponses MSHV sur une même ligne, ne refuse plus presque tout ce qui passe parce quil prenait le « worked » de lentité pour celui de la station, et ne démarre plus avec deux ratés au compteur sur une station tout juste choisie.",
"Changer de profil ne laisse plus les verdicts du carnet précédent à l’écran. En revenant dun profil au log vide, tous les décodes et spots restaient marqués NEW jusquau redémarrage : lindex des contacts, la liste chase new et les verdicts en cache sont désormais vidés au changement de carnet.",
"Chase new, corrections : len-tête compte à la fois ce que les filtres montrent et ce que le panneau contient (« 3 sur 12 entendues »), un jeu de filtres enregistré qui cache tout est ignoré, et le passage entre « new » et « new + non confirmés » relit la liste au lieu de laisser danciens verdicts à l’écran.",
"Panneau PSK Reporter, corrections : lhistorique est demandé dans les deux portées du flux et rafraîchi toutes les cinq minutes, donc une cible choisie peu après le démarrage naffiche plus une page vide ; loffset d’émission suggéré donne toujours une réponse quand il y a des données ; et les textes annoncent dix minutes, la fenêtre réellement utilisée.",
"Recherche callbook : un indicatif composé qui a sa PROPRE fiche garde la position de cette fiche. HP/WE9G est déposé sur QRZ sous cette forme exacte, avec ladresse et le locator du Panama doù la station émet, et OpsLog les jetait — la règle qui écarte ladresse personnelle dun indicatif portable sappliquait aussi aux fiches qui décrivent lopération elle-même. Cest le pays de la fiche qui les distingue. Les fiches en cache se corrigent à la lecture suivante ; les QSO déjà enregistrés sans locator le restent."
]
},
{ {
"version": "0.27.12", "version": "0.27.12",
"date": "", "date": "",
+181 -21
View File
@@ -54,7 +54,7 @@ import {
GetFlexState, FlexAmpOperate, GetFlexState, FlexAmpOperate,
GetPSKReporterStatus, GetLiveOpenings, GetChaseNew, GetPSKReporterStatus, GetLiveOpenings, GetChaseNew,
QSLViaRepairStatus, RepairQSLVia, DismissQSLViaRepair, QSLViaRepairStatus, RepairQSLVia, DismissQSLViaRepair,
GetAutoCallStatus, SetAutoCall, SetAutoCallOnly, TakeAutoCallTarget, ResetAutoCall, GetAutoCallStatus, SetAutoCall, SetAutoCallOnly, TakeAutoCallTarget, HaltAutoCall, WatchlistEntries,
} from '../wailsjs/go/main/App'; } from '../wailsjs/go/main/App';
import { Combobox } from '@/components/ui/combobox'; import { Combobox } from '@/components/ui/combobox';
import { applyAwardRefs, parseAwardRefs as parseManualRefs, spotRefList , withIOTARef, withRDARef } from '@/lib/awardRefs'; import { applyAwardRefs, parseAwardRefs as parseManualRefs, spotRefList , withIOTARef, withRDARef } from '@/lib/awardRefs';
@@ -119,10 +119,14 @@ import { DetailsPanel, type DetailsState } from '@/components/DetailsPanel';
import { SendSpotModal, type RecentSpotQSO } from '@/components/SendSpotModal'; import { SendSpotModal, type RecentSpotQSO } from '@/components/SendSpotModal';
import { WinkeyerPanel, type WKStatus, type WKMacro } from '@/components/WinkeyerPanel'; import { WinkeyerPanel, type WKStatus, type WKMacro } from '@/components/WinkeyerPanel';
import { RotorCompass } from '@/components/RotorCompass'; import { RotorCompass } from '@/components/RotorCompass';
import { RotorCompassClassic } from '@/components/RotorCompassClassic';
import { WatchlistWidget } from '@/components/WatchlistWidget';
import { rotorStyle, subscribeRotorStyle } from '@/lib/rotorStyle';
import { GridSquareMap } from '@/components/GridSquareMap'; import { GridSquareMap } from '@/components/GridSquareMap';
import { loadClusterMacros, visibleClusterMacros } from '@/lib/clusterMacros'; import { loadClusterMacros, visibleClusterMacros } from '@/lib/clusterMacros';
import { DecodesPanel, type Decode as DecodeRow, type TxMsg as TxMsgRow } from '@/components/DecodesPanel'; import { DecodesPanel, type Decode as DecodeRow, type TxMsg as TxMsgRow } from '@/components/DecodesPanel';
import { PSKReporterPanel } from '@/components/PSKReporterPanel'; import { PSKReporterPanel } from '@/components/PSKReporterPanel';
import { decoderName } from '@/lib/decoderName';
import { subscribeRotorHeading, pokeRotorHeading } from '@/lib/rotorHeading'; import { subscribeRotorHeading, pokeRotorHeading } from '@/lib/rotorHeading';
import { writeUiPref } from '@/lib/uiPref'; import { writeUiPref } from '@/lib/uiPref';
import { formatDateTimeUTC } from '@/lib/dateFormat'; import { formatDateTimeUTC } from '@/lib/dateFormat';
@@ -2406,6 +2410,17 @@ export default function App() {
// the watchlist panel: a call can be added from the cluster or the // the watchlist panel: a call can be added from the cluster or the
// DXpeditions tab, and the old inline message lived on a page nobody was // DXpeditions tab, and the old inline message lived on a page nobody was
// looking at. // looking at.
// The watch list as patterns, for the decodes badge. Loaded once and kept in
// step with the same event the notice below listens to — a call added from
// the cluster has to light up in the decodes list too.
const [watchPatterns, setWatchPatterns] = useState<string[]>([]);
const loadWatchPatterns = useCallback(() => {
WatchlistEntries()
.then((es: any[]) => setWatchPatterns((es ?? []).map((e: any) => String(e?.callsign ?? '')).filter(Boolean)))
.catch(() => {});
}, []);
useEffect(() => { loadWatchPatterns(); }, [loadWatchPatterns]);
useEffect(() => EventsOn('watchlist:changed', () => loadWatchPatterns()), [loadWatchPatterns]);
const [wlNotice, setWlNotice] = useState<{ call: string; added: boolean } | null>(null); const [wlNotice, setWlNotice] = useState<{ call: string; added: boolean } | null>(null);
const wlNoticeTimer = useRef<number | undefined>(undefined); const wlNoticeTimer = useRef<number | undefined>(undefined);
useEffect(() => EventsOn('watchlist:changed', (e: any) => { useEffect(() => EventsOn('watchlist:changed', (e: any) => {
@@ -2597,6 +2612,33 @@ export default function App() {
// Staged like the cluster's, so a period arriving as one burst of fifty // Staged like the cluster's, so a period arriving as one burst of fifty
// packets costs one status lookup and one render, not fifty of each. // packets costs one status lookup and one render, not fifty of each.
const pendingDecodesRef = useRef<DecodeRow[]>([]); const pendingDecodesRef = useRef<DecodeRow[]>([]);
// The band each receiver was last decoding on. A band change empties that
// receiver's list — see the flush below.
const decoderBandRef = useRef<Map<string, string>>(new Map());
// The RIG changing band empties the list too, without waiting for the
// decoder to catch up.
//
// Clicking a watchlist row or a spot tunes the radio; the decoder follows on
// its own clock and its first decode on the new band is up to a period away.
// Until then the operator reads a screen of stations from a band they have
// left — which is the moment the list is most misleading, because it looks
// current. Only the receivers that were on the band we came FROM are
// cleared: a second decoder parked on another band has not moved.
const rigBandRef = useRef<string>('');
useEffect(() => {
const band = (catState.band ?? '').toLowerCase();
const was = rigBandRef.current;
rigBandRef.current = band;
if (!band || !was || was === band) return;
const stale = new Set<string>();
decoderBandRef.current.forEach((b2, inst) => { if (b2 === was) stale.add(inst); });
if (stale.size === 0) return;
stale.forEach((inst) => decoderBandRef.current.delete(inst));
pendingDecodesRef.current = pendingDecodesRef.current.filter((d) => !stale.has(d.instance ?? ''));
setDecodes((arr) => arr.filter((d) => !stale.has(d.instance ?? '')));
setTxMsgs((arr) => arr.filter((m) => !stale.has(m.instance ?? '')));
}, [catState.band]);
const pendingDecodeTimer = useRef<number | undefined>(undefined); const pendingDecodeTimer = useRef<number | undefined>(undefined);
// Rotor quick-turn buttons (Settings → Rotator). Same reload trigger as the // Rotor quick-turn buttons (Settings → Rotator). Same reload trigger as the
@@ -2733,6 +2775,9 @@ export default function App() {
// Portable UI toggles (mirrored to the DB via writeUiPref / syncPortablePrefs). // Portable UI toggles (mirrored to the DB via writeUiPref / syncPortablePrefs).
const [showRotor, setShowRotor] = useState(() => localStorage.getItem('opslog.showRotor') !== '0'); const [showRotor, setShowRotor] = useState(() => localStorage.getItem('opslog.showRotor') !== '0');
// Off by default: it is an alerting panel, and one that appears uninvited on
// an operator who does not keep a watch list is just a box saying "empty".
const [showWatchWidget, setShowWatchWidget] = useState(() => localStorage.getItem('opslog.showWatchWidget') === '1');
const [showAntGenius, setShowAntGenius] = useState(() => localStorage.getItem('opslog.showAntGenius') !== '0'); const [showAntGenius, setShowAntGenius] = useState(() => localStorage.getItem('opslog.showAntGenius') !== '0');
const [showTuner, setShowTuner] = useState(() => localStorage.getItem('opslog.showTuner') !== '0'); const [showTuner, setShowTuner] = useState(() => localStorage.getItem('opslog.showTuner') !== '0');
const [showScp, setShowScp] = useState(() => localStorage.getItem('opslog.showScp') !== '0'); const [showScp, setShowScp] = useState(() => localStorage.getItem('opslog.showScp') !== '0');
@@ -2759,6 +2804,11 @@ export default function App() {
// withholding them IS the compact mode, so there is no second layout to keep // withholding them IS the compact mode, so there is no second layout to keep
// in step with the first. // in step with the first.
const [rotorCompact, setRotorCompact] = useState(() => localStorage.getItem('opslog.rotorCompact') === '1'); const [rotorCompact, setRotorCompact] = useState(() => localStorage.getItem('opslog.rotorCompact') === '1');
// Which dial (Settings → Rotator). Subscribed rather than re-read on close, so
// the choice shows the moment it is made — it is picked by looking at it.
const [rotorDial, setRotorDial] = useState(() => rotorStyle());
useEffect(() => subscribeRotorStyle(() => setRotorDial(rotorStyle())), []);
const Compass2 = rotorDial === 'classic' ? RotorCompassClassic : RotorCompass;
// Award code → scanned field (e.g. POTA→pota_ref, WWFF→wwff). Used to route // Award code → scanned field (e.g. POTA→pota_ref, WWFF→wwff). Used to route
// picked award references to the QSO field/extras each award actually reads. // picked award references to the QSO field/extras each award actually reads.
@@ -3718,9 +3768,42 @@ export default function App() {
}); });
} }
} catch { /* status unresolved — the decode still shows, just unflagged */ } } catch { /* status unresolved — the decode still shows, just unflagged */ }
// A BAND CHANGE empties that receiver's list.
//
// What is on 20 m says nothing about 40 m, and half a screen of stations
// that are no longer reachable is worse than an empty one: the period
// headings still march on, the rows still carry status badges, and the
// operator reads a band they have left. Per receiver — in a split view
// the other one has not moved — and only when the new band is known.
const moved = new Map<string, string>();
for (const d of batch) {
const inst = d.instance ?? '';
const band = (d.band ?? '').toLowerCase();
if (!band) continue;
const was = decoderBandRef.current.get(inst);
decoderBandRef.current.set(inst, band);
if (was && was !== band) moved.set(inst, band);
}
if (moved.size > 0) {
for (const [inst, band] of moved) {
LogUIError('decodes', `${decoderName(inst) || 'decoder'} moved to ${band.toUpperCase()} — its earlier decodes cleared`, '');
}
setTxMsgs((arr) => arr.filter((m) => !moved.has(m.instance ?? '')));
}
setDecodes((arr) => { setDecodes((arr) => {
const cutoff = Date.now() - DECODE_KEEP_MS; const cutoff = Date.now() - DECODE_KEEP_MS;
const next = [...arr, ...batch].filter((d) => Date.parse(d.at) >= cutoff); // Rows from a receiver that has just changed band go with it — the
// batch itself is already on the new band.
const kept = moved.size === 0 ? arr : arr.filter((d) => !moved.has(d.instance ?? ''));
// The batch can straddle the change — a 300 ms flush can hold the last
// decodes of the old band and the first of the new. Only the new band
// survives for a receiver that moved.
const fresh = moved.size === 0 ? batch
: batch.filter((d) => {
const b2 = moved.get(d.instance ?? '');
return !b2 || (d.band ?? '').toLowerCase() === b2;
});
const next = [...kept, ...fresh].filter((d) => Date.parse(d.at) >= cutoff);
return next; return next;
}); });
}; };
@@ -6278,13 +6361,41 @@ export default function App() {
// Follow the station the digital application says it is calling. A decode // Follow the station the digital application says it is calling. A decode
// clicked here sets the target directly (see onCall); this covers the QSO // clicked here sets the target directly (see onCall); this covers the QSO
// started from the other side — WSJT-X's own double-click, or auto-call. // started from the other side — WSJT-X's own double-click, or auto-call.
//
// On the EDGE — when the decoder's DX call actually changes — and not by
// comparing it with the panel's current target: the auto-call effect below
// sets that target too, and two effects each restoring "their" value from the
// other's write is a loop, not a preference.
const pskDxRef = useRef('');
useEffect(() => { useEffect(() => {
const dx = (txState?.dx_call ?? '').toUpperCase().trim(); const dx = (txState?.dx_call ?? '').toUpperCase().trim();
if (dx && dx !== pskTarget) { if (!dx || dx === pskDxRef.current) return;
setPskTarget(dx); pskDxRef.current = dx;
setPskTargetMode(txState?.mode ?? ''); setPskTarget(dx);
} setPskTargetMode(txState?.mode ?? '');
}, [txState?.dx_call, txState?.mode, pskTarget]); }, [txState?.dx_call, txState?.mode]);
// WAITING FOR A STATION IS ALREADY A REASON TO ANALYSE IT.
//
// Auto-call shows an hourglass for a station it wants and cannot call yet
// because that station is working somebody else. The analysis takes a moment
// to fill — the history query, then a period or two of live reports — so
// starting it at the instant the DX becomes free is starting it too late.
// The wait is dead time, and this is exactly what it is worth spending on.
//
// Only while nothing is actually being called: a real target is the panel's
// subject, and it arrives here through the decoder's DX call above.
const pskWaitRef = useRef('');
useEffect(() => {
const w = String(autoCallStatus?.waiting ?? '').toUpperCase().trim();
if (!w) { pskWaitRef.current = ''; return; }
if (autoCallStatus?.target) return;
if (w === pskWaitRef.current) return;
pskWaitRef.current = w;
setPskTarget(w);
setPskTargetMode(txState?.mode ?? mode ?? '');
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [autoCallStatus?.waiting, autoCallStatus?.target]);
const renderDecodesPanel = () => ( const renderDecodesPanel = () => (
<div className="flex h-full min-h-0"> <div className="flex h-full min-h-0">
@@ -6325,6 +6436,7 @@ export default function App() {
// compare with", never "the rig is on no band". // compare with", never "the rig is on no band".
rigBand={catState.connected ? (catState.band || '') : ''} rigBand={catState.connected ? (catState.band || '') : ''}
myCall={station.callsign} myCall={station.callsign}
myGrid={station.my_grid}
// A DOUBLE click answers the station: it hands the decode back to // A DOUBLE click answers the station: it hands the decode back to
// WSJT-X/MSHV as a Reply, the same thing as double-clicking the line in // WSJT-X/MSHV as a Reply, the same thing as double-clicking the line in
// their own window. A single click only selects it — see onSelect below, // their own window. A single click only selects it — see onSelect below,
@@ -6385,15 +6497,16 @@ export default function App() {
// An empty instance lets the backend fall back to whichever application // An empty instance lets the backend fall back to whichever application
// last reported its status — the normal single-receiver case. // last reported its status — the normal single-receiver case.
onHalt={(instance) => { onHalt={(instance) => {
// Halt means stop, including whatever auto-call had started — and it // Halt is a verdict on the station being called: it is set aside for
// clears the engine's state, so a target it had given up on is not // the session rather than released, or the next period would call it
// still sitting there when the operator switches it back on. // straight back — which is exactly what the operator just stopped.
ResetAutoCall().catch(() => {}); HaltAutoCall().catch(() => {});
HaltDecodeTx(instance, false).catch((e: any) => setError(String(e?.message ?? e))); HaltDecodeTx(instance, false).catch((e: any) => setError(String(e?.message ?? e)));
}} }}
autoCallOn={!!autoCallStatus?.enabled} autoCallOn={!!autoCallStatus?.enabled}
onToggleAutoCall={toggleAutoCall} onToggleAutoCall={toggleAutoCall}
autoCall={autoCallStatus} autoCall={autoCallStatus}
watchlist={watchPatterns}
autoCallOnly={autoCallStatus?.only ?? ''} autoCallOnly={autoCallStatus?.only ?? ''}
onSetAutoCallOnly={(list) => { onSetAutoCallOnly={(list) => {
setAutoCallStatus((st: any) => ({ ...st, only: list.toUpperCase() })); setAutoCallStatus((st: any) => ({ ...st, only: list.toUpperCase() }));
@@ -6737,6 +6850,18 @@ export default function App() {
<Ear className="size-4" /> <Ear className="size-4" />
{cwOn && cwStatus.active && <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-success animate-pulse" />} {cwOn && cwStatus.active && <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-success animate-pulse" />}
</button> </button>
<button
type="button"
onClick={() => { const v = !showWatchWidget; setShowWatchWidget(v); writeUiPref('opslog.showWatchWidget', v ? '1' : '0'); }}
title={showWatchWidget ? t('wlw.hide') : t('wlw.show')}
className={cn(
'relative inline-flex items-center justify-center size-7 rounded-md border transition-colors',
showWatchWidget ? 'border-success-border bg-success-muted text-success-muted-foreground hover:bg-success-muted'
: 'border-border text-muted-foreground hover:bg-muted',
)}
>
<Bell className="size-4" />
</button>
<button <button
type="button" type="button"
onClick={() => { const v = !showRotor; setShowRotor(v); writeUiPref('opslog.showRotor', v ? '1' : '0'); }} onClick={() => { const v = !showRotor; setShowRotor(v); writeUiPref('opslog.showRotor', v ? '1' : '0'); }}
@@ -7219,12 +7344,30 @@ export default function App() {
{e.date && <span className="text-[11px] text-muted-foreground">{e.date}</span>} {e.date && <span className="text-[11px] text-muted-foreground">{e.date}</span>}
</div> </div>
<ul className="space-y-1.5 text-sm"> <ul className="space-y-1.5 text-sm">
{(clLang === 'fr' ? e.fr : e.en).map((line, i) => ( {(clLang === 'fr' ? e.fr : e.en).map((line, i) => {
<li key={i} className="flex gap-2"> // "[NEW] " at the head of an entry marks a new FEATURE, as
<span className="text-primary mt-1.5 size-1.5 rounded-full bg-primary shrink-0" /> // opposed to a fix to one. The marker is written in the
<span>{line}</span> // changelog file itself and is the same in both languages,
</li> // so one convention covers EN and FR; it is stripped here
))} // and drawn as a pill. A release is mostly fixes, and the
// two or three things that are actually new should not have
// to be found by reading all of it.
const isNew = line.startsWith('[NEW] ');
const text = isNew ? line.slice(6) : line;
return (
<li key={i} className="flex gap-2">
<span className={cn('mt-1.5 size-1.5 rounded-full shrink-0', isNew ? 'bg-success' : 'bg-primary')} />
<span>
{isNew && (
<span className="mr-1.5 align-[1px] rounded px-1 py-px text-[10px] font-bold uppercase tracking-wide bg-success text-success-foreground">
{t('whatsnew.newTag')}
</span>
)}
{text}
</span>
</li>
);
})}
</ul> </ul>
</div> </div>
))} ))}
@@ -7446,7 +7589,7 @@ export default function App() {
{/* Reserved free space to the right. The WinKeyer CW keyer and/or the {/* Reserved free space to the right. The WinKeyer CW keyer and/or the
Digital Voice Keyer take this slot when enabled (Log4OM-style); Digital Voice Keyer take this slot when enabled (Log4OM-style);
otherwise it shows the QRZ profile photo. */} otherwise it shows the QRZ profile photo. */}
{!compact && (chatShown || wkEnabled || dvkEnabled || lookupResult?.image_url || (showRotor && (rotatorHeading.enabled || dxPath)) || (showMotorAnt && ubStatus.enabled) || (showAntGenius && agEnabled) || (showTuner && tgEnabled) || (showAmpWidget && ampSts.length > 0) || (showScp && scpEnabled) || (chaseNewOn && showChaseNew) || (showLiveStations && dbConn?.backend === 'mysql')) && ( {!compact && (chatShown || wkEnabled || dvkEnabled || lookupResult?.image_url || (showRotor && (rotatorHeading.enabled || dxPath)) || (showMotorAnt && ubStatus.enabled) || (showAntGenius && agEnabled) || (showTuner && tgEnabled) || (showAmpWidget && ampSts.length > 0) || (showScp && scpEnabled) || (chaseNewOn && showChaseNew) || showWatchWidget || (showLiveStations && dbConn?.backend === 'mysql')) && (
// relative + absolute inner (like the F1-F5 panel): a taller widget (e.g. // relative + absolute inner (like the F1-F5 panel): a taller widget (e.g.
// the DVK with Auto CQ) can't grow the row — the row height stays set by // the DVK with Auto CQ) can't grow the row — the row height stays set by
// the entry strip and each widget fills that height, scrolling inside. // the entry strip and each widget fills that height, scrolling inside.
@@ -7509,10 +7652,27 @@ export default function App() {
{/* Rotor compass: azimuth dial + needles + click-to-turn. Shows when a {/* Rotor compass: azimuth dial + needles + click-to-turn. Shows when a
rotator is configured or a DX bearing exists. Compact mode drops the rotator is configured or a DX bearing exists. Compact mode drops the
controls column, so the widget is just the dial and needs only its controls column, so the widget is just the dial and needs only its
width. */} width. The classic dial sizes itself from the inside and expects a fixed
column; the current one asks for the width it needs. */}
{showWatchWidget && (
<div className="w-[290px] shrink-0 min-h-0" style={{ order: wOrder('watchlist') }}>
{/* Same handler as a cluster row, for the same reason as Chase
new: a row here IS a spot, and half the reflex the call
without the frequency, or the frequency without the mode is
what makes a shortcut not worth using. */}
<WatchlistWidget
spots={spots}
spotStatus={spotStatus as any}
onPick={(s) => handleSpotClick(s as any)}
onClose={() => { setShowWatchWidget(false); writeUiPref('opslog.showWatchWidget', '0'); }}
/>
</div>
)}
{showRotor && (rotatorHeading.enabled || dxPath) && ( {showRotor && (rotatorHeading.enabled || dxPath) && (
<div className={cn('shrink-0 min-h-0', rotorCompact ? 'w-[196px]' : 'w-[320px]')} style={{ order: wOrder('rotor') }}> <div className={cn('shrink-0 min-h-0',
<RotorCompass rotorCompact ? 'w-[196px]' : rotorDial === 'classic' ? 'w-[320px]' : 'w-auto')}
style={{ order: wOrder('rotor') }}>
<Compass2
presets={rotorCompact ? undefined : rotorPresets} presets={rotorCompact ? undefined : rotorPresets}
onStop={rotorCompact ? undefined : () => { RotatorStop().then(pokeRotorHeading).catch((err) => setError(String(err?.message ?? err))); }} onStop={rotorCompact ? undefined : () => { RotatorStop().then(pokeRotorHeading).catch((err) => setError(String(err?.message ?? err))); }}
bearing={dxPath?.bearingShort ?? null} bearing={dxPath?.bearingShort ?? null}
+2 -2
View File
@@ -304,13 +304,13 @@ export function AppearancePanel() {
// place here: it comes back where the operator left it rather than at the end. // place here: it comes back where the operator left it rather than at the end.
export const WIDGET_KEYS = [ export const WIDGET_KEYS = [
'livestations', 'chat', 'rotor', 'motorant', 'antgenius', 'livestations', 'chat', 'rotor', 'motorant', 'antgenius',
'amp', 'tuner', 'scp', 'chasenew', 'dvk', 'winkeyer', 'photo', 'amp', 'tuner', 'scp', 'chasenew', 'watchlist', 'dvk', 'winkeyer', 'photo',
] as const; ] as const;
const WIDGET_LABELS: Record<string, string> = { const WIDGET_LABELS: Record<string, string> = {
livestations: 'wo.livestations', chat: 'wo.chat', rotor: 'wo.rotor', livestations: 'wo.livestations', chat: 'wo.chat', rotor: 'wo.rotor',
motorant: 'wo.motorant', antgenius: 'wo.antgenius', amp: 'wo.amp', motorant: 'wo.motorant', antgenius: 'wo.antgenius', amp: 'wo.amp',
tuner: 'wo.tuner', scp: 'wo.scp', chasenew: 'wo.chasenew', tuner: 'wo.tuner', scp: 'wo.scp', chasenew: 'wo.chasenew', watchlist: 'wo.watchlist',
dvk: 'wo.dvk', winkeyer: 'wo.winkeyer', photo: 'wo.photo', dvk: 'wo.dvk', winkeyer: 'wo.winkeyer', photo: 'wo.photo',
}; };
+35 -5
View File
@@ -15,7 +15,7 @@ import { useI18n } from '@/lib/i18n';
import { chaseAllows } from '@/lib/spotDisplay'; import { chaseAllows } from '@/lib/spotDisplay';
import { markerColour } from '@/lib/spotMarkers'; import { markerColour } from '@/lib/spotMarkers';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { GetChaseNewSpots } from '../../wailsjs/go/main/App'; import { GetChaseNewSpots, GetPSKReporterStatus } from '../../wailsjs/go/main/App';
export interface ChaseNewSpot { export interface ChaseNewSpot {
call: string; call: string;
@@ -80,15 +80,25 @@ function categoryOf(s: ChaseNewSpot): Category | null {
const FILTER_KEY = 'opslog.chaseNewFilters'; const FILTER_KEY = 'opslog.chaseNewFilters';
function allowedCategories(): Category[] {
return CATEGORIES.filter((c) => chaseAllows(c.key)).map((c) => c.key);
}
function loadFilters(): Set<Category> { function loadFilters(): Set<Category> {
const allowed = allowedCategories();
try { try {
const raw = localStorage.getItem(FILTER_KEY); const raw = localStorage.getItem(FILTER_KEY);
if (raw) { if (raw) {
const list = JSON.parse(raw) as Category[]; const list = JSON.parse(raw) as Category[];
if (Array.isArray(list)) return new Set(list); // A stored set holding NONE of the categories on offer hides the whole
// panel, for ever, with nothing to say why — and that is exactly what a
// preference written by an older build does once a category is renamed.
// Treated as "no preference": a panel that shows nothing at every launch
// is never what was meant, and the chips are one click away.
if (Array.isArray(list) && list.some((k) => allowed.includes(k))) return new Set(list);
} }
} catch { /* a corrupt preference is not worth a broken panel */ } } catch { /* a corrupt preference is not worth a broken panel */ }
return new Set(CATEGORIES.filter((c) => chaseAllows(c.key)).map((c) => c.key)); return new Set(allowed);
} }
export function ChaseNewPanel({ onPick, onClose }: Props) { export function ChaseNewPanel({ onPick, onClose }: Props) {
@@ -96,6 +106,10 @@ export function ChaseNewPanel({ onPick, onClose }: Props) {
const [spots, setSpots] = useState<ChaseNewSpot[]>([]); const [spots, setSpots] = useState<ChaseNewSpot[]>([]);
const [loaded, setLoaded] = useState(false); const [loaded, setLoaded] = useState(false);
const [on, setOn] = useState<Set<Category>>(loadFilters); const [on, setOn] = useState<Set<Category>>(loadFilters);
// The FEED, not the list: connected or not, how many reports it has taken,
// and what it is filtered on. Without it an empty panel says nothing about
// whether anything is arriving at all — which is the first question.
const [feed, setFeed] = useState<any>(null);
// Polled rather than pushed: the feed can deliver several a second under an // Polled rather than pushed: the feed can deliver several a second under an
// opening, and an event per row would be a redraw per row for a list nobody // opening, and an event per row would be a redraw per row for a list nobody
@@ -106,6 +120,8 @@ export function ChaseNewPanel({ onPick, onClose }: Props) {
try { try {
const r = ((await GetChaseNewSpots()) ?? []) as ChaseNewSpot[]; const r = ((await GetChaseNewSpots()) ?? []) as ChaseNewSpot[];
if (alive) { setSpots(r); setLoaded(true); } if (alive) { setSpots(r); setLoaded(true); }
const st = await GetPSKReporterStatus();
if (alive) setFeed(st);
} catch { /* the feed may not be up yet */ } } catch { /* the feed may not be up yet */ }
}; };
tick(); tick();
@@ -154,8 +170,13 @@ export function ChaseNewPanel({ onPick, onClose }: Props) {
))} ))}
</div> </div>
{/* Both numbers: what is on screen, and what was heard. They differ
exactly when a category is switched off, which is the one case an
operator reads this panel as broken. */}
<span className="shrink-0 text-[10px] text-muted-foreground"> <span className="shrink-0 text-[10px] text-muted-foreground">
{loaded ? t('chn.count', { n: shown.length }) : ''} {loaded ? (shown.length === spots.length
? t('chn.count', { n: spots.length })
: t('chn.countOf', { n: shown.length, total: spots.length })) : ''}
</span> </span>
{onClose && ( {onClose && (
<button <button
@@ -218,7 +239,16 @@ export function ChaseNewPanel({ onPick, onClose }: Props) {
)} )}
</div> </div>
<p className="border-t border-border px-2 py-1 text-[10px] text-muted-foreground">{t('chn.digitalOnly')}</p> {/* The radius comes from the FEED, not from a sentence: it was written
into this line as "~300 km" and stayed 300 while the setting said
1000, which is the panel telling the operator their change did not
take when it had. */}
<p className="border-t border-border px-2 py-1 text-[10px] text-muted-foreground">
{t('chn.heardWithin', { km: feed?.near_km || 300 })}
{feed && (feed.running
? <span className="text-success"> · {t('chn.feedOn', { n: feed.received ?? 0, sq: feed.squares ?? 0 })}</span>
: <span className="text-warning"> · {feed.last_err ? t('chn.feedErr', { e: feed.last_err }) : t('chn.feedOff')}</span>)}
</p>
</div> </div>
); );
} }
+138 -19
View File
@@ -17,8 +17,11 @@ import { AlertTriangle, Radio, Search, X, Signal, ArrowUpRight, Timer, Trash2, B
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { chaseAllows } from '@/lib/spotDisplay'; import { chaseAllows } from '@/lib/spotDisplay';
import { pathBetween } from '@/lib/maidenhead';
import { distanceValue, distanceUnit, subscribeDistanceUnit } from '@/lib/units';
import { markerColour, type SpotMarkerKey } from '@/lib/spotMarkers'; import { markerColour, type SpotMarkerKey } from '@/lib/spotMarkers';
import { writeUiPref } from '@/lib/uiPref'; import { writeUiPref } from '@/lib/uiPref';
import { SetAutoCallVisible } from '../../wailsjs/go/main/App';
import { decoderName } from '@/lib/decoderName'; import { decoderName } from '@/lib/decoderName';
export type Decode = { export type Decode = {
@@ -104,6 +107,9 @@ interface Props {
// point the panels at it. Absent, a click falls back to onCall. // point the panels at it. Absent, a click falls back to onCall.
onSelect?: (d: Decode) => void; onSelect?: (d: Decode) => void;
myCall?: string; myCall?: string;
// The station's own square: distance is measured from it, and without one the
// column stays empty rather than guessing.
myGrid?: string;
// Drop every decode and transmit message held for this panel. The list is a // Drop every decode and transmit message held for this panel. The list is a
// live view, not data — clearing it costs nothing but the seconds until the // live view, not data — clearing it costs nothing but the seconds until the
// next period lands. // next period lands.
@@ -120,11 +126,14 @@ interface Props {
autoCallOn?: boolean; autoCallOn?: boolean;
onToggleAutoCall?: () => void; onToggleAutoCall?: () => void;
// The engine's own account of what it is doing, straight from the backend. // The engine's own account of what it is doing, straight from the backend.
autoCall?: { target: string; calls: number; max: number; misses: number; max_miss: number; stopped: boolean; reason: string }; autoCall?: { target: string; waiting: string; calls: number; max: number; misses: number; max_miss: number; stopped: boolean; reason: string };
// The chase list, here as well as in Preferences: naming the station you are // The chase list, here as well as in Preferences: naming the station you are
// waiting for is done WHILE watching the band, not in a settings tree. // waiting for is done WHILE watching the band, not in a settings tree.
autoCallOnly?: string; autoCallOnly?: string;
onSetAutoCallOnly?: (list: string) => void; onSetAutoCallOnly?: (list: string) => void;
// The watch list, as PATTERNS (VK9*, 3Y0J). A decode of one is worth saying
// so where the operator is reading the band, not only in the watchlist tab.
watchlist?: string[];
} }
// The "new" categories, as toggle badges — the same idea and the same colours as // The "new" categories, as toggle badges — the same idea and the same colours as
@@ -171,6 +180,21 @@ function catsOf(e: StatusEntry | undefined): Set<NewCat> {
return out; return out;
} }
// isWatched applies the watch list's own rule — a trailing "*" is a prefix,
// anything else is the whole callsign — so a decode is judged here exactly as
// the backend judges a spot. Two rules for one list is how a badge and an alert
// start disagreeing about the same station.
function isWatched(call: string, patterns: string[] | undefined): boolean {
if (!patterns || patterns.length === 0 || !call) return false;
const c = call.toUpperCase();
for (const raw of patterns) {
const p = (raw ?? '').toUpperCase().trim();
if (!p) continue;
if (p.endsWith('*') ? c.startsWith(p.slice(0, -1)) : c === p) return true;
}
return false;
}
const CAT_KEY = 'opslog.decodeCats'; const CAT_KEY = 'opslog.decodeCats';
const SPLIT_KEY = 'opslog.decodeSplit'; const SPLIT_KEY = 'opslog.decodeSplit';
const FILTER_KEY = 'opslog.decodeFilters'; const FILTER_KEY = 'opslog.decodeFilters';
@@ -252,9 +276,14 @@ const CELL_LAST = 'flex items-center min-w-0 px-2 gap-1 overflow-hidden';
// One declaration per column, in display order: the header, the widths and the // One declaration per column, in display order: the header, the widths and the
// resize handles all read from this, so a column cannot be resized in the header // resize handles all read from this, so a column cannot be resized in the header
// and stay the old width in the body. // and stay the old width in the body.
type ColKey = 'time' | 'snr' | 'dt' | 'freq' | 'band' | 'mode' | 'msg' | 'grid' | 'state' | 'country' | 'status'; type ColKey = 'time' | 'rx' | 'snr' | 'dt' | 'freq' | 'band' | 'mode' | 'msg' | 'grid' | 'dist' | 'state' | 'country' | 'status';
const COLS: { key: ColKey; tkey: string; def: number; min: number }[] = [ const COLS: { key: ColKey; tkey: string; def: number; min: number }[] = [
{ key: 'time', tkey: 'dec.colTime', def: 64, min: 44 }, { key: 'time', tkey: 'dec.colTime', def: 64, min: 44 },
// WHICH RECEIVER heard it. Shown only while more than one is feeding, and
// that is the case it exists for: two decoders on one band send the same
// stations twice, each with its own SNR and DT, and a merged list gave no way
// at all to tell a second receiver from a duplicate.
{ key: 'rx', tkey: 'dec.colRx', def: 74, min: 44 },
{ key: 'snr', tkey: 'dec.colSnr', def: 50, min: 36 }, { key: 'snr', tkey: 'dec.colSnr', def: 50, min: 36 },
{ key: 'dt', tkey: 'dec.colDt', def: 44, min: 32 }, { key: 'dt', tkey: 'dec.colDt', def: 44, min: 32 },
{ key: 'freq', tkey: 'dec.colFreq', def: 56, min: 40 }, { key: 'freq', tkey: 'dec.colFreq', def: 56, min: 40 },
@@ -268,6 +297,11 @@ const COLS: { key: ColKey; tkey: string; def: number; min: number }[] = [
{ key: 'grid', tkey: 'dec.colGrid', def: 62, min: 46 }, { key: 'grid', tkey: 'dec.colGrid', def: 62, min: 46 },
// For the WAS chasers: the badge carries the two letters, the name spells // For the WAS chasers: the badge carries the two letters, the name spells
// them out — "SD" alone is a quiz for a European. // them out — "SD" alone is a quiz for a European.
// Next to the square it is computed from. A four-character grid is a square
// tens of kilometres wide, so this is rounded to whole units and never
// pretends to more: it answers "is that station across the pond or across the
// valley", which is what decides whether the report is worth anything.
{ key: 'dist', tkey: 'dec.colDist', def: 70, min: 46 },
{ key: 'state', tkey: 'dec.colState', def: 120, min: 56 }, { key: 'state', tkey: 'dec.colState', def: 120, min: 56 },
{ key: 'country', tkey: 'dec.colCountry', def: 140, min: 70 }, { key: 'country', tkey: 'dec.colCountry', def: 140, min: 70 },
{ key: 'status', tkey: 'dec.colStatus', def: 186, min: 80 }, { key: 'status', tkey: 'dec.colStatus', def: 186, min: 80 },
@@ -422,16 +456,31 @@ function periodLabel(ms: number, trSec: number): string {
// read "CQ CQ PE1NAO JO32" — the badge and the message's own first word saying // read "CQ CQ PE1NAO JO32" — the badge and the message's own first word saying
// the same thing twice. Highlighting the word already in the line keeps the // the same thing twice. Highlighting the word already in the line keeps the
// scannability and drops the stutter. // scannability and drops the stutter.
function renderMsg(msg: string, me: string, calling: string) { function renderMsg(msg: string, me: string, calling: string, toMe: boolean) {
if (!msg) return null; if (!msg) return null;
// THE WHOLE LINE, when it is addressed to YOU.
//
// Token colouring is for reading the band — it picks your call out of a wall
// of other people's traffic. A message sent TO you is a different question:
// not "is my call in there somewhere" but "what did he just send me", read
// from the far side of the shack, so the whole line carries the colour.
//
// Only that case. The station you are calling also transmits to everybody
// else — "LA8WRA LA1RAU/P +00" is your target reporting to another caller —
// and setting those lines in the same strong colour said you were in a QSO
// you were not in. Its callsign is picked out by the token pass below, which
// is what "he is on the air, working somebody else" should look like.
if (toMe) {
return <span className="font-bold text-success">{msg}</span>;
}
// Split on whitespace and colour the tokens that matter, rather than the // Split on whitespace and colour the tokens that matter, rather than the
// whole line: an operator scanning a slot is looking for their own call in // whole line: an operator scanning a slot is looking for their own call in
// the first position (someone answering) and for the station being called. // the first position (someone answering) and for the station being called.
const parts = msg.split(/(s+)/); const parts = msg.split(/(\s+)/);
return ( return (
<> <>
{parts.map((tok, i) => { {parts.map((tok, i) => {
if (/^s+$/.test(tok)) return tok; if (/^\s+$/.test(tok)) return tok;
const bare = tok.replace(/[<>]/g, '').toUpperCase(); const bare = tok.replace(/[<>]/g, '').toUpperCase();
if (i === 0 && /^CQ$/i.test(tok)) return <span key={i} className="font-bold text-success">{tok.toUpperCase()}</span>; if (i === 0 && /^CQ$/i.test(tok)) return <span key={i} className="font-bold text-success">{tok.toUpperCase()}</span>;
if (me && bare === me) return <span key={i} className="font-bold text-success">{tok}</span>; if (me && bare === me) return <span key={i} className="font-bold text-success">{tok}</span>;
@@ -450,7 +499,7 @@ function renderMsg(msg: string, me: string, calling: string) {
// //
// It is the one moving thing on the panel, and it answers the question an // It is the one moving thing on the panel, and it answers the question an
// operator actually has between overs: how long until the next batch. // operator actually has between overs: how long until the next batch.
function PeriodClock({ trSec, mode }: { trSec: number; mode?: string }) { function PeriodClock({ trSec, mode, tx }: { trSec: number; mode?: string; tx?: boolean }) {
const [now, setNow] = useState(() => Date.now()); const [now, setNow] = useState(() => Date.now());
useEffect(() => { useEffect(() => {
// 100 ms: smooth enough for a bar that fills in three and three quarter // 100 ms: smooth enough for a bar that fills in three and three quarter
@@ -466,19 +515,26 @@ function PeriodClock({ trSec, mode }: { trSec: number; mode?: string }) {
// The last fifth of a slot is when a decode is imminent and an operator // The last fifth of a slot is when a decode is imminent and an operator
// deciding whether to answer has run out of time to think. // deciding whether to answer has run out of time to think.
const closing = left <= trSec / 5; const closing = left <= trSec / 5;
// TRANSMITTING outranks both. The bar is the one thing on this screen that
// moves continuously, so it is what the eye is already on — and "am I on the
// air" is the state worth reading from across the room. Red, and it stays red
// for the whole over rather than turning amber near the end of it.
const tone = tx ? 'danger' : closing ? 'warning' : '';
return ( return (
<span className="flex items-center gap-2 shrink-0" title={mode ? `${mode} · ${trSec}s` : `${trSec}s`}> <span className="flex items-center gap-2 shrink-0" title={mode ? `${mode} · ${trSec}s` : `${trSec}s`}>
<Timer className={cn('size-4', closing ? 'text-warning' : 'text-muted-foreground')} /> <Timer className={cn('size-4',
tone === 'danger' ? 'text-danger' : tone === 'warning' ? 'text-warning' : 'text-muted-foreground')} />
<span className="relative h-1.5 w-24 rounded-full bg-muted overflow-hidden"> <span className="relative h-1.5 w-24 rounded-full bg-muted overflow-hidden">
<span <span
className={cn('absolute inset-y-0 left-0 rounded-full transition-[width] duration-100 ease-linear', className={cn('absolute inset-y-0 left-0 rounded-full transition-[width] duration-100 ease-linear',
closing ? 'bg-warning' : 'bg-primary')} tone === 'danger' ? 'bg-danger' : tone === 'warning' ? 'bg-warning' : 'bg-primary')}
style={{ width: `${pct}%` }} style={{ width: `${pct}%` }}
/> />
</span> </span>
<span className={cn('font-mono text-sm tabular-nums w-10 text-right', <span className={cn('font-mono text-sm tabular-nums w-10 text-right',
closing ? 'text-warning font-semibold' : 'text-muted-foreground')}> tone === 'danger' ? 'text-danger font-semibold'
: tone === 'warning' ? 'text-warning font-semibold' : 'text-muted-foreground')}>
{left.toFixed(1)} {left.toFixed(1)}
</span> </span>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
@@ -552,7 +608,7 @@ function buildPeriods(filtered: Decode[], txMsgs: TxMsg[]) {
})); }));
} }
export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, rigBand, onCall, onSelect, myCall, onClear, onHalt, autoCallOn, onToggleAutoCall, autoCall, autoCallOnly, onSetAutoCallOnly }: Props) { export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, rigBand, onCall, onSelect, myCall, myGrid, onClear, onHalt, autoCallOn, onToggleAutoCall, autoCall, autoCallOnly, onSetAutoCallOnly, watchlist }: Props) {
const { t } = useI18n(); const { t } = useI18n();
// Column widths, dragged in the header and shared by every row. Persisted // Column widths, dragged in the header and shared by every row. Persisted
// through writeUiPref (not raw localStorage) so the layout travels with data/ // through writeUiPref (not raw localStorage) so the layout travels with data/
@@ -567,8 +623,6 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
if (el && el.tagName === 'INPUT' && el.getAttribute('placeholder') === t('dec.chasePh')) return; if (el && el.tagName === 'INPUT' && el.getAttribute('placeholder') === t('dec.chasePh')) return;
setOnlyText(autoCallOnly ?? ''); setOnlyText(autoCallOnly ?? '');
}, [autoCallOnly, t]); }, [autoCallOnly, t]);
const template = useMemo(() => COLS.map((c) => `${colw[c.key]}px`).join(' '), [colw]);
const tableW = useMemo(() => COLS.reduce((s, c) => s + colw[c.key], 0), [colw]);
const setColWidth = (key: ColKey, px: number) => { const setColWidth = (key: ColKey, px: number) => {
const col = COLS.find((c) => c.key === key)!; const col = COLS.find((c) => c.key === key)!;
const w = Math.min(COL_MAX, Math.max(col.min, Math.round(px))); const w = Math.min(COL_MAX, Math.max(col.min, Math.round(px)));
@@ -645,6 +699,8 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
// opens with our callsign, sometimes bracketed when the sender compressed a // opens with our callsign, sometimes bracketed when the sender compressed a
// non-standard call. // non-standard call.
const me = (myCall ?? '').toUpperCase(); const me = (myCall ?? '').toUpperCase();
const [, bumpUnit] = useState(0);
useEffect(() => subscribeDistanceUnit(() => bumpUnit((n) => n + 1)), []);
const calling = (txState?.dx_call ?? '').toUpperCase(); const calling = (txState?.dx_call ?? '').toUpperCase();
const answersMe = (msg?: string): boolean => { const answersMe = (msg?: string): boolean => {
if (!me || !msg) return false; if (!me || !msg) return false;
@@ -681,6 +737,12 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [decodes, spotStatus]); }, [decodes, spotStatus]);
// The receiver column is dead weight with one decoder — which is nearly
// everybody — so it is not there at all until a second one starts feeding.
const cols = useMemo(() => COLS.filter((c) => c.key !== 'rx' || (instances.length > 1 && !splitByInstance)),
[instances.length, splitByInstance]);
const template = useMemo(() => cols.map((c) => `${colw[c.key]}px`).join(' '), [cols, colw]);
const tableW = useMemo(() => cols.reduce((s, c) => s + colw[c.key], 0), [cols, colw]);
// All seven, always, in their usual order. // All seven, always, in their usual order.
// //
// The chips used to be built from the continents ON the feed, which read as a // The chips used to be built from the continents ON the feed, which read as a
@@ -714,6 +776,22 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
}); });
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [decodes, spotStatus, cqOnly, lotwOnly, cats, bandSel, modeSel, contSel, minSnr, search]); }, [decodes, spotStatus, cqOnly, lotwOnly, cats, bandSel, modeSel, contSel, minSnr, search]);
// What this panel is SHOWING, published to the auto-call engine.
//
// The filters are the operator's control over the transmitter as well as
// over the list: a station filtered off the screen is not called. The panel
// sends the callsigns rather than the filter settings, so there is one
// definition of "shown" and not two — the engine cannot disagree with what
// is in front of the operator.
useEffect(() => {
const calls = [...new Set(filtered.map((d) => (d.call ?? '').toUpperCase()).filter(Boolean))];
SetAutoCallVisible(calls, true).catch(() => {});
}, [filtered]);
// Closed, it publishes nothing: filters that are not on the screen cannot
// silence the engine behind the operator's back.
useEffect(() => () => { SetAutoCallVisible([], false).catch(() => {}); }, []);
// Group into periods, newest first, and drop the operator's transmissions into // Group into periods, newest first, and drop the operator's transmissions into
// the slot they went out in. // the slot they went out in.
@@ -770,7 +848,11 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
{/* The slot clock. Taken from the newest decode's mode, falling back to {/* The slot clock. Taken from the newest decode's mode, falling back to
what the transmit state reports, so it is right the moment anything what the transmit state reports, so it is right the moment anything
is heard and keeps running when the band goes quiet. */} is heard and keeps running when the band goes quiet. */}
<PeriodClock trSec={liveTr} mode={liveMode} /> {/* Any receiver on the air colours it: with two decoders the shared
txState is whichever reported last, and "somebody here is
transmitting" is what the bar has to say. */}
<PeriodClock trSec={liveTr} mode={liveMode}
tx={!!txState?.transmitting || Object.values(txStates ?? {}).some((s) => s?.transmitting)} />
{bandDrift && ( {bandDrift && (
<span <span
title={t('dec.bandDriftTip')} title={t('dec.bandDriftTip')}
@@ -933,6 +1015,14 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
{autoCall.misses > 0 ? ` ·${autoCall.misses}/${autoCall.max_miss}` : ''} {autoCall.misses > 0 ? ` ·${autoCall.misses}/${autoCall.max_miss}` : ''}
</span> </span>
)} )}
{/* Wanted, decoded, and in a QSO with somebody else. Holding fire
looks exactly like having nothing to do, and the operator had no
way to tell them apart. */}
{autoCallOn && !autoCall?.target && autoCall?.waiting && (
<span className="font-mono text-xs animate-pulse" title={t('dec.autoWaitTip', { call: autoCall.waiting })}>
{autoCall.waiting}
</span>
)}
</button> </button>
)} )}
{/* The chase list. Raw text while typing, committed on blur or Enter: {/* The chase list. Raw text while typing, committed on blur or Enter:
@@ -1082,17 +1172,17 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
<div className="shrink-0 border-b border-border bg-background overflow-hidden"> <div className="shrink-0 border-b border-border bg-background overflow-hidden">
<div className={cn(ROW, 'h-7 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground')} <div className={cn(ROW, 'h-7 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground')}
style={{ gridTemplateColumns: template, width: tableW }}> style={{ gridTemplateColumns: template, width: tableW }}>
{COLS.map((c, i) => ( {cols.map((c, i) => (
<span key={c.key} <span key={c.key}
// Not CELL_LAST for the final column: its overflow-hidden would // Not CELL_LAST for the final column: its overflow-hidden would
// clip that column's own resize handle. // clip that column's own resize handle.
className={cn('relative flex items-center min-w-0 px-2', className={cn('relative flex items-center min-w-0 px-2',
i < COLS.length - 1 && 'border-r border-border/30', i < cols.length - 1 && 'border-r border-border/30',
// The three numeric columns label their own right edge, where the // The three numeric columns label their own right edge, where the
// figures are. // figures are.
(c.key === 'snr' || c.key === 'dt' || c.key === 'freq') && 'justify-end')} (c.key === 'snr' || c.key === 'dt' || c.key === 'freq' || c.key === 'dist') && 'justify-end')}
title={c.key === 'dt' ? t('dec.colDtTitle') : c.key === 'freq' ? t('dec.colFreqTitle') : undefined}> title={c.key === 'dt' ? t('dec.colDtTitle') : c.key === 'freq' ? t('dec.colFreqTitle') : undefined}>
<span className="truncate">{t(c.tkey)}</span> <span className="truncate">{c.key === 'dist' ? `${t(c.tkey)} (${distanceUnit()})` : t(c.tkey)}</span>
<ColResizer <ColResizer
onResize={(dx) => setColWidth(c.key, colw[c.key] + dx)} onResize={(dx) => setColWidth(c.key, colw[c.key] + dx)}
onReset={() => setColWidth(c.key, c.def)} onReset={() => setColWidth(c.key, c.def)}
@@ -1172,7 +1262,10 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
title={t('dec.callTitle', { call: d.call })} title={t('dec.callTitle', { call: d.call })}
style={{ gridTemplateColumns: template, width: tableW }} style={{ gridTemplateColumns: template, width: tableW }}
className={cn(ROW, 'text-left border-b border-border/20 transition-colors', className={cn(ROW, 'text-left border-b border-border/20 transition-colors',
replying ? 'bg-success/20 hover:bg-success/25' replying ? 'bg-success/25 hover:bg-success/30 border-l-2 border-l-success'
// The station being called: a tint saying "he is on the
// air", not the QSO treatment above — most of what he
// sends is to other people.
: worked ? 'bg-danger/15 hover:bg-danger/20' : worked ? 'bg-danger/15 hover:bg-danger/20'
: mine ? 'bg-info/10' : mine ? 'bg-info/10'
: 'hover:bg-muted/50')} : 'hover:bg-muted/50')}
@@ -1185,6 +1278,15 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
{hhmmssCompact(d.at)} {hhmmssCompact(d.at)}
</span> </span>
{/* Which receiver heard it — present only while more than one
is feeding a merged list. */}
{cols.some((c) => c.key === 'rx') && (
<span className={cn(CELL, 'text-[11px] text-muted-foreground truncate')}
title={d.instance ?? ''}>
{decoderName(d.instance)}
</span>
)}
<span className={cn(CELL, 'justify-end font-mono text-[13px] font-semibold tabular-nums', snrTone(d.snr))}> <span className={cn(CELL, 'justify-end font-mono text-[13px] font-semibold tabular-nums', snrTone(d.snr))}>
{d.snr > 0 ? `+${d.snr}` : d.snr} {d.snr > 0 ? `+${d.snr}` : d.snr}
</span> </span>
@@ -1218,7 +1320,7 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
{/* The message carries the callsign already — which is why {/* The message carries the callsign already — which is why
there is no column repeating it. */} there is no column repeating it. */}
<span className={cn(CELL, 'font-mono text-[13px]')}> <span className={cn(CELL, 'font-mono text-[13px]')}>
<span className="truncate">{renderMsg(d.msg ?? '', me, calling)}</span> <span className="truncate">{renderMsg(d.msg ?? '', me, calling, replying)}</span>
</span> </span>
{/* The decode's own grid first, the remembered one as the {/* The decode's own grid first, the remembered one as the
@@ -1231,6 +1333,14 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
<span className="truncate">{d.grid || e?.grid || ''}</span> <span className="truncate">{d.grid || e?.grid || ''}</span>
</span> </span>
<span className={cn(CELL, 'justify-end font-mono text-[11px] tabular-nums text-muted-foreground/80')}>
{(() => {
const g = d.grid || e?.grid || '';
const path = myGrid && g ? pathBetween(myGrid, g) : null;
return path ? distanceValue(path.distanceShort).toLocaleString() : '';
})()}
</span>
<span className={cn(CELL, 'gap-1.5')}> <span className={cn(CELL, 'gap-1.5')}>
{e?.state && ( {e?.state && (
<> <>
@@ -1256,6 +1366,15 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
{t('dec.wkd')} {t('dec.wkd')}
</span> </span>
)} )}
{/* Plainest first: the LoTW letter, the worked note, then
the coloured badges — the watch list leading them, being
the operator's own answer rather than the log's. */}
{isWatched(d.call, watchlist) && (
<span className="rounded px-1 py-px text-[10px] font-bold uppercase tracking-wide shrink-0 text-white"
style={{ background: '#f472b6' }} title={t('dec.wlTip')}>
{t('dec.wl')}
</span>
)}
{entities.map((b) => ( {entities.map((b) => (
<span key={b.label} <span key={b.label}
title={e?.unconf_status ? t('dec.unconfTip') : undefined} title={e?.unconf_status ? t('dec.unconfTip') : undefined}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,381 @@
// RotorCompassClassic — the original rotor dial, kept as an option.
//
// Settings → Rotator chooses between this and the current compass. It was
// replaced rather than removed because the two answer the same question in
// different ways: this one is a small light-map dial with the quick turns in a
// column beside it, and an operator used to it should not have to relearn a
// panel to keep working.
//
// An azimuthal-equidistant rotor display (à la 4O3A RotorGenius).
//
// A world map centred on the operator's QTH (north up) fills the dial, ringed by
// a green azimuth bezel. A green needle shows the antenna heading (two needles
// when an Ultrabeam is bidirectional, the opposite one when reversed); a small
// red marker on the bezel shows the short-path bearing to the DX. Click the dial
// to turn the antenna there.
import { useEffect, useMemo, useRef, useState } from 'react';
import { geoAzimuthalEquidistant, geoPath, geoGraticule10 } from 'd3-geo';
import { feature } from 'topojson-client';
import landTopo from 'world-atlas/land-110m.json';
import { Compass, X, Play, Square } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
// Decode the coastline outline once (≈110 m simplified land polygons).
const LAND = feature(landTopo as any, (landTopo as any).objects.land);
const GRATICULE = geoGraticule10();
interface Props {
bearing?: number | null; // short-path azimuth to DX (deg)
headings: number[]; // radiating heading(s) — rotor + Ultrabeam pattern
boomHeading?: number | null; // mechanical boom (rotor) azimuth, shown grey when it differs
pattern?: 'normal' | 'reverse' | 'bi' | null; // Ultrabeam pattern (for the badge)
centerLat?: number | null; // operator latitude (projection centre)
centerLon?: number | null; // operator longitude
rotorEnabled?: boolean;
rotors?: string[]; // logical rotor names; >1 → show a selector
activeRotor?: number; // index of the selected rotor (0-based)
onSelectRotor?: (i: number) => void; // switch the active rotor
onGoto?: (az: number) => void; // click-to-turn
onClose?: () => void;
// Quick-turn buttons and the azimuth box, shown only where the caller wants
// them: Station Control draws its own GoTo/Stop around this compass, and two
// sets of the same controls side by side would be nothing but confusing.
presets?: { label: string; azimuth: number }[];
onStop?: () => void;
}
const SIZE = 168;
const C = SIZE / 2;
const R = C - 6; // outer bezel radius
const MAP_R = R - 6; // map/clip radius (inside the bezel)
function pt(az: number, radius: number): [number, number] {
const a = ((az - 90) * Math.PI) / 180;
return [C + radius * Math.cos(a), C + radius * Math.sin(a)];
}
export function RotorCompassClassic({ bearing, headings, boomHeading, pattern, centerLat, centerLon, rotorEnabled, rotors, activeRotor, onSelectRotor, onGoto, onClose, presets, onStop }: Props) {
const { t } = useI18n();
// Raw text, not a number: binding the input to a normalised value makes
// Backspace fight the operator on the way from "230" to "23". It is parsed
// when it is sent, and only then.
const [azText, setAzText] = useState('');
const showControls = !!(presets || onStop);
// Which preset was just pressed, so it can light up for a moment.
//
// A rotor takes seconds to start moving and the needle barely twitches at
// first, so without this the only answer to "did that register?" is to press
// it again — which is how an antenna ends up ordered somewhere twice. The
// acknowledgement has to come from the button itself, at once.
const [flashIdx, setFlashIdx] = useState<number | null>(null);
const flashTimer = useRef<number | undefined>(undefined);
useEffect(() => () => window.clearTimeout(flashTimer.current), []);
const pressPreset = (i: number, az: number) => {
if (!onGoto) return;
setFlashIdx(i);
window.clearTimeout(flashTimer.current);
flashTimer.current = window.setTimeout(() => setFlashIdx(null), 450);
onGoto(az);
};
// Stop needs the same acknowledgement, for the same reason and one more: it
// is pressed when something is already wrong, and a button that stays inert
// gets hit again and again. Its own flag, so stopping does not blank a preset
// that is still lit.
const [stopFlash, setStopFlash] = useState(false);
const stopTimer = useRef<number | undefined>(undefined);
useEffect(() => () => window.clearTimeout(stopTimer.current), []);
const pressStop = () => {
if (!onStop) return;
setStopFlash(true);
window.clearTimeout(stopTimer.current);
stopTimer.current = window.setTimeout(() => setStopFlash(false), 450);
onStop();
};
// 0-359 and nothing else. 360 is refused rather than folded to 0 — it is
// almost always a typo for 36 or 306, and a rotor swinging through north on a
// slip of the finger is worth one rejected keypress.
const sendAz = () => {
const s = azText.trim();
if (s === '' || !onGoto) return;
const n = Number(s);
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0 || n > 359) return;
onGoto(n);
setAzText('');
};
const cardinals = useMemo(
() => [ { d: 0, l: 'N' }, { d: 45, l: 'NE' }, { d: 90, l: 'E' }, { d: 135, l: 'SE' },
{ d: 180, l: 'S' }, { d: 225, l: 'SW' }, { d: 270, l: 'W' }, { d: 315, l: 'NW' } ],
[],
);
// Project the world centred on the QTH (north up; antipode at the bezel).
const { land, grat } = useMemo(() => {
if (centerLat == null || centerLon == null) return { land: '', grat: '' };
const proj = geoAzimuthalEquidistant()
.rotate([-centerLon, -centerLat])
.clipAngle(179.9)
.scale(MAP_R / Math.PI)
.translate([C, C]);
const path = geoPath(proj as any);
return { land: path(LAND as any) || '', grat: path(GRATICULE as any) || '' };
}, [centerLat, centerLon]);
function handleClick(e: React.MouseEvent<SVGSVGElement>) {
if (!onGoto) return;
const rect = e.currentTarget.getBoundingClientRect();
const x = ((e.clientX - rect.left) / rect.width) * SIZE - C;
const y = ((e.clientY - rect.top) / rect.height) * SIZE - C;
let az = (Math.atan2(y, x) * 180) / Math.PI + 90;
az = ((az % 360) + 360) % 360;
onGoto(Math.round(az));
}
const headLabel = headings.length ? headings[0] : null;
// Short and long path to the DX, in figures.
//
// The bezel already carries the short path as a red marker, but a marker is a
// direction, not a number — the same pair sits in the status bar at 10px and
// operators reported not being able to read it. It lives here because it
// belongs to the compass: every place that draws one gets the readout, instead
// of each caller inventing its own. Clickable when the caller can turn, like
// the status bar's. Built as a value because it is placed in one of two
// columns depending on whether the controls are shown.
const pathReadout = (
<div className="flex gap-1.5 mt-2 font-mono w-full">
{([['SP', bearing ?? null], ['LP', bearing == null ? null : (bearing + 180) % 360]] as const).map(([lbl, az]) => (
<button key={lbl} type="button" disabled={az == null || !onGoto}
onClick={() => { if (az != null && onGoto) onGoto(Math.round(az)); }}
title={az == null ? '' : `${lbl} ${Math.round(az)}°`}
className={
'flex-1 rounded-md border py-1 text-xs font-semibold tabular-nums transition-colors active:scale-95 ' +
(az == null
? 'border-border text-muted-foreground/50 cursor-not-allowed'
: onGoto
? 'border-info-border text-info-muted-foreground hover:bg-info-muted cursor-pointer'
: 'border-border text-muted-foreground cursor-default')
}>
{lbl} {az == null ? '—' : `${Math.round(az)}°`}
</button>
))}
</div>
);
return (
<section className="flex flex-col h-full min-h-0 rounded-lg border border-border bg-card overflow-hidden">
{/* Header — matches the WinKeyer / Voice keyer panels. */}
<div className="flex items-center gap-2 px-3 py-1.5 bg-muted/40 border-b border-border shrink-0">
<Compass className="size-4 text-primary shrink-0" />
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Rotor</span>
<span className={cn('size-2 rounded-full', rotorEnabled ? 'bg-success' : 'bg-muted-foreground/40')}
title={rotorEnabled ? 'Rotator connected' : 'Rotator disabled'} />
<div className="flex-1" />
{pattern && (
<span
className={cn('px-1 py-px rounded text-[9px] font-bold tracking-wide',
pattern === 'reverse' ? 'bg-warning-muted text-warning-muted-foreground'
: pattern === 'bi' ? 'bg-info-muted text-info-muted-foreground'
: 'bg-success-muted text-success-muted-foreground')}
title={pattern === 'reverse' ? 'Ultrabeam reversed — radiates opposite the boom'
: pattern === 'bi' ? 'Ultrabeam bidirectional — radiates both ways'
: 'Ultrabeam normal'}>
{pattern === 'reverse' ? 'REV' : pattern === 'bi' ? 'BI' : 'NORM'}
</span>
)}
<span className="font-mono text-sm font-bold text-success tabular-nums">
{headLabel != null ? `${Math.round(headLabel).toString().padStart(3, '0')}°` : '—'}
</span>
{onClose && (
<button className="text-muted-foreground hover:text-foreground" title="Hide rotor" onClick={onClose}>
<X className="size-3.5" />
</button>
)}
</div>
{/* Multiple rotors — pick which one the dial shows and turns. */}
{rotors && rotors.length > 1 && (
<div className="flex flex-wrap gap-1 px-2 pt-1.5">
{rotors.map((nm, i) => {
const active = (activeRotor ?? 0) === i;
const label = nm?.trim() || `Rotor ${i + 1}`;
return (
<button
key={i}
onClick={() => onSelectRotor?.(i)}
className={cn(
'flex-1 min-w-[48px] px-1.5 py-0.5 rounded text-[10px] font-semibold truncate transition-colors',
active
? 'bg-success text-success-foreground'
: 'bg-muted text-muted-foreground hover:bg-muted/70',
)}
title={label}
>
{label}
</button>
);
})}
</div>
)}
{/* Dial on the left, controls on the right. The controls column is sized to
fit WITHIN the dial's height: the widget sits in a row whose height is
set by the entry strip, so it may grow sideways but never downwards. */}
<div className="flex items-start gap-2 p-2 min-h-0">
{/* flex-col: the readout goes BELOW the dial. This wrapper was a row, so a
sibling of the <svg> landed beside it. */}
<div className="flex flex-col items-center justify-center min-h-0 shrink-0">
<svg
viewBox={`0 0 ${SIZE} ${SIZE}`}
className={onGoto ? 'cursor-pointer select-none' : 'select-none'}
style={{ width: SIZE, height: SIZE }}
onClick={handleClick}
>
<defs>
<clipPath id="rotorDial"><circle cx={C} cy={C} r={MAP_R} /></clipPath>
</defs>
{/* water + world map, clipped to the dial */}
<circle cx={C} cy={C} r={MAP_R} fill="#d3e7f1" />
<g clipPath="url(#rotorDial)">
{grat && <path d={grat} fill="none" stroke="#9cc0d6" strokeWidth={0.4} opacity={0.7} />}
{land && <path d={land} fill="#dfe2cf" stroke="#9aa589" strokeWidth={0.4} />}
</g>
{/* green azimuth bezel */}
<circle cx={C} cy={C} r={R} fill="none" stroke="#16a34a" strokeWidth={5} />
{/* ticks every 10°, longer at 30° */}
{Array.from({ length: 36 }, (_, i) => i * 10).map((d) => {
const major = d % 30 === 0;
const [x1, y1] = pt(d, MAP_R);
const [x2, y2] = pt(d, MAP_R - (major ? 7 : 4));
return <line key={d} x1={x1} y1={y1} x2={x2} y2={y2} stroke="#475569" strokeWidth={major ? 1 : 0.6} opacity={0.7} />;
})}
{/* cardinal labels + degree numbers at 45° */}
{cardinals.map(({ d, l }) => {
const [x, y] = pt(d, MAP_R - 13);
return <text key={l} x={x} y={y} textAnchor="middle" dominantBaseline="central" className="fill-slate-700" style={{ fontSize: l.length > 1 ? 7 : 9, fontWeight: 700 }}>{l}</text>;
})}
{/* DX short-path bearing → small red marker on the bezel */}
{bearing != null && (() => { const [x, y] = pt(bearing, MAP_R); return (
<circle cx={x} cy={y} r={3} fill="#dc2626" stroke="#fff" strokeWidth={1} />
); })()}
{/* mechanical boom (rotor) heading — grey dashed needle, shown when the
Ultrabeam radiates somewhere other than the boom (reverse/bi) so the
operator sees where the antenna physically points vs where it boom-sits */}
{boomHeading != null && pattern && pattern !== 'normal' && (() => {
const [x, y] = pt(boomHeading, MAP_R - 2);
return (
<g>
<title>Boom (rotor) {Math.round(boomHeading)}°</title>
<line x1={C} y1={C} x2={x} y2={y} stroke="#64748b" strokeWidth={2} strokeDasharray="3 3" strokeLinecap="round" />
<circle cx={x} cy={y} r={3} fill="#64748b" stroke="#fff" strokeWidth={1} />
</g>
);
})()}
{/* radiating heading needle(s) — green; two when bidirectional */}
{headings.map((h, i) => { const [x, y] = pt(h, MAP_R - 2); return (
<g key={i}>
<line x1={C} y1={C} x2={x} y2={y} stroke="#15803d" strokeWidth={3} strokeLinecap="round" opacity={i === 0 ? 1 : 0.55} />
<polygon points={`${x},${y} ${pt(h - 5, MAP_R - 12).join(',')} ${pt(h + 5, MAP_R - 12).join(',')}`} fill="#15803d" opacity={i === 0 ? 1 : 0.55} />
</g>
); })}
<circle cx={C} cy={C} r={3.5} fill="#15803d" stroke="#fff" strokeWidth={1} />
</svg>
{/* With the controls column present the readout goes at the FOOT OF IT
instead: the dial sets the widget's height, the controls are shorter
than the dial, and that leftover space is exactly the right size for
the pair. Under the dial it would push the whole widget taller. */}
{!showControls && pathReadout}
</div>
{/* Quick turns + free azimuth + Stop. */}
{showControls && (
<div className="flex flex-col gap-1.5 flex-1 min-w-0">
{/* Two columns so six regions fit beside the dial rather than under
it. An operator with fewer keeps the same compact block. */}
{!!presets?.length && (
<div className="grid grid-cols-2 gap-1">
{presets.map((p, i) => (
<button
key={`${p.label}-${i}`}
type="button"
disabled={!onGoto}
onClick={() => pressPreset(i, p.azimuth)}
title={`${p.label}${p.azimuth}°`}
className={cn(
'rounded-md border py-1 text-xs font-semibold truncate transition-all duration-150 active:scale-95',
flashIdx === i
// Lit, and showing the azimuth it just sent: the label alone
// would only say the press landed, not what was ordered.
? 'border-success bg-success text-success-foreground scale-95'
: 'border-border bg-muted/40',
onGoto && flashIdx !== i ? 'hover:bg-muted' : '',
!onGoto ? 'opacity-50 cursor-not-allowed' : '',
)}
>
{flashIdx === i ? `${p.azimuth}°` : p.label}
</button>
))}
</div>
)}
{/* Free azimuth: Enter sends, so the whole thing is type-three-digits
-and-go without reaching for the mouse. */}
<div className="flex gap-1">
<input
type="text"
inputMode="numeric"
value={azText}
onChange={(e) => setAzText(e.target.value.replace(/[^0-9]/g, '').slice(0, 3))}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); sendAz(); } }}
placeholder={t('rotor.azPh')}
title={t('rotor.azTitle')}
disabled={!onGoto}
className="min-w-0 flex-1 rounded-md border border-border bg-background px-2 py-1 text-xs font-mono tabular-nums text-center disabled:opacity-50"
/>
<button
type="button"
onClick={sendAz}
disabled={!onGoto || azText.trim() === ''}
title={t('rotor.go')}
className="flex items-center gap-1 rounded-md border border-success/60 bg-success-muted px-2 py-1 text-xs font-bold text-success-muted-foreground transition-transform hover:bg-success/25 active:scale-95 disabled:opacity-40 disabled:cursor-not-allowed"
>
<Play className="size-3" /> {t('rotor.go')}
</button>
</div>
{onStop && (
<button
type="button"
onClick={pressStop}
title={t('rotor.stop')}
className={cn(
'flex items-center justify-center gap-1.5 rounded-md border py-1 text-xs font-bold transition-all duration-150 active:scale-95',
stopFlash
// Solid, not a tint: STOP reads the same in both languages, so
// the fill is the whole acknowledgement.
? 'border-destructive bg-destructive text-destructive-foreground scale-95'
: 'border-destructive/60 bg-destructive/15 text-destructive hover:bg-destructive/25',
)}
>
<Square className="size-3 fill-current" /> {t('rotor.stop')}
</button>
)}
{/* mt-auto: the readout sits at the FOOT of the column, level with the
bottom of the dial, instead of floating under the Stop button. */}
<div className="mt-auto">{pathReadout}</div>
</div>
)}
</div>
</section>
);
}
+81 -5
View File
@@ -60,7 +60,7 @@ import {
GetFolderSync, SaveFolderSync, PickFolderSyncFolder, GetFolderSyncStatus, SyncFolderNow, GetFolderSync, SaveFolderSync, PickFolderSyncFolder, GetFolderSyncStatus, SyncFolderNow,
GetRelayAuto, SaveRelayAuto, GetStationDevices, GetRelayAuto, SaveRelayAuto, GetStationDevices,
GetAwardDefs, GetTrackedAwards, SaveTrackedAwards, GetAwardDefs, GetTrackedAwards, SaveTrackedAwards,
GetBandOpenSettings, SaveBandOpenSettings, GetGridScopeSettings, SaveGridScopeSettings, GetPSKReporterStatus, GetChaseNewGrids, SetChaseNewGrids, GetChaseNew, SetChaseNew, GetPSKTargetSettings, SavePSKTargetSettings, GetAutoCallSettings, SaveAutoCallSettings, GetWatchlistContestCalls, SetWatchlistContestCalls, GetWatchlistContestPattern, SetWatchlistContestPattern, GetGridCacheStatus, GetLinkedAmps, SetLinkedAmps, GetSpotTTLMinutes, SetSpotTTLMinutes, GetSpotMax, SetSpotMax, GetBandOpenSettings, SaveBandOpenSettings, GetGridScopeSettings, SaveGridScopeSettings, GetPSKReporterStatus, GetChaseNewGrids, SetChaseNewGrids, GetChaseNew, SetChaseNew, GetPSKTargetSettings, SavePSKTargetSettings, GetAutoCallSettings, SaveAutoCallSettings, GetChaseNewBands, SetChaseNewBands, GetWatchlistContestCalls, SetWatchlistContestCalls, GetWatchlistContestPattern, SetWatchlistContestPattern, GetGridCacheStatus, GetLinkedAmps, SetLinkedAmps, GetSpotTTLMinutes, SetSpotTTLMinutes, GetSpotMax, SetSpotMax,
} from '../../wailsjs/go/main/App'; } from '../../wailsjs/go/main/App';
import type { profile as profileModels } from '../../wailsjs/go/models'; import type { profile as profileModels } from '../../wailsjs/go/models';
import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types'; import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
@@ -83,6 +83,7 @@ import {
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { writeUiPref } from '@/lib/uiPref'; import { writeUiPref } from '@/lib/uiPref';
import { setUseMiles } from '@/lib/units'; import { setUseMiles } from '@/lib/units';
import { rotorStyle, setRotorStyle, type RotorStyle } from '@/lib/rotorStyle';
import { iaruRegion, setIaruRegion, type IaruRegion } from '@/lib/bandplan'; import { iaruRegion, setIaruRegion, type IaruRegion } from '@/lib/bandplan';
import { getDateFormat, setDateFormat, type DateFormat } from '@/lib/dateFormat'; import { getDateFormat, setDateFormat, type DateFormat } from '@/lib/dateFormat';
import { useI18n, FlagGB, FlagFR, type Lang } from '@/lib/i18n'; import { useI18n, FlagGB, FlagFR, type Lang } from '@/lib/i18n';
@@ -405,16 +406,21 @@ interface TreeProps {
flexAvailable?: boolean; flexAvailable?: boolean;
} }
function Tree({ selected, onSelect, flexAvailable }: TreeProps) { // The sidebar. Memoised and its tree built once per (language, radio): it is
// sixty-odd items that depend on nothing an operator types, and it was rebuilt
// and re-rendered on every keystroke in every field of every panel — the whole
// dialog holds its state in one component, so one character redraws all of it.
const Tree = memo(function Tree({ selected, onSelect, flexAvailable }: TreeProps) {
const { t } = useI18n(); const { t } = useI18n();
const nodes = useMemo(() => buildTree(!!flexAvailable, t), [flexAvailable, t]);
return ( return (
<nav className="text-sm"> <nav className="text-sm">
{buildTree(!!flexAvailable, t).map((node, i) => ( {nodes.map((node, i) => (
<TreeNodeView key={i} node={node} depth={0} selected={selected} onSelect={onSelect} /> <TreeNodeView key={i} node={node} depth={0} selected={selected} onSelect={onSelect} />
))} ))}
</nav> </nav>
); );
} });
function TreeNodeView({ function TreeNodeView({
node, depth, selected, onSelect, node, depth, selected, onSelect,
@@ -472,6 +478,11 @@ function TreeNodeView({
// identity is STABLE across SettingsModal re-renders; defining them inside the // identity is STABLE across SettingsModal re-renders; defining them inside the
// component would give each render a fresh function, remounting the Radix // component would give each render a fresh function, remounting the Radix
// Select and slamming the open dropdown shut on any ambient re-render. // Select and slamming the open dropdown shut on any ambient re-render.
// The bands the Chase new filter offers, in band-plan order. Mirrors
// ChaseNewBands in pskchase.go, which is what actually filters the feed —
// a band added there and not here is simply one nobody can switch off.
const CHASE_BANDS = ['160m', '80m', '60m', '40m', '30m', '20m', '17m', '15m', '12m', '10m', '6m', '4m', '2m', '70cm'];
const THEME_SWATCH: Record<Exclude<ThemeChoice, 'auto'>, { bg: string; card: string; accent: string }> = { const THEME_SWATCH: Record<Exclude<ThemeChoice, 'auto'>, { bg: string; card: string; accent: string }> = {
'light-warm': { bg: '#e8dfc9', card: '#faf6ea', accent: '#b8410c' }, 'light-warm': { bg: '#e8dfc9', card: '#faf6ea', accent: '#b8410c' },
'light-cool': { bg: '#f4f6f8', card: '#ffffff', accent: '#2563eb' }, 'light-cool': { bg: '#f4f6f8', card: '#ffffff', accent: '#2563eb' },
@@ -1755,6 +1766,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
// the full widget is what the operator has today, and a setting that changes // the full widget is what the operator has today, and a setting that changes
// a panel the moment you upgrade is a setting that gets blamed for it. // a panel the moment you upgrade is a setting that gets blamed for it.
const [rotorCompact, setRotorCompact] = useState(() => localStorage.getItem('opslog.rotorCompact') === '1'); const [rotorCompact, setRotorCompact] = useState(() => localStorage.getItem('opslog.rotorCompact') === '1');
const [rotorDial, setRotorDial] = useState<RotorStyle>(() => rotorStyle());
const [startEqEnd, setStartEqEnd] = useState(() => localStorage.getItem('opslog.startEqualsEnd') === '1'); const [startEqEnd, setStartEqEnd] = useState(() => localStorage.getItem('opslog.startEqualsEnd') === '1');
const [lookupOnBlur, setLookupOnBlur] = useState(() => localStorage.getItem('opslog.lookupOnBlur') === '1'); const [lookupOnBlur, setLookupOnBlur] = useState(() => localStorage.getItem('opslog.lookupOnBlur') === '1');
const [groupDigital, setGroupDigital] = useState(() => localStorage.getItem('opslog.groupDigitalSlots') === '1'); const [groupDigital, setGroupDigital] = useState(() => localStorage.getItem('opslog.groupDigitalSlots') === '1');
@@ -2089,11 +2101,18 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
const [chaseSotaOn, setChaseSotaOn] = useState(() => localStorage.getItem('opslog.chaseSota') !== '0'); const [chaseSotaOn, setChaseSotaOn] = useState(() => localStorage.getItem('opslog.chaseSota') !== '0');
const [chaseNew, setChaseNew] = useState(false); const [chaseNew, setChaseNew] = useState(false);
const [pskTgt, setPskTgt] = useState<any>({ enabled: false, scope: 'target' }); const [pskTgt, setPskTgt] = useState<any>({ enabled: false, scope: 'target' });
const [ac, setAc] = useState<any>({ enabled: false, only: '', attempts: 7, watched_attempts: 15, misses: 3, max_rounds: 3, rest_min: 2 }); const [ac, setAc] = useState<any>({ enabled: false, only: '', attempts: 7, watched_attempts: 15, misses: 3, max_rounds: 3, rest_min: 2, on_screen_only: true });
// The named contest callsigns. Raw text in state, written on blur: it is a // The named contest callsigns. Raw text in state, written on blur: it is a
// multi-line list, and normalising it on every keystroke would fight the // multi-line list, and normalising it on every keystroke would fight the
// Return key — the one key this box is built around. // Return key — the one key this box is built around.
const [contestCalls, setContestCalls] = useState(''); const [contestCalls, setContestCalls] = useState('');
// Which bands the Chase new panel shows. The station's own band list still
// applies underneath — this one is "what am I watching tonight".
const [chaseBands, setChaseBands] = useState<string[]>([]);
const saveChaseBands = (next: string[]) => {
setChaseBands(next);
void SetChaseNewBands(next);
};
const [contestPattern, setContestPattern] = useState(''); const [contestPattern, setContestPattern] = useState('');
const saveAC = async (next: any) => { const saveAC = async (next: any) => {
setAc(next); setAc(next);
@@ -2132,6 +2151,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
try { setChaseNew(await GetChaseNew()); } catch { /* defaults stand */ } try { setChaseNew(await GetChaseNew()); } catch { /* defaults stand */ }
try { setPskTgt(await GetPSKTargetSettings()); } catch { /* defaults stand */ } try { setPskTgt(await GetPSKTargetSettings()); } catch { /* defaults stand */ }
try { setAc(await GetAutoCallSettings()); } catch { /* defaults stand */ } try { setAc(await GetAutoCallSettings()); } catch { /* defaults stand */ }
try { setChaseBands((await GetChaseNewBands()) ?? []); } catch { /* defaults stand */ }
try { setContestCalls(await GetWatchlistContestCalls()); } catch { /* defaults stand */ } try { setContestCalls(await GetWatchlistContestCalls()); } catch { /* defaults stand */ }
try { setContestPattern(await GetWatchlistContestPattern()); } catch { /* defaults stand */ } try { setContestPattern(await GetWatchlistContestPattern()); } catch { /* defaults stand */ }
try { setLinkedAmps((await GetLinkedAmps()) ?? []); } catch { /* defaults stand */ } try { setLinkedAmps((await GetLinkedAmps()) ?? []); } catch { /* defaults stand */ }
@@ -4855,6 +4875,20 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
dial to take a column, not a panel. */} dial to take a column, not a panel. */}
<div className="border-t border-border/60 pt-3 space-y-2"> <div className="border-t border-border/60 pt-3 space-y-2">
<div className="text-sm font-semibold">{t('rot.widget')}</div> <div className="text-sm font-semibold">{t('rot.widget')}</div>
{/* Which dial. Both are kept: the new one reads at a glance across
the shack, the old one is the compact dial operators learned
first, and neither is wrong. */}
<div className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground">{t('rot.dial')}</span>
<select
value={rotorDial}
onChange={(e) => { const v = e.target.value as RotorStyle; setRotorDial(v); setRotorStyle(v); }}
className="h-8 rounded-md border border-border bg-background px-2 text-sm"
>
<option value="modern">{t('rot.dialModern')}</option>
<option value="classic">{t('rot.dialClassic')}</option>
</select>
</div>
<label className="flex items-center gap-2 text-sm"> <label className="flex items-center gap-2 text-sm">
<Checkbox checked={rotorCompact} <Checkbox checked={rotorCompact}
onCheckedChange={(c) => { const v = !!c; setRotorCompact(v); writeUiPref('opslog.rotorCompact', v ? '1' : '0'); }} /> onCheckedChange={(c) => { const v = !!c; setRotorCompact(v); writeUiPref('opslog.rotorCompact', v ? '1' : '0'); }} />
@@ -5402,6 +5436,34 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
<span className="text-xs text-muted-foreground">{t('chn.nearKmHint')}</span> <span className="text-xs text-muted-foreground">{t('chn.nearKmHint')}</span>
</div> </div>
)} )}
{/* Which bands the panel lists. Not the station's band list that
one says what this station can work at all and still applies
underneath. This says what is worth watching tonight, which is
a different question and changes far more often. */}
{chaseNew && (
<div className="pl-6 space-y-1">
<span className="text-xs text-muted-foreground">{t('chn.bands')}</span>
<div className="flex flex-wrap items-center gap-1">
{CHASE_BANDS.map((b) => {
const on = chaseBands.includes(b);
return (
<button key={b} type="button"
onClick={() => saveChaseBands(on ? chaseBands.filter((x) => x !== b) : [...chaseBands, b])}
className={cn('h-6 px-2 rounded-full border text-[11px] font-medium transition-colors',
on ? 'border-primary bg-primary text-primary-foreground'
: 'border-border text-muted-foreground hover:bg-muted')}>
{b}
</button>
);
})}
<button type="button" onClick={() => saveChaseBands([...CHASE_BANDS])}
className="h-6 px-2 rounded-full border border-border text-[11px] text-muted-foreground hover:bg-muted">
{t('chn.bandsAll')}
</button>
</div>
<p className="text-xs text-muted-foreground">{t('chn.bandsHint')}</p>
</div>
)}
</div> </div>
{/* PSK Reporter analysis of the station being called. Same service as {/* PSK Reporter analysis of the station being called. Same service as
@@ -5505,6 +5567,20 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
</span> </span>
))} ))}
</div> </div>
{/* A rule about what the transmitter may call, stated once. The
decodes panel has a LoTW chip too, but that one is a way of
READING the band it is flicked on and off while looking
around, and the panel can be closed. */}
<label className="flex items-center gap-2 text-xs cursor-pointer">
<Checkbox checked={ac.on_screen_only !== false}
onCheckedChange={(c) => saveAC({ ...ac, on_screen_only: !!c })} />
<span>{t('ac.onScreen')} <span className="text-muted-foreground">{t('ac.onScreenHint')}</span></span>
</label>
<label className="flex items-center gap-2 text-xs cursor-pointer">
<Checkbox checked={!!ac.trace}
onCheckedChange={(c) => saveAC({ ...ac, trace: !!c })} />
<span>{t('ac.trace')} <span className="text-muted-foreground">{t('ac.traceHint')}</span></span>
</label>
</div> </div>
)} )}
</div> </div>
@@ -11,6 +11,8 @@ import { useI18n } from '@/lib/i18n';
import { writeUiPref } from '@/lib/uiPref'; import { writeUiPref } from '@/lib/uiPref';
import { subscribeRotorHeading, pokeRotorHeading } from '@/lib/rotorHeading'; import { subscribeRotorHeading, pokeRotorHeading } from '@/lib/rotorHeading';
import { RotorCompass } from '@/components/RotorCompass'; import { RotorCompass } from '@/components/RotorCompass';
import { RotorCompassClassic } from '@/components/RotorCompassClassic';
import { rotorStyle, subscribeRotorStyle } from '@/lib/rotorStyle';
import { AmpCard } from '@/components/AmpCard'; import { AmpCard } from '@/components/AmpCard';
import { TunerCard } from '@/components/TunerCard'; import { TunerCard } from '@/components/TunerCard';
import type { TGStatus } from '@/components/TunerGeniusPanel'; import type { TGStatus } from '@/components/TunerGeniusPanel';
@@ -128,6 +130,11 @@ function RotatorWidget({ hd, refetch, centerLat, centerLon, bearing, t }: Rotato
}) { }) {
const [goto, setGoto] = useState(''); const [goto, setGoto] = useState('');
const [err, setErr] = useState(''); const [err, setErr] = useState('');
// Both compasses in the app follow the same preference (Settings → Rotator):
// one operator's choice of dial, not one per panel.
const [dial, setDial] = useState(() => rotorStyle());
useEffect(() => subscribeRotorStyle(() => setDial(rotorStyle())), []);
const Dial = dial === 'classic' ? RotorCompassClassic : RotorCompass;
const turn = (az: number) => { const turn = (az: number) => {
const a = ((Math.round(az) % 360) + 360) % 360; const a = ((Math.round(az) % 360) + 360) % 360;
@@ -145,9 +152,12 @@ function RotatorWidget({ hd, refetch, centerLat, centerLon, bearing, t }: Rotato
title={hd.ok ? t('station.online') : t('station.rotatorNoRead')} /> title={hd.ok ? t('station.online') : t('station.rotatorNoRead')} />
</div> </div>
<div className="p-3 flex gap-4 items-start"> <div className="p-3 flex gap-4 items-start">
{/* The SP/LP readout lives INSIDE RotorCompass, so every compass in the {/* The compact compass is the dial alone — short and long path are the
app carries it rather than each caller drawing its own. */} two coloured dots on it. The figures are in the status bar. */}
<RotorCompass {/* The dial-only form is square and fills the box it is given, so its
size is set here rather than baked into the component. */}
<div className={dial === 'classic' ? 'shrink-0' : 'w-[210px] shrink-0'}>
<Dial
bearing={bearing ?? null} bearing={bearing ?? null}
headings={hd.ok ? [hd.azimuth] : []} headings={hd.ok ? [hd.azimuth] : []}
centerLat={centerLat ?? null} centerLat={centerLat ?? null}
@@ -158,6 +168,7 @@ function RotatorWidget({ hd, refetch, centerLat, centerLon, bearing, t }: Rotato
onSelectRotor={(i) => { SetActiveRotor(i).then(refetch).catch((e) => setErr(String(e?.message ?? e))); }} onSelectRotor={(i) => { SetActiveRotor(i).then(refetch).catch((e) => setErr(String(e?.message ?? e))); }}
onGoto={(az) => turn(az)} onGoto={(az) => turn(az)}
/> />
</div>
<div className="flex-1 min-w-0 space-y-2"> <div className="flex-1 min-w-0 space-y-2">
<div className="font-mono"> <div className="font-mono">
<span className="text-2xl font-bold tabular-nums">{hd.ok ? `${hd.azimuth}°` : '—'}</span> <span className="text-2xl font-bold tabular-nums">{hd.ok ? `${hd.azimuth}°` : '—'}</span>
+9 -94
View File
@@ -25,13 +25,7 @@ import {
import { EventsOn } from '../../wailsjs/runtime/runtime'; import { EventsOn } from '../../wailsjs/runtime/runtime';
import type { ClusterSpot, SpotStatusEntry } from '@/components/ClusterGrid'; import type { ClusterSpot, SpotStatusEntry } from '@/components/ClusterGrid';
import { inferSpotMode, spotStatusKey } from '@/lib/spot'; import { inferSpotMode, spotStatusKey } from '@/lib/spot';
import { useWatchlistSpots, matchesEntry, newBadge, type WLEntry } from '@/lib/watchlistSpots';
interface WLEntry {
callsign: string; lastSeenStr: string; addedAt: string; spotCount: number;
isContest: boolean; notify: boolean;
isExpedition: boolean; clubLogQSOs24h: number; clubLogTotalQSOs: number;
clubLogHasOQRS: boolean; clubLogLiveStream: boolean;
}
interface Props { interface Props {
spots: ClusterSpot[]; spots: ClusterSpot[];
@@ -40,21 +34,6 @@ interface Props {
onSpotClick?: (s: ClusterSpot) => void; onSpotClick?: (s: ClusterSpot) => void;
} }
// A spot is ON AIR for the badge while its last sighting is this fresh.
const ON_AIR_MS = 10 * 60 * 1000;
// Exact unless the entry carries a trailing * — the same rule the backend's
// Match applies to the live stream, mirrored so the tab and the alerts can
// never disagree about what an entry covers.
function matchesEntry(call: string, pattern: string): boolean {
const c = call.toUpperCase();
if (pattern.endsWith('*')) {
const p = pattern.slice(0, -1);
return p !== '' && c.startsWith(p);
}
return c === pattern;
}
export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: Props) { export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: Props) {
const { t } = useI18n(); const { t } = useI18n();
const [entries, setEntries] = useState<WLEntry[]>([]); const [entries, setEntries] = useState<WLEntry[]>([]);
@@ -95,8 +74,6 @@ export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: P
noticeTimer.current = window.setTimeout(() => { setError(''); setNotice(''); }, 4000) as unknown as number; noticeTimer.current = window.setTimeout(() => { setError(''); setNotice(''); }, 4000) as unknown as number;
}; };
// worked answer per "call|band|modeclass|contest" key.
const [worked, setWorked] = useState<Record<string, boolean>>({});
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
try { setEntries(((await WatchlistEntries()) ?? []) as any as WLEntry[]); } try { setEntries(((await WatchlistEntries()) ?? []) as any as WLEntry[]); }
@@ -117,63 +94,11 @@ export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: P
// Live spots per entry — prefix-matched, newest first, deduped per band+mode // Live spots per entry — prefix-matched, newest first, deduped per band+mode
// (one line per slot; the freshest spot represents it). // (one line per slot; the freshest spot represents it).
const spotsFor = useMemo(() => { // Which entries are on the air, and which of their slots are still needed.
const map = new Map<string, ClusterSpot[]>(); // One definition, shared with the docked watch-list widget — the answer
for (const e of entries) map.set(e.callsign, []); // involves a debounced query per visible slot, and two copies of it would be
for (const s of spots) { // two bursts of the same question and two ideas of what "needed" means.
for (const e of entries) { const { spotsFor, workedFor, settled, onAir, worked } = useWatchlistSpots(entries, spots);
if (matchesEntry(s.dx_call ?? '', e.callsign)) { map.get(e.callsign)!.push(s); break; }
}
}
for (const [k, list] of map) {
const seen = new Set<string>();
map.set(k, list.filter((s) => {
const key = `${(s.band ?? '')}|${inferSpotMode(s.comment ?? '', s.freq_hz)}|${s.dx_call}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
}));
}
return map;
}, [spots, entries]);
// The worked answers, refreshed when the visible slots change. Debounced: a
// spot burst must cost one round trip, not one per spot.
const queryTimer = useRef<number | undefined>(undefined);
useEffect(() => {
if (queryTimer.current) window.clearTimeout(queryTimer.current);
queryTimer.current = window.setTimeout(async () => {
const queries: { call: string; band: string; mode: string; contest: boolean }[] = [];
const keys: string[] = [];
for (const e of entries) {
for (const s of spotsFor.get(e.callsign) ?? []) {
const mode = inferSpotMode(s.comment ?? '', s.freq_hz) || '';
queries.push({ call: s.dx_call, band: s.band ?? '', mode, contest: e.isContest });
keys.push(`${s.dx_call}|${s.band ?? ''}|${mode}|${e.isContest ? 1 : 0}`);
}
}
if (queries.length === 0) { setWorked({}); return; }
try {
const res: boolean[] = (await WatchlistWorkedSlots(queries as any)) ?? [];
// MERGED, not replaced: replacing made every already-answered key
// momentarily unknown on each refresh, which re-hid settled lines.
setWorked((prev) => {
const next = { ...prev };
keys.forEach((k, i) => { next[k] = !!res[i]; });
return next;
});
} catch { /* the badges just stay conservative */ }
}, 150) as unknown as number;
return () => { if (queryTimer.current) window.clearTimeout(queryTimer.current); };
}, [spotsFor, entries]);
const wkey = (e: WLEntry, s: ClusterSpot) =>
`${s.dx_call}|${s.band ?? ''}|${inferSpotMode(s.comment ?? '', s.freq_hz) || ''}|${e.isContest ? 1 : 0}`;
const workedFor = (e: WLEntry, s: ClusterSpot): boolean => worked[wkey(e, s)] ?? false;
// A spot whose verdict has not come back yet is NOT drawn. Showing it as
// Needed and withdrawing it half a second later made the list twitch on
// every burst — and nobody needs a spot 400 ms early, they need it settled.
const settled = (e: WLEntry, s: ClusterSpot): boolean => wkey(e, s) in worked;
const add = async () => { const add = async () => {
const c = addCall.trim().toUpperCase(); const c = addCall.trim().toUpperCase();
@@ -195,11 +120,7 @@ export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: P
catch (e: any) { flash(String(e?.message ?? e), true); } catch (e: any) { flash(String(e?.message ?? e), true); }
}; };
const isOnAir = (e: WLEntry): boolean => const isOnAir = onAir;
(spotsFor.get(e.callsign) ?? []).some((s) => {
const ts = Date.parse(String((s as any).received_at ?? ''));
return ts > 0 && Date.now() - ts < ON_AIR_MS;
});
const shown = entries.filter((e) => { const shown = entries.filter((e) => {
if (family === 'normal' && e.isContest) return false; if (family === 'normal' && e.isContest) return false;
@@ -225,14 +146,8 @@ export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: P
// The cluster's own badge for a spot, read from the shared status index. // The cluster's own badge for a spot, read from the shared status index.
const dxccBadge = (s: ClusterSpot): { label: string; color: string } | null => { const dxccBadge = (s: ClusterSpot): { label: string; color: string } | null => {
const st = spotStatus[spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz)]; const st = spotStatus[spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz)];
switch (st?.status) { const b = newBadge(st?.status);
case 'new': return { label: t('wl.newDxcc'), color: 'var(--danger)' }; return b ? { label: t(b.key), color: b.colour } : null;
case 'new-band-mode': return { label: t('clg2.newBandMode'), color: 'var(--danger)' };
case 'new-band': return { label: t('clg2.newBand'), color: 'var(--warning)' };
case 'new-mode': return { label: t('clg2.newMode'), color: 'var(--caution)' };
case 'new-slot': return { label: t('clg2.newSlot'), color: '#5AC8FA' };
default: return null;
}
}; };
// Three decimals, and no trailing zeros beyond them: 7.056 rather than // Three decimals, and no trailing zeros beyond them: 7.056 rather than
+147
View File
@@ -0,0 +1,147 @@
// WatchlistWidget — the watch list reduced to what is worth acting on RIGHT
// NOW: entries that are on the air and still needed.
//
// The Watchlist tab is a tab, and an operator working FT8 lives on the decodes
// one. A station they asked to be told about would appear on a screen they are
// not looking at — so the same answer is docked in the widget strip, which sits
// above the tabs and is therefore always in view. Only active AND needed: a
// list of everything watched is the tab's job, and it would not fit here.
import { useEffect, useMemo, useState } from 'react';
import { Bell, X } from 'lucide-react';
import { useI18n } from '@/lib/i18n';
import { cn } from '@/lib/utils';
import { inferSpotMode, spotStatusKey } from '@/lib/spot';
import { useWatchlistSpots, newBadge, type WLEntry } from '@/lib/watchlistSpots';
import type { ClusterSpot, SpotStatusEntry } from '@/components/ClusterGrid';
import { WatchlistEntries } from '../../wailsjs/go/main/App';
import { EventsOn } from '../../wailsjs/runtime/runtime';
interface Props {
spots: ClusterSpot[];
// The cluster's own verdicts. WHAT a station is worth is the reason to leave
// what you are doing for it — "on the air and not worked" says nothing about
// whether it is a new entity or a fifth band on one worked in 1998.
spotStatus: Record<string, SpotStatusEntry>;
// A row is a spot: clicking it does what clicking one in the cluster does.
onPick?: (s: ClusterSpot) => void;
onClose?: () => void;
}
function age(s: ClusterSpot): string {
const ts = Date.parse(String((s as any).received_at ?? ''));
if (!(ts > 0)) return '';
const m = Math.floor((Date.now() - ts) / 60000);
if (m < 1) return 'now';
return `${m}m`;
}
export function WatchlistWidget({ spots, spotStatus, onPick, onClose }: Props) {
const { t } = useI18n();
const [entries, setEntries] = useState<WLEntry[]>([]);
const load = () => { WatchlistEntries().then((e: any) => setEntries((e ?? []) as WLEntry[])).catch(() => {}); };
useEffect(() => {
load();
// The list changes from four places (the tab, the cluster's star, the
// contest auto-add, an import), and a widget that only reads it at mount
// would quietly watch the wrong set for the rest of the session.
const off = EventsOn('watchlist:changed', load);
return () => { off?.(); };
}, []);
const { spotsFor, workedFor, settled } = useWatchlistSpots(entries, spots);
// Ticking, because every row carries an age and the freshest thing here is
// the reason to look at it at all.
const [, tick] = useState(0);
useEffect(() => {
const id = window.setInterval(() => tick((n) => n + 1), 30000);
return () => window.clearInterval(id);
}, []);
// One row per (entry, needed slot): the same station on two bands is two
// chances to work it, and collapsing them would hide the one that is open.
const rows = useMemo(() => {
const out: { e: WLEntry; s: ClusterSpot }[] = [];
for (const e of entries) {
for (const s of spotsFor.get(e.callsign) ?? []) {
if (!settled(e, s) || workedFor(e, s)) continue;
out.push({ e, s });
}
}
// Freshest first: a spot from twenty minutes ago is history, and this
// panel is short.
return out.sort((a, b) =>
Date.parse(String((b.s as any).received_at ?? '')) - Date.parse(String((a.s as any).received_at ?? '')));
}, [entries, spotsFor, workedFor, settled]);
return (
<section className="flex flex-col h-full min-h-0 rounded-lg border border-border bg-card overflow-hidden">
<div className="flex items-center gap-2 px-3 py-1.5 bg-muted/40 border-b border-border shrink-0">
<Bell className="size-4 text-primary shrink-0" />
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{t('wlw.title')}
</span>
<span className={cn('rounded px-1.5 py-px text-[10px] font-bold',
rows.length > 0 ? 'bg-warning text-warning-foreground' : 'bg-muted text-muted-foreground')}>
{rows.length}
</span>
<div className="flex-1" />
{onClose && (
<button type="button" onClick={onClose} title={t('wlw.hide')}
className="text-muted-foreground hover:text-foreground transition-colors">
<X className="size-3.5" />
</button>
)}
</div>
<div className="flex-1 min-h-0 overflow-y-auto">
{rows.length === 0 ? (
// Empty is the normal state, and it means something precise — say it,
// rather than leaving a blank box that reads as broken.
<div className="px-3 py-3 text-xs text-muted-foreground">
{entries.length === 0 ? t('wlw.emptyList') : t('wlw.emptyNone')}
</div>
) : rows.map(({ e, s }, i) => {
const mode = inferSpotMode(s.comment ?? '', s.freq_hz) || '';
const badge = newBadge(spotStatus[spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz)]?.status);
return (
<button
key={`${e.callsign}-${s.dx_call}-${s.band}-${mode}-${i}`}
type="button"
onClick={() => onPick?.(s)}
title={e.callsign === s.dx_call
? t('wlw.pick', { call: s.dx_call })
: `${t('wlw.pick', { call: s.dx_call })} — ${e.callsign}`}
className="w-full text-left px-2 py-1 border-b border-border/20 hover:bg-muted/50 transition-colors flex items-center gap-1.5"
>
{/* One line, in reading order: who, where, what it is worth, how
long ago. The callsign is the only part allowed to give way —
everything else is short and fixed, and a row that wraps is a
row an operator has to parse instead of scan. */}
<span className="font-mono text-[13px] font-bold text-warning truncate">{s.dx_call}</span>
{s.band && (
<span className="rounded px-1 py-px text-[10px] font-bold uppercase bg-info-muted text-info-muted-foreground shrink-0">
{s.band}
</span>
)}
{mode && <span className="font-mono text-[10px] text-muted-foreground shrink-0">{mode}</span>}
<div className="flex-1" />
{/* Why it is worth interrupting a QSO for — or not. */}
{badge && (
<span className="rounded px-1 py-px text-[10px] font-bold uppercase tracking-wide border shrink-0"
style={{ color: badge.colour, borderColor: `color-mix(in srgb, ${badge.colour} 45%, transparent)`,
background: `color-mix(in srgb, ${badge.colour} 12%, transparent)` }}>
{t(badge.key)}
</span>
)}
<span className="font-mono text-[10px] text-muted-foreground/70 tabular-nums shrink-0 w-7 text-right">
{age(s)}
</span>
</button>
);
})}
</div>
</section>
);
}
File diff suppressed because one or more lines are too long
+39
View File
@@ -0,0 +1,39 @@
// Which rotor dial to draw.
//
// Two compasses ship: the current one (night map, square scale, mouse pointer,
// target marker) and the original small light-map dial. Neither is more correct
// than the other — one operator reads the big map at a glance, another wants the
// compact dial that was there before — so it is a preference, not a migration.
//
// A UI preference rather than a settings-database key: it decides what a widget
// looks like, nothing is transmitted from it, and it belongs to the screen it is
// read on. It is portable all the same (see lib/uiPref), so a copied folder
// keeps the dial its owner chose.
import { writeUiPref } from '@/lib/uiPref';
export const KEY_ROTOR_STYLE = 'opslog.rotorStyle';
export type RotorStyle = 'modern' | 'classic';
export function rotorStyle(): RotorStyle {
try {
return localStorage.getItem(KEY_ROTOR_STYLE) === 'classic' ? 'classic' : 'modern';
} catch {
return 'modern';
}
}
// Same shape as the distance unit: the compasses are rendered from two call
// sites, and a preference changed in Settings has to reach both without either
// of them polling for it.
const listeners = new Set<() => void>();
export function setRotorStyle(style: RotorStyle): void {
writeUiPref(KEY_ROTOR_STYLE, style);
listeners.forEach((l) => l());
}
export function subscribeRotorStyle(fn: () => void): () => void {
listeners.add(fn);
return () => { listeners.delete(fn); };
}
+6
View File
@@ -15,6 +15,12 @@ export function cleanSpotter(s: string): string {
// alone instead of guessing wrong. // alone instead of guessing wrong.
export function inferSpotMode(comment: string, freqHz: number): string { export function inferSpotMode(comment: string, freqHz: number): string {
const c = (comment || '').toUpperCase(); const c = (comment || '').toUpperCase();
// SuperFox and Fox/Hound are FT8 — they are WSJT-X's DXpedition transmit
// modes, not modes of their own. A spot commented "super fox" fell through to
// the band plan and came out DATA, and that verdict is not cosmetic: the
// band+mode status is computed from this answer, so a ZD8 on 21.071 read as a
// new DATA slot rather than the new FT8 one it is.
if (/\bSUPER\s*FOX\b|\bSFOX\b|\bFOX\s*\/?\s*HOUND\b|\bF\/H\b/.test(c)) return 'FT8';
if (/\bFT8\b/.test(c)) return 'FT8'; if (/\bFT8\b/.test(c)) return 'FT8';
if (/\bFT4\b/.test(c)) return 'FT4'; if (/\bFT4\b/.test(c)) return 'FT4';
if (/\bJS8\b/.test(c)) return 'JS8'; if (/\bJS8\b/.test(c)) return 'JS8';
+4
View File
@@ -44,6 +44,10 @@ const PORTABLE_KEYS = [
'opslog.iaruRegion', // IARU region (1/2/3) — band edges and segments on the band maps 'opslog.iaruRegion', // IARU region (1/2/3) — band edges and segments on the band maps
'opslog.chasePota', // show POTA references and the NEW POTA marker on spots 'opslog.chasePota', // show POTA references and the NEW POTA marker on spots
'opslog.chaseSota', // show SOTA references on spots 'opslog.chaseSota', // show SOTA references on spots
// The other chase switches. In the DB as well as locally because AUTO-CALL
// reads them: what the operator does not hunt is not something to transmit
// for, and the backend cannot see localStorage.
'opslog.chasePfx', 'opslog.chaseCounty', 'opslog.chaseState', 'opslog.chaseGrids',
'opslog.activeTab', // last selected tab 'opslog.activeTab', // last selected tab
'opslog.mainSplit', // Main tab: width share of the left pane (percent) — legacy, read once to seed mainShares 'opslog.mainSplit', // Main tab: width share of the left pane (percent) — legacy, read once to seed mainShares
'opslog.mainShares', // Main tab: column shares per column count, as {2:[..],3:[..],4:[..]} 'opslog.mainShares', // Main tab: column shares per column count, as {2:[..],3:[..],4:[..]}
+133
View File
@@ -0,0 +1,133 @@
// What the watch list is HEARING, and what of it is still needed.
//
// Two places ask the same question — the Watchlist tab and the docked widget —
// and the answer involves a debounced round trip to the logbook per visible
// slot. Written twice it would be two definitions of "needed" drifting apart,
// and two bursts of the same query on every spot; written here it is one.
import { useEffect, useMemo, useRef, useState } from 'react';
import type { ClusterSpot } from '@/components/ClusterGrid';
import { inferSpotMode } from '@/lib/spot';
import { WatchlistWorkedSlots } from '../../wailsjs/go/main/App';
export interface WLEntry {
callsign: string; lastSeenStr: string; addedAt: string; spotCount: number;
isContest: boolean; notify: boolean;
isExpedition: boolean; clubLogQSOs24h: number; clubLogTotalQSOs: number;
clubLogHasOQRS: boolean; clubLogLiveStream: boolean;
}
// A spot is ON AIR while its last sighting is this fresh.
export const ON_AIR_MS = 10 * 60 * 1000;
// Exact unless the entry carries a trailing * — the same rule the backend's
// Match applies to the live stream, mirrored so the list and the alerts can
// never disagree about what an entry covers.
export function matchesEntry(call: string, pattern: string): boolean {
const c = call.toUpperCase();
if (pattern.endsWith('*')) {
const p = pattern.slice(0, -1);
return p !== '' && c.startsWith(p);
}
return c === pattern;
}
// What the cluster's verdict is worth saying, and in what colour.
//
// The order of severity is the one the band map and Chase new use: a new entity
// first, then the band and mode inside it. Shared because the tab and the docked
// widget must not label the same spot differently — an operator reads one of
// them to decide whether to leave what they are doing.
export const NEW_BADGES: Record<string, { key: string; colour: string }> = {
'new': { key: 'wl.newDxcc', colour: 'var(--danger)' },
'new-band-mode': { key: 'clg2.newBandMode', colour: 'var(--danger)' },
'new-band': { key: 'clg2.newBand', colour: 'var(--warning)' },
'new-mode': { key: 'clg2.newMode', colour: 'var(--caution)' },
'new-slot': { key: 'clg2.newSlot', colour: '#5AC8FA' },
};
export function newBadge(status?: string): { key: string; colour: string } | null {
return (status && NEW_BADGES[status]) || null;
}
export interface WatchlistSpots {
// The raw verdicts, exposed for dependency arrays: it changes only when an
// answer arrives, where the closures below are new on every render.
worked: Record<string, boolean>;
// The spots each entry covers, deduplicated by band+mode.
spotsFor: Map<string, ClusterSpot[]>;
// Worked on this exact slot (and, for a contest entry, today).
workedFor: (e: WLEntry, s: ClusterSpot) => boolean;
// Whether the logbook has actually answered for this pair yet. A spot whose
// verdict has not come back is NOT drawn: showing it as needed and
// withdrawing it half a second later made the list twitch on every burst,
// and nobody needs a spot 400 ms early — they need it settled.
settled: (e: WLEntry, s: ClusterSpot) => boolean;
onAir: (e: WLEntry) => boolean;
}
export function useWatchlistSpots(entries: WLEntry[], spots: ClusterSpot[]): WatchlistSpots {
const [worked, setWorked] = useState<Record<string, boolean>>({});
const spotsFor = useMemo(() => {
const map = new Map<string, ClusterSpot[]>();
for (const e of entries) map.set(e.callsign, []);
for (const s of spots) {
for (const e of entries) {
if (matchesEntry(s.dx_call ?? '', e.callsign)) { map.get(e.callsign)!.push(s); break; }
}
}
for (const [k, list] of map) {
const seen = new Set<string>();
map.set(k, list.filter((s) => {
const key = `${(s.band ?? '')}|${inferSpotMode(s.comment ?? '', s.freq_hz)}|${s.dx_call}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
}));
}
return map;
}, [spots, entries]);
// Debounced: a spot burst must cost one round trip, not one per spot.
const queryTimer = useRef<number | undefined>(undefined);
useEffect(() => {
if (queryTimer.current) window.clearTimeout(queryTimer.current);
queryTimer.current = window.setTimeout(async () => {
const queries: { call: string; band: string; mode: string; contest: boolean }[] = [];
const keys: string[] = [];
for (const e of entries) {
for (const s of spotsFor.get(e.callsign) ?? []) {
const mode = inferSpotMode(s.comment ?? '', s.freq_hz) || '';
queries.push({ call: s.dx_call, band: s.band ?? '', mode, contest: e.isContest });
keys.push(`${s.dx_call}|${s.band ?? ''}|${mode}|${e.isContest ? 1 : 0}`);
}
}
if (queries.length === 0) { setWorked({}); return; }
try {
const res: boolean[] = (await WatchlistWorkedSlots(queries as any)) ?? [];
// MERGED, not replaced: replacing made every already-answered key
// momentarily unknown on each refresh, which re-hid settled lines.
setWorked((prev) => {
const next = { ...prev };
keys.forEach((k, i) => { next[k] = !!res[i]; });
return next;
});
} catch { /* the badges just stay conservative */ }
}, 150) as unknown as number;
return () => { if (queryTimer.current) window.clearTimeout(queryTimer.current); };
}, [spotsFor, entries]);
const wkey = (e: WLEntry, s: ClusterSpot) =>
`${s.dx_call}|${s.band ?? ''}|${inferSpotMode(s.comment ?? '', s.freq_hz) || ''}|${e.isContest ? 1 : 0}`;
return {
worked,
spotsFor,
workedFor: (e, s) => worked[wkey(e, s)] ?? false,
settled: (e, s) => wkey(e, s) in worked,
onAir: (e) => (spotsFor.get(e.callsign) ?? []).some((s) => {
const ts = Date.parse(String((s as any).received_at ?? ''));
return ts > 0 && Date.now() - ts < ON_AIR_MS;
}),
};
}
+1 -1
View File
@@ -1,6 +1,6 @@
// Single source of truth for the app version shown in the UI (header + About). // Single source of truth for the app version shown in the UI (header + About).
// Bump this on a release (the release script updates it alongside telemetry.go). // Bump this on a release (the release script updates it alongside telemetry.go).
export const APP_VERSION = '0.27.12'; export const APP_VERSION = '0.27.13';
// Author / credits, shown in Help -> About. // Author / credits, shown in Help -> About.
export const APP_AUTHOR = 'F4BPO'; export const APP_AUTHOR = 'F4BPO';
+8
View File
@@ -454,6 +454,8 @@ export function GetChangelog():Promise<Array<main.ChangelogEntry>>;
export function GetChaseNew():Promise<boolean>; export function GetChaseNew():Promise<boolean>;
export function GetChaseNewBands():Promise<Array<string>>;
export function GetChaseNewGrids():Promise<boolean>; export function GetChaseNewGrids():Promise<boolean>;
export function GetChaseNewSpots():Promise<Array<main.ChaseNewSpot>>; export function GetChaseNewSpots():Promise<Array<main.ChaseNewSpot>>;
@@ -654,6 +656,8 @@ export function GetYaesuState():Promise<cat.YaesuTXState>;
export function GridSquares(arg1:string):Promise<Array<qso.GridSquare>>; export function GridSquares(arg1:string):Promise<Array<qso.GridSquare>>;
export function HaltAutoCall():Promise<void>;
export function HaltDecodeTx(arg1:string,arg2:boolean):Promise<void>; export function HaltDecodeTx(arg1:string,arg2:boolean):Promise<void>;
export function HasBuiltinReferences(arg1:string):Promise<boolean>; export function HasBuiltinReferences(arg1:string):Promise<boolean>;
@@ -1158,6 +1162,8 @@ export function SetAutoCall(arg1:boolean):Promise<void>;
export function SetAutoCallOnly(arg1:string):Promise<void>; export function SetAutoCallOnly(arg1:string):Promise<void>;
export function SetAutoCallVisible(arg1:Array<string>,arg2:boolean):Promise<void>;
export function SetCATFrequency(arg1:number):Promise<void>; export function SetCATFrequency(arg1:number):Promise<void>;
export function SetCATMode(arg1:string):Promise<void>; export function SetCATMode(arg1:string):Promise<void>;
@@ -1168,6 +1174,8 @@ export function SetCWDecoderPitch(arg1:number):Promise<void>;
export function SetChaseNew(arg1:boolean):Promise<void>; export function SetChaseNew(arg1:boolean):Promise<void>;
export function SetChaseNewBands(arg1:Array<string>):Promise<void>;
export function SetChaseNewGrids(arg1:boolean):Promise<void>; export function SetChaseNewGrids(arg1:boolean):Promise<void>;
export function SetClublogCtyEnabled(arg1:boolean):Promise<void>; export function SetClublogCtyEnabled(arg1:boolean):Promise<void>;
+16
View File
@@ -842,6 +842,10 @@ export function GetChaseNew() {
return window['go']['main']['App']['GetChaseNew'](); return window['go']['main']['App']['GetChaseNew']();
} }
export function GetChaseNewBands() {
return window['go']['main']['App']['GetChaseNewBands']();
}
export function GetChaseNewGrids() { export function GetChaseNewGrids() {
return window['go']['main']['App']['GetChaseNewGrids'](); return window['go']['main']['App']['GetChaseNewGrids']();
} }
@@ -1242,6 +1246,10 @@ export function GridSquares(arg1) {
return window['go']['main']['App']['GridSquares'](arg1); return window['go']['main']['App']['GridSquares'](arg1);
} }
export function HaltAutoCall() {
return window['go']['main']['App']['HaltAutoCall']();
}
export function HaltDecodeTx(arg1, arg2) { export function HaltDecodeTx(arg1, arg2) {
return window['go']['main']['App']['HaltDecodeTx'](arg1, arg2); return window['go']['main']['App']['HaltDecodeTx'](arg1, arg2);
} }
@@ -2250,6 +2258,10 @@ export function SetAutoCallOnly(arg1) {
return window['go']['main']['App']['SetAutoCallOnly'](arg1); return window['go']['main']['App']['SetAutoCallOnly'](arg1);
} }
export function SetAutoCallVisible(arg1, arg2) {
return window['go']['main']['App']['SetAutoCallVisible'](arg1, arg2);
}
export function SetCATFrequency(arg1) { export function SetCATFrequency(arg1) {
return window['go']['main']['App']['SetCATFrequency'](arg1); return window['go']['main']['App']['SetCATFrequency'](arg1);
} }
@@ -2270,6 +2282,10 @@ export function SetChaseNew(arg1) {
return window['go']['main']['App']['SetChaseNew'](arg1); return window['go']['main']['App']['SetChaseNew'](arg1);
} }
export function SetChaseNewBands(arg1) {
return window['go']['main']['App']['SetChaseNewBands'](arg1);
}
export function SetChaseNewGrids(arg1) { export function SetChaseNewGrids(arg1) {
return window['go']['main']['App']['SetChaseNewGrids'](arg1); return window['go']['main']['App']['SetChaseNewGrids'](arg1);
} }
+12
View File
@@ -1962,6 +1962,8 @@ export namespace main {
misses: number; misses: number;
max_rounds: number; max_rounds: number;
rest_min: number; rest_min: number;
on_screen_only: boolean;
trace: boolean;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new AutoCallSettings(source); return new AutoCallSettings(source);
@@ -1976,17 +1978,21 @@ export namespace main {
this.misses = source["misses"]; this.misses = source["misses"];
this.max_rounds = source["max_rounds"]; this.max_rounds = source["max_rounds"];
this.rest_min = source["rest_min"]; this.rest_min = source["rest_min"];
this.on_screen_only = source["on_screen_only"];
this.trace = source["trace"];
} }
} }
export class AutoCallStatus { export class AutoCallStatus {
enabled: boolean; enabled: boolean;
only: string; only: string;
target: string; target: string;
waiting: string;
calls: number; calls: number;
max: number; max: number;
misses: number; misses: number;
max_miss: number; max_miss: number;
stopped: boolean; stopped: boolean;
greylisted: number;
reason: string; reason: string;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
@@ -1998,11 +2004,13 @@ export namespace main {
this.enabled = source["enabled"]; this.enabled = source["enabled"];
this.only = source["only"]; this.only = source["only"];
this.target = source["target"]; this.target = source["target"];
this.waiting = source["waiting"];
this.calls = source["calls"]; this.calls = source["calls"];
this.max = source["max"]; this.max = source["max"];
this.misses = source["misses"]; this.misses = source["misses"];
this.max_miss = source["max_miss"]; this.max_miss = source["max_miss"];
this.stopped = source["stopped"]; this.stopped = source["stopped"];
this.greylisted = source["greylisted"];
this.reason = source["reason"]; this.reason = source["reason"];
} }
} }
@@ -4962,6 +4970,8 @@ export namespace pskr {
last_err?: string; last_err?: string;
broker: string; broker: string;
bands: string[]; bands: string[];
near_km: number;
squares: number;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new Status(source); return new Status(source);
@@ -4975,6 +4985,8 @@ export namespace pskr {
this.last_err = source["last_err"]; this.last_err = source["last_err"];
this.broker = source["broker"]; this.broker = source["broker"];
this.bands = source["bands"]; this.bands = source["bands"];
this.near_km = source["near_km"];
this.squares = source["squares"];
} }
convertValues(a: any, classs: any, asMap: boolean = false): any { convertValues(a: any, classs: any, asMap: boolean = false): any {
+558 -85
View File
@@ -47,6 +47,7 @@ package autocall
import ( import (
"fmt" "fmt"
"regexp" "regexp"
"sort"
"strings" "strings"
"time" "time"
"unicode" "unicode"
@@ -57,10 +58,20 @@ type Need int
const ( const (
NeedNone Need = iota NeedNone Need = iota
NeedSlot // entity worked on this band and in this mode, never together // NeedExtra: the ENTITY has nothing left to give on this band and mode, but
NeedMode // entity never worked in this mode // the station carries something else that has never been worked — a WPX
NeedBand // entity never worked on this band // prefix, a square, a US county or state, a park. Those are what the cluster
NeedDXCC // entity never worked at all // calls the orthogonal markers, and an operator who ticked them in the chase
// settings is hunting them: leaving them at nothing-needed meant auto-call
// watched a never-worked prefix call CQ and did nothing.
//
// The lowest rung, deliberately. It is worth calling when nothing better is
// on the air, and never worth leaving a new band for.
NeedExtra
NeedSlot // entity worked on this band and in this mode, never together
NeedMode // entity never worked in this mode
NeedBand // entity never worked on this band
NeedDXCC // entity never worked at all
) )
func (n Need) String() string { func (n Need) String() string {
@@ -73,6 +84,8 @@ func (n Need) String() string {
return "mode" return "mode"
case NeedSlot: case NeedSlot:
return "slot" return "slot"
case NeedExtra:
return "extra"
} }
return "-" return "-"
} }
@@ -109,6 +122,17 @@ type Candidate struct {
Decode Decode
Need Need Need Need
Watched bool Watched bool
// Extra names what NeedExtra is about — "prefix", "county", "square" — so the
// decision line says why the station was worth a call. Empty otherwise.
Extra string
// Unconfirmed says the need above exists ONLY because a contact was never
// confirmed — the operator hunts "new + unconfirmed", the band is in the log
// and the QSL is not. A real need outranks it; see rank.
Unconfirmed bool
// Hidden is true when the decodes panel's own filters are keeping this
// station off the screen. The operator's filters are the control: what is
// not shown is not called — see Settings.OnScreenOnly.
Hidden bool
// Worked is "already in the log on this band AND mode". Nothing is gained // 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 // 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 // makes the same station be called all evening, because an unconfirmed QSO
@@ -152,6 +176,19 @@ type Settings struct {
MaxRounds int MaxRounds int
// MaxHold is the wall-clock backstop on one target. // MaxHold is the wall-clock backstop on one target.
MaxHold time.Duration MaxHold time.Duration
// OnScreenOnly restricts the calling to what the decodes panel is actually
// showing.
//
// The filters an operator sets while reading the band — CQ only, LoTW only,
// the new-category chips, continents, a minimum report, the search box — now
// bind the transmitter too: a station filtered off the screen is not called.
// It replaces a separate "LoTW users only" setting, which said one thing
// while the chip above the list said another.
//
// The panel publishes what it is showing; when it is publishing nothing —
// the tab was never opened this session — there is nothing to restrict and
// the ladder decides alone.
OnScreenOnly bool
} }
// Defaults are what the operator gets before touching anything. // Defaults are what the operator gets before touching anything.
@@ -201,6 +238,19 @@ type Action struct {
Kind ActionKind Kind ActionKind
Decode Decode Decode Decode
Reason string Reason string
// Soft asks the decoder to finish the over it is sending and only then stop
// transmitting, rather than cutting the carrier where it stands.
//
// It matters for exactly one brake. The attempts cap trips WHILE the seventh
// call is going out — that is what counts it — so a hard halt cuts that
// transmission a second in, which on the air is a half-sent call and on the
// screen is an auto-call that "starts and stops". Nothing is saved by
// stopping it: the frame is already half gone.
//
// Every other brake fires on a station that is not being transmitted to (it
// has vanished, or the clock ran out between overs), and there a hard halt
// is right: it is the one that also clears the decoder's own auto-sequence.
Soft bool
} }
// Engine holds the state between periods. Not safe for concurrent use; the // Engine holds the state between periods. Not safe for concurrent use; the
@@ -214,6 +264,19 @@ type Engine struct {
// targetInst is the receiver the target was picked on, so the brakes are // targetInst is the receiver the target was picked on, so the brakes are
// counted against ITS periods and its transmissions. // counted against ITS periods and its transmissions.
targetInst string targetInst string
// now is the time of the last period seen. Every deadline in here is measured
// against it rather than against the wall clock: the periods ARE the clock —
// they arrive stamped — and mixing the two gave a rest that expired against
// one reading and a hold that ran against another.
now time.Time
// waiting is the station the engine WOULD call and cannot, because it is in
// a QSO with somebody else. Kept so the toolbar can say so: an auto-call
// deliberately holding its fire looks exactly like one with nothing to do,
// and from the outside there was no way to tell them apart.
waiting string
// answered says the target has replied to us at least once — the exchange is
// under way and nothing may take its place.
answered bool
// heldSince is when this station BECAME the target, and is never refreshed // 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: // 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 // a station decoded every period for ever kept pushing its own deadline
@@ -230,6 +293,20 @@ type Engine struct {
// how many series it has already had this session. // how many series it has already had this session.
rested map[string]time.Time rested map[string]time.Time
rounds map[string]int rounds map[string]int
// trace, when set, receives one line per period: what was on the air, why
// each station was refused, and what was decided. Off unless the operator
// asks for it — a line per period is a line every fifteen seconds, all
// night.
trace func(string, ...any)
// greyed is every station the OPERATOR stopped a call to.
//
// Halt is a verdict, not a pause: the operator watched the engine call this
// station and said no. Answering that by releasing the target and picking
// the same station again the next period — which is what clearing the state
// did — is the machine overruling them, and it is what Halt exists to
// prevent. It holds until auto-call is switched off and on again, which is
// the one gesture that plainly means "start over".
greyed map[string]bool
// done is every station the exchange was completed with this session. // 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 // The log is the authority on what is worked, and it is SLOW: the QSO is
@@ -242,7 +319,8 @@ type Engine struct {
func New(s Settings) *Engine { func New(s Settings) *Engine {
return &Engine{set: s.withDefaults(), txSlot: -1, return &Engine{set: s.withDefaults(), txSlot: -1,
rested: map[string]time.Time{}, rounds: map[string]int{}, done: map[string]bool{}} rested: map[string]time.Time{}, rounds: map[string]int{}, done: map[string]bool{},
greyed: map[string]bool{}}
} }
// SetSettings swaps the settings in place. A target already being called is // SetSettings swaps the settings in place. A target already being called is
@@ -261,7 +339,9 @@ func (e *Engine) Target() string {
// Status is what the panel shows: who, how far into the brakes, and whether // 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. // the engine has given up and is waiting for the operator.
type Status struct { type Status struct {
Target string `json:"target"` Target string `json:"target"`
// Waiting: wanted, decoded, and in a QSO with somebody else.
Waiting string `json:"waiting"`
Attempts int `json:"attempts"` Attempts int `json:"attempts"`
Max int `json:"max"` Max int `json:"max"`
Misses int `json:"misses"` Misses int `json:"misses"`
@@ -271,7 +351,8 @@ type Status struct {
} }
func (e *Engine) Status() Status { func (e *Engine) Status() Status {
st := Status{Misses: e.misses, MaxMiss: e.set.Misses, Stopped: e.stopped, StoppedOn: e.stoppedOn} st := Status{Misses: e.misses, MaxMiss: e.set.Misses, Stopped: e.stopped, StoppedOn: e.stoppedOn,
Waiting: e.waiting}
if e.target != nil { if e.target != nil {
st.Target = e.target.Call st.Target = e.target.Call
st.Attempts = e.attempts st.Attempts = e.attempts
@@ -287,9 +368,38 @@ func (e *Engine) Reset() {
e.targetInst = "" e.targetInst = ""
e.lastMiss, e.stopped, e.stoppedOn = "", false, "" e.lastMiss, e.stopped, e.stoppedOn = "", false, ""
e.rested, e.rounds = map[string]time.Time{}, map[string]int{} e.rested, e.rounds = map[string]time.Time{}, map[string]int{}
e.done = map[string]bool{} e.done, e.greyed = map[string]bool{}, map[string]bool{}
} }
// Halt is the operator stopping the call in progress.
//
// The station is set aside for the session — see greyed — and the target is
// released. Returns the callsign so the caller can say what happened; empty
// when nothing was being called, where Halt is just a halt.
func (e *Engine) Halt() string {
if e.target == nil {
return ""
}
call := strings.ToUpper(e.target.Call)
e.greyed[call] = true
e.release(call, true)
// Not counted as a series: the brakes are about a station that will not
// answer, and this one was never given the chance. It will not be called
// again anyway.
e.rounds[call]--
if e.rounds[call] < 0 {
e.rounds[call] = 0
}
delete(e.rested, call)
return call
}
// SetTrace turns the per-period trace on (a non-nil function) or off (nil).
func (e *Engine) SetTrace(fn func(string, ...any)) { e.trace = fn }
// Greylisted reports how many stations the operator has stopped this session.
func (e *Engine) Greylisted() int { return len(e.greyed) }
// Take hands the operator's own click to the engine: the station they picked // 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 // 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. // brakes, the hand-off at the end of the QSO — then applies as usual.
@@ -309,23 +419,47 @@ func (e *Engine) maxAttempts(c Candidate) int {
return e.set.Attempts return e.set.Attempts
} }
// rank is the ladder, as one number. Watched lifts a station by one rung, and // rank is the ladder, as one number.
// 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 // A WATCHED callsign outranks everything that is not watched. That is what a
// 5 WL mode 4 mode 3 WL slot 2 slot 1 watched, nothing needed // watch list means: the operator has named this station, and the machine's
// 0 nothing to call for // opinion of what the log needs does not get to overrule it.
//
// It did before, one rung at a time — watched new band above plain new band,
// but a watched station with nothing needed at the very bottom. On a band full
// of stations that ARE needed, that station was never reached: a watched
// DXpedition sat there all evening while the engine worked Brazilians. The
// list is not a tie-breaker, it is the answer.
//
// Below that line the old order stands, and for the same reasons: what is
// needed first, and a real need above one that exists only because a QSL never
// came — an unconfirmed new BAND still outranks a real new SLOT, because the
// band is the bigger prize whatever state it is in.
//
// 100+ every watched callsign, ordered among themselves by the same rule
// 18 DXCC 16 DXCC unconf
// 14 band 12 band unconf
// 10 mode 8 mode unconf
// 6 slot 4 slot unconf
// 0 nothing to call for
//
// needLabel is what the candidate is worth, in words: the rung, or the name of
// the orthogonal marker when that is the whole reason for the call.
func needLabel(c Candidate) string {
if c.Need == NeedExtra && c.Extra != "" {
return "new " + c.Extra
}
return c.Need.String()
}
func rank(c Candidate) int { func rank(c Candidate) int {
if c.Need == NeedNone { r := int(c.Need) * 4 // slot 4, mode 8, band 12, DXCC 16
if c.Watched { if c.Need != NeedNone && !c.Unconfirmed {
return 1 r += 2
}
return 0
} }
r := int(c.Need) * 2 // slot 2, mode 4, band 6, DXCC 8
if c.Watched { if c.Watched {
r++ // Above every unwatched station, whatever the log makes of either.
return 100 + r
} }
return r return r
} }
@@ -371,6 +505,40 @@ func isGrid(tok string) bool {
return gridRe.MatchString(tok) return gridRe.MatchString(tok)
} }
// messages splits a decoded line into the messages it carries.
//
// One transmission can hold two. MSHV answers several callers at once — its
// own multi-answer transmission, not SuperFox — and the decoder prints them as
// one line:
//
// YV5ALI RR73; F4BPO <HK0/PY8WW> -08
//
// The second half is a report to F4BPO. Reading only the start of the line, the
// engine saw a station working YV5ALI, decided it could not answer us, and
// dropped the target — at the exact moment the DX was answering. Each segment
// is its own message and names its own recipient first.
func messages(msg string) []string {
return strings.Split(strings.ToUpper(strings.TrimSpace(msg)), ";")
}
// bare strips the angle brackets a decoder puts round a compressed callsign,
// and upper-cases what is left. "<HP/WE9G>" and "HP/WE9G" are one station, and
// every comparison in here has to agree about that.
func bare(call string) string {
return strings.Trim(strings.ToUpper(strings.TrimSpace(call)), "<>")
}
// addressedTo reports whether one message segment is addressed to a callsign.
// The recipient is the first token, sometimes bracketed when the sender has
// compressed a non-standard call.
func addressedTo(part, call string) bool {
toks := strings.Fields(part)
if len(toks) == 0 || call == "" {
return false
}
return bare(toks[0]) == bare(call)
}
// callable reports whether a station can be answered RIGHT NOW. // callable reports whether a station can be answered RIGHT NOW.
// //
// A station in mid-exchange is committed to somebody else: it will not answer, // A station in mid-exchange is committed to somebody else: it will not answer,
@@ -383,16 +551,22 @@ func callable(c Candidate, myCall string) bool {
if c.CQ { if c.CQ {
return true return true
} }
toks := strings.Fields(strings.ToUpper(strings.TrimSpace(c.Msg))) parts := messages(c.Msg)
if len(toks) == 0 { if len(parts) == 0 {
return false // nothing to read: assume it is busy rather than call blind return false // nothing to read: assume it is busy rather than call blind
} }
if myCall != "" && toks[0] == strings.ToUpper(myCall) { for _, part := range parts {
return true // it is calling us toks := strings.Fields(part)
} if len(toks) == 0 {
switch toks[len(toks)-1] { continue
case "RR73", "RRR", "73": }
return true if addressedTo(part, myCall) {
return true // it is calling us
}
switch toks[len(toks)-1] {
case "RR73", "RRR", "73":
return true
}
} }
return false return false
} }
@@ -403,8 +577,12 @@ func callingUs(c Candidate, myCall string) bool {
if myCall == "" || c.CQ { if myCall == "" || c.CQ {
return false return false
} }
toks := strings.Fields(strings.ToUpper(strings.TrimSpace(c.Msg))) for _, part := range messages(c.Msg) {
return len(toks) > 0 && toks[0] == strings.ToUpper(myCall) if addressedTo(part, myCall) {
return true
}
}
return false
} }
// finished reports that the exchange with this station is over: it sent us the // finished reports that the exchange with this station is over: it sent us the
@@ -420,13 +598,17 @@ func finished(decodes []Candidate, call, myCall string) bool {
if strings.ToUpper(c.Call) != call { if strings.ToUpper(c.Call) != call {
continue continue
} }
toks := strings.Fields(strings.ToUpper(strings.TrimSpace(c.Msg))) // Every segment: a fox says goodbye to one caller and hello to the next
if len(toks) < 2 || toks[0] != me { // in the same transmission.
continue for _, part := range messages(c.Msg) {
} toks := strings.Fields(part)
switch toks[len(toks)-1] { if len(toks) < 2 || !addressedTo(part, me) {
case "RR73", "RRR", "73": continue
return true }
switch toks[len(toks)-1] {
case "RR73", "RRR", "73":
return true
}
} }
} }
return false return false
@@ -466,6 +648,9 @@ func (e *Engine) OnPeriod(p Period) Action {
if !e.set.Enabled { if !e.set.Enabled {
return Action{} return Action{}
} }
if !p.At.IsZero() {
e.now = p.At
}
// ONE station at a time, on the receiver it is being called on. // 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, // This is the guarantee with two decoders running: while a target is held,
@@ -482,16 +667,38 @@ func (e *Engine) OnPeriod(p Period) Action {
t := *e.target t := *e.target
tc := strings.ToUpper(t.Call) tc := strings.ToUpper(t.Call)
// HAS IT ANSWERED US? Settled first, before any of the checks below can
// decide to leave it.
//
// The reply lands in the same period the ladder is re-read, and that
// period used to be judged with answered still false — so a station
// answering our CQ-call was dropped for a better-ranked one in the exact
// period it came back to us. Watched from the shack: mid-exchange with
// V31MA, report sent, and OpsLog switched to a station calling on the
// other side of the screen. A QSO in progress outranks the ladder.
if seen := bestOf(p.Decodes, tc); seen != nil && callingUs(*seen, p.MyCall) {
e.answered = true
}
// The exchange is over — either the station sent us its last frame, or // The 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 // 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. // period rather than waiting for the next one: the slot is the resource.
if finished(p.Decodes, tc, p.MyCall) || workedNow(p.Decodes, tc) { if finished(p.Decodes, tc, p.MyCall) || workedNow(p.Decodes, tc) {
e.release(tc, false) e.release(tc, false)
// Straight on to the next station, list or no list: pick() is what // AND NOTHING ELSE THIS PERIOD. The next station is picked on the next
// knows the chase list, and with one on it the answer is simply the // one, fifteen seconds later, and it is still there.
// next callsign there — a list of two DXpeditions must not stop //
// after the first. // His RR73 is decoded while we are between overs, so the decoder is
return e.pick(p, fmt.Sprintf("QSO with %s finished", tc)) // about to send our own 73 in the slot that is opening. Answering
// somebody else now takes that slot: the reply switches the DX call
// mid-sequence and the 73 never goes out whole — an operator watching
// saw both transmissions in one period, and the station at the other
// end never got the frame that closes the contact.
//
// This used to hand straight on "because the slot is the resource".
// The slot is worth less than the QSO it would spoil.
return Action{Kind: DoNothing,
Reason: fmt.Sprintf("QSO with %s finished — leaving the next slot for our 73", tc)}
} }
// The clock backstop. Every counter below depends on decodes arriving in // The clock backstop. Every counter below depends on decodes arriving in
@@ -501,6 +708,29 @@ func (e *Engine) OnPeriod(p Period) Action {
return Action{Kind: DoHalt, Reason: fmt.Sprintf("%s held for %s with nothing to show for it", tc, e.set.MaxHold)} return Action{Kind: DoHalt, Reason: fmt.Sprintf("%s held for %s with nothing to show for it", tc, e.set.MaxHold)}
} }
if e.trace != nil {
e.trace("period %s holding %s calls=%d/%d misses=%d/%d%s",
p.Key, tc, e.attempts, e.maxAttempts(t), e.misses, e.set.Misses,
map[bool]string{true: " (transmitting)", false: ""}[p.TX.Transmitting])
}
// SOMETHING BETTER HAS COME ON THE AIR.
//
// A target is held until it answers or a brake releases it, and that is
// right once an exchange has started — but before it has, holding means
// spending the next two minutes on a new slot while the watched
// DXpedition calls CQ three rows above it. Which is what an operator
// sees: the first CQ ignored, then the second one answered because the
// other call had run its course.
//
// So while the target has NOT answered us, a strictly better station
// takes its place. "Better" is the ladder, which does not flicker: a
// station's need does not change from period to period, so this cannot
// oscillate between two of them — only a genuinely higher rung wins.
if !e.answered {
if a, ok := e.preempt(p); ok {
return a
}
}
if seen := bestOf(p.Decodes, tc); seen != nil { if seen := bestOf(p.Decodes, tc); seen != nil {
// The decode is refreshed so a reply carries a current timestamp; the // The decode is refreshed so a reply carries a current timestamp; the
// hold clock is NOT — see heldSince. // hold clock is NOT — see heldSince.
@@ -512,11 +742,44 @@ func (e *Engine) OnPeriod(p Period) Action {
// reply is how two transmissions land in one slot. // reply is how two transmissions land in one slot.
if callingUs(*seen, p.MyCall) { if callingUs(*seen, p.MyCall) {
e.attempts = 0 e.attempts = 0
e.answered = true
// The exchange is under way, so the hold clock starts again from
// here: it exists to end a call nobody answers, and this one has
// been answered. Without this a QSO that began at the end of a
// long wait could be halted mid-exchange by a backstop that was
// counting the wait.
e.heldSince = p.At
return Action{}
}
// IT IS WORKING SOMEBODY ELSE, AND WE GO ON CALLING.
//
// This used to stop, on the reasoning that a station in a QSO cannot
// hear the call. That is how a pileup is NOT worked: a DX with a
// queue answers one caller per period, and the only way to be the
// next one is to keep calling while it works the others. An operator
// watched exactly that — D44TWO finishing a contact, the call
// started, the DX coming back to somebody else, and the engine
// giving up on a station that was about to be free.
//
// The effort is already bounded: seven calls, fifteen for a watched
// station, and the miss counter for one that goes off the air. And
// nothing is wasted while it is busy — a better station may still
// take the slot, because it has not answered us (see preempt).
//
// The callable test still governs the CHOICE of a target, where it
// belongs: a reply to a decode in mid-exchange is one WSJT-X and
// JTDX may refuse outright.
if e.trace != nil && !callable(*seen, p.MyCall) {
e.trace("period %s %s is working %s — carrying on calling", p.Key, tc, addressee(seen.Msg))
} }
return Action{} return Action{}
} }
// Absent. Only its OWN transmit periods count against it. // Absent. Only its OWN transmit periods count against it — and never one
// of ours: our transmission is the reason it is not there.
if p.TX.Transmitting {
return Action{}
}
if e.txSlot >= 0 && slotOf(p.At, periodSecs(p, t)) != e.txSlot { if e.txSlot >= 0 && slotOf(p.At, periodSecs(p, t)) != e.txSlot {
return Action{} return Action{}
} }
@@ -536,65 +799,132 @@ func (e *Engine) OnPeriod(p Period) Action {
if e.stopped { if e.stopped {
return Action{} // an explicit target gave up; the operator restarts it 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, "") return e.pick(p, "")
} }
// pick chooses the best station on the air and answers it. // pick chooses the best station on the air and answers it.
//
// NEVER over a transmission in progress, and that guard belongs HERE because
// there are two ways in. A reply is not a request the decoder queues: WSJT-X
// acts on it at once, drops the exchange it is in and starts calling the new
// station — so a reply sent while our own 73 was going out cut that 73 about a
// second in, and the station we had just worked never got it. Reported from the
// air, and the sequence is exactly this one: his RRR is decoded, the QSO reads
// as finished, and the next station is picked in the same instant.
//
// The slot is worth having, but not at the price of the QSO in hand. The next
// period is a fifteen-second wait and everything worth calling is still there.
func (e *Engine) pick(p Period, why string) Action { func (e *Engine) pick(p Period, why string) Action {
if e.trace != nil {
e.tracePick(p)
}
if p.TX.Transmitting {
if why != "" {
return Action{Kind: DoNothing, Reason: why + " — holding until the transmission ends"}
}
return Action{}
}
only := onlyList(e.set.Only) only := onlyList(e.set.Only)
var best *Candidate var best *Candidate
var bestRank int var bestRank int
// bestRank of everything eligible, rested or not: a station that is resting // The best station that is wanted, decoded, and working somebody else. It
// may only be re-picked while nothing better is on the air, and that is the // is not a candidate — it cannot answer — but it is the reason the engine
// comparison. // is silent, and the operator is entitled to know that.
topRank := 0 waiting, waitingRank := "", 0
for i := range p.Decodes { for i := range p.Decodes {
c := p.Decodes[i] c := p.Decodes[i]
if !e.eligible(c, p, only) { if ok, why := e.judge(c, p, only); !ok {
continue if why == "busy" {
} if r := rank(c); r > waitingRank {
if r := rank(c); r > topRank { waiting, waitingRank = strings.ToUpper(c.Call), r
topRank = r }
} }
}
for i := range p.Decodes {
c := p.Decodes[i]
if !e.eligible(c, p, only) {
continue continue
} }
r := rank(c) 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) { if best == nil || better(c, r, *best, bestRank, p.MyCall) {
cc := c cc := c
best, bestRank = &cc, r best, bestRank = &cc, r
} }
} }
e.waiting = waiting
if best == nil { if best == nil {
if waiting != "" {
return Action{Kind: DoNothing,
Reason: fmt.Sprintf("waiting for %s — it is working somebody else", waiting)}
}
if why != "" { if why != "" {
return Action{Kind: DoNothing, Reason: why + " — nothing else worth calling"} return Action{Kind: DoNothing, Reason: why + " — nothing else worth calling"}
} }
return Action{} return Action{}
} }
e.waiting = ""
e.target, e.heldSince, e.targetInst = best, p.At, best.Instance e.target, e.heldSince, e.targetInst = best, p.At, best.Instance
e.attempts, e.misses, e.txSlot, e.lastMiss = 0, 0, -1, "" // ITS SLOT IS KNOWN FROM THE DECODE THAT MADE IT A CANDIDATE.
reason := fmt.Sprintf("calling %s (%s%s)", best.Call, watchedTag(*best), best.Need) //
// This used to start at -1, meaning "parity unknown", and with parity
// unknown the miss counter has nothing to filter on: every period without
// the station counted, INCLUDING the one we spend transmitting to it, where
// it cannot be decoded by definition. An operator answering a CQ saw the
// counter at two misses out of three before the station had had a single
// chance to come back — one bad period from being given up on.
e.attempts, e.misses, e.lastMiss = 0, 0, ""
e.txSlot = slotOf(p.At, periodSecs(p, *best))
e.answered = false
e.waiting = ""
reason := fmt.Sprintf("calling %s (%s%s)", best.Call, watchedTag(*best), needLabel(*best))
if why != "" { if why != "" {
reason = why + " — " + reason reason = why + " — " + reason
} }
return Action{Kind: DoReply, Decode: best.Decode, Reason: reason} return Action{Kind: DoReply, Decode: best.Decode, Reason: reason}
} }
// tracePick writes the one line that says what this period held and what it
// was worth. Counts by refusal, then the best few candidates in rank order —
// enough to see WHY a station on the screen was not the one called.
func (e *Engine) tracePick(p Period) {
only := onlyList(e.set.Only)
refused := map[string]int{}
// The callsigns too, not only the counts. "worked=2" answers "how many" and
// leaves "which" — and which is the whole question when an operator is
// looking at one station on the screen and asking why it was passed over.
who := map[string][]string{}
type scored struct {
c Candidate
r int
}
var ok []scored
for i := range p.Decodes {
if good, why := e.judge(p.Decodes[i], p, only); good {
ok = append(ok, scored{p.Decodes[i], rank(p.Decodes[i])})
} else {
refused[why]++
if len(who[why]) < 4 {
who[why] = append(who[why], p.Decodes[i].Call)
}
}
}
sort.Slice(ok, func(i, j int) bool { return ok[i].r > ok[j].r })
best := make([]string, 0, 3)
for i := 0; i < len(ok) && i < 3; i++ {
best = append(best, fmt.Sprintf("%s(%s%s,%d dB,r%d)",
ok[i].c.Call, watchedTag(ok[i].c), needLabel(ok[i].c), ok[i].c.SNR, ok[i].r))
}
why := make([]string, 0, len(refused))
for _, k := range []string{"worked", "nothing-needed", "busy", "resting", "halted", "parked", "hidden", "not-chased", "replay", "self"} {
if n := refused[k]; n > 0 {
names := strings.Join(who[k], ",")
if n > len(who[k]) {
names += ",…"
}
why = append(why, fmt.Sprintf("%s=%d(%s)", k, n, names))
}
}
e.trace("period %s rx=%q decodes=%d callable=%d [%s] refused[%s]%s",
p.Key, p.Instance, len(p.Decodes), len(ok), strings.Join(best, " "),
strings.Join(why, " "),
map[bool]string{true: " (transmitting)", false: ""}[p.TX.Transmitting])
}
func watchedTag(c Candidate) string { func watchedTag(c Candidate) string {
if c.Watched { if c.Watched {
return "watched " return "watched "
@@ -604,30 +934,88 @@ func watchedTag(c Candidate) string {
// eligible is every refusal that applies before a station is even ranked. // eligible is every refusal that applies before a station is even ranked.
func (e *Engine) eligible(c Candidate, p Period, only []string) bool { func (e *Engine) eligible(c Candidate, p Period, only []string) bool {
ok, _ := e.judge(c, p, only)
return ok
}
// judge is eligible() with the reason attached, in one word.
//
// The reason exists for the log. An auto-call that calls nobody is the hardest
// thing to argue with — the band is full, the panel shows a dozen stations
// wanted, and nothing goes out — and "twenty-three decodes, nineteen already
// worked, three greyed, one resting" answers it in a line where a boolean
// cannot.
func (e *Engine) judge(c Candidate, p Period, only []string) (bool, string) {
call := strings.ToUpper(strings.TrimSpace(c.Call)) call := strings.ToUpper(strings.TrimSpace(c.Call))
if call == "" || call == strings.ToUpper(p.MyCall) { if call == "" || call == strings.ToUpper(p.MyCall) {
return false return false, "self"
} }
if !c.IsNew { if !c.IsNew {
return false // replayed history: the station may be hours gone return false, "replay" // the station may be hours gone
} }
if len(only) > 0 { if len(only) > 0 {
// The named stations, and each only until the QSO with it is made: an // 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 // explicit request is not a standing order to work the same station all
// evening. The others on the list stay callable. // evening. The others on the list stay callable.
return inList(only, call) && !e.done[call] && !c.Worked switch {
case !inList(only, call):
return false, "not-chased"
case e.greyed[call]:
return false, "halted"
case e.done[call] || c.Worked:
return false, "worked"
}
return true, ""
} }
if c.Worked || e.done[call] { if c.Worked || e.done[call] {
return false return false, "worked"
}
if e.greyed[call] {
return false, "halted"
}
// A STATION CALLING US is answered, needed or not.
//
// It has heard us, it is waiting, and the QSO is one over away — refusing it
// because the log has nothing to gain is how an operator finishes a contact,
// sees two stations calling them on the next sequence, and watches the
// machine ignore both. Everything below this point is about choosing whom to
// CALL; a caller is not chosen, it is already there.
//
// The refusals above still apply — worked on this band and mode, the QSO
// already made, a station the operator stopped. Those are answers about this
// station, not about the log's appetite.
if callingUs(c, p.MyCall) {
return true, ""
}
// "LoTW users only" is a rule about STRANGERS. It cannot overrule the watch
// list: naming a callsign is the operator saying they want that station, and
// a DXpedition that uploads nowhere is exactly the kind of station one puts
// on the list. Reported from the air — a watched J38DX, plainly calling CQ,
// never called all evening because it is not in the LoTW user file.
// FILTERED OFF THE SCREEN. The operator's own filters, applied to the
// transmitter: what is not shown is not called.
if e.set.OnScreenOnly && c.Hidden {
return false, "hidden"
} }
if rank(c) == 0 { if rank(c) == 0 {
return false return false, "nothing-needed"
} }
if e.rounds[call] >= e.set.MaxRounds { if e.rounds[call] >= e.set.MaxRounds {
return false // parked for the session return false, "parked" // its series are spent for the session
}
// RESTING. A series that ended in a brake is followed by a real pause,
// whatever else is on the air — the exception used to be "unless nothing
// better is decoding", and for the station everybody is chasing that is
// every period: seven calls, a halt, and the same station called again four
// seconds later. From the outside that is not a rest, it is a stutter.
if until, ok := e.rested[call]; ok && p.At.Before(until) {
return false, "resting"
} }
// Mid-exchange with somebody else: it cannot answer us — see callable. // Mid-exchange with somebody else: it cannot answer us — see callable.
return callable(c, p.MyCall) if !callable(c, p.MyCall) {
return false, "busy"
}
return true, ""
} }
// better orders two eligible stations: the need first, then the one already // better orders two eligible stations: the need first, then the one already
@@ -645,11 +1033,78 @@ func better(a Candidate, ra int, b Candidate, rb int, myCall string) bool {
return a.SNR > b.SNR return a.SNR > b.SNR
} }
// preempt hands the slot to a better station, if one is on the air.
func (e *Engine) preempt(p Period) (Action, bool) {
if e.target == nil || p.TX.Transmitting {
// Mid-over: the reply would switch the decoder's call in the middle of
// a transmission. It waits for the gap, like every other call.
return Action{}, false
}
held := rank(*e.target)
heldSeen := bestOf(p.Decodes, strings.ToUpper(e.target.Call)) != nil
only := onlyList(e.set.Only)
best, bestRank := (*Candidate)(nil), held
for i := range p.Decodes {
c := p.Decodes[i]
if strings.EqualFold(c.Call, e.target.Call) || !e.eligible(c, p, only) {
continue
}
// Strictly better takes over. So does an EQUAL rung when the station we
// are calling is not even on the air this period: the seven calls are
// going into the void while a station of the same value is calling CQ
// three rows above it, which is what an operator sees and cannot explain.
r := rank(c)
switch {
case r > bestRank:
case r == bestRank && !heldSeen && best == nil:
case best != nil && r == bestRank && better(c, r, *best, bestRank, p.MyCall):
default:
continue
}
{
cc := c
best, bestRank = &cc, r
}
}
if best == nil {
return Action{}, false
}
was := strings.ToUpper(e.target.Call)
e.drop()
e.target, e.heldSince, e.targetInst = best, p.At, best.Instance
return Action{Kind: DoReply, Decode: best.Decode,
Reason: fmt.Sprintf("%s (%s%s) takes over from %s — nothing had been answered yet",
best.Call, watchedTag(*best), needLabel(*best), was)}, true
}
// drop lets the target go with no verdict attached: not worked, not given up
// on, not rested. It is simply not the station to be calling right now, and it
// is picked again like any other the moment it is.
func (e *Engine) drop() {
e.target, e.attempts, e.misses, e.txSlot, e.lastMiss = nil, 0, 0, -1, ""
e.targetInst, e.answered = "", false
}
// addressee is who a message is being sent to — the first token, which is the
// callsign being answered — the first segment of a multi-answer line, which is
// the QSO it is finishing.
func addressee(msg string) string {
parts := messages(msg)
if len(parts) == 0 {
return "someone else"
}
toks := strings.Fields(parts[0])
if len(toks) == 0 {
return "someone else"
}
return bare(toks[0])
}
// release clears the target. gaveUp marks it as a series that ended in a brake // release clears the target. gaveUp marks it as a series that ended in a brake
// rather than in a QSO. // rather than in a QSO.
func (e *Engine) release(call string, gaveUp bool) { func (e *Engine) release(call string, gaveUp bool) {
e.target, e.attempts, e.misses, e.txSlot, e.lastMiss = nil, 0, 0, -1, "" e.target, e.attempts, e.misses, e.txSlot, e.lastMiss = nil, 0, 0, -1, ""
e.targetInst = "" e.targetInst, e.answered = "", false
if !gaveUp { if !gaveUp {
// A finished QSO is not a failed series — the callsign keeps its rounds — // 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, // but it IS finished: nothing more is wanted from this station today,
@@ -662,7 +1117,11 @@ func (e *Engine) release(call string, gaveUp bool) {
func (e *Engine) giveUp(call string, t Candidate) { func (e *Engine) giveUp(call string, t Candidate) {
e.release(call, true) e.release(call, true)
e.rounds[call]++ e.rounds[call]++
e.rested[call] = time.Now().Add(e.set.Rest) at := e.now
if at.IsZero() {
at = time.Now()
}
e.rested[call] = at.Add(e.set.Rest)
// An explicit "call this station" that runs out of attempts stops the // 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. // feature instead of moving on. There is nothing else it was asked to do.
if strings.TrimSpace(e.set.Only) != "" { if strings.TrimSpace(e.set.Only) != "" {
@@ -693,19 +1152,32 @@ func (e *Engine) NoteTX(tx TXState) Action {
return Action{} return Action{}
} }
t := *e.target t := *e.target
tc := strings.ToUpper(t.Call) tc := bare(t.Call)
msg := strings.ToUpper(strings.TrimSpace(tx.Msg)) msg := strings.ToUpper(strings.TrimSpace(tx.Msg))
switch { switch {
case msg != "": case msg != "":
toks := strings.Fields(msg) toks := strings.Fields(msg)
if len(toks) < 3 || toks[0] != tc || !isGrid(toks[2]) { // bare(): a decoder compresses a non-standard callsign into angle
// brackets — "<HP/WE9G> F4BPO JN36" is a call to HP/WE9G — and comparing
// the raw token left the counter at 0/15 while the operator watched call
// after call go out. Every brake downstream of it was dead too.
if len(toks) < 3 || bare(toks[0]) != tc {
return Action{}
}
if !isGrid(toks[2]) {
// A report, an R-report, RR73: we are INSIDE the exchange, not
// opening it. It does not count as a call — and it settles that
// nothing may take this station's place, which matters when the
// decoder is answering somebody the engine never picked (the
// operator double-clicked a row) and no reply has been decoded yet.
e.answered = true
return Action{} return Action{}
} }
default: default:
// JTDX reports no transmit message. The DX call is all there is, so // JTDX reports no transmit message. The DX call is all there is, so
// every transmit period aimed at the target counts — more generous, and // every transmit period aimed at the target counts — more generous, and
// far better than a counter frozen at zero for ever. // far better than a counter frozen at zero for ever.
if strings.ToUpper(strings.TrimSpace(tx.DXCall)) != tc { if bare(tx.DXCall) != tc {
return Action{} return Action{}
} }
} }
@@ -715,7 +1187,8 @@ func (e *Engine) NoteTX(tx TXState) Action {
} }
max := e.maxAttempts(t) max := e.maxAttempts(t)
e.giveUp(tc, t) e.giveUp(tc, t)
return Action{Kind: DoHalt, Reason: fmt.Sprintf("%s called %d times without an answer", tc, max)} return Action{Kind: DoHalt, Soft: true,
Reason: fmt.Sprintf("%s called %d times without an answer — stopping after this over", tc, max)}
} }
// bestOf returns the most callable decode of one station in a period. // bestOf returns the most callable decode of one station in a period.
+479 -17
View File
@@ -2,6 +2,7 @@ package autocall
import ( import (
"fmt" "fmt"
"strings"
"testing" "testing"
"time" "time"
) )
@@ -56,13 +57,17 @@ func on() *Engine { return New(Settings{Enabled: true}) }
// ── The ladder ──────────────────────────────────────────────────────────── // ── The ladder ────────────────────────────────────────────────────────────
func TestLadderOrder(t *testing.T) { func TestLadderOrder(t *testing.T) {
// Every rung, in the order the operator asked for. // Every rung. The watched ones come FIRST, ordered among themselves by what
// is needed — the list is the operator's answer, not a tie-breaker.
order := []Candidate{ order := []Candidate{
cq("A", NeedDXCC, 0, watched), cq("B", NeedDXCC, 0), cq("A", NeedDXCC, 0, watched), cq("C", NeedBand, 0, watched),
cq("C", NeedBand, 0, watched), cq("D", NeedBand, 0), cq("E", NeedMode, 0, watched), cq("G", NeedSlot, 0, watched),
cq("E", NeedMode, 0, watched), cq("F", NeedMode, 0),
cq("G", NeedSlot, 0, watched), cq("H", NeedSlot, 0),
cq("I", NeedNone, 0, watched), cq("I", NeedNone, 0, watched),
cq("B", NeedDXCC, 0), cq("D", NeedBand, 0),
cq("F", NeedMode, 0), cq("H", NeedSlot, 0),
// The orthogonal markers sit at the foot of the ladder: worth a call
// when nothing better is on the air, never worth leaving a band for.
cq("J", NeedExtra, 0),
} }
for i := 1; i < len(order); i++ { for i := 1; i < len(order); i++ {
if rank(order[i-1]) <= rank(order[i]) { if rank(order[i-1]) <= rank(order[i]) {
@@ -76,10 +81,17 @@ func TestLadderOrder(t *testing.T) {
} }
// And the pick agrees with the ladder, whatever order the period lists them. // And the pick agrees with the ladder, whatever order the period lists them.
e := on() e := on()
a := e.OnPeriod(period(0, order[7], order[3], order[0], order[5])) a := e.OnPeriod(period(0, order[8], order[5], order[0], order[6]))
if a.Kind != DoReply || a.Decode.Call != "A" { if a.Kind != DoReply || a.Decode.Call != "A" {
t.Fatalf("picked %+v, want the watched new entity", a) t.Fatalf("picked %+v, want the watched new entity", a)
} }
// A watched station with NOTHING needed still beats a new entity that is not
// watched — the case that sent an operator hunting: a watched DXpedition sat
// on the band all evening while the engine worked what the log wanted.
e = on()
if a := e.OnPeriod(period(2, cq("RARE", NeedDXCC, 0), cq("WATCHED", NeedNone, -20, watched))); a.Decode.Call != "WATCHED" {
t.Errorf("picked %q, want the watched callsign", a.Decode.Call)
}
} }
func TestStrongestWinsBetweenEquals(t *testing.T) { func TestStrongestWinsBetweenEquals(t *testing.T) {
@@ -232,24 +244,52 @@ func TestAReleasedStationYieldsToAnythingBetter(t *testing.T) {
} }
} }
func TestAReleasedStationIsCalledAgainWhenNothingBetterIsOnTheAir(t *testing.T) { func TestAReleasedStationRestsBeforeItIsCalledAgain(t *testing.T) {
e := on() e := on()
e.OnPeriod(period(0, cq("DX", NeedBand, -5))) e.OnPeriod(period(0, cq("DX", NeedBand, -5)))
tx := TXState{Transmitting: true, Msg: "DX " + me + " JN36"} tx := TXState{Transmitting: true, Msg: "DX " + me + " JN36"}
for i := 0; i < 7; i++ { for i := 0; i < 7; i++ {
e.NoteTX(tx) e.NoteTX(tx)
} }
a := e.OnPeriod(period(2, cq("DX", NeedBand, -5))) // Still the only thing on the air, and still resting: seven calls, a halt,
if a.Kind != DoReply || a.Decode.Call != "DX" { // and the same station called again four seconds later is not a rest.
t.Errorf("nothing better on the air and the station was not called again: %+v", a) for _, n := range []int{2, 4, 6} { // all inside the two minutes
if a := e.OnPeriod(period(n, cq("DX", NeedBand, -5))); a.Kind != DoNothing {
t.Fatalf("period %d: called again during the rest: %+v", n, a)
}
}
// Two minutes later (period 8 × 15 s = 2 min past the give-up) it may go
// again — the station is still there and nothing better is.
if a := e.OnPeriod(period(9, cq("DX", NeedBand, -5))); a.Kind != DoReply {
t.Errorf("after the rest: %+v, want the station called again", a)
}
}
func TestTheAttemptsCapLetsTheOverFinish(t *testing.T) {
e := on()
e.OnPeriod(period(0, cq("DX", NeedDXCC, -5)))
tx := TXState{Transmitting: true, Msg: "DX " + me + " JN36"}
var a Action
for i := 0; i < 7; i++ {
a = e.NoteTX(tx)
}
if a.Kind != DoHalt {
t.Fatalf("no halt after seven calls: %+v", a)
}
// The cap trips WHILE the seventh call is going out — cutting the carrier
// there sends half a call. It asks the decoder to finish the over first.
if !a.Soft {
t.Error("the attempts cap cut the transmission that counted it")
} }
} }
func TestAStationIsParkedAfterItsRounds(t *testing.T) { func TestAStationIsParkedAfterItsRounds(t *testing.T) {
e := on() e := on()
tx := TXState{Transmitting: true, Msg: "DX " + me + " JN36"} tx := TXState{Transmitting: true, Msg: "DX " + me + " JN36"}
// Rounds are spaced past the rest (2 min = 8 periods) or the station would
// simply be resting rather than parked.
for round := 1; round <= 3; round++ { for round := 1; round <= 3; round++ {
a := e.OnPeriod(period(round*2, cq("DX", NeedBand, -5))) a := e.OnPeriod(period(round*10, cq("DX", NeedBand, -5)))
if a.Kind != DoReply { if a.Kind != DoReply {
t.Fatalf("round %d: not called (%+v)", round, a) t.Fatalf("round %d: not called (%+v)", round, a)
} }
@@ -259,7 +299,7 @@ func TestAStationIsParkedAfterItsRounds(t *testing.T) {
} }
// Three series of seven is twenty-one calls. That is the end of it for this // 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. // 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 { if a := e.OnPeriod(period(60, cq("DX", NeedBand, -5))); a.Kind != DoNothing {
t.Errorf("a fourth series was started: %+v", a) t.Errorf("a fourth series was started: %+v", a)
} }
} }
@@ -271,9 +311,13 @@ func TestFinishedQSOMovesToTheNextPriority(t *testing.T) {
e.OnPeriod(period(0, cq("DX", NeedDXCC, -5))) e.OnPeriod(period(0, cq("DX", NeedDXCC, -5)))
done := callsMe("DX", NeedDXCC, -5) done := callsMe("DX", NeedDXCC, -5)
done.Msg = me + " DX RR73" done.Msg = me + " DX RR73"
a := e.OnPeriod(period(2, done, cq("NEXT", NeedBand, -10))) // The period the QSO ends in belongs to our own 73 — nothing else is called.
if a := e.OnPeriod(period(2, done, cq("NEXT", NeedBand, -10))); a.Kind != DoNothing {
t.Fatalf("took the slot our 73 goes out in: %+v", a)
}
a := e.OnPeriod(period(4, cq("NEXT", NeedBand, -10)))
if a.Kind != DoReply || a.Decode.Call != "NEXT" { if a.Kind != DoReply || a.Decode.Call != "NEXT" {
t.Errorf("after the QSO ended: %+v, want the next priority in the same period", a) t.Errorf("next period: %+v, want the next priority", a)
} }
} }
@@ -282,12 +326,14 @@ func TestFinishedQSOWithNoPriorityAnswersWhoeverIsCallingUs(t *testing.T) {
e.OnPeriod(period(0, cq("DX", NeedDXCC, -5))) e.OnPeriod(period(0, cq("DX", NeedDXCC, -5)))
done := callsMe("DX", NeedDXCC, -5) done := callsMe("DX", NeedDXCC, -5)
done.Msg = me + " DX RR73" done.Msg = me + " DX RR73"
// Two stations calling us, nothing needed from either: the strongest wins. // Two stations calling us, nothing needed from either: the strongest wins
// on the period after the one our 73 goes out in.
weak := callsMe("WEAK", NeedNone, -18) weak := callsMe("WEAK", NeedNone, -18)
weak.Watched = true weak.Watched = true
loud := callsMe("LOUD", NeedNone, -4) loud := callsMe("LOUD", NeedNone, -4)
loud.Watched = true loud.Watched = true
a := e.OnPeriod(period(2, done, weak, loud)) e.OnPeriod(period(2, done))
a := e.OnPeriod(period(4, weak, loud))
if a.Kind != DoReply || a.Decode.Call != "LOUD" { if a.Kind != DoReply || a.Decode.Call != "LOUD" {
t.Errorf("answered %+v, want the strongest of the stations calling us", a) t.Errorf("answered %+v, want the strongest of the stations calling us", a)
} }
@@ -403,7 +449,8 @@ func TestChaseListTakesSeveralCallsigns(t *testing.T) {
// single request. // single request.
done := callsMe("VP6D", NeedDXCC, -22) done := callsMe("VP6D", NeedDXCC, -22)
done.Msg = me + " VP6D RR73" done.Msg = me + " VP6D RR73"
a = e.OnPeriod(period(4, done, cq("3Y0J", NeedSlot, -1))) e.OnPeriod(period(4, done, cq("3Y0J", NeedSlot, -1)))
a = e.OnPeriod(period(6, cq("3Y0J", NeedSlot, -1)))
if a.Kind != DoReply || a.Decode.Call != "3Y0J" { if a.Kind != DoReply || a.Decode.Call != "3Y0J" {
t.Errorf("after working VP6D: %+v, want the other station on the list", a) t.Errorf("after working VP6D: %+v, want the other station on the list", a)
} }
@@ -460,3 +507,418 @@ func TestTheOtherReceiverCannotBreakTheQSOInProgress(t *testing.T) {
t.Errorf("after the QSO ended, the other receiver was still locked out: %+v", a) t.Errorf("after the QSO ended, the other receiver was still locked out: %+v", a)
} }
} }
// The one reported from the air: his RRR arrives, we are sending our 73, and
// the engine picked the next station in the same instant. WSJT-X acts on a
// reply at once — it dropped the exchange and started calling the new station,
// cutting the 73 a second in.
func TestNothingIsCalledOverOurOwnTransmission(t *testing.T) {
e := on()
e.OnPeriod(period(0, cq("DX", NeedDXCC, -5)))
done := callsMe("DX", NeedDXCC, -5)
done.Msg = me + " DX RRR"
p := period(2, done, cq("NEXT", NeedBand, -10))
p.TX = TXState{Transmitting: true, Msg: "DX " + me + " 73"}
if a := e.OnPeriod(p); a.Kind != DoNothing {
t.Fatalf("called %+v while our own over was still going out", a)
}
// The QSO is still released — only the next call waits.
if e.Target() != "" {
t.Errorf("target = %q after the QSO finished, want none", e.Target())
}
// Next period, carrier down: now it may take the next station.
if a := e.OnPeriod(period(4, cq("NEXT", NeedBand, -10))); a.Kind != DoReply || a.Decode.Call != "NEXT" {
t.Errorf("once the transmission ended: %+v, want the next station called", a)
}
}
func TestTheHoldClockRestartsWhenTheStationAnswers(t *testing.T) {
e := on()
e.OnPeriod(period(0, cq("DX", NeedDXCC, -5)))
// Called for a long time — nearly the backstop — and then he answers.
for p := 2; p <= 14; p += 2 {
e.OnPeriod(period(p, cq("DX", NeedDXCC, -5)))
}
e.OnPeriod(period(16, callsMe("DX", NeedDXCC, -5)))
// The exchange must not be halted by a backstop that was counting the wait.
for _, p := range []int{18, 20} {
if a := e.OnPeriod(period(p, callsMe("DX", NeedDXCC, -5))); a.Kind == DoHalt {
t.Fatalf("period %d: halted an exchange in progress (%s)", p, a.Reason)
}
}
}
func TestARealNeedOutranksAnUnconfirmedOne(t *testing.T) {
real := cq("REAL", NeedBand, -20)
unconf := cq("UNCONF", NeedBand, -2)
unconf.Unconfirmed = true
// Same need, and the unconfirmed one is 18 dB louder. The band never worked
// is still the catch: the other is a QSL to chase, not a QSO to make.
e := on()
if a := e.OnPeriod(period(0, unconf, real)); a.Decode.Call != "REAL" {
t.Errorf("picked %q, want the band never worked", a.Decode.Call)
}
// Watched changes that, and is meant to: the list is the operator's answer.
unconf.Watched = true
e = on()
if a := e.OnPeriod(period(0, unconf, real)); a.Decode.Call != "UNCONF" {
t.Errorf("picked %q, want the watched station", a.Decode.Call)
}
unconf.Watched = false
// Between two unwatched stations, an unconfirmed BAND still beats a real
// SLOT: the band is the bigger prize whatever state it is in.
slot := cq("SLOT", NeedSlot, 0)
e = on()
if a := e.OnPeriod(period(0, slot, unconf)); a.Decode.Call != "UNCONF" {
t.Errorf("picked %q, want the unconfirmed band over a real slot", a.Decode.Call)
}
}
// ── The operator's Halt ───────────────────────────────────────────────────
func TestHaltSetsTheStationAsideForTheSession(t *testing.T) {
e := on()
if a := e.OnPeriod(period(0, cq("DX", NeedDXCC, -5))); a.Decode.Call != "DX" {
t.Fatalf("not called: %+v", a)
}
// The operator stops it mid-call.
if got := e.Halt(); got != "DX" {
t.Fatalf("Halt returned %q, want the station being called", got)
}
if e.Target() != "" {
t.Error("the target survived a halt")
}
// Still the loudest new entity on the air, period after period. It is the
// operator's verdict, so it is not called again — not next period, not in
// ten minutes.
for _, n := range []int{2, 4, 40} {
if a := e.OnPeriod(period(n, cq("DX", NeedDXCC, -5))); a.Kind != DoNothing {
t.Errorf("period %d: called a station the operator had stopped: %+v", n, a)
}
}
// Naming it explicitly does not override the operator either.
e.SetSettings(Settings{Enabled: true, Only: "DX"})
if a := e.OnPeriod(period(42, cq("DX", NeedDXCC, -5))); a.Kind != DoNothing {
t.Errorf("the chase list overrode a halt: %+v", a)
}
// Switching auto-call off and on is what starts over.
e.Reset()
e.SetSettings(Settings{Enabled: true})
if a := e.OnPeriod(period(44, cq("DX", NeedDXCC, -5))); a.Kind != DoReply {
t.Errorf("after a restart the station is fair game again: %+v", a)
}
}
func TestHaltWithNothingBeingCalledIsJustAHalt(t *testing.T) {
e := on()
if got := e.Halt(); got != "" {
t.Errorf("Halt returned %q with no target", got)
}
if e.Greylisted() != 0 {
t.Errorf("greylisted %d stations from an idle halt", e.Greylisted())
}
}
func TestTraceSaysWhyNobodyWasCalled(t *testing.T) {
var lines []string
e := on()
e.SetTrace(func(f string, args ...any) { lines = append(lines, fmt.Sprintf(f, args...)) })
worked := cq("A", NeedDXCC, -5, worked)
nothing := cq("B", NeedNone, -5)
busyOne := busy("C", NeedBand, -5)
e.Halt() // nothing held: no-op, keeps the set empty
e.OnPeriod(period(0, worked, nothing, busyOne))
if len(lines) == 0 {
t.Fatal("tracing on and nothing was written")
}
got := lines[len(lines)-1]
for _, want := range []string{"decodes=3", "callable=0", "worked=1", "nothing-needed=1", "busy=1"} {
if !strings.Contains(got, want) {
t.Errorf("trace %q does not say %q", got, want)
}
}
// And when it DOES call, the line names the candidates it ranked.
lines = nil
e.OnPeriod(period(2, cq("DX", NeedBand, -9, watched)))
if len(lines) == 0 || !strings.Contains(lines[0], "DX(watched band,-9 dB,r118)") {
t.Errorf("trace %v does not describe the station it called", lines)
}
}
func TestAStationCallingUsIsAnsweredEvenWithNothingToGain(t *testing.T) {
e := on()
// A QSO has just ended and two stations are calling us. Neither is worth
// anything to the log — and both are worth answering: they have heard us,
// they are waiting, and the contact is one over away.
weak := callsMe("WEAK", NeedNone, -18)
loud := callsMe("LOUD", NeedNone, -3)
a := e.OnPeriod(period(0, weak, loud))
if a.Kind != DoReply || a.Decode.Call != "LOUD" {
t.Fatalf("answered %+v, want the strongest of the stations calling us", a)
}
// A caller already worked on this band and mode is a duplicate, not a QSO.
e = on()
done := callsMe("DUPE", NeedNone, -1)
done.Worked = true
if a := e.OnPeriod(period(2, done)); a.Kind != DoNothing {
t.Errorf("answered a station already in the log on this band and mode: %+v", a)
}
// And one the operator halted stays halted, however politely it calls.
e = on()
e.OnPeriod(period(4, cq("STOP", NeedDXCC, -5)))
e.Halt()
if a := e.OnPeriod(period(6, callsMe("STOP", NeedDXCC, -5))); a.Kind != DoNothing {
t.Errorf("answered a station the operator had stopped: %+v", a)
}
// A needed station still comes first: a caller with nothing to gain is
// answered when nothing better is on the air, which is what was asked for.
e = on()
if a := e.OnPeriod(period(8, callsMe("CALLER", NeedNone, 0), cq("RARE", NeedDXCC, -20))); a.Decode.Call != "RARE" {
t.Errorf("picked %q, want the new entity ahead of a caller with nothing needed", a.Decode.Call)
}
}
// TestAStationWorkingSomebodyElseIsNotCHOSEN — the test above is about a target
// already being called; this is about picking one. A reply to a decode in
// mid-exchange is a reply WSJT-X and JTDX may refuse outright, so it never
// starts a call there.
func TestAStationWorkingSomebodyElseIsNotCHOSEN(t *testing.T) {
e := on()
busyNow := busy("ON7GB", NeedSlot, +5)
busyNow.Msg = "PY2SAD ON7GB JO21"
if a := e.OnPeriod(period(0, busyNow)); a.Kind == DoReply {
t.Fatalf("called %+v — it is in a QSO with PY2SAD", a)
}
if e.Target() != "" {
t.Errorf("target is %q", e.Target())
}
// Its final frame is different: one period from free is the best moment
// there is to be calling it.
last := busy("ON7GB", NeedSlot, +5)
last.Msg = "PY2SAD ON7GB RR73"
if a := e.OnPeriod(period(2, last)); a.Kind != DoReply {
t.Errorf("%+v — a station on its last frame was not called", a)
}
}
func TestAFinalFrameToSomebodyElseIsNotABusyTarget(t *testing.T) {
e := on()
e.OnPeriod(period(0, cq("DX", NeedSlot, 0)))
// Sending RR73 to another station: one period from being free, and the best
// moment there is to be calling it.
wrap := busy("DX", NeedSlot, 0)
wrap.Msg = "PY2SAD DX RR73"
if a := e.OnPeriod(period(2, wrap)); a.Kind != DoNothing || e.Target() != "DX" {
t.Errorf("dropped a station that was finishing: %+v (target %q)", a, e.Target())
}
}
func TestNothingHiddenByThePanelIsCalled(t *testing.T) {
e := New(Settings{Enabled: true, OnScreenOnly: true})
// The operator has filtered the list down to CQs: what is not on the screen
// is not called, whatever the log makes of it.
hidden := cq("RARE", NeedDXCC, 0)
hidden.Hidden = true
shown := cq("PLAIN", NeedSlot, -20)
if a := e.OnPeriod(period(0, hidden, shown)); a.Decode.Call != "PLAIN" {
t.Errorf("picked %q, want the station the panel is showing", a.Decode.Call)
}
// With the panel publishing nothing, nothing is hidden and the ladder
// decides alone.
e = New(Settings{Enabled: true, OnScreenOnly: true})
free := cq("RARE", NeedDXCC, 0)
if a := e.OnPeriod(period(2, free, shown)); a.Decode.Call != "RARE" {
t.Errorf("picked %q with no filtering in force, want the new entity", a.Decode.Call)
}
}
// MSHV answers two stations in one transmission and the decoder prints them as
// one line. Reported from the air: the DX was answering us in the second half
// and the engine, reading only the first, dropped it as busy.
func TestAMultiAnswerLineIsReadWhole(t *testing.T) {
fox := cq("HK0/PY8WW", NeedDXCC, -17, watched)
fox.CQ = false
fox.Msg = "YV5ALI RR73; " + me + " <HK0/PY8WW> -08"
if !callingUs(fox, me) {
t.Error("the second half is a report to us and was not read as one")
}
if !callable(fox, me) {
t.Error("a station answering us was judged uncallable")
}
// Held as the target, that line must not release it — it is the answer we
// were waiting for, not a station working somebody else.
e := on()
e.OnPeriod(period(0, cq("HK0/PY8WW", NeedDXCC, -17, watched)))
if a := e.OnPeriod(period(2, fox)); a.Kind != DoNothing || e.Target() != "HK0/PY8WW" {
t.Errorf("dropped the DX as it answered us: %+v (target %q)", a, e.Target())
}
// The exchange is under way, so the attempt counter has done its job.
if e.Status().Attempts != 0 {
t.Errorf("attempts = %d while in QSO, want 0", e.Status().Attempts)
}
// And its final frame to us, in the same shape, ends the QSO.
done := fox
done.Msg = "IK2ABC RR73; " + me + " <HK0/PY8WW> RR73"
if !finished([]Candidate{done}, "HK0/PY8WW", me) {
t.Error("a 73 to us inside a multi-answer line did not end the QSO")
}
}
// Reported from the air twice: the DX sends RR73, our own 73 is about to go
// out, and the engine answered somebody else in that very slot — two
// transmissions in one period, and the station at the other end never got the
// frame that closes the contact.
func TestTheSlotAfterAQSOBelongsToOur73(t *testing.T) {
e := on()
e.OnPeriod(period(0, cq("HP/WE9G", NeedDXCC, -6)))
bye := callsMe("HP/WE9G", NeedDXCC, -6)
bye.Msg = "<" + me + "> HP/WE9G RR73"
// A new band is decoding in the same period and is not called.
a := e.OnPeriod(period(2, bye, cq("GW8DX", NeedBand, +2)))
if a.Kind != DoNothing {
t.Fatalf("called %+v in the slot our 73 goes out in", a)
}
if !strings.Contains(a.Reason, "73") {
t.Errorf("reason %q does not say why the slot was left alone", a.Reason)
}
// It is still there fifteen seconds later, which is the whole cost.
if a := e.OnPeriod(period(4, cq("GW8DX", NeedBand, +2))); a.Kind != DoReply || a.Decode.Call != "GW8DX" {
t.Errorf("next period: %+v, want the new band called", a)
}
}
func TestABetterStationTakesOverBeforeAnybodyHasAnswered(t *testing.T) {
e := on()
e.OnPeriod(period(0, cq("PLAIN", NeedSlot, +10)))
// A watched station comes on the air: nothing has answered us yet, so the
// call in progress is worth less than the one now possible.
a := e.OnPeriod(period(2, cq("PLAIN", NeedSlot, +10), cq("WATCHED", NeedNone, -15, watched)))
if a.Kind != DoReply || a.Decode.Call != "WATCHED" {
t.Fatalf("%+v — a better station did not take over", a)
}
// Equal rungs do NOT take over while the station being called is on the air.
e = on()
e.OnPeriod(period(4, cq("A", NeedBand, 0)))
if a := e.OnPeriod(period(6, cq("A", NeedBand, 0), cq("B", NeedBand, +20))); a.Kind != DoNothing {
t.Errorf("%+v — swapped between two stations of equal value", a)
}
// But they do when it is absent: seven calls into the void while a station
// of the same value is calling CQ is what the operator was watching.
if a := e.OnPeriod(period(8, cq("B", NeedBand, +20))); a.Kind != DoReply || a.Decode.Call != "B" {
t.Errorf("%+v — kept calling a station that was not on the air", a)
}
// Once the station has answered, nothing takes its place.
e = on()
e.OnPeriod(period(10, cq("DX", NeedSlot, 0)))
e.OnPeriod(period(12, callsMe("DX", NeedSlot, 0)))
if a := e.OnPeriod(period(14, callsMe("DX", NeedSlot, 0), cq("RARE", NeedDXCC, 0, watched))); a.Kind != DoNothing {
t.Errorf("%+v — abandoned an exchange in progress", a)
}
}
// TestAnExchangeInProgressIsNeverAbandoned is the shack report: mid-QSO with
// V31MA — its report decoded, our RR73 going out — and a station of a higher
// rung called us from the other side of the screen. The engine switched.
func TestAnExchangeInProgressIsNeverAbandoned(t *testing.T) {
e := on()
// V31MA is worth nothing on the ladder: worked before, nothing needed.
if a := e.OnPeriod(period(0, callsMe("V31MA", NeedNone, -12))); a.Kind != DoReply {
t.Fatalf("%+v — a station calling us was not answered", a)
}
// Its report arrives in the same period a better station calls us. That
// period used to be judged before the reply was taken into account.
a := e.OnPeriod(period(2,
callsMe("V31MA", NeedNone, -12),
callsMe("F5NNN", NeedSlot, -10)))
if a.Kind != DoNothing {
t.Fatalf("%+v — left V31MA mid-exchange", a)
}
if e.target == nil || e.target.Call != "V31MA" {
t.Fatalf("target is %v — the QSO in progress lost its place", e.target)
}
// Our own transmission settles it too: a report is not an opening call, so
// even a QSO the operator started by hand is protected.
e = on()
e.OnPeriod(period(4, cq("V31MA", NeedNone, -12, watched)))
e.NoteTX(TXState{Transmitting: true, Msg: "V31MA F4BPO RR73"})
if !e.answered {
t.Error("sending a report did not count as being inside the exchange")
}
if a := e.OnPeriod(period(6, cq("V31MA", NeedNone, -12, watched), callsMe("F5NNN", NeedDXCC, -10))); a.Kind != DoNothing {
t.Errorf("%+v — abandoned a QSO we were in the middle of", a)
}
if e.attempts != 0 {
t.Errorf("attempts=%d — an exchange frame was counted as a call", e.attempts)
}
}
// TestAPileupIsWorkedByCallingThroughIt is the shack report: D44TWO finishing a
// contact, the call started, the DX coming back to somebody else — and the
// engine giving up on a station that was one period from being free.
func TestAPileupIsWorkedByCallingThroughIt(t *testing.T) {
e := on()
if a := e.OnPeriod(period(0, cq("D44TWO", NeedDXCC, -7))); a.Kind != DoReply {
t.Fatalf("%+v", a)
}
// It answers three other callers in a row — which is what a DX with a queue
// does, and calling through it is how the queue is joined.
does := func(n int) Action { return e.OnPeriod(period(n, busy("D44TWO", NeedDXCC, -7))) }
for _, n := range []int{2, 4, 6} {
if a := does(n); a.Kind != DoNothing {
t.Fatalf("%+v — stopped calling a station working the pileup", a)
}
if e.Target() != "D44TWO" {
t.Fatalf("target is %q — the station was released while it worked others", e.Target())
}
}
// And when it comes to us, the exchange starts as usual.
if a := e.OnPeriod(period(8, callsMe("D44TWO", NeedDXCC, -7))); a.Kind != DoNothing {
t.Errorf("%+v", a)
}
if !e.answered {
t.Error("the reply was not taken as the start of the exchange")
}
// A BETTER station may still take the slot while it is busy: nothing has
// been answered, so nothing is lost by moving.
e = on()
e.OnPeriod(period(10, cq("D44TWO", NeedSlot, -7)))
if a := e.OnPeriod(period(12, busy("D44TWO", NeedSlot, -7), cq("RARE", NeedDXCC, -20, watched))); a.Kind != DoReply || a.Decode.Call != "RARE" {
t.Errorf("%+v — a watched new one did not take the slot from a busy station", a)
}
}
// TestTheFirstCallStartsWithNoMisses is the shack report: a CQ answered for the
// first time, and the toolbar already reading two misses out of three.
func TestTheFirstCallStartsWithNoMisses(t *testing.T) {
e := on()
// It calls CQ on an odd slot; we answer.
if a := e.OnPeriod(period(1, cq("ER1CW", NeedBand, -2))); a.Kind != DoReply {
t.Fatalf("%+v", a)
}
// The next period is OURS: we are transmitting to it, and of course it is
// not decoded. That is not a miss.
pp := period(2)
pp.TX = TXState{Transmitting: true}
e.OnPeriod(pp)
if e.misses != 0 {
t.Fatalf("misses=%d after our own transmit period", e.misses)
}
// Nor is a period of the wrong parity with nothing in it.
e.OnPeriod(period(4))
if e.misses != 0 {
t.Fatalf("misses=%d — counted a period the station never transmits in", e.misses)
}
// ITS period, and it is not there: that is a miss.
e.OnPeriod(period(3))
if e.misses != 1 {
t.Fatalf("misses=%d — the station's own silent period was not counted", e.misses)
}
}
+55 -2
View File
@@ -395,6 +395,47 @@ func titleCase(s string) string {
// lives, and keeps the ones that say WHO they are — name, address, QSL route. // lives, and keeps the ones that say WHO they are — name, address, QSL route.
// A portable operator's cards still go to the home address, so that address is // A portable operator's cards still go to the home address, so that address is
// not wrong; their county is. // not wrong; their county is.
// sameEntityName reports whether two country names denote the same entity.
//
// The two come from different vocabularies — the callbook writes "Germany"
// where cty.dat writes "Fed. Rep. of Germany", "United States" where the ADIF
// list says "United States of America" — so they are compared on their
// significant words with the boilerplate of officialdom removed, and one
// containing the other counts as a match.
//
// Deliberately generous. Getting it wrong in the strict direction discards a
// correct grid, which is the fault this exists to fix; getting it wrong in the
// generous direction keeps a location from a neighbouring entity, which is the
// state of every callbook record that has no page for the portable call anyway.
func sameEntityName(a, b string) bool {
na, nb := entityKey(a), entityKey(b)
if na == "" || nb == "" {
return false
}
return na == nb || strings.Contains(na, nb) || strings.Contains(nb, na)
}
var entityNoise = map[string]bool{
"THE": true, "OF": true, "FED": true, "REP": true, "REPUBLIC": true,
"FEDERAL": true, "FEDERATION": true, "DEM": true, "DEMOCRATIC": true,
"STATE": true, "KINGDOM": true, "AMERICA": true,
}
// entityKey reduces a country name to its significant letters.
func entityKey(s string) string {
s = strings.ToUpper(strings.TrimSpace(s))
var b strings.Builder
for _, tok := range strings.FieldsFunc(s, func(r rune) bool {
return r == ' ' || r == '.' || r == ',' || r == '-'
}) {
if entityNoise[tok] {
continue
}
b.WriteString(tok)
}
return b.String()
}
func clearHomeLocation(r *Result) { func clearHomeLocation(r *Result) {
r.Country, r.Continent = "", "" r.Country, r.Continent = "", ""
r.CQZ, r.ITUZ, r.DXCC = 0, 0, 0 r.CQZ, r.ITUZ, r.DXCC = 0, 0, 0
@@ -430,11 +471,23 @@ func fillFromDXCC(r *Result, dxcc DXCCResolver) bool {
// //
// Same-entity portables (F4BPO/P, W2RE/2) are untouched: the entities match, // Same-entity portables (F4BPO/P, W2RE/2) are untouched: the entities match,
// and there the home details ARE where the operator is. // and there the home details ARE where the operator is.
// UNLESS THE RECORD IS ABOUT THE OPERATION ITSELF.
//
// Some compound calls have a callbook page of their own, filed under the
// slashed form and describing where the station actually is: QRZ's HP/WE9G
// carries Altos del Maria, Panama, square EJ98xq. Clearing that threw away
// the one field the lookup existed to find, and the QSO was logged with no
// grid at all while the page plainly showed one.
//
// The record's OWN country is what tells the two apart: a page for the
// operation names the entity being operated from, a home page names home.
if dxccNum != 0 && strings.ContainsRune(r.Callsign, '/') && !saysNothingAboutLocation(r.Callsign) { if dxccNum != 0 && strings.ContainsRune(r.Callsign, '/') && !saysNothingAboutLocation(r.Callsign) {
if home := homeCall(r.Callsign); home != "" && home != r.Callsign { if home := homeCall(r.Callsign); home != "" && home != r.Callsign {
if homeNum, _, _, _, _, _, _, homeOK := dxcc.Resolve(home); homeOK && homeNum != 0 && homeNum != dxccNum { if homeNum, _, _, _, _, _, _, homeOK := dxcc.Resolve(home); homeOK && homeNum != 0 && homeNum != dxccNum {
clearHomeLocation(r) if !sameEntityName(r.Country, country) {
filled = true clearHomeLocation(r)
filled = true
}
} }
} }
} }
+57
View File
@@ -91,3 +91,60 @@ func TestSameEntityPortableKeepsItsLocation(t *testing.T) {
t.Errorf("lat/lon = %v/%v — the precise home position was replaced by the entity centroid", r.Lat, r.Lon) t.Errorf("lat/lon = %v/%v — the precise home position was replaced by the entity centroid", r.Lat, r.Lon)
} }
} }
// The other half again, and the one an operator reported: a compound call with
// a callbook page OF ITS OWN. QRZ files HP/WE9G under that exact form, with the
// Panama address and square the station is actually operating from — and the
// QSO was being logged with no grid at all while the page plainly showed one.
func TestACompoundCallWithItsOwnPageKeepsThatPagesLocation(t *testing.T) {
dxcc := testDXCC()
dxcc["HP/WE9G"] = struct {
num int
country string
cont string
cqz, ituz int
lat, lon float64
}{num: 88, country: "Panama", cont: "NA", cqz: 7, ituz: 11, lat: 8.5, lon: -80.0}
dxcc["WE9G"] = struct {
num int
country string
cont string
cqz, ituz int
lat, lon float64
}{num: 291, country: "United States", cont: "NA", cqz: 5, ituz: 8, lat: 39.8, lon: -98.5}
r := Result{
Callsign: "HP/WE9G",
Name: "Richard B",
Country: "Panama", // the RECORD's own country: this page is the operation
Grid: "EJ98xq",
Lat: 8.686667, Lon: -80.043333,
}
fillFromDXCC(&r, dxcc)
if r.Grid != "EJ98xq" {
t.Errorf("grid = %q, want EJ98xq — the page describes the operation, not a home address", r.Grid)
}
if r.Lat != 8.686667 || r.Lon != -80.043333 {
t.Errorf("lat/lon = %v/%v — the station's own position was replaced by the entity centroid", r.Lat, r.Lon)
}
}
func TestSameEntityNameAcrossVocabularies(t *testing.T) {
same := [][2]string{
{"Germany", "Fed. Rep. of Germany"},
{"United States", "United States of America"},
{"Panama", "Panama"},
{"Kosovo", "Republic of Kosovo"},
}
for _, p := range same {
if !sameEntityName(p[0], p[1]) {
t.Errorf("%q and %q read as different entities", p[0], p[1])
}
}
for _, p := range [][2]string{{"Costa Rica", "United States"}, {"France", "Belgium"}, {"", "Panama"}} {
if sameEntityName(p[0], p[1]) {
t.Errorf("%q and %q read as the same entity", p[0], p[1])
}
}
}
+7
View File
@@ -319,6 +319,12 @@ type Status struct {
LastErr string `json:"last_err,omitempty"` LastErr string `json:"last_err,omitempty"`
Broker string `json:"broker"` Broker string `json:"broker"`
Bands []string `json:"bands"` Bands []string `json:"bands"`
// What the feed is actually filtered on, so a panel showing nothing can say
// WHY instead of leaving the operator to guess: the radius in force, and how
// many receiver squares it came to. A radius raised in Preferences that
// never reached the subscription is invisible without these two.
NearKm int `json:"near_km"`
Squares int `json:"squares"`
} }
func (w *Watcher) Status() Status { func (w *Watcher) Status() Status {
@@ -327,6 +333,7 @@ func (w *Watcher) Status() Status {
st := Status{ st := Status{
Running: w.running, Received: w.received, LastAt: w.lastAt, Running: w.running, Received: w.received, LastAt: w.lastAt,
LastErr: w.lastErr, Broker: w.cfg.Broker, LastErr: w.lastErr, Broker: w.cfg.Broker,
NearKm: w.cfg.NearKm, Squares: len(w.cfg.RxGrids),
} }
st.Bands = append(st.Bands, w.cfg.Bands...) st.Bands = append(st.Bands, w.cfg.Bands...)
return st return st
+81 -26
View File
@@ -62,6 +62,12 @@ const (
// whether it is worth calling at all. // whether it is worth calling at all.
pileupWindow = 2 * time.Minute pileupWindow = 2 * time.Minute
// backfillEvery is how often the history query is asked again for a target
// that is still being watched. Five minutes is PSK Reporter's own courtesy
// interval for repeating a query, and it happens to be the period most
// uploaders batch on.
backfillEvery = 5 * time.Minute
// The passband histogram: 60 Hz bins from 200 Hz to 4000 Hz. Above 4 kHz // 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 // there is essentially no FT8, and drawing the empty space made the strip
// look broken rather than empty. // look broken rather than empty.
@@ -202,10 +208,12 @@ type Watcher struct {
subs []string subs []string
spots []spot spots []spot
// backfilled remembers the target the REST history was fetched for, so the // backfilled remembers the target the REST history was fetched for and when,
// panel's polling cannot re-fetch it every second. PSK Reporter's query API // so the panel's polling cannot re-fetch it every second PSK Reporter's
// answers that with a rate limit, and rightly. // query API answers that with a rate limit, and rightly — while a target
backfilled string // held for a while still gets a fresh look every few minutes.
backfilled string
backfilledAt time.Time
} }
func New(cfg Config) *Watcher { func New(cfg Config) *Watcher {
@@ -282,11 +290,12 @@ func (w *Watcher) Watch(target, mode string, dialHz int64) error {
if err := w.resubscribe(client); err != nil { if err := w.resubscribe(client); err != nil {
return err return err
} }
if changed && w.cfg.Scope == ScopeTarget { if changed {
// Under the band-wide subscription the window is already full of the new // In BOTH scopes. The band-wide subscription was assumed to arrive with
// target's reports; under the narrow one it is empty, and the REST query // the target's reports already in the window — true only once it has been
// is what makes the panel useful in the first fifteen seconds instead of // running a while, and false in the case that matters: the operator picks
// after five minutes. // a station a minute after opening the panel and sees one decode where
// another program, running for an hour, shows four.
go w.backfill(target, mode) go w.backfill(target, mode)
} }
return nil return nil
@@ -377,7 +386,7 @@ func (w *Watcher) connect() (mqtt.Client, error) {
if err := w.resubscribe(c); err != nil { if err := w.resubscribe(c); err != nil {
w.cfg.Logf("pskr target: %v", err) w.cfg.Logf("pskr target: %v", err)
} }
if target != "" && w.cfg.Scope == ScopeTarget { if target != "" {
go w.backfill(target, mode) go w.backfill(target, mode)
} }
} }
@@ -449,7 +458,15 @@ func (w *Watcher) SetOperator(call, grid string) {
// Snapshot recomputes the analysis from the window. // Snapshot recomputes the analysis from the window.
func (w *Watcher) Snapshot() Analysis { func (w *Watcher) Snapshot() Analysis {
// Due another look at the history? Checked here because this is what the
// panel calls every second; the query itself is rate-limited inside
// backfill, so this cannot turn into a request per poll.
w.mu.Lock() w.mu.Lock()
if t, m := w.target, w.mode; t != "" && time.Since(w.backfilledAt) >= backfillEvery {
w.mu.Unlock()
go w.backfill(t, m)
w.mu.Lock()
}
defer w.mu.Unlock() defer w.mu.Unlock()
now := time.Now() now := time.Now()
@@ -633,34 +650,42 @@ func top(m map[string]Entry, limit int) []Entry {
return out return out
} }
// suggestOffset picks an audio slot to call on: the middle of the widest run of // suggestOffset picks an audio slot to call on.
// empty bins below the ceiling.
// //
// Below the CEILING, not below 4000 Hz. The ceiling is the highest offset he // 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 // 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 // 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. // somebody whose passband stops at 2700 is advice to transmit into a filter.
//
// Two passes, and the second is the one that matters on a busy DX. Looking for
// an empty run alone answered "nowhere" exactly when the answer was most
// wanted: a hundred decodes across a 2800 Hz passband leave no run of clear
// bins at all, and the panel drew a full histogram with no advice under it.
// Failing a real gap, the quietest slot is still better than the one the
// operator would have picked by eye.
func suggestOffset(bins []Bin, ceiling int) int { func suggestOffset(bins []Bin, ceiling int) int {
if ceiling < 1000 { if ceiling < 1000 {
return 0 return 0
} }
used := map[int]bool{} count := make(map[int]int, len(bins))
busy := make(map[int]bool, len(bins)*3)
for _, b := range bins { for _, b := range bins {
count[b.OffsetHz] = b.Count
if b.Count > 0 { if b.Count > 0 {
used[b.OffsetHz] = true
// The neighbours too: FT8 is 50 Hz wide and the bins are 60, so a // 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. // signal on a bin edge covers the next one as surely as its own.
used[b.OffsetHz-binHz] = true busy[b.OffsetHz], busy[b.OffsetHz-binHz], busy[b.OffsetHz+binHz] = true, true, 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, // 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 // so it is the most crowded part of the passband and the least useful advice.
// advice. const low = 1020
for edge := 1020; edge+binHz <= ceiling; edge += binHz { high := (ceiling / binHz) * binHz
if used[edge] {
// Pass 1 — the widest clear run, and call from its middle.
bestStart, bestLen, start, run := -1, 0, -1, 0
for edge := low; edge <= high; edge += binHz {
if busy[edge] {
start, run = -1, 0 start, run = -1, 0
continue continue
} }
@@ -672,10 +697,32 @@ func suggestOffset(bins []Bin, ceiling int) int {
bestStart, bestLen = start, run bestStart, bestLen = start, run
} }
} }
if bestStart < 0 || bestLen < 2 { if bestStart >= 0 && bestLen >= 2 {
return bestStart + bestLen*binHz/2
}
// Pass 2 — no clear run: the least busy slot. Walked from the top down, so
// a tie goes to the higher offset, which is the less crowded half of any
// passband and the half a pile-up leaves alone.
quietest, fewest := -1, 1<<30
for edge := high; edge >= low; edge -= binHz {
if c := count[edge]; c < fewest {
quietest, fewest = edge, c
}
}
if quietest < 0 {
return 0 return 0
} }
return bestStart + bestLen*binHz/2 // Never past the ceiling: the top bin CONTAINS it, so its middle can sit
// beyond the highest offset he has been shown to decode — which is the one
// thing this function exists to avoid.
if quietest+binHz/2 > ceiling {
quietest -= binHz
}
if quietest < low {
return 0
}
return quietest + binHz/2
} }
// bandTag names the band a dial frequency is on, in PSK Reporter's own // bandTag names the band a dial frequency is on, in PSK Reporter's own
@@ -755,11 +802,19 @@ type pskrReports struct {
// is what gets an application rate-limited off the service for everyone. // is what gets an application rate-limited off the service for everyone.
func (w *Watcher) backfill(target, mode string) { func (w *Watcher) backfill(target, mode string) {
w.mu.Lock() w.mu.Lock()
if w.backfilled == target { // Once per target, then no more often than the refresh interval.
//
// The live feed alone lags by design: PSK Reporter's uploaders batch their
// reports, most of them every five minutes, so between two batches the
// window only holds what happened to have been sent. Asking the history
// again at that same cadence keeps it as full as a program that has been
// subscribed for an hour — which is the whole of the difference an operator
// sees when comparing the two side by side.
if w.backfilled == target && time.Since(w.backfilledAt) < backfillEvery {
w.mu.Unlock() w.mu.Unlock()
return return
} }
w.backfilled = target w.backfilled, w.backfilledAt = target, time.Now()
w.mu.Unlock() w.mu.Unlock()
got := 0 got := 0
+28 -4
View File
@@ -99,10 +99,12 @@ func TestSuggestOffsetAvoidsTheOccupiedBinsAndTheCeiling(t *testing.T) {
if got < 1620 || got > 2340 { if got < 1620 || got > 2340 {
t.Errorf("suggested %d Hz, want somewhere in the empty 1560-2400 run", got) 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 // A passband with no gap at all still gets an answer — the quietest slot,
// past the DX's filter is the one outcome worse than picking a busy slot. // which is what an operator would look for by eye. What it must never do is
if got := suggestOffset(bins, 1500); got != 0 { // advise ABOVE the ceiling: transmitting past the DX's filter is the one
t.Errorf("suggested %d Hz with a 1500 Hz ceiling and no room, want none", got) // outcome worse than picking a busy slot.
if got := suggestOffset(bins, 1500); got <= 1000 || got > 1500 {
t.Errorf("suggested %d Hz with a full 1500 Hz passband, want a slot inside it", got)
} }
if got := suggestOffset(nil, 0); got != 0 { if got := suggestOffset(nil, 0); got != 0 {
t.Errorf("suggested %d Hz with no data at all, want none", got) t.Errorf("suggested %d Hz with no data at all, want none", got)
@@ -132,3 +134,25 @@ func TestTopicsFollowTheScope(t *testing.T) {
t.Errorf("band scope = %v, want the one band-wide filter", got) t.Errorf("band scope = %v, want the one band-wide filter", got)
} }
} }
// The case reported from the air: a busy DX, 103 decodes across a 2805 Hz
// passband, and the panel drew the whole histogram with no advice under it.
func TestACrowdedPassbandStillGetsAnAnswer(t *testing.T) {
// Every bin from 1020 to 2800 occupied — no clear run anywhere, and the
// quietest slot is the answer.
bins := []Bin{}
for hz := 1020; hz <= 2760; hz += binHz {
n := 5
if hz == 2400 { // one slot noticeably quieter than the rest
n = 1
}
bins = append(bins, Bin{OffsetHz: hz, Count: n})
}
got := suggestOffset(bins, 2805)
if got == 0 {
t.Fatal("no advice on a full passband — this is exactly when it is wanted")
}
if got < 2400 || got > 2460 {
t.Errorf("suggested %d Hz, want the quietest slot around 2400", got)
}
}
+90 -1
View File
@@ -36,6 +36,28 @@ import (
// have one without the other. They only share the feed, which either one starts. // have one without the other. They only share the feed, which either one starts.
const keyChaseNew = "cluster.chase_new" const keyChaseNew = "cluster.chase_new"
// keyChaseNewBandsOff lists the bands the panel is NOT to show, comma
// separated.
//
// Stored as the EXCLUSIONS rather than the selection, so that an empty setting
// — every operator who has never opened this — means "show them all", and so
// that a band added to the station's list later appears here on its own. Stored
// the other way round, a selection saved today would silently hide 4 m the day
// a transverter arrives.
const keyChaseNewBandsOff = "cluster.chase_new_bands_off"
// ChaseNewBands is the band vocabulary of the panel's own filter: HF through
// 70 cm, in the order an operator reads a band plan.
//
// A fixed list, not the station's: the two answer different questions. The
// station list says what this station can work at all, and still applies —
// this one says what is worth WATCHING right now, which changes with the hour
// and the season, not with the shack.
var ChaseNewBands = []string{
"160m", "80m", "60m", "40m", "30m", "20m", "17m", "15m", "12m", "10m",
"6m", "4m", "2m", "70cm",
}
// chaseNewMax bounds the panel. A list nobody can read to the bottom is not more // chaseNewMax bounds the panel. A list nobody can read to the bottom is not more
// information, and the oldest rows are the least likely to still be on the air. // information, and the oldest rows are the least likely to still be on the air.
const chaseNewMax = 200 const chaseNewMax = 200
@@ -149,6 +171,69 @@ var chaseModes = map[string]bool{
// atomic rather than the settings store. // atomic rather than the settings store.
func (a *App) chaseNewEnabled() bool { return a.chaseNewOn.Load() } func (a *App) chaseNewEnabled() bool { return a.chaseNewOn.Load() }
// GetChaseNewBands returns the bands the panel is set to SHOW — every band in
// the vocabulary that has not been switched off.
func (a *App) GetChaseNewBands() []string {
off := map[string]bool{}
for _, b := range splitCSV(a.settingOr(keyChaseNewBandsOff, "")) {
off[strings.ToLower(strings.TrimSpace(b))] = true
}
out := make([]string, 0, len(ChaseNewBands))
for _, b := range ChaseNewBands {
if !off[b] {
out = append(out, b)
}
}
return out
}
// SetChaseNewBands takes the bands to SHOW and stores the complement.
//
// Nothing is filtered out of the store retroactively: the rows already
// collected stay until they age out, and the next message is the one that
// obeys. A panel that emptied itself on a settings click would look like it had
// lost the feed.
func (a *App) SetChaseNewBands(show []string) {
want := map[string]bool{}
for _, b := range show {
want[strings.ToLower(strings.TrimSpace(b))] = true
}
var off []string
for _, b := range ChaseNewBands {
if !want[b] {
off = append(off, b)
}
}
a.setSetting(keyChaseNewBandsOff, strings.Join(off, ","))
a.refreshChaseNewBands()
applog.Printf("chase new: showing %d of %d bands", len(ChaseNewBands)-len(off), len(ChaseNewBands))
}
// refreshChaseNewBands re-reads the panel's own band filter into the cache the
// feed consults. Called at startup, and whenever the selection is saved.
func (a *App) refreshChaseNewBands() {
off := map[string]bool{}
for _, b := range splitCSV(a.settingOr(keyChaseNewBandsOff, "")) {
if b = strings.ToLower(strings.TrimSpace(b)); b != "" {
off[b] = true
}
}
a.chaseNewBandsOff.Store(off)
}
// chaseNewBandShown is the per-message half: a map read on the MQTT goroutine.
func (a *App) chaseNewBandShown(band string) bool {
v := a.chaseNewBandsOff.Load()
if v == nil {
return true // nothing loaded yet — better to show than to swallow
}
off, ok := v.(map[string]bool)
if !ok {
return true
}
return !off[strings.ToLower(strings.TrimSpace(band))]
}
// chaseBandAllowed reports whether a band is one the operator uses. // chaseBandAllowed reports whether a band is one the operator uses.
// //
// PSK Reporter carries every band its receivers listen on, including microwave // PSK Reporter carries every band its receivers listen on, including microwave
@@ -217,7 +302,11 @@ func (a *App) feedChaseNew(sp pskr.Spot) {
// Both tests before the status lookup: they are map reads on short keys and // Both tests before the status lookup: they are map reads on short keys and
// they throw away most of the feed, so nothing further down pays for a band // they throw away most of the feed, so nothing further down pays for a band
// this station cannot work or a mode nobody answers. // this station cannot work or a mode nobody answers.
if !chaseModes[mode] || !a.chaseBandAllowed(band) { // Two band tests, and they answer two questions: can this station work the
// band at all (its own list), and does the operator want to WATCH it right
// now (this panel's own filter). A station with 2 m equipment that is
// chasing HF tonight needs the second one.
if !chaseModes[mode] || !a.chaseBandAllowed(band) || !a.chaseNewBandShown(band) {
return return
} }
+1 -1
View File
@@ -21,7 +21,7 @@ import (
const ( const (
// appVersion is stamped on every heartbeat (and could feed the About box). // appVersion is stamped on every heartbeat (and could feed the About box).
appVersion = "0.27.12" appVersion = "0.27.13"
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change // posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
// to https://us.i.posthog.com for a US project. // to https://us.i.posthog.com for a US project.